mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 22:56:19 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b3cebaf64 |
@@ -412,15 +412,15 @@ void APIConnection::finalize_iterator_sync_() {
|
||||
}
|
||||
|
||||
void APIConnection::process_iterator_batch_(ComponentIterator &iterator) {
|
||||
// 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);
|
||||
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();
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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) {
|
||||
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;
|
||||
// Deferred batch size cap during initial state/info sync
|
||||
static constexpr size_t MAX_INITIAL_BATCH_SIZE = 34;
|
||||
// Maximum number of entities to process in a single batch during initial state/info sending
|
||||
static constexpr size_t MAX_INITIAL_PER_BATCH = 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_BATCH_SIZE,
|
||||
"MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_BATCH_SIZE");
|
||||
static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH,
|
||||
"MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_PER_BATCH");
|
||||
|
||||
#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_BATCH_SIZE in api_connection.h (enforced by static_assert there)
|
||||
// Must be >= MAX_INITIAL_PER_BATCH 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,17 +95,9 @@ 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();
|
||||
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;
|
||||
return this->client_->send_message(resp);
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -206,36 +206,32 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType:
|
||||
interval = config[CONF_INTERVAL]
|
||||
window = config[CONF_WINDOW]
|
||||
|
||||
# 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})"
|
||||
)
|
||||
if window > interval:
|
||||
raise cv.Invalid(
|
||||
f"Scan window ({window}) 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 (("Scan interval", interval), *windows):
|
||||
for name, value in (("interval", interval), ("window", window)):
|
||||
if value.total_microseconds < 2500 or value.total_microseconds > 10_240_000:
|
||||
raise cv.Invalid(f"{name} ({value}) must be between 2.5 ms and 10240 ms")
|
||||
raise cv.Invalid(
|
||||
f"Scan {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)
|
||||
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."
|
||||
)
|
||||
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."
|
||||
)
|
||||
|
||||
if interval.total_microseconds * 3 > duration.total_microseconds:
|
||||
raise cv.Invalid(
|
||||
@@ -251,14 +247,11 @@ 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.
|
||||
|
||||
@@ -270,9 +263,7 @@ 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. 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.
|
||||
tracker must not share this schema.
|
||||
"""
|
||||
schema = {
|
||||
cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds,
|
||||
@@ -281,8 +272,6 @@ 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)
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ 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,
|
||||
@@ -74,9 +73,8 @@ 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(CLIENT_COUNT_DEFINE)
|
||||
_request_client_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT")
|
||||
|
||||
|
||||
def register_ble_features(features: set[BLEFeatures]) -> None:
|
||||
@@ -149,7 +147,6 @@ 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:
|
||||
@@ -178,34 +175,17 @@ 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. The connection window is
|
||||
checked against the window here, after the raise.
|
||||
parameters, so no re-validation is needed.
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
@@ -214,7 +194,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, connection_window=True
|
||||
"320ms", window_default=_scan_window_default
|
||||
)
|
||||
|
||||
# Codegen helpers are owned by ble_device_base; kept under the historical names
|
||||
@@ -308,25 +288,6 @@ 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,9 +122,6 @@ 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.
|
||||
@@ -147,19 +144,6 @@ 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:
|
||||
@@ -211,23 +195,19 @@ 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_(); }
|
||||
|
||||
bool ESP32BLETracker::stop_scan_() {
|
||||
void 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 false;
|
||||
return;
|
||||
}
|
||||
// Reset timeout state machine when stopping scan
|
||||
this->scan_timeout_state_ = ScanTimeoutState::INACTIVE;
|
||||
@@ -235,9 +215,8 @@ bool 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 false;
|
||||
return;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ESP32BLETracker::start_scan_(bool first) {
|
||||
@@ -251,11 +230,16 @@ void ESP32BLETracker::start_scan_(bool first) {
|
||||
}
|
||||
this->set_scanner_state_(ScannerState::STARTING);
|
||||
ESP_LOGV(TAG, "Starting scan, set scanner state to STARTING.");
|
||||
if (!first)
|
||||
this->notify_scan_end_();
|
||||
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
|
||||
this->skip_next_scan_end_ = false;
|
||||
if (!first) {
|
||||
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
|
||||
for (auto *listener : this->listeners_)
|
||||
listener->on_scan_end();
|
||||
#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
|
||||
@@ -263,17 +247,7 @@ 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_;
|
||||
#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;
|
||||
this->scan_params_.scan_window = this->scan_window_;
|
||||
|
||||
// Start timeout monitoring in loop() instead of using scheduler
|
||||
// This prevents false reboots when the loop is blocked
|
||||
@@ -434,11 +408,6 @@ 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",
|
||||
@@ -518,18 +487,6 @@ 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();
|
||||
@@ -538,6 +495,8 @@ void ESP32BLETracker::notify_scan_end_() {
|
||||
for (auto *listener : this->neutral_listeners_)
|
||||
listener->on_scan_end();
|
||||
#endif
|
||||
|
||||
this->set_scanner_state_(ScannerState::IDLE);
|
||||
}
|
||||
|
||||
void ESP32BLETracker::handle_scanner_failure_() {
|
||||
@@ -575,8 +534,6 @@ 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,9 +169,6 @@ 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; }
|
||||
@@ -229,10 +226,7 @@ class ESP32BLETracker final : public Component,
|
||||
ScannerState get_scanner_state() const { return this->scanner_state_; }
|
||||
|
||||
protected:
|
||||
/// 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_();
|
||||
void stop_scan_();
|
||||
/// 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.
|
||||
@@ -319,15 +313,6 @@ 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};
|
||||
|
||||
@@ -345,20 +330,15 @@ 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};
|
||||
// Packed 1-bit flags.
|
||||
bool scan_continuous_ : 1;
|
||||
bool scan_active_ : 1;
|
||||
bool scan_continuous_;
|
||||
bool scan_active_;
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
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};
|
||||
bool scan_continuous_before_ota_{false};
|
||||
#endif
|
||||
bool ble_was_disabled_{true};
|
||||
bool parse_advertisements_{false};
|
||||
#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE
|
||||
bool coex_prefer_ble_ : 1 {false};
|
||||
bool coex_prefer_ble_{false};
|
||||
#endif
|
||||
// Scan timeout state machine
|
||||
enum class ScanTimeoutState : uint8_t {
|
||||
@@ -366,10 +346,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
|
||||
|
||||
@@ -133,7 +133,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(var.set_pin(pin))
|
||||
|
||||
if config[CONF_USE_INTERRUPT]:
|
||||
cg.add_define("USE_GPIO_BINARY_SENSOR_INTERRUPT")
|
||||
cg.add(var.set_interrupt_type(config[CONF_INTERRUPT_TYPE]))
|
||||
else:
|
||||
cg.add(var.set_use_interrupt(False))
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace esphome::gpio {
|
||||
static const char *const TAG = "gpio.binary_sensor";
|
||||
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG
|
||||
#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT
|
||||
// Interrupt type strings indexed by edge-triggered InterruptType values:
|
||||
// indices 1-3: RISING_EDGE, FALLING_EDGE, ANY_EDGE; other values (e.g. level-triggered) map to UNKNOWN (index 0).
|
||||
PROGMEM_STRING_TABLE(InterruptTypeStrings, "UNKNOWN", "RISING_EDGE", "FALLING_EDGE", "ANY_EDGE");
|
||||
@@ -20,9 +19,7 @@ static const LogString *gpio_mode_to_string(bool use_interrupt) {
|
||||
return use_interrupt ? LOG_STR("interrupt") : LOG_STR("polling");
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT
|
||||
void IRAM_ATTR GPIOBinarySensorStore::gpio_intr(GPIOBinarySensorStore *arg) {
|
||||
bool new_state = arg->isr_pin_.digital_read();
|
||||
if (new_state != arg->state_) {
|
||||
@@ -46,36 +43,28 @@ void GPIOBinarySensorStore::setup(InternalGPIOPin *pin, Component *component) {
|
||||
// Attach interrupt - from this point on, any changes will be caught by the interrupt
|
||||
pin->attach_interrupt(&GPIOBinarySensorStore::gpio_intr, this, this->interrupt_type_);
|
||||
}
|
||||
#endif // USE_GPIO_BINARY_SENSOR_INTERRUPT
|
||||
|
||||
void GPIOBinarySensor::setup() {
|
||||
#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT
|
||||
if (this->store_.use_interrupt_) {
|
||||
auto *internal_pin = static_cast<InternalGPIOPin *>(this->pin_);
|
||||
this->store_.setup(internal_pin, this);
|
||||
this->publish_initial_state(this->store_.get_state());
|
||||
return;
|
||||
} else {
|
||||
this->pin_->setup();
|
||||
this->publish_initial_state(this->pin_->digital_read());
|
||||
}
|
||||
#endif
|
||||
this->pin_->setup();
|
||||
this->publish_initial_state(this->pin_->digital_read());
|
||||
}
|
||||
|
||||
void GPIOBinarySensor::dump_config() {
|
||||
LOG_BINARY_SENSOR("", "GPIO Binary Sensor", this);
|
||||
LOG_PIN(" Pin: ", this->pin_);
|
||||
#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT
|
||||
ESP_LOGCONFIG(TAG, " Mode: %s", LOG_STR_ARG(gpio_mode_to_string(this->store_.use_interrupt_)));
|
||||
if (this->store_.use_interrupt_) {
|
||||
ESP_LOGCONFIG(TAG, " Interrupt Type: %s", LOG_STR_ARG(interrupt_type_to_string(this->store_.interrupt_type_)));
|
||||
}
|
||||
#else
|
||||
ESP_LOGCONFIG(TAG, " Mode: polling");
|
||||
#endif
|
||||
}
|
||||
|
||||
void GPIOBinarySensor::loop() {
|
||||
#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT
|
||||
if (this->store_.use_interrupt_) {
|
||||
if (this->store_.is_changed()) {
|
||||
// Clear the flag immediately to minimize the window where we might miss changes
|
||||
@@ -89,10 +78,9 @@ void GPIOBinarySensor::loop() {
|
||||
// No changes, disable the loop until the next interrupt
|
||||
this->disable_loop();
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
this->publish_state(this->pin_->digital_read());
|
||||
}
|
||||
#endif
|
||||
this->publish_state(this->pin_->digital_read());
|
||||
}
|
||||
|
||||
float GPIOBinarySensor::get_setup_priority() const { return setup_priority::HARDWARE; }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
@@ -11,7 +10,6 @@ namespace esphome::gpio {
|
||||
// Store class for ISR data and configuration (no vtables, ISR-safe)
|
||||
class GPIOBinarySensorStore {
|
||||
public:
|
||||
#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT
|
||||
void setup(InternalGPIOPin *pin, Component *component);
|
||||
|
||||
static void gpio_intr(GPIOBinarySensorStore *arg);
|
||||
@@ -31,18 +29,15 @@ class GPIOBinarySensorStore {
|
||||
// Separate method to clear the flag
|
||||
this->changed_ = false;
|
||||
}
|
||||
#endif
|
||||
|
||||
protected:
|
||||
friend class GPIOBinarySensor;
|
||||
#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT
|
||||
ISRInternalGPIOPin isr_pin_;
|
||||
Component *component_{nullptr}; // Pointer to the component for enable_loop_soon_any_context()
|
||||
volatile bool state_{false};
|
||||
volatile bool changed_{false};
|
||||
gpio::InterruptType interrupt_type_{gpio::INTERRUPT_ANY_EDGE};
|
||||
bool use_interrupt_{true};
|
||||
#endif
|
||||
gpio::InterruptType interrupt_type_{gpio::INTERRUPT_ANY_EDGE};
|
||||
};
|
||||
|
||||
class GPIOBinarySensor final : public binary_sensor::BinarySensor, public Component {
|
||||
@@ -51,14 +46,8 @@ class GPIOBinarySensor final : public binary_sensor::BinarySensor, public Compon
|
||||
// Interrupts are only detached on reboot when memory is cleared anyway.
|
||||
|
||||
void set_pin(GPIOPin *pin) { this->pin_ = pin; }
|
||||
#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT
|
||||
void set_use_interrupt(bool use_interrupt) { this->store_.use_interrupt_ = use_interrupt; }
|
||||
void set_interrupt_type(gpio::InterruptType type) { this->store_.interrupt_type_ = type; }
|
||||
#else
|
||||
// Polling-only build: codegen still emits set_use_interrupt(false) calls,
|
||||
// so keep the setter as an inlined no-op instead of storing the flag.
|
||||
void set_use_interrupt(bool /*use_interrupt*/) {}
|
||||
#endif
|
||||
// ========== INTERNAL METHODS ==========
|
||||
// (In most use cases you won't need these)
|
||||
/// Setup pin
|
||||
|
||||
@@ -196,9 +196,6 @@ 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();
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
namespace esphome::mitsubishi_cn105 {
|
||||
|
||||
template<typename... Ts>
|
||||
class SetRemoteTemperatureAction final : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
|
||||
class SetRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(float, temperature)
|
||||
|
||||
@@ -17,12 +17,12 @@ class SetRemoteTemperatureAction final : public Action<Ts...>, public Parented<M
|
||||
};
|
||||
|
||||
template<typename... Ts>
|
||||
class ClearRemoteTemperatureAction final : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
|
||||
class ClearRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
|
||||
public:
|
||||
void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); }
|
||||
};
|
||||
|
||||
template<typename... Ts> class VaneControlAction final : public Action<Ts...> {
|
||||
template<typename... Ts> class VaneControlAction : 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->swing_mode_manager_.supported_swing_modes());
|
||||
traits.set_supported_swing_modes(this->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,11 +109,33 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) {
|
||||
}
|
||||
|
||||
if (const auto swing_mode = call.get_swing_mode()) {
|
||||
if (const auto vane = this->swing_mode_manager_.vane_from(*swing_mode)) {
|
||||
this->parent_->set_vane_mode(*vane);
|
||||
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 wide = this->swing_mode_manager_.wide_vane_from(*swing_mode)) {
|
||||
this->parent_->set_wide_vane_mode(*wide);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,39 +166,64 @@ void MitsubishiCN105Climate::apply_values_() {
|
||||
ESP_LOGD(TAG, "Unable to map fan mode");
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
this->publish_state();
|
||||
}
|
||||
|
||||
void MitsubishiCN105Climate::set_supported_swing_mode(climate::ClimateSwingMode mode) {
|
||||
climate::ClimateSwingModeMask supported_swing_modes;
|
||||
this->supported_swing_modes_.clear();
|
||||
switch (mode) {
|
||||
case climate::CLIMATE_SWING_VERTICAL:
|
||||
supported_swing_modes.insert(climate::CLIMATE_SWING_OFF);
|
||||
supported_swing_modes.insert(climate::CLIMATE_SWING_VERTICAL);
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF);
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_VERTICAL);
|
||||
break;
|
||||
|
||||
case climate::CLIMATE_SWING_HORIZONTAL:
|
||||
supported_swing_modes.insert(climate::CLIMATE_SWING_OFF);
|
||||
supported_swing_modes.insert(climate::CLIMATE_SWING_HORIZONTAL);
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF);
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_HORIZONTAL);
|
||||
break;
|
||||
|
||||
case 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);
|
||||
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);
|
||||
break;
|
||||
|
||||
case climate::CLIMATE_SWING_OFF:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
this->swing_mode_manager_.set_supported_swing_modes(supported_swing_modes);
|
||||
}
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105
|
||||
|
||||
@@ -6,13 +6,10 @@
|
||||
#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 final : public climate::Climate,
|
||||
public Component,
|
||||
public Parented<MitsubishiCN105Component> {
|
||||
class MitsubishiCN105Climate : public climate::Climate, public Component, public Parented<MitsubishiCN105Component> {
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
@@ -28,12 +25,14 @@ class MitsubishiCN105Climate final : public climate::Climate,
|
||||
protected:
|
||||
void apply_values_();
|
||||
|
||||
SwingModeManager swing_mode_manager_;
|
||||
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};
|
||||
};
|
||||
|
||||
// Legacy climate action compatibility. Remove in 2027.2.0.
|
||||
template<typename... Ts>
|
||||
class LegacySetRemoteTemperatureAction final : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
|
||||
class LegacySetRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(float, temperature)
|
||||
|
||||
@@ -42,7 +41,7 @@ class LegacySetRemoteTemperatureAction final : public Action<Ts...>, public Pare
|
||||
|
||||
// Legacy climate action compatibility. Remove in 2027.2.0.
|
||||
template<typename... Ts>
|
||||
class LegacyClearRemoteTemperatureAction final : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
|
||||
class LegacyClearRemoteTemperatureAction : 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 final : public Component, public uart::UARTDevice {
|
||||
class MitsubishiCN105Component : public Component, public uart::UARTDevice {
|
||||
public:
|
||||
explicit MitsubishiCN105Component() : hp_(*this) {}
|
||||
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
#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 final : public select::Select,
|
||||
public Component,
|
||||
public Parented<MitsubishiCN105Component> {
|
||||
class MitsubishiCN105VerticalVaneDirectionSelect : public select::Select,
|
||||
public Component,
|
||||
public Parented<MitsubishiCN105Component> {
|
||||
public:
|
||||
void setup() override;
|
||||
void publish_vane_state(MitsubishiCN105::VaneMode mode);
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Literal, NamedTuple
|
||||
from typing import Any, Literal
|
||||
|
||||
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_CONTINUOUS,
|
||||
CONF_DISABLE_CRC,
|
||||
CONF_FLOW_CONTROL_PIN,
|
||||
CONF_ID,
|
||||
)
|
||||
from esphome.const import CONF_ADDRESS, 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, TemplateArgsType
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -54,73 +48,6 @@ 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.options.continuous)
|
||||
if (cmd.state == FrameState::READY && !cmd.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->options.continuous)
|
||||
if (!this->continuous)
|
||||
this->decrement_pending();
|
||||
if (this->device == nullptr)
|
||||
return false;
|
||||
@@ -1070,12 +1070,15 @@ 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).
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// A duplicate of a live entry with the same owner is not queued twice; it resolves against that
|
||||
@@ -1101,10 +1104,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 (options.continuous) {
|
||||
if (continuous) {
|
||||
item.make_continuous(true);
|
||||
ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", now polled continuously", address);
|
||||
} else if (item.options.continuous) {
|
||||
} else if (item.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);
|
||||
@@ -1137,7 +1140,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, options, this->next_seq_++);
|
||||
this->tx_buffer_.emplace_back(device, address, pdu, continuous, this->next_seq_++);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -118,14 +118,6 @@ 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};
|
||||
@@ -134,29 +126,26 @@ struct CommandOptions {
|
||||
struct ModbusDeviceCommand {
|
||||
ModbusClientDevice *device;
|
||||
ModbusFrame frame;
|
||||
// Place-in-line stamp (hub's free-running counter); selection takes the oldest for round-robin
|
||||
// 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.
|
||||
bool continuous{false};
|
||||
// Accepted requests this entry stands for, capped at max_pending(); drains one terminal each.
|
||||
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;
|
||||
// Place-in-line stamp (hub's free-running counter); selection takes the oldest for round-robin
|
||||
// fairness within a class. Meant to wrap.
|
||||
uint16_t seq{0};
|
||||
|
||||
// Build a command from a PDU span (caller bounds it to MAX_PDU_SIZE) and pre-normalized options;
|
||||
// fully initialized here.
|
||||
// Build a command from a PDU span (caller bounds it to MAX_PDU_SIZE); fully initialized here.
|
||||
ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, std::span<const uint8_t> pdu,
|
||||
CommandOptions options = {}, uint16_t seq = 0)
|
||||
: device(device), frame(address, pdu.data(), static_cast<uint16_t>(pdu.size())), seq(seq), options(options) {}
|
||||
bool continuous = false, uint16_t seq = 0)
|
||||
: device(device),
|
||||
frame(address, pdu.data(), static_cast<uint16_t>(pdu.size())),
|
||||
continuous(continuous),
|
||||
seq(seq) {}
|
||||
|
||||
// Transmit ordering class, derived (never stored): a continuous poll ranks below every one-shot.
|
||||
CommandPriority priority() const {
|
||||
return this->options.continuous ? CommandPriority::CONTINUOUS : classify(this->frame.pdu()[0]);
|
||||
return this->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) {
|
||||
@@ -172,7 +161,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->options.continuous) ? 2 : 1;
|
||||
return (requeueable && !this->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.
|
||||
@@ -207,11 +196,11 @@ struct ModbusDeviceCommand {
|
||||
// retroactively inflating that no-op.
|
||||
void make_continuous(bool continuous) {
|
||||
if (continuous) {
|
||||
this->options.continuous = true;
|
||||
this->continuous = true;
|
||||
this->pending = 1;
|
||||
} else {
|
||||
this->increment_pending();
|
||||
this->options.continuous = false;
|
||||
this->continuous = false;
|
||||
}
|
||||
}
|
||||
// Address-scoped clear: keep pending and device so the sweep delivers one on_not_sent() per un-run
|
||||
@@ -229,7 +218,7 @@ struct ModbusDeviceCommand {
|
||||
} else if (!this->waiting_state()) { // an already-retired shell stays put; off the wire -> RETIRED
|
||||
this->state = FrameState::RETIRED;
|
||||
}
|
||||
this->options = {}; // reset every option so a future field is torn down without editing here
|
||||
this->continuous = false;
|
||||
}
|
||||
|
||||
// True while the entry is still waiting for a response; the erase pass exempts these even at pending 0.
|
||||
@@ -264,9 +253,6 @@ 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 &&
|
||||
|
||||
@@ -7,7 +7,6 @@ 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,
|
||||
@@ -157,45 +156,16 @@ _ACTION_BASE_SCHEMA = cv.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,
|
||||
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(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -204,7 +174,6 @@ 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.
|
||||
|
||||
@@ -266,12 +235,6 @@ 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
|
||||
|
||||
|
||||
@@ -355,7 +318,6 @@ 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),
|
||||
@@ -417,9 +379,7 @@ 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, [], command_direction="write"
|
||||
)
|
||||
return await register_client_action(var, config, args, [])
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
@@ -498,9 +458,7 @@ 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, [], command_direction="write"
|
||||
)
|
||||
return await register_client_action(var, config, args, [])
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
@@ -524,9 +482,7 @@ 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, [], command_direction="write"
|
||||
)
|
||||
return await register_client_action(var, config, args, [])
|
||||
|
||||
|
||||
# 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, modbus::CommandOptions options = {}) {
|
||||
if (!this->queue_pdu(pdu, options))
|
||||
void send_or_resolve_(std::span<const uint8_t> pdu) {
|
||||
if (!this->queue_pdu(pdu))
|
||||
this->on_not_sent(pdu);
|
||||
}
|
||||
|
||||
@@ -80,26 +80,6 @@ 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.
|
||||
@@ -107,8 +87,7 @@ template<typename... Ts> class ReadCommandOptions {
|
||||
/// 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...>, public ReadCommandOptions<Ts...> {
|
||||
template<typename... Ts> class ModbusClientSendAction : public ClientActionBase<Ts...> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu)
|
||||
|
||||
@@ -116,7 +95,7 @@ class ModbusClientSendAction : public ClientActionBase<Ts...>, public ReadComman
|
||||
return &this->response_trigger_;
|
||||
}
|
||||
|
||||
void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(x...), this->command_options_(x...)); }
|
||||
void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(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);
|
||||
@@ -161,8 +140,7 @@ 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...>, public ReadCommandOptions<Ts...> {
|
||||
template<typename... Ts> class ReadRegistersAction : public TypedClientActionBase<Ts...> {
|
||||
public:
|
||||
explicit ReadRegistersAction(bool holding) : holding_(holding) {}
|
||||
TEMPLATABLE_VALUE(uint16_t, start_address)
|
||||
@@ -174,8 +152,7 @@ class ReadRegistersAction : public TypedClientActionBase<Ts...>, public ReadComm
|
||||
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...)),
|
||||
this->command_options_(x...));
|
||||
modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...)));
|
||||
}
|
||||
void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span<const uint16_t> registers,
|
||||
modbus::ResponseStatus status) override {
|
||||
@@ -190,7 +167,7 @@ class ReadRegistersAction : public TypedClientActionBase<Ts...>, public ReadComm
|
||||
|
||||
/// 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...>, public ReadCommandOptions<Ts...> {
|
||||
template<typename... Ts> class ReadBitsAction : public TypedClientActionBase<Ts...> {
|
||||
public:
|
||||
explicit ReadBitsAction(bool coils) : coils_(coils) {}
|
||||
TEMPLATABLE_VALUE(uint16_t, start_address)
|
||||
@@ -202,8 +179,7 @@ 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...)),
|
||||
this->command_options_(x...));
|
||||
modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...)));
|
||||
}
|
||||
void on_read_bits(modbus::EntityType entity_type, uint16_t start_address, modbus::PackedBits bits,
|
||||
modbus::ResponseStatus status) override {
|
||||
|
||||
@@ -4,16 +4,8 @@ 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_FORMAT,
|
||||
CONF_ID,
|
||||
CONF_ON_ERROR,
|
||||
CONF_TYPE,
|
||||
CONF_URL,
|
||||
)
|
||||
from esphome.const import CONF_BUFFER_SIZE, 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
|
||||
@@ -39,6 +31,7 @@ ReleaseImageAction = online_image_ns.class_(
|
||||
"OnlineImageReleaseAction", automation.Action, cg.Parented.template(OnlineImage)
|
||||
)
|
||||
|
||||
|
||||
ONLINE_IMAGE_SCHEMA = (
|
||||
runtime_image.runtime_image_schema(OnlineImage)
|
||||
.extend(
|
||||
@@ -46,8 +39,6 @@ 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,11 +1,9 @@
|
||||
#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";
|
||||
@@ -64,8 +62,7 @@ void OnlineImage::update() {
|
||||
|
||||
// Add Accept header based on image format
|
||||
const char *accept_mime_type;
|
||||
runtime_image::ImageFormat format = this->get_format();
|
||||
switch (format) {
|
||||
switch (this->get_format()) {
|
||||
#ifdef USE_RUNTIME_IMAGE_BMP
|
||||
case runtime_image::BMP:
|
||||
accept_mime_type = "image/bmp,*/*;q=0.8";
|
||||
@@ -92,8 +89,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, CONTENT_TYPE_HEADER_NAME});
|
||||
this->downloader_ = this->parent_->get(this->url_, headers, {ETAG_HEADER_NAME, LAST_MODIFIED_HEADER_NAME});
|
||||
|
||||
if (this->downloader_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Download failed.");
|
||||
this->end_connection_();
|
||||
@@ -118,54 +115,17 @@ 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, format)) {
|
||||
ESP_LOGE(TAG, "Failed to initialize decoder for format %d", format);
|
||||
if (!this->begin_decode(total_size)) {
|
||||
ESP_LOGE(TAG, "Failed to initialize decoder for format %d", this->get_format());
|
||||
this->end_connection_();
|
||||
this->download_error_callback_.call();
|
||||
return;
|
||||
}
|
||||
|
||||
// JPEG requires the complete image in the download buffer before decoding
|
||||
if (format == runtime_image::JPEG && total_size > this->download_buffer_.size()) {
|
||||
if (this->get_format() == runtime_image::JPEG && total_size > this->download_buffer_.size()) {
|
||||
this->download_buffer_.resize(total_size);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,18 +58,6 @@ 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."""
|
||||
|
||||
@@ -114,25 +102,18 @@ class PNGFormat(Format):
|
||||
cg.add_library("pngle", "1.1.0")
|
||||
|
||||
|
||||
# Decodable formats only; platforms that support runtime detection accept
|
||||
# "AUTO" in their own schema and get_format() resolves it
|
||||
_JPEG_FORMAT = JPEGFormat()
|
||||
# Registry of available formats
|
||||
IMAGE_FORMATS = {
|
||||
"BMP": BMPFormat(),
|
||||
"JPEG": _JPEG_FORMAT,
|
||||
"JPG": _JPEG_FORMAT, # Alias for JPEG
|
||||
"JPEG": JPEGFormat(),
|
||||
"PNG": PNGFormat(),
|
||||
"JPG": JPEGFormat(), # Alias for JPEG
|
||||
}
|
||||
|
||||
AUTO_FORMAT = AUTOFormat()
|
||||
|
||||
|
||||
def get_format(format_name: str) -> Format | None:
|
||||
"""Get a format instance by name."""
|
||||
name = format_name.upper()
|
||||
if name == "AUTO":
|
||||
return AUTO_FORMAT
|
||||
return IMAGE_FORMATS.get(name)
|
||||
return IMAGE_FORMATS.get(format_name.upper())
|
||||
|
||||
|
||||
def enable_format(format_name: str) -> Format | None:
|
||||
|
||||
@@ -6,8 +6,7 @@ namespace esphome::runtime_image {
|
||||
* @brief Image format types that can be decoded dynamically.
|
||||
*/
|
||||
enum ImageFormat {
|
||||
/** Format is supplied per decode, e.g. detected from the Content-Type header
|
||||
* by online_image; sniffing the image data is not implemented. */
|
||||
/** Automatically detect from data. Not implemented yet. */
|
||||
AUTO,
|
||||
/** JPEG format. */
|
||||
JPEG,
|
||||
|
||||
@@ -171,27 +171,22 @@ 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, ImageFormat format) {
|
||||
bool RuntimeImage::begin_decode(size_t expected_size) {
|
||||
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() != format) {
|
||||
ESP_LOGD(TAG, "Decoder format mismatch: current: %d, new: %d", this->decoder_->get_format(), format);
|
||||
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_);
|
||||
this->decoder_ = nullptr;
|
||||
}
|
||||
|
||||
if (!this->decoder_) {
|
||||
this->decoder_ = this->create_decoder_(format);
|
||||
this->decoder_ = this->create_decoder_(this->format_);
|
||||
if (!this->decoder_) {
|
||||
ESP_LOGE(TAG, "Failed to create decoder for format %d", format);
|
||||
ESP_LOGE(TAG, "Failed to create decoder for format %d", this->format_);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -369,9 +364,6 @@ 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,10 +62,9 @@ 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, ImageFormat format = AUTO);
|
||||
bool begin_decode(size_t expected_size = 0);
|
||||
|
||||
/**
|
||||
* @brief Feed data to the decoder.
|
||||
@@ -104,7 +103,6 @@ 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,7 +5,10 @@ import re
|
||||
from esphome import automation, pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS
|
||||
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_AFTER,
|
||||
@@ -521,7 +524,7 @@ async def final_step():
|
||||
cg.add_define("USE_UART_WAKE_LOOP_ON_RX")
|
||||
|
||||
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
_platform_filter = filter_source_files_from_platform(
|
||||
{
|
||||
"uart_component_esp_idf.cpp": {
|
||||
PlatformFramework.ESP32_IDF,
|
||||
@@ -537,3 +540,13 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# uart_debugger.cpp is fully #ifdef'd on USE_UART_DEBUGGER, set only when a
|
||||
# debug block is configured.
|
||||
_define_filter = filter_source_files_from_defines(
|
||||
{"uart_debugger.cpp": "USE_UART_DEBUGGER"}
|
||||
)
|
||||
|
||||
|
||||
def FILTER_SOURCE_FILES() -> list[str]:
|
||||
return _platform_filter() + _define_filter()
|
||||
|
||||
@@ -35,6 +35,7 @@ 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_();
|
||||
// One step per loop; refusals retry next pass
|
||||
this->entities_iterator_.try_advance(1);
|
||||
if (!this->entities_iterator_.completed())
|
||||
this->entities_iterator_.advance();
|
||||
}
|
||||
|
||||
void DeferredUpdateEventSource::deferrable_send_state(void *source, const char *event_type,
|
||||
@@ -321,6 +321,12 @@ 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_();
|
||||
// One step per loop; refusals retry next pass
|
||||
this->entities_iterator_.try_advance(1);
|
||||
if (!this->entities_iterator_.completed())
|
||||
this->entities_iterator_.advance();
|
||||
}
|
||||
|
||||
bool AsyncEventSourceResponse::try_send_nodefer(const char *message, size_t message_len, const char *event, uint32_t id,
|
||||
|
||||
@@ -22,23 +22,23 @@ void ComponentIterator::advance_platform_() {
|
||||
this->at_ = 0;
|
||||
}
|
||||
|
||||
bool ComponentIterator::advance_step_() {
|
||||
void ComponentIterator::advance() {
|
||||
switch (this->state_) {
|
||||
case IteratorState::NONE:
|
||||
// not started
|
||||
return false;
|
||||
return;
|
||||
case IteratorState::BEGIN:
|
||||
if (this->on_begin()) {
|
||||
advance_platform_();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
break;
|
||||
|
||||
// Entity iterator cases (generated from entity_types.h)
|
||||
// NOLINTBEGIN(bugprone-macro-parentheses)
|
||||
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
|
||||
case IteratorState::upper: \
|
||||
return this->process_platform_item_(App.get_##plural(), &ComponentIterator::on_##singular);
|
||||
this->process_platform_item_(App.get_##plural(), &ComponentIterator::on_##singular); \
|
||||
break;
|
||||
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
|
||||
ENTITY_TYPE_(type, singular, plural, count, upper)
|
||||
#include "esphome/core/entity_types.h"
|
||||
@@ -48,29 +48,26 @@ bool ComponentIterator::advance_step_() {
|
||||
|
||||
#ifdef USE_API_USER_DEFINED_ACTIONS
|
||||
case IteratorState::SERVICE:
|
||||
return this->process_platform_item_(api::global_api_server->get_user_services(), &ComponentIterator::on_service);
|
||||
this->process_platform_item_(api::global_api_server->get_user_services(), &ComponentIterator::on_service);
|
||||
break;
|
||||
#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)) {
|
||||
return false;
|
||||
if (camera_instance != nullptr && (!camera_instance->is_internal() || this->include_internal_)) {
|
||||
this->on_camera(camera_instance);
|
||||
}
|
||||
advance_platform_();
|
||||
return true;
|
||||
}
|
||||
} break;
|
||||
#endif
|
||||
|
||||
case IteratorState::MAX:
|
||||
if (this->on_end()) {
|
||||
this->state_ = IteratorState::NONE;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ComponentIterator::on_end() { return true; }
|
||||
|
||||
@@ -30,23 +30,7 @@ class RadioFrequency;
|
||||
class ComponentIterator {
|
||||
public:
|
||||
void begin(bool include_internal = false);
|
||||
/// 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); }
|
||||
void advance();
|
||||
bool completed() const { return this->state_ == IteratorState::NONE; }
|
||||
virtual bool on_begin();
|
||||
// Pure virtual entity callbacks (generated from entity_types.h)
|
||||
@@ -89,34 +73,23 @@ 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 yield_requested_ : 1 {false};
|
||||
bool include_internal_ : 1 {false};
|
||||
bool include_internal_{false};
|
||||
|
||||
template<typename Container>
|
||||
bool process_platform_item_(const Container &items,
|
||||
void process_platform_item_(const Container &items,
|
||||
bool (ComponentIterator::*on_item)(typename Container::value_type)) {
|
||||
if (this->at_ >= items.size()) {
|
||||
this->advance_platform_();
|
||||
return true;
|
||||
} else {
|
||||
typename Container::value_type item = items[this->at_];
|
||||
if ((item->is_internal() && !this->include_internal_) || (this->*on_item)(item)) {
|
||||
this->at_++;
|
||||
}
|
||||
}
|
||||
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_();
|
||||
};
|
||||
|
||||
|
||||
@@ -72,7 +72,6 @@
|
||||
#define USE_ESP32_IMPROV_STATE_CALLBACK
|
||||
#define USE_EVENT
|
||||
#define USE_FAN
|
||||
#define USE_GPIO_BINARY_SENSOR_INTERRUPT
|
||||
#define USE_GPIO_SWITCH_INTERLOCK
|
||||
#define USE_GRAPH
|
||||
#define USE_GRAPHICAL_DISPLAY_MENU
|
||||
|
||||
@@ -16,7 +16,6 @@ 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__)
|
||||
@@ -158,17 +157,8 @@ def has_remote_file_changed(
|
||||
}
|
||||
if etag := _read_etag(local_file_path):
|
||||
headers[IF_NONE_MATCH] = etag
|
||||
# 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",
|
||||
response = requests.head(
|
||||
url, headers=headers, timeout=timeout, allow_redirects=True
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
@@ -303,7 +293,7 @@ def download_content(
|
||||
_LOGGER.info("Downloading %s", url)
|
||||
_LOGGER.debug("Saving to %s", path)
|
||||
|
||||
def _fetch() -> tuple[requests.Response, bytes]:
|
||||
try:
|
||||
req = requests.get(
|
||||
url,
|
||||
timeout=timeout,
|
||||
@@ -314,10 +304,7 @@ 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.
|
||||
return req, req.content
|
||||
|
||||
try:
|
||||
req, data = fetch_with_retry(url, _fetch)
|
||||
data = req.content
|
||||
except requests.exceptions.RequestException as e:
|
||||
if path.exists():
|
||||
# Memoized so a flaky host warns once per run, not per consumer.
|
||||
|
||||
@@ -15,7 +15,6 @@ 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
|
||||
@@ -30,9 +29,8 @@ _LOGGER = logging.getLogger(__name__)
|
||||
_MIRROR_ATTEMPTS = 3
|
||||
|
||||
# Passes over the whole mirror list when a transient network error is in
|
||||
# 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
|
||||
# the mix; matches git.py's _NETWORK_MAX_ATTEMPTS (3 tries, 2s/4s backoff).
|
||||
_MIRROR_SWEEP_ATTEMPTS = 3
|
||||
|
||||
|
||||
def get_project_link_flags() -> list[str]:
|
||||
@@ -905,6 +903,30 @@ 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,
|
||||
@@ -1109,7 +1131,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:
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
"""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()
|
||||
+1
-1
@@ -12,7 +12,7 @@ pyserial==3.5
|
||||
platformio==6.1.19
|
||||
esptool==5.3.1
|
||||
click==8.3.3
|
||||
aioesphomeapi==46.0.0
|
||||
aioesphomeapi==45.13.1
|
||||
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
|
||||
zeroconf==0.150.0
|
||||
puremagic==2.2.0
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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:
|
||||
@@ -1,17 +0,0 @@
|
||||
esphome:
|
||||
name: scan-window-raised
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
|
||||
esp32_ble_tracker:
|
||||
|
||||
bluetooth_proxy:
|
||||
active: true
|
||||
|
||||
api:
|
||||
@@ -1,12 +0,0 @@
|
||||
esphome:
|
||||
name: scan-window-scan-only
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
|
||||
esp32_ble_tracker:
|
||||
@@ -1,14 +0,0 @@
|
||||
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,12 +12,11 @@ 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 CONF_CONNECTION_SCAN_WINDOW, to_ble_units
|
||||
from esphome.components.ble_device_base import 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 (
|
||||
@@ -121,103 +120,3 @@ 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
|
||||
|
||||
@@ -3,15 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.core import CORE
|
||||
|
||||
INTERRUPT_DEFINE = "USE_GPIO_BINARY_SENSOR_INTERRUPT"
|
||||
|
||||
|
||||
def test_gpio_binary_sensor_basic_setup(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
@@ -74,62 +69,3 @@ def test_gpio_binary_sensor_explicit_polling_mode(
|
||||
)
|
||||
|
||||
assert "bs_polling->set_use_interrupt(false);" in main_cpp
|
||||
|
||||
|
||||
def test_gpio_binary_sensor_interrupt_emits_define(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
"""
|
||||
An interrupt-mode sensor must emit the define that compiles the ISR code,
|
||||
since the platform ISR pin implementation is only built when needed
|
||||
"""
|
||||
generate_main("tests/component_tests/gpio/test_gpio_binary_sensor.yaml")
|
||||
|
||||
assert INTERRUPT_DEFINE in {d.name for d in CORE.defines}
|
||||
|
||||
|
||||
def test_gpio_binary_sensor_polling_omits_define(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
"""
|
||||
A polling-only config must not emit the interrupt define, so the ISR code
|
||||
(and its reference to ISRInternalGPIOPin) is compiled out
|
||||
"""
|
||||
generate_main("tests/component_tests/gpio/test_gpio_binary_sensor_polling.yaml")
|
||||
|
||||
assert INTERRUPT_DEFINE not in {d.name for d in CORE.defines}
|
||||
|
||||
|
||||
def test_gpio_binary_sensor_mixed_modes_emit_define(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
"""
|
||||
With one interrupt and one polling sensor, the define is emitted and the
|
||||
polling instance still opts out via its setter
|
||||
"""
|
||||
main_cpp = generate_main(
|
||||
"tests/component_tests/gpio/test_gpio_binary_sensor_mixed.yaml"
|
||||
)
|
||||
|
||||
assert INTERRUPT_DEFINE in {d.name for d in CORE.defines}
|
||||
assert "bs_polling->set_use_interrupt(false);" in main_cpp
|
||||
assert "bs_interrupt->set_use_interrupt" not in main_cpp
|
||||
|
||||
|
||||
def test_gpio_binary_sensor_expander_pin_omits_define(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""
|
||||
An expander pin can't use interrupts: final validation falls back to
|
||||
polling and the interrupt define must not be emitted. This is the config
|
||||
that fails to link if the ISR code is compiled without an internal pin
|
||||
"""
|
||||
with caplog.at_level(logging.INFO):
|
||||
main_cpp = generate_main(
|
||||
"tests/component_tests/gpio/test_gpio_binary_sensor_expander.yaml"
|
||||
)
|
||||
|
||||
assert "bs_expander->set_use_interrupt(false);" in main_cpp
|
||||
assert INTERRUPT_DEFINE not in {d.name for d in CORE.defines}
|
||||
assert "falling back to polling mode" in caplog.text
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
i2c:
|
||||
scl: 16
|
||||
sda: 17
|
||||
|
||||
ch422g:
|
||||
- id: ch422g_hub
|
||||
|
||||
binary_sensor:
|
||||
- platform: gpio
|
||||
name: "Expander Sensor"
|
||||
id: bs_expander
|
||||
pin:
|
||||
ch422g: ch422g_hub
|
||||
number: 1
|
||||
mode: INPUT
|
||||
@@ -1,17 +0,0 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
binary_sensor:
|
||||
- platform: gpio
|
||||
pin: 5
|
||||
name: "Interrupt Sensor"
|
||||
id: bs_interrupt
|
||||
|
||||
- platform: gpio
|
||||
pin: 4
|
||||
name: "Polling Sensor"
|
||||
id: bs_polling
|
||||
use_interrupt: false
|
||||
@@ -16,13 +16,7 @@ from esphome.components.modbus_client import (
|
||||
CONFIG_SCHEMA,
|
||||
MODBUS_CLIENT_SEND_SCHEMA,
|
||||
)
|
||||
from esphome.const import (
|
||||
CONF_ADDRESS,
|
||||
CONF_CONTINUOUS,
|
||||
CONF_ID,
|
||||
CONF_ON_ERROR,
|
||||
CONF_ON_RESPONSE,
|
||||
)
|
||||
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_ON_ERROR, CONF_ON_RESPONSE
|
||||
from esphome.core import Lambda
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -124,29 +118,6 @@ 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.
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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
|
||||
@@ -1,79 +0,0 @@
|
||||
#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
|
||||
@@ -1,11 +0,0 @@
|
||||
# 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"
|
||||
@@ -1,195 +0,0 @@
|
||||
#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,19 +2,10 @@
|
||||
#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) {
|
||||
MitsubishiCN105ClimateTestContext context;
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
const auto mapping = TemperatureMapping();
|
||||
|
||||
for (int temperature = 16; temperature <= 31; ++temperature) {
|
||||
@@ -22,7 +13,7 @@ TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpecte
|
||||
EXPECT_EQ(mapping.from_mitsubishi(temperature), temperature);
|
||||
}
|
||||
|
||||
const auto traits = context.sut.traits();
|
||||
const auto traits = 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);
|
||||
@@ -31,10 +22,10 @@ TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpecte
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingAndTraitsMatchExpectedValues) {
|
||||
MitsubishiCN105ClimateTestContext context;
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
auto mapping = TemperatureMapping();
|
||||
mapping.set_use_fahrenheit(true);
|
||||
context.component.set_use_fahrenheit(true);
|
||||
sut.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},
|
||||
@@ -49,7 +40,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 = context.sut.traits();
|
||||
const auto traits = 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);
|
||||
@@ -72,44 +63,163 @@ TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingUsesLinearConversi
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeOffLeavesTraitsEmpty) {
|
||||
MitsubishiCN105ClimateTestContext context;
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_OFF);
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_OFF);
|
||||
|
||||
EXPECT_FALSE(context.sut.traits().get_supports_swing_modes());
|
||||
EXPECT_FALSE(sut.traits().get_supports_swing_modes());
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeVerticalExposesOffAndVertical) {
|
||||
MitsubishiCN105ClimateTestContext context;
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
|
||||
|
||||
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));
|
||||
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));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeHorizontalExposesOffAndHorizontal) {
|
||||
MitsubishiCN105ClimateTestContext context;
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
|
||||
|
||||
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));
|
||||
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));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeBothExposesAllExpectedModes) {
|
||||
MitsubishiCN105ClimateTestContext context;
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
|
||||
sut.set_supported_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_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
|
||||
EXPECT_TRUE(context.sut.traits().supports_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);
|
||||
}
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105::testing
|
||||
|
||||
-99
@@ -1,99 +0,0 @@
|
||||
#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,4 +64,26 @@ 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) {
|
||||
MitsubishiCN105Component hub;
|
||||
TestableMitsubishiCN105Component hub;
|
||||
size_t callback_count = 0;
|
||||
std::optional<VerticalVaneMode> callback_direction;
|
||||
hub.add_on_vane_state_callback([&](const VaneState &state) {
|
||||
@@ -11,9 +11,8 @@ TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) {
|
||||
callback_direction = state.vertical.direction;
|
||||
});
|
||||
|
||||
hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
|
||||
hub.set_target_temperature(20.0f);
|
||||
hub.set_vane_mode(MitsubishiCN105::VaneMode::POSITION_4);
|
||||
hub.mutable_status().room_temperature = 20.0f;
|
||||
hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4;
|
||||
hub.publish_status();
|
||||
|
||||
EXPECT_EQ(callback_count, 1);
|
||||
@@ -26,7 +25,7 @@ TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) {
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) {
|
||||
MitsubishiCN105Component hub;
|
||||
TestableMitsubishiCN105Component hub;
|
||||
size_t status_callback_count = 0;
|
||||
size_t vane_callback_count = 0;
|
||||
std::optional<VerticalVaneMode> callback_direction;
|
||||
@@ -36,16 +35,15 @@ TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) {
|
||||
callback_direction = state.vertical.direction;
|
||||
});
|
||||
|
||||
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.mutable_status().room_temperature = 20.0f;
|
||||
hub.mutable_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.set_vane_mode(MitsubishiCN105::VaneMode::POSITION_4);
|
||||
hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4;
|
||||
hub.publish_status();
|
||||
|
||||
EXPECT_EQ(status_callback_count, 2);
|
||||
@@ -54,7 +52,7 @@ TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) {
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ComponentTests, VaneCallAppliesVerticalDirection) {
|
||||
MitsubishiCN105Component hub;
|
||||
TestableMitsubishiCN105Component hub;
|
||||
|
||||
auto call = hub.make_vane_call();
|
||||
call.vertical.set_direction(VERTICAL_VANE_MODE_POSITION_5);
|
||||
@@ -64,11 +62,12 @@ TEST(MitsubishiCN105ComponentTests, VaneCallAppliesVerticalDirection) {
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ComponentTests, VaneControlActionAppliesConfiguredFields) {
|
||||
MitsubishiCN105Component hub;
|
||||
TestableMitsubishiCN105Component 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
|
||||
|
||||
+18
-15
@@ -3,9 +3,14 @@
|
||||
|
||||
namespace esphome::mitsubishi_cn105::testing {
|
||||
|
||||
class TestableMitsubishiCN105VerticalVaneDirectionSelect : public MitsubishiCN105VerticalVaneDirectionSelect {
|
||||
public:
|
||||
using MitsubishiCN105VerticalVaneDirectionSelect::control;
|
||||
};
|
||||
|
||||
struct VerticalVaneDirectionSelectTestContext {
|
||||
MitsubishiCN105Component hub;
|
||||
MitsubishiCN105VerticalVaneDirectionSelect select;
|
||||
TestableMitsubishiCN105Component hub;
|
||||
TestableMitsubishiCN105VerticalVaneDirectionSelect select;
|
||||
|
||||
VerticalVaneDirectionSelectTestContext() {
|
||||
this->select.traits.set_options({"Auto", "1", "2", "3", "4", "5", "Swing"});
|
||||
@@ -26,15 +31,13 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, MapsIndexesToVaneModes) {
|
||||
|
||||
for (size_t i = 0; i < expected_modes.size(); ++i) {
|
||||
SCOPED_TRACE(i);
|
||||
ctx.select.make_call().set_index(i).perform();
|
||||
ctx.select.control(i);
|
||||
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,
|
||||
@@ -45,12 +48,13 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, PublishesIncomingVaneModes
|
||||
|
||||
for (size_t i = 0; i < modes.size(); ++i) {
|
||||
SCOPED_TRACE(i);
|
||||
ctx.hub.set_vane_mode(modes[i]);
|
||||
ctx.hub.publish_status();
|
||||
ctx.hub.mutable_status().vane_mode = modes[i];
|
||||
ctx.hub.notify_status();
|
||||
EXPECT_EQ(ctx.select.active_index(), std::optional{i});
|
||||
}
|
||||
|
||||
ctx.select.publish_vane_state(MitsubishiCN105::VaneMode::UNKNOWN);
|
||||
ctx.hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN;
|
||||
ctx.hub.notify_status();
|
||||
EXPECT_EQ(ctx.select.active_index(), std::optional{modes.size() - 1});
|
||||
}
|
||||
|
||||
@@ -60,15 +64,14 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ControlPublishesSelectAndC
|
||||
climate_entity.set_parent(&ctx.hub);
|
||||
climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
|
||||
|
||||
ctx.hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
|
||||
ctx.hub.set_target_temperature(20.0f);
|
||||
ctx.hub.mutable_status().room_temperature = 20.0f;
|
||||
climate_entity.setup();
|
||||
|
||||
ctx.select.make_call().set_index(6).perform();
|
||||
ctx.select.control(6);
|
||||
EXPECT_EQ(ctx.select.active_index(), std::optional<size_t>{6});
|
||||
EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_VERTICAL);
|
||||
|
||||
ctx.select.make_call().set_index(3).perform();
|
||||
ctx.select.control(3);
|
||||
EXPECT_EQ(ctx.select.active_index(), std::optional<size_t>{3});
|
||||
EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_OFF);
|
||||
}
|
||||
@@ -79,8 +82,7 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ClimateControlPublishesSel
|
||||
climate_entity.set_parent(&ctx.hub);
|
||||
climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
|
||||
|
||||
ctx.hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
|
||||
ctx.hub.set_target_temperature(20.0f);
|
||||
ctx.hub.mutable_status().room_temperature = 20.0f;
|
||||
climate_entity.setup();
|
||||
|
||||
climate_entity.make_call().set_swing_mode(climate::CLIMATE_SWING_VERTICAL).perform();
|
||||
@@ -93,9 +95,10 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ClimateControlPublishesSel
|
||||
TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, BeforeInitializationDoesNotPublishSelectState) {
|
||||
VerticalVaneDirectionSelectTestContext ctx;
|
||||
|
||||
ctx.select.make_call().set_index(3).perform();
|
||||
ctx.select.control(3);
|
||||
|
||||
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).options.continuous);
|
||||
EXPECT_TRUE(hub.queued(0).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).options.continuous);
|
||||
EXPECT_TRUE(hub.queued(0).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).options.continuous);
|
||||
ASSERT_TRUE(hub.queued(0).continuous);
|
||||
hub.force_send_next();
|
||||
|
||||
hub.timeout_waiting(); // no response -> device requests retry
|
||||
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
EXPECT_TRUE(hub.queued(0).options.continuous); // the retried poll stays continuous
|
||||
EXPECT_TRUE(hub.queued(0).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).options.continuous);
|
||||
ASSERT_TRUE(hub.queued(0).continuous);
|
||||
|
||||
device.read_holding_registers(0x100, 2); // one-shot duplicate downgrades the poll
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
EXPECT_FALSE(hub.queued(0).options.continuous);
|
||||
EXPECT_FALSE(hub.queued(0).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().options.continuous);
|
||||
EXPECT_FALSE(hub.waiting_command().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).options.continuous);
|
||||
ASSERT_TRUE(hub.queued(0).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).options.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).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).options.continuous);
|
||||
ASSERT_FALSE(hub.queued(0).continuous);
|
||||
|
||||
device.read_holding_registers(0x100, 2, {.continuous = true});
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
EXPECT_TRUE(hub.queued(0).options.continuous);
|
||||
EXPECT_TRUE(hub.queued(0).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).options.continuous);
|
||||
EXPECT_TRUE(hub.queued(0).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().options.continuous); // and the poll takes what is left
|
||||
EXPECT_TRUE(hub.waiting_command().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).options.continuous);
|
||||
EXPECT_FALSE(hub.queued(0).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).options.continuous);
|
||||
ASSERT_TRUE(hub.queued(0).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).options.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).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,7 +51,6 @@ 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;"
|
||||
@@ -92,7 +91,6 @@ 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());'
|
||||
@@ -100,7 +98,6 @@ button:
|
||||
then:
|
||||
- logger.log: "typed read timeout"
|
||||
- modbus_client.read_input_registers:
|
||||
continuous: !lambda "return false;"
|
||||
address: 0x01
|
||||
start_address: 0x20
|
||||
on_custom_response:
|
||||
@@ -116,14 +113,12 @@ 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,11 +57,6 @@ 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:
|
||||
|
||||
@@ -77,12 +77,18 @@ 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, ImageFormat format = AUTO) {
|
||||
static bool decode_all(TestableRuntimeImage &img, const uint8_t *data, size_t len) {
|
||||
std::vector<uint8_t> buffer(data, data + len); // feed_data needs mutable bytes
|
||||
if (!img.begin_decode(len, format)) {
|
||||
if (!img.begin_decode(len)) {
|
||||
return false;
|
||||
}
|
||||
size_t offset = 0;
|
||||
@@ -197,51 +203,25 @@ TEST(RuntimeImageDecoder, ChunkedFeedDecodesLikeDownloadLoop) {
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, FormatSwitchEvictsMismatchedDecoder) {
|
||||
// Drive the format switch through begin_decode()'s format parameter, the way
|
||||
// a dynamic-format producer (online_image MIME detection) does.
|
||||
TestableRuntimeImage img(AUTO);
|
||||
// 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);
|
||||
|
||||
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);
|
||||
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);
|
||||
|
||||
// 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);
|
||||
// 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);
|
||||
|
||||
// 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));
|
||||
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);
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, ReleaseKeepsDecoderWarm) {
|
||||
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
@@ -348,7 +347,6 @@ 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
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
@@ -1,20 +0,0 @@
|
||||
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
@@ -1,55 +0,0 @@
|
||||
#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
@@ -1,21 +0,0 @@
|
||||
#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
|
||||
@@ -1,28 +0,0 @@
|
||||
esphome:
|
||||
name: online-image-bmp
|
||||
|
||||
host:
|
||||
|
||||
http_request:
|
||||
|
||||
display:
|
||||
|
||||
image:
|
||||
- platform: online_image
|
||||
url: http://127.0.0.1:HTTP_PORT/foo.bmp
|
||||
format: AUTO
|
||||
id: myimg
|
||||
type: RGB
|
||||
on_download_finished:
|
||||
logger.log:
|
||||
format: "download finished. cache hit: %u"
|
||||
args: [cached]
|
||||
|
||||
api:
|
||||
actions:
|
||||
- action: fetch_image
|
||||
then:
|
||||
- component.update: myimg
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
@@ -1,28 +0,0 @@
|
||||
esphome:
|
||||
name: online-image-bmp
|
||||
|
||||
host:
|
||||
|
||||
http_request:
|
||||
|
||||
display:
|
||||
|
||||
image:
|
||||
- platform: online_image
|
||||
url: http://127.0.0.1:HTTP_PORT/foo.bmp
|
||||
id: myimg
|
||||
format: AUTO
|
||||
type: RGB
|
||||
on_download_finished:
|
||||
logger.log:
|
||||
format: "download finished. cache hit: %u"
|
||||
args: [cached]
|
||||
|
||||
api:
|
||||
actions:
|
||||
- action: fetch_image
|
||||
then:
|
||||
- component.update: myimg
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
@@ -7,9 +7,8 @@ http_request:
|
||||
|
||||
display:
|
||||
|
||||
image:
|
||||
- platform: online_image
|
||||
url: http://127.0.0.1:HTTP_PORT/foo.bmp
|
||||
online_image:
|
||||
- url: http://127.0.0.1:HTTP_PORT/foo.bmp
|
||||
id: myimg
|
||||
format: BMP
|
||||
type: RGB
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
"""Shared fixture server and log helpers for the online_image integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
import re
|
||||
|
||||
# black 8x8 RGB BMP, generated with
|
||||
# from PIL import Image
|
||||
# from io import BytesIO
|
||||
# b = BytesIO()
|
||||
# img = Image.new("RGB", (8, 8))
|
||||
# img.save(b, format="BMP")
|
||||
# b.getvalue()
|
||||
BMP_IMAGE = b"BM\xf6\x00\x00\x00\x00\x00\x00\x006\x00\x00\x00(\x00\x00\x00\x08\x00\x00\x00\x08\x00\x00\x00\x01\x00\x18\x00\x00\x00\x00\x00\xc0\x00\x00\x00\xc4\x0e\x00\x00\xc4\x0e\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
|
||||
LEN_BMP_IMAGE = len(BMP_IMAGE)
|
||||
|
||||
|
||||
async def wait_for_download(
|
||||
downloaded_bytes_future: asyncio.Future,
|
||||
server_error_future: asyncio.Future,
|
||||
) -> int:
|
||||
"""Await the downloaded byte count, raising a server handler error first."""
|
||||
await asyncio.wait(
|
||||
{downloaded_bytes_future, server_error_future},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
if server_error_future.done() and (exc := server_error_future.exception()):
|
||||
raise exc
|
||||
# Retrieve a late teardown error so asyncio does not log it at GC
|
||||
server_error_future.add_done_callback(lambda f: f.exception())
|
||||
return downloaded_bytes_future.result()
|
||||
|
||||
|
||||
def make_download_watcher(
|
||||
downloaded_bytes_future: asyncio.Future,
|
||||
download_finished_future: asyncio.Future,
|
||||
) -> Callable[[str], None]:
|
||||
"""Build a line callback resolving the futures from the device log."""
|
||||
|
||||
def check_output(line: str) -> None:
|
||||
if (
|
||||
match := re.search(r"Image fully downloaded, (\d+) bytes", line)
|
||||
) and not downloaded_bytes_future.done():
|
||||
downloaded_bytes_future.set_result(int(match.group(1)))
|
||||
if "download finished" in line and not download_finished_future.done():
|
||||
download_finished_future.set_result(True)
|
||||
|
||||
return check_output
|
||||
|
||||
|
||||
def handle_http(
|
||||
http_request_future,
|
||||
content_type: str = "text/plain",
|
||||
*,
|
||||
request_path: str = "/foo.bmp",
|
||||
request_line_consumed: bool = False,
|
||||
server_error_future: asyncio.Future | None = None,
|
||||
):
|
||||
async def handler(reader, writer):
|
||||
try:
|
||||
# Only read the request line if it hasn't been consumed by a caller
|
||||
if not request_line_consumed:
|
||||
async with asyncio.timeout(1.0):
|
||||
data = await reader.readuntil(b"\r\n")
|
||||
|
||||
expected_request = f"GET {request_path} HTTP/1.1\r\n".encode()
|
||||
assert data[: len(expected_request)] == expected_request
|
||||
|
||||
async with asyncio.timeout(1.0):
|
||||
await reader.readuntil(b"\r\n\r\n")
|
||||
|
||||
if not http_request_future.done():
|
||||
http_request_future.set_result(True)
|
||||
|
||||
http_response = [
|
||||
b"HTTP/1.1 200 OK",
|
||||
b"Content-Length: %d" % LEN_BMP_IMAGE,
|
||||
f"Content-Type: {content_type}".encode(),
|
||||
b"Connection: close",
|
||||
b"",
|
||||
b"",
|
||||
]
|
||||
writer.write(b"\r\n".join(http_response))
|
||||
await writer.drain()
|
||||
|
||||
writer.write(BMP_IMAGE)
|
||||
|
||||
await writer.drain()
|
||||
except Exception as exc:
|
||||
if server_error_future is not None and not server_error_future.done():
|
||||
server_error_future.set_exception(exc)
|
||||
if not http_request_future.done():
|
||||
http_request_future.set_exception(exc)
|
||||
raise
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def handle_http_redirect(
|
||||
http_request_future, final_request_future, server_error_future, port_holder
|
||||
):
|
||||
async def handler(reader, writer):
|
||||
try:
|
||||
async with asyncio.timeout(1.0):
|
||||
request = await reader.readuntil(b"\r\n")
|
||||
|
||||
if (
|
||||
request[: len(b"GET /foo.bmp HTTP/1.1\r\n")]
|
||||
== b"GET /foo.bmp HTTP/1.1\r\n"
|
||||
):
|
||||
if not http_request_future.done():
|
||||
http_request_future.set_result(True)
|
||||
async with asyncio.timeout(1.0):
|
||||
await reader.readuntil(b"\r\n\r\n")
|
||||
|
||||
http_response = [
|
||||
b"HTTP/1.1 302 Found",
|
||||
f"Location: http://127.0.0.1:{port_holder['port']}/final.bmp".encode(),
|
||||
b"Content-Type: text/html",
|
||||
b"Content-Length: 0",
|
||||
b"Connection: close",
|
||||
b"",
|
||||
b"",
|
||||
]
|
||||
writer.write(b"\r\n".join(http_response))
|
||||
await writer.drain()
|
||||
return
|
||||
|
||||
assert (
|
||||
request[: len(b"GET /final.bmp HTTP/1.1\r\n")]
|
||||
== b"GET /final.bmp HTTP/1.1\r\n"
|
||||
)
|
||||
if not final_request_future.done():
|
||||
final_request_future.set_result(True)
|
||||
await handle_http(
|
||||
final_request_future,
|
||||
"image/bmp",
|
||||
request_path="/final.bmp",
|
||||
request_line_consumed=True,
|
||||
server_error_future=server_error_future,
|
||||
)(reader, writer)
|
||||
except Exception as exc:
|
||||
# Route handler failures to the dedicated error future so they're not silently lost
|
||||
if not server_error_future.done():
|
||||
server_error_future.set_exception(exc)
|
||||
if not http_request_future.done():
|
||||
http_request_future.set_exception(exc)
|
||||
if not final_request_future.done():
|
||||
final_request_future.set_exception(exc)
|
||||
raise
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
return handler
|
||||
@@ -1,148 +0,0 @@
|
||||
"""Minimal plaintext native-api client over a raw socket.
|
||||
|
||||
Reads only when told to, so tests control when the TCP pipe backs up toward
|
||||
the device; payloads are skipped and only message types are counted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import Counter
|
||||
import socket
|
||||
from typing import Self
|
||||
|
||||
from aioesphomeapi import api_pb2
|
||||
import aioesphomeapi.core as api_core
|
||||
from google.protobuf import message
|
||||
|
||||
from .const import LOCALHOST
|
||||
|
||||
# Message type ids are protocol constants; derive them from aioesphomeapi so
|
||||
# they cannot drift from the client library in use.
|
||||
MESSAGE_TYPE_OF = {cls: num for num, cls in api_core.MESSAGE_TYPE_TO_PROTO.items()}
|
||||
|
||||
_READ_CHUNK = 4096
|
||||
|
||||
|
||||
def encode_varint(value: int) -> bytes:
|
||||
out = bytearray()
|
||||
while True:
|
||||
byte = value & 0x7F
|
||||
value >>= 7
|
||||
if value:
|
||||
out.append(byte | 0x80)
|
||||
else:
|
||||
out.append(byte)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def decode_varint(buf: bytearray, pos: int) -> tuple[int, int] | None:
|
||||
"""Decode one varint at pos; return (value, new_pos) or None if short."""
|
||||
value = shift = 0
|
||||
while pos < len(buf):
|
||||
byte = buf[pos]
|
||||
pos += 1
|
||||
value |= (byte & 0x7F) << shift
|
||||
if not byte & 0x80:
|
||||
return value, pos
|
||||
shift += 7
|
||||
return None
|
||||
|
||||
|
||||
def encode_frame(msg_type: int, payload: bytes) -> bytes:
|
||||
"""Encode one plaintext api frame: 0x00, payload length, message type."""
|
||||
return b"\x00" + encode_varint(len(payload)) + encode_varint(msg_type) + payload
|
||||
|
||||
|
||||
class FrameParser:
|
||||
"""Incremental parser for the plaintext api frame stream."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buf = bytearray()
|
||||
|
||||
def feed(self, data: bytes) -> list[int]:
|
||||
self._buf.extend(data)
|
||||
types: list[int] = []
|
||||
while (msg_type := self._try_parse()) is not None:
|
||||
types.append(msg_type)
|
||||
return types
|
||||
|
||||
def _try_parse(self) -> int | None:
|
||||
buf = self._buf
|
||||
if not buf:
|
||||
return None
|
||||
assert buf[0] == 0, f"expected plaintext frame, got indicator {buf[0]}"
|
||||
if (size_decoded := decode_varint(buf, 1)) is None:
|
||||
return None
|
||||
size, pos = size_decoded
|
||||
if (type_decoded := decode_varint(buf, pos)) is None:
|
||||
return None
|
||||
msg_type, pos = type_decoded
|
||||
if len(buf) - pos < size:
|
||||
return None
|
||||
del buf[: pos + size]
|
||||
return msg_type
|
||||
|
||||
|
||||
class RawApiClient:
|
||||
"""Plaintext api client whose reads happen only on request."""
|
||||
|
||||
def __init__(self, port: int, recv_buffer_size: int | None = None) -> None:
|
||||
self._port = port
|
||||
self._parser = FrameParser()
|
||||
self.bytes_received = 0
|
||||
self.frame_counts: Counter[int] = Counter()
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
if recv_buffer_size is not None:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, recv_buffer_size)
|
||||
# Kernels may round up (Linux doubles) but must not clamp below
|
||||
applied = sock.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF)
|
||||
assert applied >= recv_buffer_size, (
|
||||
f"SO_RCVBUF clamped to {applied}, requested {recv_buffer_size}"
|
||||
)
|
||||
sock.setblocking(False)
|
||||
except Exception:
|
||||
sock.close()
|
||||
raise
|
||||
self._sock = sock
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info: object) -> None:
|
||||
self.close()
|
||||
|
||||
async def connect(self, client_info: str = "raw-api-client") -> None:
|
||||
"""Connect and complete the Hello handshake (no auth step since 2026.1.0)."""
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.sock_connect(self._sock, (LOCALHOST, self._port))
|
||||
hello = api_pb2.HelloRequest()
|
||||
hello.client_info = client_info
|
||||
hello.api_version_major = 1
|
||||
hello.api_version_minor = 10
|
||||
await self.send_message(hello)
|
||||
await self.read_until_frame(MESSAGE_TYPE_OF[api_pb2.HelloResponse])
|
||||
|
||||
async def send_message(self, msg: message.Message) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.sock_sendall(
|
||||
self._sock,
|
||||
encode_frame(MESSAGE_TYPE_OF[type(msg)], msg.SerializeToString()),
|
||||
)
|
||||
|
||||
async def read_until_frame(self, msg_type: int, timeout: float = 10.0) -> None:
|
||||
"""Read until at least one frame of msg_type has been received."""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
async def _read_loop() -> None:
|
||||
while not self.frame_counts[msg_type]:
|
||||
data = await loop.sock_recv(self._sock, _READ_CHUNK)
|
||||
assert data, "server closed the connection unexpectedly"
|
||||
self.bytes_received += len(data)
|
||||
self.frame_counts.update(self._parser.feed(data))
|
||||
|
||||
await asyncio.wait_for(_read_loop(), timeout)
|
||||
|
||||
def close(self) -> None:
|
||||
self._sock.close()
|
||||
@@ -1,110 +0,0 @@
|
||||
"""A client that stops reading the entity listing must not starve other clients.
|
||||
|
||||
Service responses are sent directly (not via the deferred batch), so a full
|
||||
TCP pipe makes the send path refuse; the drive loop now lives in
|
||||
try_advance(), which stops on refusal instead of retrying forever. Not a
|
||||
before/after regression test: pre-fix builds survive here because the
|
||||
refusal path yields and pumps the socket each retry.
|
||||
|
||||
The sndbuf_pin_component fixture pins the device's send buffers so the pipe
|
||||
fills deterministically regardless of kernel autotuning; the test waits for
|
||||
its log line before proceeding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from aioesphomeapi import api_pb2
|
||||
import pytest
|
||||
|
||||
from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
SERVICES_RESPONSE = MESSAGE_TYPE_OF[api_pb2.ListEntitiesServicesResponse]
|
||||
LIST_DONE_RESPONSE = MESSAGE_TYPE_OF[api_pb2.ListEntitiesDoneResponse]
|
||||
|
||||
# Both ends of the pipe are pinned small; only tens of KB fit in the kernel
|
||||
RECV_BUFFER_SIZE = 4096
|
||||
SERVER_SNDBUF = 8192 # substituted into the fixture yaml
|
||||
# Logged by the sndbuf_pin_component fixture when it pins a socket
|
||||
SNDBUF_PIN_LOG = "SO_SNDBUF pinned to"
|
||||
# One response (~6.4 KB) must stay smaller than the pinned send buffer; an
|
||||
# oversized message parks in the overflow buffer and reports as sent.
|
||||
ARGS_PER_SERVICE = 8
|
||||
ARG_NAME_LEN = 800
|
||||
# ~160 KB listing versus a tens-of-KB pipe guarantees a mid-services block
|
||||
NUM_SERVICES = 25
|
||||
assert ARGS_PER_SERVICE * ARG_NAME_LEN < SERVER_SNDBUF
|
||||
# The pipe fills in well under a second
|
||||
STALL_SECONDS = 0.5
|
||||
# Well above pipe capacity, well below the listing size
|
||||
MIN_DRAINED_BYTES = 60_000
|
||||
|
||||
|
||||
def _generated_actions() -> str:
|
||||
"""Build the api actions block: services with long argument names."""
|
||||
lines: list[str] = []
|
||||
for i in range(NUM_SERVICES):
|
||||
lines.append(f" - action: backpressure_service_{i:04d}")
|
||||
lines.append(" variables:")
|
||||
for j in range(ARGS_PER_SERVICE):
|
||||
prefix = f"arg_{i:04d}_{j:02d}_"
|
||||
lines.append(
|
||||
f" {prefix}{'x' * (ARG_NAME_LEN - len(prefix))}: string"
|
||||
)
|
||||
lines.append(" then:")
|
||||
lines.append(" - logger.log: service called")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_list_entities_backpressure(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
unused_tcp_port: int,
|
||||
) -> None:
|
||||
"""A stalled reader mid-services must not block other api clients."""
|
||||
assert "# GENERATED_ACTIONS" in yaml_config
|
||||
config = yaml_config.replace("# GENERATED_ACTIONS", _generated_actions())
|
||||
config = config.replace("SERVER_SNDBUF", str(SERVER_SNDBUF))
|
||||
|
||||
pin_applied = asyncio.Event()
|
||||
|
||||
def _on_log_line(line: str) -> None:
|
||||
if SNDBUF_PIN_LOG in line:
|
||||
pin_applied.set()
|
||||
|
||||
async with run_compiled(config, line_callback=_on_log_line):
|
||||
# Fails loudly if the pin never applied
|
||||
await asyncio.wait_for(pin_applied.wait(), 10)
|
||||
|
||||
async with RawApiClient(
|
||||
unused_tcp_port, recv_buffer_size=RECV_BUFFER_SIZE
|
||||
) as stalled:
|
||||
await stalled.connect(client_info="backpressure-stall-client")
|
||||
await stalled.send_message(api_pb2.ListEntitiesRequest())
|
||||
# The client now stops reading entirely.
|
||||
|
||||
# Let the server run against the full pipe
|
||||
await asyncio.sleep(STALL_SECONDS)
|
||||
|
||||
# Other clients must still be served while the first is blocked
|
||||
async with api_client_connected(timeout=20) as client:
|
||||
device_info = await asyncio.wait_for(client.device_info(), 20)
|
||||
assert device_info.name == "api-backpressure-test"
|
||||
_, services = await asyncio.wait_for(
|
||||
client.list_entities_services(), 30
|
||||
)
|
||||
assert len(services) == NUM_SERVICES
|
||||
|
||||
# Fixture-size guard: the listing must dwarf the pinned pipe
|
||||
before = stalled.bytes_received
|
||||
await stalled.read_until_frame(LIST_DONE_RESPONSE, timeout=60)
|
||||
drained = stalled.bytes_received - before
|
||||
assert drained > MIN_DRAINED_BYTES, (
|
||||
f"only {drained} bytes drained; the listing never backed up"
|
||||
)
|
||||
assert stalled.frame_counts[SERVICES_RESPONSE] == NUM_SERVICES
|
||||
assert stalled.frame_counts[LIST_DONE_RESPONSE] == 1
|
||||
@@ -1,71 +0,0 @@
|
||||
"""Test that online_image AUTO format detection reads the Content-Type header."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from .online_image_utils import (
|
||||
LEN_BMP_IMAGE,
|
||||
handle_http,
|
||||
make_download_watcher,
|
||||
wait_for_download,
|
||||
)
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_online_image_auto_detects_image_bmp_mime(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""AUTO format detection should honor the final response MIME type without explicit format."""
|
||||
loop = asyncio.get_running_loop()
|
||||
http_request_future = loop.create_future()
|
||||
server_error_future = loop.create_future()
|
||||
download_finished_future = loop.create_future()
|
||||
downloaded_bytes_future = loop.create_future()
|
||||
|
||||
check_output = make_download_watcher(
|
||||
downloaded_bytes_future, download_finished_future
|
||||
)
|
||||
|
||||
server = await asyncio.start_server(
|
||||
handle_http(
|
||||
http_request_future,
|
||||
"image/bmp",
|
||||
server_error_future=server_error_future,
|
||||
),
|
||||
"127.0.0.1",
|
||||
0,
|
||||
)
|
||||
http_server_port = server.sockets[0].getsockname()[1]
|
||||
|
||||
config = yaml_config.replace("HTTP_PORT", str(http_server_port))
|
||||
|
||||
async with (
|
||||
server,
|
||||
run_compiled(config, line_callback=check_output),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
device_info = await client.device_info()
|
||||
assert device_info is not None
|
||||
assert device_info.name == "online-image-bmp"
|
||||
|
||||
_, services = await client.list_entities_services()
|
||||
request_service = next((s for s in services if s.name == "fetch_image"), None)
|
||||
assert request_service is not None
|
||||
|
||||
await client.execute_service(request_service, {})
|
||||
|
||||
async with asyncio.timeout(0.1):
|
||||
await http_request_future
|
||||
|
||||
async with asyncio.timeout(0.5):
|
||||
numbytes = await wait_for_download(
|
||||
downloaded_bytes_future, server_error_future
|
||||
)
|
||||
assert numbytes == LEN_BMP_IMAGE
|
||||
await download_finished_future
|
||||
@@ -1,71 +0,0 @@
|
||||
"""Test that AUTO format detection uses the final Content-Type after redirects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from .online_image_utils import (
|
||||
LEN_BMP_IMAGE,
|
||||
handle_http_redirect,
|
||||
make_download_watcher,
|
||||
wait_for_download,
|
||||
)
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_online_image_auto_detects_redirected_image_bmp_mime(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Redirect hops should not leave the 302 HTML Content-Type in place for the final image."""
|
||||
loop = asyncio.get_running_loop()
|
||||
http_request_future = loop.create_future()
|
||||
final_request_future = loop.create_future()
|
||||
server_error_future = loop.create_future()
|
||||
download_finished_future = loop.create_future()
|
||||
downloaded_bytes_future = loop.create_future()
|
||||
|
||||
check_output = make_download_watcher(
|
||||
downloaded_bytes_future, download_finished_future
|
||||
)
|
||||
|
||||
port_holder = {}
|
||||
server = await asyncio.start_server(
|
||||
handle_http_redirect(
|
||||
http_request_future, final_request_future, server_error_future, port_holder
|
||||
),
|
||||
"127.0.0.1",
|
||||
0,
|
||||
)
|
||||
port_holder["port"] = server.sockets[0].getsockname()[1]
|
||||
|
||||
config = yaml_config.replace("HTTP_PORT", str(port_holder["port"]))
|
||||
|
||||
async with (
|
||||
server,
|
||||
run_compiled(config, line_callback=check_output),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
device_info = await client.device_info()
|
||||
assert device_info is not None
|
||||
assert device_info.name == "online-image-bmp"
|
||||
|
||||
_, services = await client.list_entities_services()
|
||||
request_service = next((s for s in services if s.name == "fetch_image"), None)
|
||||
assert request_service is not None
|
||||
|
||||
await client.execute_service(request_service, {})
|
||||
|
||||
async with asyncio.timeout(0.1):
|
||||
await http_request_future
|
||||
async with asyncio.timeout(0.5):
|
||||
await final_request_future
|
||||
numbytes = await wait_for_download(
|
||||
downloaded_bytes_future, server_error_future
|
||||
)
|
||||
assert numbytes == LEN_BMP_IMAGE
|
||||
await download_finished_future
|
||||
@@ -1,12 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from .online_image_utils import LEN_BMP_IMAGE, handle_http, make_download_watcher
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
# black 8x8 RGB BMP, generated with
|
||||
# from PIL import Image
|
||||
# from io import BytesIO
|
||||
# b = BytesIO()
|
||||
# img = Image.new("RGB", (8, 8))
|
||||
# img.save(b, format="BMP")
|
||||
# b.getvalue()
|
||||
BMP_IMAGE = b"BM\xf6\x00\x00\x00\x00\x00\x00\x006\x00\x00\x00(\x00\x00\x00\x08\x00\x00\x00\x08\x00\x00\x00\x01\x00\x18\x00\x00\x00\x00\x00\xc0\x00\x00\x00\xc4\x0e\x00\x00\xc4\x0e\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
|
||||
LEN_BMP_IMAGE = len(BMP_IMAGE)
|
||||
|
||||
|
||||
def handle_http(http_request_future):
|
||||
async def handler(reader, writer):
|
||||
try:
|
||||
async with asyncio.timeout(1.0):
|
||||
data = await reader.readuntil(b"\r\n")
|
||||
|
||||
# ensure our request matches the expectation
|
||||
expected_request = b"GET /foo.bmp HTTP/1.1\r\n"
|
||||
assert data[: len(expected_request)] == expected_request
|
||||
|
||||
# consume rest of request
|
||||
async with asyncio.timeout(1.0):
|
||||
data = await reader.readuntil(b"\r\n\r\n")
|
||||
|
||||
http_request_future.set_result(True)
|
||||
|
||||
http_response = [
|
||||
b"HTTP/1.1 200 OK",
|
||||
b"Content-Length: %d" % LEN_BMP_IMAGE,
|
||||
b"Content-Type: text/plain",
|
||||
b"Connection: close",
|
||||
b"",
|
||||
b"",
|
||||
]
|
||||
writer.write(b"\r\n".join(http_response))
|
||||
await writer.drain()
|
||||
|
||||
writer.write(BMP_IMAGE)
|
||||
|
||||
await writer.drain()
|
||||
except Exception as exc:
|
||||
if not http_request_future.done():
|
||||
http_request_future.set_exception(exc)
|
||||
raise
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_online_image_bmp(
|
||||
@@ -22,9 +72,14 @@ async def test_online_image_bmp(
|
||||
download_finished_future = loop.create_future()
|
||||
downloaded_bytes_future = loop.create_future()
|
||||
|
||||
check_output = make_download_watcher(
|
||||
downloaded_bytes_future, download_finished_future
|
||||
)
|
||||
def check_output(line: str) -> None:
|
||||
"""Check log output for expected messages."""
|
||||
|
||||
if match := re.search(r"Image fully downloaded, (\d+) bytes", line):
|
||||
downloaded_bytes_future.set_result(int(match.group(1)))
|
||||
|
||||
if "download finished" in line:
|
||||
download_finished_future.set_result(True)
|
||||
|
||||
server = await asyncio.start_server(
|
||||
handle_http(http_request_future), "127.0.0.1", 0
|
||||
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
@@ -81,15 +81,6 @@ def mock_download_content_many() -> MagicMock:
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_retry_sleep() -> MagicMock:
|
||||
"""Patch the retry backoff sleep (process-wide; net_retry.time is the
|
||||
global module) so transient-error tests don't really wait 2s/4s.
|
||||
"""
|
||||
with patch("esphome.net_retry.time.sleep") as m:
|
||||
yield m
|
||||
|
||||
|
||||
def test_compute_local_file_dir(setup_core: Path) -> None:
|
||||
"""Test compute_local_file_dir creates and returns correct path."""
|
||||
domain = "font"
|
||||
@@ -504,7 +495,6 @@ class _BodyReadErrorResponse:
|
||||
def test_download_content_with_body_read_error_uses_cache(
|
||||
mock_has_remote_file_changed: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
mock_retry_sleep: MagicMock,
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""Body-read errors (chunked-decode/gzip-decode/mid-stream connection
|
||||
@@ -529,7 +519,6 @@ def test_download_content_with_body_read_error_uses_cache(
|
||||
def test_download_content_with_body_read_error_no_cache_fails(
|
||||
mock_has_remote_file_changed: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
mock_retry_sleep: MagicMock,
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""A body-read failure with no cache available must surface as a
|
||||
@@ -546,131 +535,6 @@ def test_download_content_with_body_read_error_no_cache_fails(
|
||||
external_files.download_content("https://example.com/file.txt", test_file)
|
||||
|
||||
|
||||
def test_download_content_retries_transient_error_then_succeeds(
|
||||
mock_has_remote_file_changed: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
mock_retry_sleep: MagicMock,
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""Transient failures (connection reset, timeout) are retried with 2s/4s
|
||||
backoff before giving up; a late success downloads normally."""
|
||||
test_file = setup_core / "downloads" / "file.txt"
|
||||
mock_has_remote_file_changed.return_value = True
|
||||
|
||||
ok = MagicMock()
|
||||
ok.content = b"downloaded"
|
||||
ok.headers = {}
|
||||
mock_requests_get.side_effect = [
|
||||
requests.exceptions.ConnectionError("reset by peer"),
|
||||
requests.exceptions.Timeout("timed out"),
|
||||
ok,
|
||||
]
|
||||
|
||||
result = external_files.download_content("https://example.com/file.txt", test_file)
|
||||
|
||||
assert result == b"downloaded"
|
||||
assert test_file.read_bytes() == b"downloaded"
|
||||
assert mock_retry_sleep.call_args_list == [call(2), call(4)]
|
||||
|
||||
|
||||
def test_download_content_transient_error_exhausts_attempts(
|
||||
mock_has_remote_file_changed: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
mock_retry_sleep: MagicMock,
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""A persistent transient failure gives up after three attempts and then
|
||||
follows the normal no-cache error path."""
|
||||
test_file = setup_core / "nonexistent.txt"
|
||||
mock_has_remote_file_changed.return_value = True
|
||||
mock_requests_get.side_effect = requests.exceptions.ConnectionError("reset by peer")
|
||||
|
||||
with pytest.raises(Invalid, match="Could not download from.*reset by peer"):
|
||||
external_files.download_content("https://example.com/file.txt", test_file)
|
||||
|
||||
assert mock_retry_sleep.call_args_list == [call(2), call(4)]
|
||||
|
||||
|
||||
def test_download_content_non_transient_error_not_retried(
|
||||
mock_has_remote_file_changed: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
mock_retry_sleep: MagicMock,
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""Permanent failures like a 404 fail on the first attempt."""
|
||||
test_file = setup_core / "nonexistent.txt"
|
||||
mock_has_remote_file_changed.return_value = True
|
||||
|
||||
response = MagicMock()
|
||||
response.status_code = 404
|
||||
mock_requests_get.side_effect = requests.exceptions.HTTPError(
|
||||
"404 Client Error", response=response
|
||||
)
|
||||
|
||||
with pytest.raises(Invalid, match="Could not download from.*404"):
|
||||
external_files.download_content("https://example.com/file.txt", test_file)
|
||||
|
||||
assert mock_requests_get.call_count == 1
|
||||
mock_retry_sleep.assert_not_called()
|
||||
|
||||
|
||||
def test_download_content_retries_body_read_error(
|
||||
mock_has_remote_file_changed: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
mock_retry_sleep: MagicMock,
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""Mid-stream failures surfacing from `.content` are retried too."""
|
||||
test_file = setup_core / "downloads" / "file.txt"
|
||||
mock_has_remote_file_changed.return_value = True
|
||||
|
||||
ok = MagicMock()
|
||||
ok.content = b"downloaded"
|
||||
ok.headers = {}
|
||||
mock_requests_get.side_effect = [
|
||||
_BodyReadErrorResponse(
|
||||
requests.exceptions.ChunkedEncodingError("body truncated")
|
||||
),
|
||||
ok,
|
||||
]
|
||||
|
||||
result = external_files.download_content("https://example.com/file.txt", test_file)
|
||||
|
||||
assert result == b"downloaded"
|
||||
assert mock_requests_get.call_count == 2
|
||||
assert mock_retry_sleep.call_args_list == [call(2)]
|
||||
|
||||
|
||||
def test_has_remote_file_changed_retries_transient_error(
|
||||
mock_requests_head: MagicMock,
|
||||
mock_retry_sleep: MagicMock,
|
||||
setup_core: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A HEAD revalidation that fails transiently then returns 304 does not
|
||||
mark the cached copy stale, and the retry warning names the operation."""
|
||||
test_file = setup_core / "cached.txt"
|
||||
test_file.write_bytes(b"cached content")
|
||||
|
||||
ok = MagicMock()
|
||||
ok.status_code = 304
|
||||
ok.headers = {}
|
||||
mock_requests_head.side_effect = [
|
||||
requests.exceptions.ConnectionError("reset by peer"),
|
||||
ok,
|
||||
]
|
||||
|
||||
changed = external_files.has_remote_file_changed(
|
||||
"https://example.com/file.txt", test_file
|
||||
)
|
||||
|
||||
assert changed is False
|
||||
assert test_file not in external_files._run_data().stale_paths
|
||||
assert mock_requests_head.call_count == 2
|
||||
assert mock_retry_sleep.call_args_list == [call(2)]
|
||||
assert "Revalidation of" in caplog.text
|
||||
|
||||
|
||||
def test_download_content_skip_external_update_uses_cache(
|
||||
mock_has_remote_file_changed: MagicMock,
|
||||
mock_requests_get: MagicMock,
|
||||
|
||||
@@ -23,6 +23,7 @@ from esphome.core import EsphomeError
|
||||
from esphome.framework_helpers import (
|
||||
_7z_extract_all,
|
||||
_detect_archive_root,
|
||||
_is_transient_download_error,
|
||||
_rename_with_retry,
|
||||
_tar_extract_all,
|
||||
_zip_extract_all,
|
||||
@@ -1593,6 +1594,43 @@ class TestDownloadFromMirrors:
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
|
||||
def _http_error(status: int) -> req.HTTPError:
|
||||
"""An HTTPError carrying a response with the given status, as raised by
|
||||
``raise_for_status`` on a real response."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = status
|
||||
return req.HTTPError(str(status), response=resp)
|
||||
|
||||
|
||||
class TestIsTransientDownloadError:
|
||||
def test_connection_errors_are_transient(self) -> None:
|
||||
assert _is_transient_download_error(req.ConnectionError("reset"))
|
||||
assert _is_transient_download_error(req.Timeout("timed out"))
|
||||
assert _is_transient_download_error(
|
||||
req.exceptions.ChunkedEncodingError("dropped")
|
||||
)
|
||||
|
||||
def test_http_statuses(self) -> None:
|
||||
assert not _is_transient_download_error(_http_error(404))
|
||||
assert not _is_transient_download_error(_http_error(403))
|
||||
assert _is_transient_download_error(_http_error(429))
|
||||
assert _is_transient_download_error(_http_error(503))
|
||||
|
||||
def test_http_error_without_response_is_permanent(self) -> None:
|
||||
assert not _is_transient_download_error(req.HTTPError("boom"))
|
||||
|
||||
def test_exhausted_resume_attempts_are_permanent(self) -> None:
|
||||
"""download_with_resume already spent its own resume attempts; its
|
||||
EsphomeError wrapper is not retried again at the sweep level."""
|
||||
wrapped = EsphomeError("Failed to download after 3 attempts")
|
||||
wrapped.__cause__ = req.ConnectionError("down")
|
||||
assert not _is_transient_download_error(wrapped)
|
||||
|
||||
def test_unrelated_errors_are_permanent(self) -> None:
|
||||
assert not _is_transient_download_error(OSError("disk full"))
|
||||
assert not _is_transient_download_error(EsphomeError("size mismatch"))
|
||||
|
||||
|
||||
def test_importing_framework_helpers_does_not_import_requests() -> None:
|
||||
"""Importing framework_helpers must not drag in requests.
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from collections.abc import Generator
|
||||
import errno
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
@@ -177,34 +178,37 @@ def _run_probe_on_pty(
|
||||
output = b""
|
||||
deadline = time.monotonic() + 60
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
_probe_command(fixture_path),
|
||||
stdout=follower,
|
||||
stderr=follower if stderr_to_pty else subprocess.PIPE,
|
||||
stdin=follower,
|
||||
env=probe_env,
|
||||
)
|
||||
# The parent keeps the follower open until the child has exited and
|
||||
# the controller is drained: macOS discards buffered pty output once
|
||||
# the last follower closes, so closing it early loses the probe's
|
||||
# output whenever the child finishes before the first read.
|
||||
while proc.poll() is None:
|
||||
if time.monotonic() > deadline:
|
||||
pytest.fail(f"pty probe did not exit in time; got {output!r}")
|
||||
if select.select([controller], [], [], 0.01)[0]:
|
||||
output += os.read(controller, 4096)
|
||||
# Everything the child wrote is already buffered, so drain without waiting.
|
||||
while select.select([controller], [], [], 0)[0] and (
|
||||
chunk := os.read(controller, 4096)
|
||||
):
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
_probe_command(fixture_path),
|
||||
stdout=follower,
|
||||
stderr=follower if stderr_to_pty else subprocess.PIPE,
|
||||
stdin=follower,
|
||||
env=probe_env,
|
||||
)
|
||||
finally:
|
||||
os.close(follower)
|
||||
while True:
|
||||
timeout = deadline - time.monotonic()
|
||||
if timeout <= 0 or not select.select([controller], [], [], timeout)[0]:
|
||||
pytest.fail(f"pty probe produced no EOF in time; got {output!r}")
|
||||
try:
|
||||
chunk = os.read(controller, 1024)
|
||||
except OSError as err:
|
||||
# macOS raises EIO once the child closes its end of the pty;
|
||||
# anything else is a real failure, not end-of-stream.
|
||||
if err.errno != errno.EIO:
|
||||
raise
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
output += chunk
|
||||
stderr_text = ""
|
||||
if proc.stderr is not None:
|
||||
stderr_text = proc.stderr.read().decode(errors="replace")
|
||||
proc.stderr.close()
|
||||
assert proc.returncode == 0, stderr_text
|
||||
assert proc.wait(60) == 0, stderr_text
|
||||
finally:
|
||||
os.close(follower)
|
||||
os.close(controller)
|
||||
if proc is not None and proc.poll() is None:
|
||||
proc.kill()
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
"""Tests for esphome.net_retry."""
|
||||
|
||||
import socket
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
import requests as req
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.net_retry import fetch_with_retry, is_transient_download_error
|
||||
|
||||
|
||||
def _http_error(status: int) -> req.HTTPError:
|
||||
"""An HTTPError carrying a response with the given status, as raised by
|
||||
``raise_for_status`` on a real response."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = status
|
||||
return req.HTTPError(str(status), response=resp)
|
||||
|
||||
|
||||
class TestIsTransientDownloadError:
|
||||
def test_connection_errors_are_transient(self) -> None:
|
||||
assert is_transient_download_error(req.ConnectionError("reset"))
|
||||
assert is_transient_download_error(req.Timeout("timed out"))
|
||||
assert is_transient_download_error(
|
||||
req.exceptions.ChunkedEncodingError("dropped")
|
||||
)
|
||||
assert is_transient_download_error(
|
||||
req.exceptions.ContentDecodingError("gzip stream truncated")
|
||||
)
|
||||
|
||||
def test_http_statuses(self) -> None:
|
||||
assert not is_transient_download_error(_http_error(404))
|
||||
assert not is_transient_download_error(_http_error(403))
|
||||
assert is_transient_download_error(_http_error(429))
|
||||
assert is_transient_download_error(_http_error(503))
|
||||
|
||||
def test_http_error_without_response_is_permanent(self) -> None:
|
||||
assert not is_transient_download_error(req.HTTPError("boom"))
|
||||
|
||||
def test_hard_dns_failures_are_permanent(self) -> None:
|
||||
"""Hard resolution failures are permanent via both the cause chain
|
||||
and MaxRetryError.reason."""
|
||||
from urllib3.exceptions import MaxRetryError, NameResolutionError
|
||||
|
||||
gai = socket.gaierror(socket.EAI_NONAME, "nodename nor servname provided")
|
||||
|
||||
chained = req.ConnectionError("resolution failed")
|
||||
chained.__cause__ = gai
|
||||
assert not is_transient_download_error(chained)
|
||||
|
||||
# The real urllib3 shape: gaierror on NameResolutionError.__cause__,
|
||||
# carried by MaxRetryError.reason.
|
||||
try:
|
||||
raise NameResolutionError("example.invalid", None, gai) from gai
|
||||
except NameResolutionError as nre:
|
||||
wrapped = req.ConnectionError(
|
||||
MaxRetryError(None, "http://example.invalid/", reason=nre)
|
||||
)
|
||||
assert not is_transient_download_error(wrapped)
|
||||
|
||||
# A garden-variety connection reset stays transient.
|
||||
assert is_transient_download_error(req.ConnectionError("reset by peer"))
|
||||
|
||||
def test_temporary_dns_failure_stays_transient(self) -> None:
|
||||
"""EAI_AGAIN (flaky resolver) stays retryable."""
|
||||
gai = socket.gaierror(socket.EAI_AGAIN, "temporary failure in name resolution")
|
||||
chained = req.ConnectionError("resolution failed")
|
||||
chained.__cause__ = gai
|
||||
|
||||
assert is_transient_download_error(chained)
|
||||
|
||||
def test_implicit_context_does_not_reclassify(self) -> None:
|
||||
"""A gaierror riding along as implicit __context__ must not turn a
|
||||
genuine connection reset permanent."""
|
||||
try:
|
||||
try:
|
||||
raise socket.gaierror(socket.EAI_NONAME, "first attempt")
|
||||
except socket.gaierror:
|
||||
raise req.ConnectionError("reset by peer") from None
|
||||
except req.ConnectionError as reset:
|
||||
assert reset.__context__ is not None
|
||||
assert is_transient_download_error(reset)
|
||||
|
||||
def test_gaierror_without_errno_stays_transient(self) -> None:
|
||||
"""A gaierror carrying no EAI code cannot prove a hard failure."""
|
||||
chained = req.ConnectionError("resolution failed")
|
||||
chained.__cause__ = socket.gaierror("no errno")
|
||||
|
||||
assert is_transient_download_error(chained)
|
||||
|
||||
def test_mixed_chain_hard_failure_wins(self) -> None:
|
||||
"""EAI_AGAIN in the chain does not mask a hard failure elsewhere."""
|
||||
again = socket.gaierror(socket.EAI_AGAIN, "temporary failure")
|
||||
hard = socket.gaierror(socket.EAI_NONAME, "unknown host")
|
||||
|
||||
outer = req.ConnectionError(hard)
|
||||
outer.__cause__ = again
|
||||
assert not is_transient_download_error(outer)
|
||||
|
||||
outer = req.ConnectionError(again)
|
||||
outer.__cause__ = hard
|
||||
assert not is_transient_download_error(outer)
|
||||
|
||||
def test_dns_walk_survives_exception_cycles(self) -> None:
|
||||
"""A cyclic cause chain must terminate (and stay transient when no
|
||||
resolution failure is present)."""
|
||||
outer = req.ConnectionError("a")
|
||||
inner = ValueError("b")
|
||||
outer.__cause__ = inner
|
||||
inner.__cause__ = outer
|
||||
|
||||
assert is_transient_download_error(outer)
|
||||
|
||||
def test_exhausted_resume_attempts_are_permanent(self) -> None:
|
||||
"""download_with_resume already spent its own resume attempts; its
|
||||
EsphomeError wrapper is not retried again at the sweep level."""
|
||||
wrapped = EsphomeError("Failed to download after 3 attempts")
|
||||
wrapped.__cause__ = req.ConnectionError("down")
|
||||
assert not is_transient_download_error(wrapped)
|
||||
|
||||
def test_unrelated_errors_are_permanent(self) -> None:
|
||||
assert not is_transient_download_error(OSError("disk full"))
|
||||
assert not is_transient_download_error(EsphomeError("size mismatch"))
|
||||
|
||||
|
||||
class TestFetchWithRetry:
|
||||
def test_logs_the_upcoming_attempt_number(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The warning names the attempt about to run, not the failed one."""
|
||||
with (
|
||||
patch("esphome.net_retry.time.sleep") as mock_sleep,
|
||||
pytest.raises(req.ConnectionError),
|
||||
):
|
||||
fetch_with_retry(
|
||||
"https://example.com/f",
|
||||
lambda: (_ for _ in ()).throw(req.ConnectionError("reset")),
|
||||
)
|
||||
|
||||
assert mock_sleep.call_args_list == [call(2), call(4)]
|
||||
assert "(attempt 2/3)" in caplog.text
|
||||
assert "(attempt 3/3)" in caplog.text
|
||||
Reference in New Issue
Block a user