Compare commits

..
Author SHA1 Message Date
J. Nick Koston 21e9957fa4 Add tests for encrypted OTA 2026-08-23 17:25:40 -05:00
J. Nick Koston d7b1656464 Support encrypted uploads in the OTA client 2026-08-23 17:25:40 -05:00
J. Nick Koston 51dc9b31f4 Add noise encryption to the esphome OTA platform 2026-08-23 17:25:40 -05:00
J. Nick Koston 5d0522b88c Add host unit tests for the noise component 2026-08-23 17:25:40 -05:00
J. Nick Koston f0f9a1c57a Refactor the api noise handshake onto the shared responder 2026-08-23 17:25:40 -05:00
J. Nick Koston 44f80efcca Share the noise wire constants and reject formatter 2026-08-23 17:25:40 -05:00
J. Nick Koston a9b8719ef9 Move encryption key validation into the noise component 2026-08-23 17:25:39 -05:00
J. Nick Koston c1ef59c4b6 Add shared noise component and move noise-c primitives out of api 2026-08-23 17:25:39 -05:00
J. Nick KostonandGitHub 98f138f6b2 [esp32_ble_tracker] Scan at the default window while a GATT connection is active (#18609) 2026-08-24 09:16:01 +12:00
J. Nick KostonandGitHub 9179fe26ce [api] Fix loop stall that triggers the task watchdog when entity list sends block (#18577) 2026-08-24 08:38:19 +12:00
J. Nick KostonandGitHub e6c49e2bd9 [core] Retry transient network errors when downloading external files (#18538) 2026-08-24 08:25:39 +12:00
guillempagesGitHubJ. Nick Kostonpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>J. Nick Koston
d84293931b [online_image] Support image format auto-detection (#16337)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-08-23 19:58:01 +00:00
Boris KrivonogandGitHub 2c32ac2221 [mitsubishi_cn105] Mark configurable classes as final (#18272) 2026-08-23 19:40:08 +00:00
dde6906f98 [modbus_client] Add continuous option to the read and send actions (#18542)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-23 13:01:34 -05:00
J. Nick KostonandGitHub 4efd308345 [tests] Fix flaky pty log probe test on macOS (#18681) 2026-08-23 11:26:55 -05:00
esphome[bot]andGitHub f741c274d5 Bump aioesphomeapi from 45.13.1 to 46.0.0 (#18683) 2026-08-23 15:55:42 +00:00
118 changed files with 5171 additions and 941 deletions
+1
View File
@@ -381,6 +381,7 @@ esphome/components/nextion/switch/* @senexcrenshaw
esphome/components/nextion/text_sensor/* @senexcrenshaw
esphome/components/nfc/* @jesserockz @kbx81
esphome/components/noblex/* @AGalfra
esphome/components/noise/* @esphome/core
esphome/components/npi19/* @bakerkj
esphome/components/nrf52/* @tomaszduda23
esphome/components/number/* @esphome/core
+36 -3
View File
@@ -23,7 +23,8 @@ For this repository there are two trusted inputs by design:
1. **The configuration.** Anyone who can supply or edit a YAML config is trusted
(see below).
2. **Authenticated peers of a running device** — clients holding the device's
API encryption key / password, OTA password, or web server credentials.
API/OTA encryption key, API password, OTA password, or web server
credentials.
The security boundary is therefore **unauthenticated network traffic vs. those
trusted inputs.** A bug that lets an unauthenticated attacker cross it is a
@@ -76,8 +77,8 @@ These *are* security bugs in this repo, and we want to hear about them privately
captive portal, etc.) **without** valid credentials.
- Authentication or encryption bypass on the device — reaching API calls, OTA
updates, or the web server without the configured key/password.
- Flaws that weaken the device's API encryption (Noise), OTA, or web server auth
below their documented guarantees.
- Flaws that weaken the device's API or OTA encryption (Noise), OTA auth, or
web server auth below their documented guarantees.
## The web server is an open HTTP API by design
@@ -121,6 +122,38 @@ and any memory-safety or protocol bug in the server reachable without credential
This section documents the current design and scope; it is not a judgment that the
design is optimal or that it will not change.
## OTA update encryption
The `esphome` OTA platform optionally encrypts updates with the same Noise
`NNpsk0` pattern the native API uses; one key protects the device. With an
`encryption:` block configured the guarantees are: the firmware image is
confidential in transit, the uploader is authenticated by the pre-shared key,
and the plaintext negotiation preceding the handshake is bound into the
handshake prologue, so stripping or tampering with it fails the first MAC.
Both ends fail closed with no override: a device built with a key refuses
plaintext uploads, and the CLI refuses to send plaintext when a key is
configured.
Defeating any of that without the key is in scope: a keyed device accepting a
plaintext or downgraded upload, getting past the MAC, or recovering image
contents from captured traffic.
The following are **not** vulnerabilities, by design:
- Plaintext OTA on a device with no `encryption:` block. That is the
documented default, authenticated (if at all) by the OTA password.
- The enablement window: turning encryption on takes one last upload of the
encryption-enabled firmware over the existing plaintext channel, with the
pre-existing plaintext exposure.
- The captive portal's update endpoint. `captive_portal:` auto-loads the web
OTA platform, and while the fallback AP is active its plaintext `/update`
endpoint is reachable on that AP; validation warns about the combination.
Combining `encryption:` with the always-on `web_server` component is a
config error instead.
- CLI retry behavior on transport or MAC failures; every attempt renegotiates
a fresh handshake with fresh ephemerals, so retrying does not weaken
authentication.
## Explicitly out of scope
- Local attackers who already have shell access on the host that runs `esphome`.
+26 -1
View File
@@ -26,7 +26,9 @@ from esphome.const import (
CONF_DEASSERT_RTS_DTR,
CONF_DISABLED,
CONF_DISCOVER_IP,
CONF_ENCRYPTION,
CONF_ESPHOME,
CONF_KEY,
CONF_LEVEL,
CONF_LOG,
CONF_LOG_TOPIC,
@@ -1323,6 +1325,17 @@ def _upload_via_native_api(
remote_port = int(ota_conf[CONF_PORT])
password = ota_conf.get(CONF_PASSWORD)
# Final validate resolved a bare `encryption:` block to the api key.
# Fail closed: if the block is present but no key was resolved, never
# fall back to a plaintext upload of an image that carries credentials.
noise_psk = None
if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
noise_psk = encryption_conf.get(CONF_KEY)
if not noise_psk:
raise EsphomeError(
"OTA encryption is configured but no key was resolved; "
"set the key under 'ota: encryption:' or 'api: encryption:'"
)
def check_partition_access(option_string: str) -> None:
if not ota_conf.get("allow_partition_access"):
@@ -1353,7 +1366,9 @@ def _upload_via_native_api(
if ota_type == espota2.OTA_TYPE_UPDATE_BOOTLOADER:
_validate_bootloader_binary(binary)
return espota2.run_ota(network_devices, remote_port, password, binary, ota_type)
return espota2.run_ota(
network_devices, remote_port, password, binary, ota_type, noise_psk
)
def _upload_via_web_server(
@@ -1362,6 +1377,16 @@ def _upload_via_web_server(
from esphome import web_server_ota
from esphome.web_server_helpers import get_web_server_connection
if any(
ota_item.get(CONF_PLATFORM) == CONF_ESPHOME
and ota_item.get(CONF_ENCRYPTION) is not None
for ota_item in config.get(CONF_OTA, [])
):
_LOGGER.warning(
"This config has OTA encryption, but the web_server OTA path sends "
"the image over plaintext HTTP; use the esphome OTA platform to "
"keep it confidential"
)
remote_port, username, password = get_web_server_connection(config)
return web_server_ota.run_ota(
network_devices, remote_port, username, password, binary
+22 -34
View File
@@ -1,4 +1,3 @@
import base64
import logging
from typing import Any
@@ -6,6 +5,15 @@ from esphome import automation
from esphome.automation import Condition
import esphome.codegen as cg
from esphome.components.logger import request_log_listener
# ENCRYPTION_SCHEMA and validate_encryption_key are re-exported for external
# components and downstream consumers that import them from api
from esphome.components.noise import ( # noqa: F401
ENCRYPTION_SCHEMA,
decode_encryption_key,
encryption_schema,
validate_encryption_key,
)
from esphome.config_helpers import get_logger_level
import esphome.config_validation as cv
from esphome.const import (
@@ -38,6 +46,10 @@ from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_pr
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigFragmentType, ConfigType
# Compat alias: downstream consumers (e.g. device-builder) referenced the
# schema by its old private name before it moved to the noise component
_encryption_schema = encryption_schema
_LOGGER = logging.getLogger(__name__)
DOMAIN = "api"
@@ -46,9 +58,15 @@ CODEOWNERS = ["@esphome/core"]
def AUTO_LOAD(config: ConfigType) -> list[str]:
"""Conditionally auto-load json only when capture_response is used."""
"""Conditionally auto-load noise (encryption) and json (capture_response)."""
base = ["socket"]
# A falsy config is a tooling probe for the maximal set (None from
# dependency resolution, {} from the components-graph platform probe);
# a validated config always carries defaults, never empty
if not config or CONF_ENCRYPTION in config:
base = base + ["noise"]
# Check if any homeassistant.action/homeassistant.service has capture_response: true
# This flag is set during config validation in _validate_response_config
if not config or CORE.data.get(DOMAIN, {}).get(CONF_CAPTURE_RESPONSE, False):
@@ -130,20 +148,6 @@ def _register_provisioning_source(config: ConfigType) -> ConfigType:
return config
def validate_encryption_key(value: Any) -> str:
value = cv.string_strict(value)
try:
decoded = base64.b64decode(value, validate=True)
except ValueError as err:
raise cv.Invalid("Invalid key format, please check it's using base64") from err
if len(decoded) != 32:
raise cv.Invalid("Encryption key must be base64 and 32 bytes long")
# Return original data for roundtrip conversion
return value
CONF_SUPPORTS_RESPONSE = "supports_response"
# Enum values in api::enums namespace
@@ -250,18 +254,6 @@ ACTIONS_SCHEMA = automation.validate_automation(
),
)
ENCRYPTION_SCHEMA = cv.Schema(
{
cv.Optional(CONF_KEY): cv.sensitive(validate_encryption_key),
}
)
def _encryption_schema(config: ConfigType | None) -> ConfigType:
if config is None:
config = {}
return ENCRYPTION_SCHEMA(config)
def _consume_api_sockets(config: ConfigType) -> ConfigType:
"""Register socket needs for API component."""
@@ -297,7 +289,7 @@ CONFIG_SCHEMA = cv.All(
CONF_SERVICES, group_of_exclusion=CONF_ACTIONS
): ACTIONS_SCHEMA,
cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA,
cv.Optional(CONF_ENCRYPTION): _encryption_schema,
cv.Optional(CONF_ENCRYPTION): encryption_schema,
cv.Optional(CONF_BATCH_DELAY, default="100ms"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(max=cv.TimePeriod(milliseconds=65535)),
@@ -484,7 +476,7 @@ async def to_code(config: ConfigType) -> None:
if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None:
if key := encryption_config.get(CONF_KEY):
decoded = base64.b64decode(key)
decoded = decode_encryption_key(key)
cg.add(var.set_noise_psk(list(decoded)))
cg.add_define("USE_API_NOISE_PSK_FROM_YAML")
else:
@@ -498,10 +490,6 @@ async def to_code(config: ConfigType) -> None:
# and plaintext disabled. Only a factory reset can remove it.
cg.add_define("USE_API_PLAINTEXT")
cg.add_define("USE_API_NOISE")
cg.add_library("esphome/noise-c", "0.1.21")
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
else:
cg.add_define("USE_API_PLAINTEXT")
+10 -10
View File
@@ -412,15 +412,15 @@ void APIConnection::finalize_iterator_sync_() {
}
void APIConnection::process_iterator_batch_(ComponentIterator &iterator) {
size_t initial_size = this->deferred_batch_.size();
size_t max_batch = MAX_INITIAL_PER_BATCH;
while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < max_batch) {
iterator.advance();
}
// Budget by remaining batch capacity so a pass cannot overfill the batch;
// stops early on a refused send and resumes next loop pass
size_t batch_size = this->deferred_batch_.size();
if (batch_size < MAX_INITIAL_BATCH_SIZE)
iterator.try_advance(MAX_INITIAL_BATCH_SIZE - batch_size);
// If the batch is full, process it immediately
// Note: iterator.advance() already calls schedule_batch_() via schedule_message_()
if (this->deferred_batch_.size() >= max_batch) {
// Flush immediately once enough is queued (not guaranteed every pass);
// partial batches go out via the batch timer or finalize_iterator_sync_()
if (this->deferred_batch_.size() >= MAX_INITIAL_BATCH_SIZE) {
this->process_batch_();
}
}
@@ -2130,7 +2130,7 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
}
#endif
psk_t psk{};
noise::psk_t psk{};
if (msg.key_len == 0) {
if (this->parent_->clear_noise_psk(true)) {
resp.success = true;
@@ -2139,7 +2139,7 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
}
} else if (base64_decode(msg.key, msg.key_len, psk.data(), psk.size()) != psk.size()) {
ESP_LOGW(TAG, "Invalid encryption key length");
} else if (APINoiseContext::is_all_zeros(psk)) {
} else if (noise::NoiseContext::is_all_zeros(psk)) {
// Accepting the reserved provisioning PSK would report success without
// enabling encryption (or silently clear an existing key)
ESP_LOGW(TAG, "Rejecting all-zero encryption key");
+4 -4
View File
@@ -53,11 +53,11 @@ void log_dropped_message(const char *tag, int line, const LogString *what);
// Keepalive timeout in milliseconds
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
// Maximum number of entities to process in a single batch during initial state/info sending
static constexpr size_t MAX_INITIAL_PER_BATCH = 34;
// Deferred batch size cap during initial state/info sync
static constexpr size_t MAX_INITIAL_BATCH_SIZE = 34;
// Verify MAX_MESSAGES_PER_BATCH (defined in api_frame_helper.h) can hold the initial batch
static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH,
"MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_PER_BATCH");
static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_BATCH_SIZE,
"MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_BATCH_SIZE");
#ifdef USE_BENCHMARK
class APIConnection;
+1 -1
View File
@@ -36,7 +36,7 @@ static constexpr uint16_t MAX_MESSAGE_SIZE = 32768; // 32 KiB for ESP32 and oth
static constexpr uint16_t RX_BUF_NULL_TERMINATOR = 1;
// Maximum number of messages to batch in a single write operation
// Must be >= MAX_INITIAL_PER_BATCH in api_connection.h (enforced by static_assert there)
// Must be >= MAX_INITIAL_BATCH_SIZE in api_connection.h (enforced by static_assert there)
static constexpr size_t MAX_MESSAGES_PER_BATCH = 34;
// Max client name length (e.g., "Home Assistant 2026.1.0.dev0" = 28 chars)
+55 -155
View File
@@ -2,9 +2,9 @@
#ifdef USE_API
#ifdef USE_API_NOISE
#include "api_connection.h" // For ClientInfo struct
#include "esphome/components/noise/noise.h"
#include "esphome/core/application.h"
#include "esphome/core/entity_base.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "proto.h"
@@ -17,6 +17,14 @@
namespace esphome::api {
using noise::noise_err_to_logstr;
// api_frame_helper.h keeps its own MAX_HANDSHAKE_SIZE because that header is
// also compiled in plaintext-only builds without the noise component; keep
// the two definitions from drifting apart.
static_assert(MAX_HANDSHAKE_SIZE == noise::MAX_HANDSHAKE_SIZE,
"api and noise component handshake size limits must match");
static const char *const TAG = "api.noise";
#ifdef USE_ESP8266
static constexpr char PROLOGUE_INIT[] PROGMEM = "NoiseAPIInit";
@@ -51,45 +59,6 @@ static constexpr size_t API_MAX_LOG_BYTES = 168;
#define LOG_PACKET_RECEIVED(buffer) ((void) 0)
#endif
/// Convert a noise error code to a readable error
const LogString *noise_err_to_logstr(int err) {
if (err == NOISE_ERROR_NO_MEMORY)
return LOG_STR("NO_MEMORY");
if (err == NOISE_ERROR_UNKNOWN_ID)
return LOG_STR("UNKNOWN_ID");
if (err == NOISE_ERROR_UNKNOWN_NAME)
return LOG_STR("UNKNOWN_NAME");
if (err == NOISE_ERROR_MAC_FAILURE)
return LOG_STR("MAC_FAILURE");
if (err == NOISE_ERROR_NOT_APPLICABLE)
return LOG_STR("NOT_APPLICABLE");
if (err == NOISE_ERROR_SYSTEM)
return LOG_STR("SYSTEM");
if (err == NOISE_ERROR_REMOTE_KEY_REQUIRED)
return LOG_STR("REMOTE_KEY_REQUIRED");
if (err == NOISE_ERROR_LOCAL_KEY_REQUIRED)
return LOG_STR("LOCAL_KEY_REQUIRED");
if (err == NOISE_ERROR_PSK_REQUIRED)
return LOG_STR("PSK_REQUIRED");
if (err == NOISE_ERROR_INVALID_LENGTH)
return LOG_STR("INVALID_LENGTH");
if (err == NOISE_ERROR_INVALID_PARAM)
return LOG_STR("INVALID_PARAM");
if (err == NOISE_ERROR_INVALID_STATE)
return LOG_STR("INVALID_STATE");
if (err == NOISE_ERROR_INVALID_NONCE)
return LOG_STR("INVALID_NONCE");
if (err == NOISE_ERROR_INVALID_PRIVATE_KEY)
return LOG_STR("INVALID_PRIVATE_KEY");
if (err == NOISE_ERROR_INVALID_PUBLIC_KEY)
return LOG_STR("INVALID_PUBLIC_KEY");
if (err == NOISE_ERROR_INVALID_FORMAT)
return LOG_STR("INVALID_FORMAT");
if (err == NOISE_ERROR_INVALID_SIGNATURE)
return LOG_STR("INVALID_SIGNATURE");
return LOG_STR("UNKNOWN");
}
/// Initialize the frame helper, returns OK if successful.
APIError APINoiseFrameHelper::init() {
APIError err = init_common_();
@@ -194,9 +163,9 @@ APIError APINoiseFrameHelper::loop() {
*/
APIError APINoiseFrameHelper::try_read_frame_() {
// read header
if (rx_header_buf_len_ < 3) {
if (rx_header_buf_len_ < noise::FRAME_HEADER_SIZE) {
// no header information yet
uint8_t to_read = 3 - rx_header_buf_len_;
uint8_t to_read = static_cast<uint8_t>(noise::FRAME_HEADER_SIZE) - rx_header_buf_len_;
ssize_t received = this->socket_->read(&rx_header_buf_[rx_header_buf_len_], to_read);
APIError err = handle_socket_read_result_(received);
if (err != APIError::OK) {
@@ -208,7 +177,7 @@ APIError APINoiseFrameHelper::try_read_frame_() {
return APIError::WOULD_BLOCK;
}
if (rx_header_buf_[0] != 0x01) {
if (rx_header_buf_[0] != noise::FRAME_INDICATOR) {
state_ = State::FAILED;
HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]);
return APIError::BAD_INDICATOR;
@@ -348,15 +317,15 @@ APIError APINoiseFrameHelper::state_action_server_hello_() {
return APIError::OK;
}
APIError APINoiseFrameHelper::state_action_handshake_() {
int action = noise_handshakestate_get_action(this->handshake_);
if (action == NOISE_ACTION_READ_MESSAGE) {
noise::NoiseResponderHandshake::Action action = this->handshake_.action();
if (action == noise::NoiseResponderHandshake::Action::ACTION_READ) {
return this->state_action_handshake_read_();
} else if (action == NOISE_ACTION_WRITE_MESSAGE) {
} else if (action == noise::NoiseResponderHandshake::Action::ACTION_WRITE) {
return this->state_action_handshake_write_();
}
// bad state for action
this->state_ = State::FAILED;
HELPER_LOG("Bad action for handshake: %d", action);
HELPER_LOG("Bad action for handshake: %d", (int) action);
return APIError::HANDSHAKESTATE_BAD_STATE;
}
APIError APINoiseFrameHelper::state_action_handshake_read_() {
@@ -368,20 +337,16 @@ APIError APINoiseFrameHelper::state_action_handshake_read_() {
if (this->rx_buf_.empty()) {
this->send_explicit_handshake_reject_(LOG_STR("Empty handshake message"));
return APIError::BAD_HANDSHAKE_ERROR_BYTE;
} else if (this->rx_buf_[0] != 0x00) {
} else if (this->rx_buf_[0] != noise::HANDSHAKE_STATUS_OK) {
HELPER_LOG("Bad handshake error byte: %u", this->rx_buf_[0]);
this->send_explicit_handshake_reject_(LOG_STR("Bad handshake error byte"));
return APIError::BAD_HANDSHAKE_ERROR_BYTE;
}
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_input(mbuf, this->rx_buf_.data() + 1, this->rx_buf_.size() - 1);
int err = noise_handshakestate_read_message(this->handshake_, &mbuf, nullptr);
int err = this->handshake_.read_message(this->rx_buf_.data() + 1, this->rx_buf_.size() - 1);
if (err != 0) {
// Special handling for MAC failure
this->send_explicit_handshake_reject_(err == NOISE_ERROR_MAC_FAILURE ? LOG_STR("Handshake MAC failure")
: LOG_STR("Handshake error"));
this->send_explicit_handshake_reject_(noise::reject_reason_for(err));
return this->handle_noise_error_(err, LOG_STR("noise_handshakestate_read_message"),
APIError::HANDSHAKESTATE_READ_FAILED);
}
@@ -390,18 +355,16 @@ APIError APINoiseFrameHelper::state_action_handshake_read_() {
}
APIError APINoiseFrameHelper::state_action_handshake_write_() {
uint8_t buffer[65];
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_output(mbuf, buffer + 1, sizeof(buffer) - 1);
size_t msg_len = 0;
int err = noise_handshakestate_write_message(this->handshake_, &mbuf, nullptr);
int err = this->handshake_.write_message(buffer + 1, sizeof(buffer) - 1, msg_len);
APIError aerr = this->handle_noise_error_(err, LOG_STR("noise_handshakestate_write_message"),
APIError::HANDSHAKESTATE_WRITE_FAILED);
if (aerr != APIError::OK)
return aerr;
buffer[0] = 0x00; // success
buffer[0] = noise::HANDSHAKE_STATUS_OK;
aerr = this->write_frame_(buffer, mbuf.size + 1);
aerr = this->write_frame_(buffer, msg_len + 1);
if (aerr != APIError::OK)
return aerr;
return this->check_handshake_finished_();
@@ -409,33 +372,22 @@ APIError APINoiseFrameHelper::state_action_handshake_write_() {
void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reason) {
// Max reject message: "Bad handshake packet len" (24) + 1 (failure byte) = 25 bytes
uint8_t data[32];
data[0] = 0x01; // failure
#ifdef USE_STORE_LOG_STR_IN_FLASH
// On ESP8266 with flash strings, we need to use PROGMEM-aware functions
size_t reason_len = strlen_P(reinterpret_cast<PGM_P>(reason));
reason_len = std::min(reason_len, sizeof(data) - 1);
if (reason_len > 0) {
memcpy_P(data + 1, reinterpret_cast<PGM_P>(reason), reason_len);
}
#else
// Normal memory access
const char *reason_str = LOG_STR_ARG(reason);
size_t reason_len = strlen(reason_str);
reason_len = std::min(reason_len, sizeof(data) - 1);
if (reason_len > 0) {
// NOLINTNEXTLINE(bugprone-not-null-terminated-result) - binary protocol, not a C string
std::memcpy(data + 1, reason_str, reason_len);
}
#endif
size_t data_size = reason_len + 1;
static_assert(sizeof(data) >= noise::MAC_FAILURE_PAYLOAD_SIZE,
"reject buffer must fit the MAC failure wire contract");
size_t data_size = noise::format_reject_payload(data, sizeof(data), reason);
// temporarily remove failed state
auto orig_state = state_;
state_ = State::EXPLICIT_REJECT;
write_frame_(data, data_size);
state_ = orig_state;
APIError aerr = write_frame_(data, data_size);
if (aerr != APIError::OK) {
// Best effort; the reject reason is a diagnosis aid, not a protocol step
ESP_LOGW(TAG, "Sending handshake reject failed: %d", (int) aerr);
}
if (state_ == State::EXPLICIT_REJECT) {
// write_frame_ may have moved the state to FAILED; keep that decision
state_ = orig_state;
}
}
APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) {
APIError aerr = this->check_data_state_();
@@ -492,12 +444,10 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) {
// Returns APIError::OK on success.
APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type,
uint16_t &encrypted_len_out) {
// Write noise header
buf_start[0] = 0x01; // indicator
// buf_start[1], buf_start[2] to be set after encryption
// The noise frame header is written after encryption, when the size is known
// Write message header (to be encrypted)
constexpr uint8_t msg_offset = 3;
constexpr uint8_t msg_offset = noise::FRAME_HEADER_SIZE;
buf_start[msg_offset] = static_cast<uint8_t>(message_type >> 8); // type high byte
buf_start[msg_offset + 1] = static_cast<uint8_t>(message_type); // type low byte
buf_start[msg_offset + 2] = static_cast<uint8_t>(payload_size >> 8); // data_len high byte
@@ -515,11 +465,10 @@ APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_
if (aerr != APIError::OK)
return aerr;
// Fill in the encrypted size
buf_start[1] = static_cast<uint8_t>(mbuf.size >> 8);
buf_start[2] = static_cast<uint8_t>(mbuf.size);
// Fill in the frame header now that the encrypted size is known
noise::write_frame_header(buf_start, static_cast<uint16_t>(mbuf.size));
encrypted_len_out = static_cast<uint16_t>(3 + mbuf.size); // indicator + size + encrypted data
encrypted_len_out = static_cast<uint16_t>(noise::FRAME_HEADER_SIZE + mbuf.size);
return APIError::OK;
}
@@ -568,21 +517,19 @@ APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, s
}
APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
uint8_t header[3];
header[0] = 0x01; // indicator
header[1] = (uint8_t) (len >> 8);
header[2] = (uint8_t) len;
uint8_t header[noise::FRAME_HEADER_SIZE];
noise::write_frame_header(header, len);
if (len == 0) {
return this->write_raw_buf_(header, 3);
return this->write_raw_buf_(header, noise::FRAME_HEADER_SIZE);
}
struct iovec iov[2];
iov[0].iov_base = header;
iov[0].iov_len = 3;
iov[0].iov_len = noise::FRAME_HEADER_SIZE;
iov[1].iov_base = const_cast<uint8_t *>(data);
iov[1].iov_len = len;
return this->write_raw_iov_(iov, 2, 3 + len);
return this->write_raw_iov_(iov, 2, noise::FRAME_HEADER_SIZE + len);
}
/** Initiate the data structures for the handshake.
@@ -590,45 +537,12 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
* @return 0 on success, -1 on error (check errno)
*/
APIError APINoiseFrameHelper::init_handshake_() {
int err;
// Noise_NNpsk0_25519_ChaChaPoly_SHA256, built on the stack:
// noise_handshakestate_new_by_id copies it, so a member would waste
// 104 bytes per connection, and a static const would sit in RAM on
// ESP8266 (.rodata is DRAM there).
const NoiseProtocolId nid = {
.prefix_id = NOISE_PREFIX_STANDARD,
.pattern_id = NOISE_PATTERN_NN,
.modifier_ids = {NOISE_MODIFIER_PSK0},
.dh_id = NOISE_DH_CURVE25519,
.cipher_id = NOISE_CIPHER_CHACHAPOLY,
.hash_id = NOISE_HASH_SHA256,
.hybrid_id = NOISE_DH_NONE,
};
err = noise_handshakestate_new_by_id(&handshake_, &nid, NOISE_ROLE_RESPONDER);
APIError aerr =
handle_noise_error_(err, LOG_STR("noise_handshakestate_new_by_id"), APIError::HANDSHAKESTATE_SETUP_FAILED);
int err = this->handshake_.init(this->ctx_.get_psk(), prologue_.data(), prologue_.size());
APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshake_init"), APIError::HANDSHAKESTATE_SETUP_FAILED);
if (aerr != APIError::OK)
return aerr;
const auto &psk = this->ctx_.get_psk();
err = noise_handshakestate_set_pre_shared_key(handshake_, psk.data(), psk.size());
aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_set_pre_shared_key"),
APIError::HANDSHAKESTATE_SETUP_FAILED);
if (aerr != APIError::OK)
return aerr;
err = noise_handshakestate_set_prologue(handshake_, prologue_.data(), prologue_.size());
aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_set_prologue"), APIError::HANDSHAKESTATE_SETUP_FAILED);
if (aerr != APIError::OK)
return aerr;
// set_prologue copies it into handshakestate, so we can get rid of it now
// init copies the prologue into the handshakestate, so we can get rid of it now
prologue_.release();
err = noise_handshakestate_start(handshake_);
aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_start"), APIError::HANDSHAKESTATE_SETUP_FAILED);
if (aerr != APIError::OK)
return aerr;
return APIError::OK;
}
@@ -637,15 +551,17 @@ APIError APINoiseFrameHelper::check_handshake_finished_() {
assert(state_ == State::HANDSHAKE);
#endif
int action = noise_handshakestate_get_action(handshake_);
if (action == NOISE_ACTION_READ_MESSAGE || action == NOISE_ACTION_WRITE_MESSAGE)
noise::NoiseResponderHandshake::Action action = this->handshake_.action();
if (action == noise::NoiseResponderHandshake::Action::ACTION_READ ||
action == noise::NoiseResponderHandshake::Action::ACTION_WRITE)
return APIError::OK;
if (action != NOISE_ACTION_SPLIT) {
if (action != noise::NoiseResponderHandshake::Action::ACTION_SPLIT) {
state_ = State::FAILED;
HELPER_LOG("Bad action for handshake: %d", action);
HELPER_LOG("Bad action for handshake: %d", (int) action);
return APIError::HANDSHAKESTATE_BAD_STATE;
}
int err = noise_handshakestate_split(handshake_, &send_cipher_, &recv_cipher_);
// split() also frees the handshake state
int err = this->handshake_.split(send_cipher_, recv_cipher_);
APIError aerr =
handle_noise_error_(err, LOG_STR("noise_handshakestate_split"), APIError::HANDSHAKESTATE_SPLIT_FAILED);
if (aerr != APIError::OK)
@@ -654,17 +570,11 @@ APIError APINoiseFrameHelper::check_handshake_finished_() {
this->frame_footer_size_ = noise_cipherstate_get_mac_length(send_cipher_);
HELPER_LOG("Handshake complete!");
noise_handshakestate_free(handshake_);
handshake_ = nullptr;
state_ = State::DATA;
return APIError::OK;
}
APINoiseFrameHelper::~APINoiseFrameHelper() {
if (handshake_ != nullptr) {
noise_handshakestate_free(handshake_);
handshake_ = nullptr;
}
if (send_cipher_ != nullptr) {
noise_cipherstate_free(send_cipher_);
send_cipher_ = nullptr;
@@ -675,16 +585,6 @@ APINoiseFrameHelper::~APINoiseFrameHelper() {
}
}
extern "C" {
// declare how noise generates random bytes (here with a good HWRNG based on the RF system)
void noise_rand_bytes(void *output, size_t len) {
if (!esphome::random_bytes(reinterpret_cast<uint8_t *>(output), len)) {
ESP_LOGE(TAG, "Acquiring random bytes failed; rebooting");
arch_restart();
}
}
}
} // namespace esphome::api
#endif // USE_API_NOISE
#endif // USE_API
@@ -3,7 +3,7 @@
#ifdef USE_API
#ifdef USE_API_NOISE
#include "noise/protocol.h"
#include "api_noise_context.h"
#include "esphome/components/noise/noise_handshake.h"
namespace esphome::api {
@@ -14,9 +14,9 @@ class APINoiseFrameHelper final : public APIFrameHelper {
// Pos 1-2: encrypted payload size (16-bit big-endian)
// Pos 3-6: encrypted type (16-bit) + data_len (16-bit)
// Pos 7+: actual payload data
static constexpr uint8_t HEADER_PADDING = 1 + 2 + 2 + 2; // indicator + size + type + data_len
static constexpr uint8_t HEADER_PADDING = noise::FRAME_HEADER_SIZE + 2 + 2; // frame header + type + data_len
APINoiseFrameHelper(std::unique_ptr<socket::Socket> socket, APINoiseContext &ctx)
APINoiseFrameHelper(std::unique_ptr<socket::Socket> socket, noise::NoiseContext &ctx)
: APIFrameHelper(std::move(socket)), ctx_(ctx) {
frame_header_padding_ = HEADER_PADDING;
}
@@ -52,13 +52,13 @@ class APINoiseFrameHelper final : public APIFrameHelper {
APIError handle_handshake_frame_error_(APIError aerr);
APIError handle_noise_error_(int err, const LogString *func_name, APIError api_err);
// Pointers first (4 bytes each)
NoiseHandshakeState *handshake_{nullptr};
// Pointers first (4 bytes each; the handshake wrapper holds one pointer)
noise::NoiseResponderHandshake handshake_;
NoiseCipherState *send_cipher_{nullptr};
NoiseCipherState *recv_cipher_{nullptr};
// Reference to noise context (4 bytes on 32-bit)
APINoiseContext &ctx_;
noise::NoiseContext &ctx_;
// Buffer for noise handshake prologue (released after handshake)
APIBuffer prologue_;
@@ -67,7 +67,7 @@ class APINoiseFrameHelper final : public APIFrameHelper {
// Fixed-size header buffer for noise protocol:
// 1 byte for indicator + 2 bytes for message size (16-bit value, not varint)
// Note: Maximum message size is UINT16_MAX (65535), with a limit of 128 bytes during handshake phase
uint8_t rx_header_buf_[3];
uint8_t rx_header_buf_[noise::FRAME_HEADER_SIZE];
uint8_t rx_header_buf_len_ = 0;
// 4 bytes total, no padding
};
@@ -1,37 +0,0 @@
#pragma once
#include <array>
#include <cstdint>
#include "esphome/core/defines.h"
namespace esphome::api {
#ifdef USE_API_NOISE
using psk_t = std::array<uint8_t, 32>;
class APINoiseContext {
public:
// The all-zeros PSK is reserved: it marks the device as unprovisioned and
// doubles as the well-known provisioning PSK that unprovisioned devices
// accept for Noise handshakes (passive-sniffing protection only, no
// authentication). It is never a valid real key.
static bool is_all_zeros(const psk_t &psk) {
uint8_t acc = 0;
for (uint8_t b : psk) {
acc |= b;
}
return acc == 0;
}
void set_psk(psk_t psk) {
this->psk_ = psk;
this->has_psk_ = !is_all_zeros(psk);
}
const psk_t &get_psk() const { return this->psk_; }
bool has_psk() const { return this->has_psk_; }
protected:
psk_t psk_{};
bool has_psk_{false};
};
#endif // USE_API_NOISE
} // namespace esphome::api
+1 -1
View File
@@ -588,7 +588,7 @@ bool APIServer::load_and_apply_noise_psk_() {
return true;
}
bool APIServer::save_noise_psk(psk_t psk, bool make_active) {
bool APIServer::save_noise_psk(noise::psk_t psk, bool make_active) {
#ifdef USE_API_NOISE_PSK_FROM_YAML
// When PSK is set from YAML, this function should never be called
// but if it is, reject the change
+9 -6
View File
@@ -5,7 +5,10 @@
#include "api_buffer.h"
// Must precede clients_ so APIConnection is complete for default_delete (libc++).
#include "api_connection.h"
#include "api_noise_context.h"
#ifdef USE_API_NOISE
// Only present in the build when the noise component is loaded
#include "esphome/components/noise/noise.h"
#endif
#include "api_pb2.h"
#include "api_pb2_service.h"
#include "esphome/components/socket/socket.h"
@@ -37,7 +40,7 @@ class UserServiceDescriptor;
#ifdef USE_API_NOISE
struct SavedNoisePsk {
psk_t psk;
noise::psk_t psk;
} PACKED; // NOLINT
#endif
@@ -73,10 +76,10 @@ class APIServer final : public Component,
APIBuffer &get_shared_buffer_ref() { return shared_write_buffer_; }
#ifdef USE_API_NOISE
bool save_noise_psk(psk_t psk, bool make_active = true);
bool save_noise_psk(noise::psk_t psk, bool make_active = true);
bool clear_noise_psk(bool make_active = true);
void set_noise_psk(psk_t psk) { this->noise_ctx_.set_psk(psk); }
APINoiseContext &get_noise_ctx() { return this->noise_ctx_; }
void set_noise_psk(noise::psk_t psk) { this->noise_ctx_.set_psk(psk); }
noise::NoiseContext &get_noise_ctx() { return this->noise_ctx_; }
#endif // USE_API_NOISE
void handle_disconnect(APIConnection *conn);
@@ -354,7 +357,7 @@ class APIServer final : public Component,
#endif
#ifdef USE_API_NOISE
APINoiseContext noise_ctx_;
noise::NoiseContext noise_ctx_;
ESPPreferenceObject noise_pref_;
#endif // USE_API_NOISE
};
+9 -1
View File
@@ -95,9 +95,17 @@ bool ListEntitiesIterator::on_end() { return this->client_->send_list_info_done(
ListEntitiesIterator::ListEntitiesIterator(APIConnection *client) : client_(client) {}
#ifdef USE_API_USER_DEFINED_ACTIONS
// Yield after every Nth service; bounds direct (non-batched) writes per loop pass
static constexpr uint8_t SERVICE_YIELD_INTERVAL = 3;
bool ListEntitiesIterator::on_service(UserServiceDescriptor *service) {
auto resp = service->encode_list_service_response();
return this->client_->send_message(resp);
if (!this->client_->send_message(resp))
return false;
// at_ is this service's index
if ((this->at_ + 1) % SERVICE_YIELD_INTERVAL == 0)
this->yield_after_step_();
return true;
}
#endif
+27 -16
View File
@@ -206,32 +206,36 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType:
interval = config[CONF_INTERVAL]
window = config[CONF_WINDOW]
if window > interval:
raise cv.Invalid(
f"Scan window ({window}) needs to be smaller than scan interval ({interval})"
)
# Labels are reused in every error below; the optional one names its key.
windows = [("Scan window", window)]
if (connection_window := config.get(CONF_CONNECTION_SCAN_WINDOW)) is not None:
windows.append((CONF_CONNECTION_SCAN_WINDOW, connection_window))
for name, value in windows:
if value > interval:
raise cv.Invalid(
f"{name} ({value}) needs to be smaller than scan interval ({interval})"
)
# BLE scan interval/window are programmed in 0.625 ms units as a 16-bit value; the
# controller only accepts 2.5 ms .. 10240 ms (0x0004 .. 0x4000). Reject out-of-range
# values here instead of letting the unit conversion silently overflow.
for name, value in (("interval", interval), ("window", window)):
for name, value in (("Scan interval", interval), *windows):
if value.total_microseconds < 2500 or value.total_microseconds > 10_240_000:
raise cv.Invalid(
f"Scan {name} ({value}) must be between 2.5 ms and 10240 ms"
)
raise cv.Invalid(f"{name} ({value}) must be between 2.5 ms and 10240 ms")
# Validate what actually reaches the controller: both values are truncated to
# whole 0.625 ms units, so a window/interval pair that differs by less than one
# unit collapses to the same value — silently programming a 100 % duty cycle
# (radio permanently on) from a config that asked for less.
interval_units = to_ble_units(interval)
window_units = to_ble_units(window)
if window_units == interval_units and window < interval:
raise cv.Invalid(
f"Scan window ({window}) and interval ({interval}) both truncate to "
f"{interval_units} x 0.625 ms, which the controller scans at a 100 % duty "
f"cycle. Separate them by at least 0.625 ms."
)
for name, value in windows:
if to_ble_units(value) == interval_units and value < interval:
raise cv.Invalid(
f"{name} ({value}) and interval ({interval}) both truncate to "
f"{interval_units} x 0.625 ms, which the controller scans at a 100 % duty "
f"cycle. Separate them by at least 0.625 ms."
)
if interval.total_microseconds * 3 > duration.total_microseconds:
raise cv.Invalid(
@@ -247,11 +251,14 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType:
# their own; also the fallback for esp32's conditional default.
DEFAULT_SCAN_WINDOW = "30ms"
CONF_CONNECTION_SCAN_WINDOW = "connection_scan_window"
def scan_parameters_schema(
interval_default: str,
*,
window_default: str | Callable[[], TimePeriod] = DEFAULT_SCAN_WINDOW,
connection_window: bool = False,
) -> cv.All:
"""Build the scan_parameters value schema shared by all BLE trackers.
@@ -263,7 +270,9 @@ def scan_parameters_schema(
can adjust it once sibling keys are resolved). The `active` option
(default on) is unconditional: active scanning is part of the tracker
contract every current proxy client assumes it, so a passive-only
tracker must not share this schema.
tracker must not share this schema. connection_window opts in to the
`connection_scan_window` option for trackers that can fall back to a
smaller window while a GATT connection is active.
"""
schema = {
cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds,
@@ -272,6 +281,8 @@ def scan_parameters_schema(
cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean,
cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
}
if connection_window:
schema[cv.Optional(CONF_CONNECTION_SCAN_WINDOW)] = cv.positive_time_period
return cv.All(cv.Schema(schema), validate_scan_parameters)
@@ -7,6 +7,7 @@ import logging
from esphome import automation
import esphome.codegen as cg
from esphome.components import ble_device_base, esp32_ble, ota
from esphome.components.ble_device_base import CONF_CONNECTION_SCAN_WINDOW
from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW
from esphome.components.esp32 import (
add_idf_sdkconfig_option,
@@ -73,8 +74,9 @@ def _get_required_features() -> set[BLEFeatures]:
# Slot counters sizing the tracker's StaticVector storage; one request per
# registered listener or client.
CLIENT_COUNT_DEFINE = "ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT"
_request_listener_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT")
_request_client_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT")
_request_client_slot = cg.slot_counter(CLIENT_COUNT_DEFINE)
def register_ble_features(features: set[BLEFeatures]) -> None:
@@ -147,6 +149,7 @@ class TrackerData:
"""Per-run validation state, namespaced under DOMAIN in CORE.data."""
scan_window_defaulted: bool = False
connection_window_injected: bool = False
def _get_data() -> TrackerData:
@@ -175,17 +178,34 @@ def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType:
honors the window strictly (>= 5.5.5); without the arbiter a full-duty
scan would starve wifi outright, and a user-set window is never touched.
Raising to the interval cannot invalidate the already-validated
parameters, so no re-validation is needed.
parameters, so no re-validation is needed. The connection window is
checked against the window here, after the raise.
"""
params = config[CONF_SCAN_PARAMETERS]
if (
_get_data().scan_window_defaulted
and config.get(CONF_SOFTWARE_COEXISTENCE)
and idf_version() >= IDF_SCAN_WINDOW_FIX_VERSION
):
params = config[CONF_SCAN_PARAMETERS]
# Copy so the config dump shows a plain value instead of a YAML
# anchor/alias pair pointing at the interval.
params[CONF_WINDOW] = copy.copy(params[CONF_INTERVAL])
# Arm the connection-time fallback unless the user set one. Injected
# after validation; safe because it equals the validated window default.
if CONF_CONNECTION_SCAN_WINDOW not in params:
params[CONF_CONNECTION_SCAN_WINDOW] = cv.positive_time_period(
ble_device_base.DEFAULT_SCAN_WINDOW
)
_get_data().connection_window_injected = True
if (
connection_window := params.get(CONF_CONNECTION_SCAN_WINDOW)
) is not None and connection_window > params[CONF_WINDOW]:
# A larger value would widen the scan during connections.
raise cv.Invalid(
f"{CONF_CONNECTION_SCAN_WINDOW} ({connection_window}) needs to be "
f"smaller than the scan window ({params[CONF_WINDOW]})",
path=[CONF_SCAN_PARAMETERS, CONF_CONNECTION_SCAN_WINDOW],
)
return config
@@ -194,7 +214,7 @@ def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType:
# window/interval pairs that collapse to the same 0.625 ms unit count.
# The window default is conditional (see _scan_window_default above).
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema(
"320ms", window_default=_scan_window_default
"320ms", window_default=_scan_window_default, connection_window=True
)
# Codegen helpers are owned by ble_device_base; kept under the historical names
@@ -288,6 +308,25 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_scan_duration(params[CONF_DURATION]))
cg.add(var.set_scan_interval(ble_device_base.to_ble_units(params[CONF_INTERVAL])))
cg.add(var.set_scan_window(ble_device_base.to_ble_units(params[CONF_WINDOW])))
if (connection_window := params.get(CONF_CONNECTION_SCAN_WINDOW)) is not None:
# Emitted at FINAL so a scan-only build, where the guarded C++ path
# compiles out, skips the call entirely.
window_units = ble_device_base.to_ble_units(connection_window)
@coroutine_with_priority(CoroPriority.FINAL)
async def _emit_connection_scan_window() -> None:
if cg.get_slot_count(CLIENT_COUNT_DEFINE):
cg.add(var.set_connection_scan_window(window_units))
elif not _get_data().connection_window_injected:
# Warn only for a user-set value; the injected default drops silently.
_LOGGER.warning(
"'%s' has no effect because this build has no BLE client "
"components (for example bluetooth_proxy with active "
"connections, or ble_client)",
CONF_CONNECTION_SCAN_WINDOW,
)
CORE.add_job(_emit_connection_scan_window)
cg.add(var.set_scan_active(params[CONF_ACTIVE]))
cg.add(var.set_scan_continuous(params[CONF_CONTINUOUS]))
@@ -122,6 +122,9 @@ void ESP32BLETracker::loop() {
// - start_scan_(): scanner_state_ becomes IDLE via set_scanner_state_() in cleanup_scan_state_()
// - try_promote_discovered_clients_(): client enters DISCOVERED via set_state(), or
// connecting client finishes (state change), or scanner reaches RUNNING/IDLE
// - connection-window restart: scan_params_ is only written in start_scan_()
// (which changes scanner state via set_scanner_state_()), and
// counts.active/disconnecting only change on client state changes
//
// All conditions that affect the logic below are tied to state changes that increment
// state_version_, so the fast path is safe.
@@ -144,6 +147,19 @@ void ESP32BLETracker::loop() {
(this->scan_set_param_failed_ && this->scanner_state_ == ScannerState::RUNNING)) {
this->handle_scanner_failure_();
}
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
// The programmed window no longer matches the connection state (typically
// the last connection dropped): restart so the right window applies now
// instead of at the end of the scan period. Continuous only (a user-started
// scan would not restart); !disconnecting matches the restart gate below.
if (this->scanner_state_ == ScannerState::RUNNING && this->scan_continuous_ && !counts.disconnecting &&
this->scan_params_.scan_window != this->desired_scan_window_(counts.active)) {
// Same logical scan period continues: no on_scan_end sweeps for this
// restart. Only armed when the stop was issued.
this->skip_next_scan_end_ = this->stop_scan_();
}
#endif
/*
Avoid starting the scanner if:
@@ -195,19 +211,23 @@ void ESP32BLETracker::stop_scan() {
// reason at D themselves, and the user-facing stop action is deliberate.
ESP_LOGV(TAG, "Stopping scan.");
this->scan_continuous_ = false;
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
// The window-change restart is abandoned with continuous scanning.
this->skip_next_scan_end_ = false;
#endif
this->stop_scan_();
}
void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_(); }
void ESP32BLETracker::stop_scan_() {
bool ESP32BLETracker::stop_scan_() {
if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) {
// IDLE means there is nothing to stop; STOPPING means a stop is already in
// flight and will finish on its own. Neither is an error.
if (this->scanner_state_ != ScannerState::IDLE && this->scanner_state_ != ScannerState::STOPPING) {
ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_));
}
return;
return false;
}
// Reset timeout state machine when stopping scan
this->scan_timeout_state_ = ScanTimeoutState::INACTIVE;
@@ -215,8 +235,9 @@ void ESP32BLETracker::stop_scan_() {
esp_err_t err = esp_ble_gap_stop_scanning();
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ble_gap_stop_scanning failed: %d", err);
return;
return false;
}
return true;
}
void ESP32BLETracker::start_scan_(bool first) {
@@ -230,16 +251,11 @@ void ESP32BLETracker::start_scan_(bool first) {
}
this->set_scanner_state_(ScannerState::STARTING);
ESP_LOGV(TAG, "Starting scan, set scanner state to STARTING.");
if (!first) {
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
for (auto *listener : this->listeners_)
listener->on_scan_end();
if (!first)
this->notify_scan_end_();
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
this->skip_next_scan_end_ = false;
#endif
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
for (auto *listener : this->neutral_listeners_)
listener->on_scan_end();
#endif
}
#ifdef USE_ESP32_BLE_DEVICE
this->discovered_log_.clear();
#endif
@@ -247,7 +263,17 @@ void ESP32BLETracker::start_scan_(bool first) {
this->scan_params_.own_addr_type = BLE_ADDR_TYPE_PUBLIC;
this->scan_params_.scan_filter_policy = BLE_SCAN_FILTER_ALLOW_ALL;
this->scan_params_.scan_interval = this->scan_interval_;
this->scan_params_.scan_window = this->scan_window_;
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
// Count fresh: an automation can start a scan before loop() refreshes the counts.
const uint32_t window = this->desired_scan_window_(this->count_client_states_().active);
if (window != this->scan_window_) {
// Guarantee the connection airtime instead of scanning wall to wall.
ESP_LOGV(TAG, "Connection active, using %" PRIu32 " unit scan window", window);
}
#else
const uint32_t window = this->scan_window_;
#endif
this->scan_params_.scan_window = window;
// Start timeout monitoring in loop() instead of using scheduler
// This prevents false reboots when the loop is blocked
@@ -408,6 +434,11 @@ void ESP32BLETracker::dump_config() {
" Continuous Scanning: %s",
this->scan_duration_, this->scan_interval_ * 0.625f, this->scan_window_ * 0.625f,
this->scan_active_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_));
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
if (this->connection_scan_window_ != 0) {
ESP_LOGCONFIG(TAG, " Connection Scan Window: %.1f ms", this->connection_scan_window_ * 0.625f);
}
#endif
ESP_LOGCONFIG(TAG,
" Scanner State: %s\n"
" Connecting: %d, discovered: %d, disconnecting: %d, active: %d",
@@ -487,6 +518,18 @@ void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) {
// Reset timeout state machine instead of cancelling scheduler timeout
this->scan_timeout_state_ = ScanTimeoutState::INACTIVE;
this->notify_scan_end_();
this->set_scanner_state_(ScannerState::IDLE);
}
void ESP32BLETracker::notify_scan_end_() {
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
// Window-change restart continues the same scan period; the flag stays set
// across the stop and is cleared by the restart in start_scan_.
if (this->skip_next_scan_end_)
return;
#endif
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
for (auto *listener : this->listeners_)
listener->on_scan_end();
@@ -495,8 +538,6 @@ void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) {
for (auto *listener : this->neutral_listeners_)
listener->on_scan_end();
#endif
this->set_scanner_state_(ScannerState::IDLE);
}
void ESP32BLETracker::handle_scanner_failure_() {
@@ -534,6 +575,8 @@ void ESP32BLETracker::try_promote_discovered_clients_() {
}
ESP_LOGD(TAG, "Promoting client to connect");
// A connect ends the scan period a window-change restart was continuing.
this->skip_next_scan_end_ = false;
#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE
this->update_coex_preference_(true);
#endif
@@ -169,6 +169,9 @@ class ESP32BLETracker final : public Component,
void set_scan_duration(uint32_t scan_duration) { scan_duration_ = scan_duration; }
void set_scan_interval(uint32_t scan_interval) { scan_interval_ = scan_interval; }
void set_scan_window(uint32_t scan_window) { scan_window_ = scan_window; }
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
void set_connection_scan_window(uint32_t scan_window) { connection_scan_window_ = scan_window; }
#endif
void set_scan_active(bool scan_active) { scan_active_ = scan_active; }
bool get_scan_active() const { return scan_active_; }
void set_scan_continuous(bool scan_continuous) { scan_continuous_ = scan_continuous; }
@@ -226,7 +229,10 @@ class ESP32BLETracker final : public Component,
ScannerState get_scanner_state() const { return this->scanner_state_; }
protected:
void stop_scan_();
/// Returns true when a stop was issued to the controller.
bool stop_scan_();
/// Fire on_scan_end on every listener unless a window-change restart suppressed it.
void notify_scan_end_();
/// Start a single scan by setting up the parameters and doing some esp-idf calls.
void start_scan_(bool first);
/// Called when a `ESP_GAP_BLE_SCAN_RESULT_EVT` event is received.
@@ -313,6 +319,15 @@ class ESP32BLETracker final : public Component,
uint32_t scan_duration_;
uint32_t scan_interval_;
uint32_t scan_window_;
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
/// Window used while a GATT connection is active; set by the user, or
/// defaulted when the window was raised to full duty (0 = no fallback).
uint32_t connection_scan_window_{0};
/// The window to scan at for the given number of active GATT connections.
uint32_t desired_scan_window_(uint8_t active) const {
return (this->connection_scan_window_ != 0 && active > 0) ? this->connection_scan_window_ : this->scan_window_;
}
#endif
esp_bt_status_t scan_start_failed_{ESP_BT_STATUS_SUCCESS};
esp_bt_status_t scan_set_param_failed_{ESP_BT_STATUS_SUCCESS};
@@ -330,15 +345,20 @@ class ESP32BLETracker final : public Component,
/// state_version_ to detect if any state changed since last iteration.
uint8_t last_processed_version_{0};
ScannerState scanner_state_{ScannerState::IDLE};
bool scan_continuous_;
bool scan_active_;
// Packed 1-bit flags.
bool scan_continuous_ : 1;
bool scan_active_ : 1;
#ifdef USE_OTA_STATE_LISTENER
bool scan_continuous_before_ota_{false};
bool scan_continuous_before_ota_ : 1 {false};
#endif
bool ble_was_disabled_ : 1 {true};
bool parse_advertisements_ : 1 {false};
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
/// Suppress the window-change restart's on_scan_end sweeps (stop and start).
bool skip_next_scan_end_ : 1 {false};
#endif
bool ble_was_disabled_{true};
bool parse_advertisements_{false};
#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE
bool coex_prefer_ble_{false};
bool coex_prefer_ble_ : 1 {false};
#endif
// Scan timeout state machine
enum class ScanTimeoutState : uint8_t {
@@ -346,10 +366,10 @@ class ESP32BLETracker final : public Component,
MONITORING, // Actively monitoring for timeout
EXCEEDED_WAIT, // Timeout exceeded, waiting one loop before reboot
};
ScanTimeoutState scan_timeout_state_{ScanTimeoutState::INACTIVE};
uint32_t scan_start_time_{0};
/// Precomputed timeout value: scan_duration_ * 2000
uint32_t scan_timeout_ms_{0};
ScanTimeoutState scan_timeout_state_{ScanTimeoutState::INACTIVE};
};
// NOLINTNEXTLINE
+125 -2
View File
@@ -1,12 +1,16 @@
import logging
import esphome.codegen as cg
from esphome.components.noise import decode_encryption_key, encryption_schema
from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code
from esphome.config_helpers import merge_config
import esphome.config_validation as cv
from esphome.const import (
CONF_API,
CONF_ENCRYPTION,
CONF_ESPHOME,
CONF_ID,
CONF_KEY,
CONF_NUM_ATTEMPTS,
CONF_OTA,
CONF_PASSWORD,
@@ -15,6 +19,7 @@ from esphome.const import (
CONF_REBOOT_TIMEOUT,
CONF_SAFE_MODE,
CONF_VERSION,
CONF_WEB_SERVER,
)
from esphome.core import CORE, coroutine_with_priority
from esphome.coroutine import CoroPriority
@@ -30,7 +35,15 @@ CODEOWNERS = ["@esphome/core"]
DEPENDENCIES = ["network"]
AUTO_LOAD = ["sha256", "socket"]
def AUTO_LOAD(config: ConfigType) -> list[str]:
"""Auto-load noise only when encryption is configured."""
base = ["sha256", "socket"]
# A falsy config is a tooling probe for the maximal set (None from
# dependency resolution, {} from the components-graph platform probe);
# a validated config always carries defaults, never empty
if not config or CONF_ENCRYPTION in config:
return base + ["noise"]
return base
esphome = cg.esphome_ns.namespace("esphome")
@@ -67,11 +80,24 @@ def ota_esphome_final_validate(config):
CONF_PASSWORD in merged_ota_esphome_configs_by_port[conf_port]
and CONF_PASSWORD in ota_conf
and merged_ota_esphome_configs_by_port[conf_port][CONF_PASSWORD]
!= ota_conf.get(CONF_PASSWORD)
!= ota_conf[CONF_PASSWORD]
):
raise cv.Invalid(
f"Found multiple configurations but {CONF_PASSWORD} is inconsistent"
)
# Encryption blocks conflict only when both pin a key; a bare
# `encryption:` (a package/device split) is compatible with a
# keyed one, and merge_config yields the keyed result
merged_key = (
merged_ota_esphome_configs_by_port[conf_port]
.get(CONF_ENCRYPTION, {})
.get(CONF_KEY)
)
other_key = ota_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY)
if merged_key and other_key and merged_key != other_key:
raise cv.Invalid(
f"Found multiple configurations but {CONF_ENCRYPTION} is inconsistent"
)
ports_with_merged_configs.append(conf_port)
merged_ota_esphome_configs_by_port[conf_port] = merge_config(
@@ -94,6 +120,73 @@ def ota_esphome_final_validate(config):
new_ota_conf.extend(merged_ota_esphome_configs_by_port.values())
# There is one encryption key per device: when the api component has one,
# ota uses it, and an explicit ota key must match it. A bare `encryption:`
# block resolves to the api key here so both codegen and the upload CLI
# see the actual key.
api_conf = full_conf.get(CONF_API) or {}
api_key = api_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY)
has_web_server_ota = any(
conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf
)
for ota_conf in merged_ota_esphome_configs_by_port.values():
# Merging same-port blocks can combine a password from one block with
# encryption from another; re-check the exclusion on the merged result.
_validate_no_password_with_encryption(ota_conf)
if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is None:
continue
if has_web_server_ota:
# The web_server ota platform accepts the same image over plain
# HTTP with basic auth, a full bypass of the encryption.
if CONF_WEB_SERVER in full_conf:
# With the web_server component the endpoint is always on;
# fail closed like the password combination
raise cv.Invalid(
f"'{CONF_OTA}' {CONF_ENCRYPTION} cannot be combined with the "
f"'{CONF_WEB_SERVER}' component; its '{CONF_OTA}' platform "
f"accepts the same image over plaintext HTTP, remove one of them"
)
# Without the component the platform is the captive_portal
# auto-load: the endpoint only exists while the fallback AP is
# active, so keep the recovery path and warn instead
_LOGGER.warning(
"OTA encryption does not cover the %s OTA platform (auto-loaded "
"by captive_portal); the plaintext /update endpoint stays "
"reachable while the fallback AP is active",
CONF_WEB_SERVER,
)
if ota_key := encryption_conf.get(CONF_KEY):
if api_key and ota_key != api_key:
raise cv.Invalid(
f"'{CONF_OTA}' {CONF_ENCRYPTION} {CONF_KEY} must match the "
f"'{CONF_API}' {CONF_ENCRYPTION} {CONF_KEY}; omit the "
f"'{CONF_OTA}' {CONF_KEY} to use the '{CONF_API}' one"
)
elif not api_key:
if CONF_ENCRYPTION in api_conf:
# A keyless `api: encryption:` block gets its key provisioned
# at runtime and stored in flash, so there is nothing to
# inherit at build time
raise cv.Invalid(
f"the '{CONF_API}' {CONF_ENCRYPTION} {CONF_KEY} is provisioned at "
f"runtime and cannot be inherited at build time; set an explicit "
f"'{CONF_OTA}' {CONF_ENCRYPTION} {CONF_KEY}"
)
raise cv.Invalid(
f"'{CONF_OTA}' {CONF_ENCRYPTION} has no {CONF_KEY} and there is no "
f"'{CONF_API}' {CONF_ENCRYPTION} {CONF_KEY} to inherit; set one of them"
)
else:
encryption_conf[CONF_KEY] = api_key
# The device treats the all-zeros PSK as "no key configured" (it is the
# api provisioning sentinel), so letting it through would leave the OTA
# port accepting plaintext while the YAML says encryption. Fail closed.
if not any(decode_encryption_key(encryption_conf[CONF_KEY])):
raise cv.Invalid(
f"The all-zeros {CONF_KEY} is reserved and provides no protection; "
f"generate a real key with: openssl rand -base64 32"
)
full_conf[CONF_OTA] = new_ota_conf
fv.full_config.set(full_conf)
@@ -107,6 +200,17 @@ def ota_esphome_final_validate(config):
)
# Not cv.has_at_most_one_key: this message explains the why, and the check is
# reused on merged same-port configs in final validate where schemas do not run
def _validate_no_password_with_encryption(config: ConfigType) -> ConfigType:
if CONF_PASSWORD in config and CONF_ENCRYPTION in config:
raise cv.Invalid(
f"'{CONF_PASSWORD}' cannot be combined with '{CONF_ENCRYPTION}'; the "
f"encryption key already authenticates the uploader, remove '{CONF_PASSWORD}'"
)
return config
def _consume_ota_sockets(config: ConfigType) -> ConfigType:
"""Register socket needs for OTA component."""
from esphome.components import socket
@@ -134,6 +238,7 @@ CONFIG_SCHEMA = cv.All(
): cv.port,
cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean,
cv.Optional(CONF_PASSWORD): cv.sensitive(),
cv.Optional(CONF_ENCRYPTION): encryption_schema,
cv.Optional(CONF_NUM_ATTEMPTS): cv.invalid(
f"'{CONF_SAFE_MODE}' (and its related configuration variables) has moved from 'ota' to its own component. See https://esphome.io/components/safe_mode"
),
@@ -147,12 +252,24 @@ CONFIG_SCHEMA = cv.All(
)
.extend(BASE_OTA_SCHEMA)
.extend(cv.COMPONENT_SCHEMA),
_validate_no_password_with_encryption,
_consume_ota_sockets,
)
FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate
def FILTER_SOURCE_FILES() -> list[str]:
"""Filter out the noise transport when no ota entry configures encryption."""
for ota_conf in CORE.config.get(CONF_OTA, []):
if (
ota_conf.get(CONF_PLATFORM) == CONF_ESPHOME
and ota_conf.get(CONF_ENCRYPTION) is not None
):
return []
return ["ota_esphome_noise.cpp"]
@coroutine_with_priority(CoroPriority.OTA_UPDATES)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
@@ -171,6 +288,12 @@ async def to_code(config: ConfigType) -> None:
if config.get(CONF_ALLOW_PARTITION_ACCESS):
cg.add_define("USE_OTA_PARTITIONS")
if (encryption_conf := config.get(CONF_ENCRYPTION)) is not None:
# A missing key was resolved from the api component in final validate.
key = encryption_conf[CONF_KEY]
cg.add_define("USE_OTA_ENCRYPTION")
cg.add(var.set_noise_psk(list(decode_encryption_key(key))))
# Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it.
cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME")
+96 -26
View File
@@ -27,7 +27,6 @@ namespace esphome {
static const char *const TAG = "esphome.ota";
static constexpr uint16_t OTA_BLOCK_SIZE = 8192;
static constexpr size_t OTA_BUFFER_SIZE = 1024; // buffer size for OTA data transfer
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer
@@ -105,6 +104,11 @@ void ESPHomeOTAComponent::dump_config() {
ESP_LOGCONFIG(TAG, " Password configured");
}
#endif
#ifdef USE_OTA_ENCRYPTION
if (this->noise_ctx_.has_psk()) {
ESP_LOGCONFIG(TAG, " Encryption configured");
}
#endif
#ifdef USE_OTA_PARTITIONS
ESP_LOGCONFIG(TAG,
" Partition access allowed\n"
@@ -148,8 +152,10 @@ void ESPHomeOTAComponent::loop() {
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01;
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02;
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04;
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_NOISE = 0x08;
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01;
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02;
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_NOISE = 0x04;
void ESPHomeOTAComponent::handle_handshake_() {
/// Handle the OTA handshake and authentication.
@@ -201,8 +207,7 @@ void ESPHomeOTAComponent::handle_handshake_() {
}
// Validate magic bytes
static const uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45};
if (memcmp(this->handshake_buf_, MAGIC_BYTES, 5) != 0) {
if (memcmp(this->handshake_buf_, MAGIC_BYTES, sizeof(MAGIC_BYTES)) != 0) {
ESP_LOGW(TAG, "Magic bytes mismatch! 0x%02X-0x%02X-0x%02X-0x%02X-0x%02X", this->handshake_buf_[0],
this->handshake_buf_[1], this->handshake_buf_[2], this->handshake_buf_[3], this->handshake_buf_[4]);
this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_MAGIC);
@@ -234,6 +239,19 @@ void ESPHomeOTAComponent::handle_handshake_() {
}
this->ota_features_ = this->handshake_buf_[0];
ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_);
#ifdef USE_OTA_ENCRYPTION
// Fail closed: with a PSK configured the client must negotiate encryption
// (which requires the extended protocol); refuse plaintext uploads.
static constexpr uint8_t NOISE_REQUIRED_FEATURES =
CLIENT_FEATURE_SUPPORTS_NOISE | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL;
if (this->noise_ctx_.has_psk() && (this->ota_features_ & NOISE_REQUIRED_FEATURES) != NOISE_REQUIRED_FEATURES) {
ESP_LOGW(TAG, "Client does not support encryption");
this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_ENCRYPTION_REQUIRED);
return;
}
#endif
this->transition_ota_state_(OTAState::FEATURE_ACK);
const bool supports_compression =
@@ -249,6 +267,12 @@ void ESPHomeOTAComponent::handle_handshake_() {
this->handshake_buf_[1] = (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0);
#ifdef USE_OTA_PARTITIONS
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS;
#endif
#ifdef USE_OTA_ENCRYPTION
if (this->noise_ctx_.has_psk()) {
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_NOISE;
}
this->server_feature_flags_ = this->handshake_buf_[1];
#endif
} else {
this->handshake_buf_[0] =
@@ -264,6 +288,18 @@ void ESPHomeOTAComponent::handle_handshake_() {
if (!this->try_write_(ack_size, LOG_STR("ack feature"))) {
return;
}
#ifdef USE_OTA_ENCRYPTION
// With a PSK configured the rest of the session runs inside the noise
// transport; the client sends the first handshake frame next, so there
// is nothing to do until data arrives.
if (this->noise_ctx_.has_psk()) {
if (!this->noise_start_session_()) {
return;
}
this->transition_ota_state_(OTAState::NOISE_HANDSHAKE);
return;
}
#endif
#ifdef USE_OTA_PASSWORD
// If password is set, move to auth phase
if (!this->password_.empty()) {
@@ -301,6 +337,16 @@ void ESPHomeOTAComponent::handle_handshake_() {
this->handle_data_();
return;
#ifdef USE_OTA_ENCRYPTION
case OTAState::NOISE_HANDSHAKE:
if (!this->handle_noise_handshake_()) {
return;
}
this->transition_ota_state_(OTAState::DATA);
this->handle_data_();
return;
#endif
default:
break;
}
@@ -360,12 +406,13 @@ void ESPHomeOTAComponent::handle_data_() {
this->client_->setblocking(true);
// Acknowledge auth OK - 1 byte
this->write_byte_(ota::OTA_RESPONSE_AUTH_OK);
this->data_write_byte_(ota::OTA_RESPONSE_AUTH_OK);
if (this->extended_proto_) {
// Read ota type, 1 byte
if (!this->readall_(buf, 1)) {
if (!this->data_readall_(buf, 1)) {
this->log_read_error_(LOG_STR("OTA type"));
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
ota_type = static_cast<ota::OTAType>(buf[0]);
@@ -373,8 +420,9 @@ void ESPHomeOTAComponent::handle_data_() {
ESP_LOGV(TAG, "OTA type is 0x%02x", ota_type);
// Read size, 4 bytes MSB first
if (!this->readall_(buf, 4)) {
if (!this->data_readall_(buf, 4)) {
this->log_read_error_(LOG_STR("size"));
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
ota_size = (static_cast<size_t>(buf[0]) << 24) | (static_cast<size_t>(buf[1]) << 16) |
@@ -404,11 +452,12 @@ void ESPHomeOTAComponent::handle_data_() {
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
// Acknowledge prepare OK - 1 byte
this->write_byte_(ota::OTA_RESPONSE_UPDATE_PREPARE_OK);
this->data_write_byte_(ota::OTA_RESPONSE_UPDATE_PREPARE_OK);
// Read binary MD5, 32 bytes
if (!this->readall_(buf, 32)) {
if (!this->data_readall_(buf, 32)) {
this->log_read_error_(LOG_STR("MD5 checksum"));
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
sbuf[32] = '\0';
@@ -416,7 +465,7 @@ void ESPHomeOTAComponent::handle_data_() {
this->backend_->set_update_md5(sbuf);
// Acknowledge MD5 OK - 1 byte
this->write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK);
this->data_write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK);
// Track when we last received data so a silently-vanished peer (no FIN/RST
// delivered, e.g. uploader killed mid-transfer or NAT/router dropped state)
@@ -432,19 +481,37 @@ void ESPHomeOTAComponent::handle_data_() {
}
size_t remaining = ota_size - total;
size_t requested = remaining < OTA_BUFFER_SIZE ? remaining : OTA_BUFFER_SIZE;
ssize_t read = this->client_->read(buf, requested);
if (read == -1) {
const int err = errno;
if (this->would_block_(err)) {
// read() already waited up to SO_RCVTIMEO for data, just feed WDT
App.feed_wdt();
continue;
ssize_t read;
#ifdef USE_OTA_ENCRYPTION
if (this->noise_ != nullptr) {
// One frame per call; noise_read_data_ waits internally (readall_), so
// there is no would-block retry here and failures are already logged.
read = this->noise_read_data_(buf, requested);
if (read <= 0) {
// error_code still holds the last OK; report a real failure instead
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
} else
#endif
{
read = this->client_->read(buf, requested);
if (read == -1) {
const int err = errno;
if (this->would_block_(err)) {
// read() already waited up to SO_RCVTIMEO for data, just feed WDT
App.feed_wdt();
continue;
}
ESP_LOGW(TAG, "Read err %d", err);
// error_code still holds the last OK; report a real failure instead
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
} else if (read == 0) {
ESP_LOGW(TAG, "Remote closed");
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
ESP_LOGW(TAG, "Read err %d", err);
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
} else if (read == 0) {
ESP_LOGW(TAG, "Remote closed");
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
last_data_ms = millis();
@@ -456,7 +523,7 @@ void ESPHomeOTAComponent::handle_data_() {
total += read;
#if USE_OTA_VERSION == 2
while (size_acknowledged + OTA_BLOCK_SIZE <= total || (total == ota_size && size_acknowledged < ota_size)) {
this->write_byte_(ota::OTA_RESPONSE_CHUNK_OK);
this->data_write_byte_(ota::OTA_RESPONSE_CHUNK_OK);
size_acknowledged += OTA_BLOCK_SIZE;
}
#endif
@@ -475,7 +542,7 @@ void ESPHomeOTAComponent::handle_data_() {
}
// Acknowledge receive OK - 1 byte
this->write_byte_(ota::OTA_RESPONSE_RECEIVE_OK);
this->data_write_byte_(ota::OTA_RESPONSE_RECEIVE_OK);
error_code = this->backend_->end();
if (error_code != ota::OTA_RESPONSE_OK) {
@@ -484,10 +551,10 @@ void ESPHomeOTAComponent::handle_data_() {
}
// Acknowledge Update end OK - 1 byte
this->write_byte_(ota::OTA_RESPONSE_UPDATE_END_OK);
this->data_write_byte_(ota::OTA_RESPONSE_UPDATE_END_OK);
// Read ACK
if (!this->readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) {
if (!this->data_readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) {
this->log_read_error_(LOG_STR("ack"));
// do not go to error, this is not fatal
}
@@ -510,7 +577,7 @@ void ESPHomeOTAComponent::handle_data_() {
App.safe_reboot();
error:
this->write_byte_(static_cast<uint8_t>(error_code));
this->data_write_byte_(static_cast<uint8_t>(error_code));
// Abort backend before cleanup - cleanup_connection_() destroys the backend.
// Always call abort() unconditionally: backends register external partitions before
@@ -677,6 +744,9 @@ void ESPHomeOTAComponent::cleanup_connection_() {
this->backend_ = nullptr;
#ifdef USE_OTA_PASSWORD
this->cleanup_auth_();
#endif
#ifdef USE_OTA_ENCRYPTION
this->noise_ = nullptr;
#endif
// Intentionally no disable_loop() — letting loop() run one more iteration catches
// any connection that queued on the listener mid-session (otherwise the wake flag,
+67 -1
View File
@@ -4,6 +4,9 @@
#ifdef USE_OTA
#include "esphome/components/ota/ota_backend_factory.h"
#include "esphome/components/socket/socket.h"
#ifdef USE_OTA_ENCRYPTION
#include "esphome/components/noise/noise_handshake.h"
#endif
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/preferences.h"
@@ -24,7 +27,10 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
AUTH_SEND, // Sending authentication request
AUTH_READ, // Reading authentication data
#endif // USE_OTA_PASSWORD
DATA, // BLOCKING! Processing OTA data (update, etc.)
#ifdef USE_OTA_ENCRYPTION
NOISE_HANDSHAKE, // Exchanging Noise handshake frames
#endif
DATA, // BLOCKING! Processing OTA data (update, etc.)
};
#ifdef USE_OTA_PASSWORD
void set_auth_password(const std::string &password) { password_ = password; }
@@ -38,6 +44,10 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
}
#endif // USE_OTA_PASSWORD
#ifdef USE_OTA_ENCRYPTION
void set_noise_psk(noise::psk_t psk) { this->noise_ctx_.set_psk(psk); }
#endif
/// Manually set the port OTA should listen on
void set_port(uint16_t port) { this->port_ = port; }
@@ -63,6 +73,48 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
bool writeall_(const uint8_t *buf, size_t len);
inline bool write_byte_(uint8_t byte) { return this->writeall_(&byte, 1); }
#ifdef USE_OTA_ENCRYPTION
// Heap-allocated only while an encrypted OTA session is active.
struct NoiseSession {
~NoiseSession();
noise::NoiseResponderHandshake handshake;
NoiseCipherState *send_cipher{nullptr};
NoiseCipherState *recv_cipher{nullptr};
uint16_t frame_len{0}; // total frame size once the header is parsed, 0 until then
uint16_t frame_pos{0}; // bytes read or written so far
bool writing{false}; // a produced handshake frame is still being flushed
uint8_t frame_buf[noise::FRAME_HEADER_SIZE + 1 + noise::MAX_HANDSHAKE_SIZE];
};
bool noise_start_session_();
bool handle_noise_handshake_();
bool noise_try_read_frame_();
bool noise_try_write_frame_();
void noise_send_reject_(const LogString *reason);
ssize_t noise_decrypt_(uint8_t *buf, size_t len);
ssize_t noise_read_frame_blocking_(uint8_t *buf, size_t min_ciphertext, size_t max_ciphertext);
bool noise_readall_(uint8_t *buf, size_t len);
ssize_t noise_read_data_(uint8_t *buf, size_t capacity);
bool noise_write_byte_(uint8_t byte);
#endif // USE_OTA_ENCRYPTION
// Data-phase I/O dispatch: through the noise transport when a session is
// active, straight to the socket otherwise.
inline bool data_write_byte_(uint8_t byte) {
#ifdef USE_OTA_ENCRYPTION
if (this->noise_ != nullptr)
return this->noise_write_byte_(byte);
#endif
return this->write_byte_(byte);
}
// When encrypted, buf must have room for len + noise::MAC_SIZE bytes.
inline bool data_readall_(uint8_t *buf, size_t len) {
#ifdef USE_OTA_ENCRYPTION
if (this->noise_ != nullptr)
return this->noise_readall_(buf, len);
#endif
return this->readall_(buf, len);
}
bool try_read_(size_t to_read, const LogString *desc);
bool try_write_(size_t to_write, const LogString *desc);
@@ -91,6 +143,11 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
std::string password_;
std::unique_ptr<uint8_t[]> auth_buf_;
#endif // USE_OTA_PASSWORD
#ifdef USE_OTA_ENCRYPTION
noise::NoiseContext noise_ctx_;
std::unique_ptr<NoiseSession> noise_;
uint8_t server_feature_flags_{0}; // as sent in the feature ack, bound into the prologue
#endif // USE_OTA_ENCRYPTION
socket::ListenSocket *server_{nullptr};
std::unique_ptr<socket::Socket> client_;
@@ -98,6 +155,15 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
uint32_t client_connect_time_{0};
static constexpr size_t HANDSHAKE_BUF_SIZE = 5;
// Buffer size for OTA data transfer. The upload client derives its maximum
// encrypted frame plaintext from this (espota2.NOISE_MAX_PLAINTEXT is this
// minus the 16-byte MAC); both must change together.
static constexpr size_t OTA_BUFFER_SIZE = 1024;
#ifdef USE_OTA_ENCRYPTION
// Shrinking the buffer would reject every frame a current CLI sends
static_assert(OTA_BUFFER_SIZE >= 1008 + noise::MAC_SIZE, "OTA_BUFFER_SIZE must fit a full encrypted data frame");
#endif
static constexpr uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45};
#ifdef USE_OTA_PARTITIONS
uint32_t running_app_offset_{0};
size_t running_app_size_{0};
@@ -0,0 +1,274 @@
#include "ota_esphome.h"
#ifdef USE_OTA
#ifdef USE_OTA_ENCRYPTION
#include "esphome/components/noise/noise.h"
#include "esphome/components/ota/ota_backend.h"
#include "esphome/core/log.h"
#include <cstring>
#include <new>
#ifdef USE_ESP8266
#include <pgmspace.h>
#endif
namespace esphome {
static const char *const TAG = "esphome.ota";
#ifdef USE_ESP8266
static constexpr char OTA_NOISE_PROLOGUE_INIT[] PROGMEM = "NoiseOTAInit";
#else
static const char *const OTA_NOISE_PROLOGUE_INIT = "NoiseOTAInit";
#endif
static constexpr size_t OTA_NOISE_PROLOGUE_INIT_LEN = 12; // strlen("NoiseOTAInit")
ESPHomeOTAComponent::NoiseSession::~NoiseSession() {
if (this->send_cipher != nullptr) {
noise_cipherstate_free(this->send_cipher);
}
if (this->recv_cipher != nullptr) {
noise_cipherstate_free(this->recv_cipher);
}
}
/** Allocate the session and start the responder handshake.
*
* The prologue binds the whole plaintext preamble, so any tampering with the
* negotiation (a stripped feature flag, a changed version) breaks the first
* handshake MAC on either side:
* "NoiseOTAInit" | magic(5) | OK,version | client_features | FEATURE_FLAGS,server_flags
*/
bool ESPHomeOTAComponent::noise_start_session_() {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
this->noise_ = std::unique_ptr<NoiseSession>(new (std::nothrow) NoiseSession());
if (this->noise_ == nullptr) {
ESP_LOGW(TAG, "Session allocation failed");
this->cleanup_connection_();
return false;
}
uint8_t prologue[OTA_NOISE_PROLOGUE_INIT_LEN + 5 + 2 + 1 + 2];
#ifdef USE_ESP8266
memcpy_P(prologue, OTA_NOISE_PROLOGUE_INIT, OTA_NOISE_PROLOGUE_INIT_LEN);
#else
std::memcpy(prologue, OTA_NOISE_PROLOGUE_INIT, OTA_NOISE_PROLOGUE_INIT_LEN);
#endif
uint8_t *p = prologue + OTA_NOISE_PROLOGUE_INIT_LEN;
// Magic bytes, already validated in MAGIC_READ
std::memcpy(p, MAGIC_BYTES, sizeof(MAGIC_BYTES));
p += sizeof(MAGIC_BYTES);
// Our magic ack
*p++ = ota::OTA_RESPONSE_OK;
*p++ = USE_OTA_VERSION;
// The feature byte the client sent
*p++ = this->ota_features_;
// The feature ack we sent (noise requires the extended protocol)
*p++ = ota::OTA_RESPONSE_FEATURE_FLAGS;
*p++ = this->server_feature_flags_;
int err = this->noise_->handshake.init(this->noise_ctx_.get_psk(), prologue, sizeof(prologue));
if (err != 0) {
ESP_LOGW(TAG, "Handshake init: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
this->cleanup_connection_();
return false;
}
return true;
}
/** Drive the non-blocking handshake from loop(); returns true once the
* transport ciphers are ready and the session can enter the data phase.
* On failure the connection is cleaned up and false is returned.
*/
bool ESPHomeOTAComponent::handle_noise_handshake_() {
NoiseSession &s = *this->noise_;
while (true) {
if (s.writing) {
if (!this->noise_try_write_frame_()) {
return false; // would block, or errored and cleaned up
}
s.writing = false;
s.frame_pos = 0;
s.frame_len = 0;
}
switch (s.handshake.action()) {
case noise::NoiseResponderHandshake::Action::ACTION_READ: {
if (!this->noise_try_read_frame_()) {
return false;
}
const uint16_t payload_len = s.frame_len - noise::FRAME_HEADER_SIZE;
s.frame_pos = 0;
s.frame_len = 0;
if (s.frame_buf[noise::FRAME_HEADER_SIZE] != noise::HANDSHAKE_STATUS_OK) {
ESP_LOGW(TAG, "Bad handshake error byte: %u", s.frame_buf[noise::FRAME_HEADER_SIZE]);
this->cleanup_connection_();
return false;
}
int err = s.handshake.read_message(s.frame_buf + noise::FRAME_HEADER_SIZE + 1, payload_len - 1);
if (err != 0) {
ESP_LOGW(TAG, "Handshake read: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
this->noise_send_reject_(noise::reject_reason_for(err));
this->cleanup_connection_();
return false;
}
break;
}
case noise::NoiseResponderHandshake::Action::ACTION_WRITE: {
size_t msg_len = 0;
int err =
s.handshake.write_message(s.frame_buf + noise::FRAME_HEADER_SIZE + 1, noise::MAX_HANDSHAKE_SIZE, msg_len);
if (err != 0) {
ESP_LOGW(TAG, "Handshake write: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
this->cleanup_connection_();
return false;
}
const uint16_t payload_len = msg_len + 1;
noise::write_frame_header(s.frame_buf, payload_len);
s.frame_buf[noise::FRAME_HEADER_SIZE] = noise::HANDSHAKE_STATUS_OK;
s.frame_len = noise::FRAME_HEADER_SIZE + payload_len;
s.frame_pos = 0;
s.writing = true;
break;
}
case noise::NoiseResponderHandshake::Action::ACTION_SPLIT: {
int err = s.handshake.split(s.send_cipher, s.recv_cipher);
if (err != 0) {
ESP_LOGW(TAG, "Handshake split: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
this->cleanup_connection_();
return false;
}
ESP_LOGD(TAG, "Noise handshake complete");
return true;
}
default: {
ESP_LOGW(TAG, "Bad handshake state");
this->cleanup_connection_();
return false;
}
}
}
}
/// Non-blocking read of one handshake frame into the session buffer.
bool ESPHomeOTAComponent::noise_try_read_frame_() {
NoiseSession &s = *this->noise_;
while (s.frame_pos < noise::FRAME_HEADER_SIZE) {
ssize_t read = this->client_->read(s.frame_buf + s.frame_pos, noise::FRAME_HEADER_SIZE - s.frame_pos);
if (!this->handle_read_error_(read, LOG_STR("read noise header"))) {
return false;
}
s.frame_pos += read;
}
if (s.frame_len == 0) {
const uint16_t payload_len = encode_uint16(s.frame_buf[1], s.frame_buf[2]);
if (s.frame_buf[0] != noise::FRAME_INDICATOR || payload_len < 1 || payload_len > 1 + noise::MAX_HANDSHAKE_SIZE) {
ESP_LOGW(TAG, "Bad handshake frame: 0x%02X, %u bytes", s.frame_buf[0], payload_len);
this->cleanup_connection_();
return false;
}
s.frame_len = noise::FRAME_HEADER_SIZE + payload_len;
}
while (s.frame_pos < s.frame_len) {
ssize_t read = this->client_->read(s.frame_buf + s.frame_pos, s.frame_len - s.frame_pos);
if (!this->handle_read_error_(read, LOG_STR("read noise frame"))) {
return false;
}
s.frame_pos += read;
}
return true;
}
/// Non-blocking write of the pending session-buffer frame.
bool ESPHomeOTAComponent::noise_try_write_frame_() {
NoiseSession &s = *this->noise_;
while (s.frame_pos < s.frame_len) {
ssize_t written = this->client_->write(s.frame_buf + s.frame_pos, s.frame_len - s.frame_pos);
if (!this->handle_write_error_(written, LOG_STR("write noise frame"))) {
return false;
}
s.frame_pos += written;
}
return true;
}
/// Best-effort explicit reject frame so the client can log a readable reason.
void ESPHomeOTAComponent::noise_send_reject_(const LogString *reason) {
uint8_t data[noise::FRAME_HEADER_SIZE + 1 + 32];
static_assert(sizeof(data) - noise::FRAME_HEADER_SIZE >= noise::MAC_FAILURE_PAYLOAD_SIZE,
"reject buffer must fit the MAC failure wire contract");
const size_t payload_len =
noise::format_reject_payload(data + noise::FRAME_HEADER_SIZE, sizeof(data) - noise::FRAME_HEADER_SIZE, reason);
noise::write_frame_header(data, payload_len);
this->client_->write(data, noise::FRAME_HEADER_SIZE + payload_len); // Best effort, non-blocking
}
/// Decrypt a ciphertext in place; returns the plaintext size or -1.
ssize_t ESPHomeOTAComponent::noise_decrypt_(uint8_t *buf, size_t len) {
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_inout(mbuf, buf, len, len);
int err = noise_cipherstate_decrypt(this->noise_->recv_cipher, &mbuf);
if (err != 0) {
ESP_LOGW(TAG, "Decrypt: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
return -1;
}
return mbuf.size;
}
/** Blocking read of one frame whose ciphertext size must be within the given
* bounds, decrypted in place; returns the plaintext size, or -1 on error.
* buf needs max_ciphertext capacity.
*/
ssize_t ESPHomeOTAComponent::noise_read_frame_blocking_(uint8_t *buf, size_t min_ciphertext, size_t max_ciphertext) {
uint8_t header[noise::FRAME_HEADER_SIZE];
if (!this->readall_(header, sizeof(header))) {
return -1;
}
const size_t ciphertext_len = encode_uint16(header[1], header[2]);
if (header[0] != noise::FRAME_INDICATOR || ciphertext_len < min_ciphertext || ciphertext_len > max_ciphertext) {
ESP_LOGW(TAG, "Bad frame: 0x%02X, %zu bytes", header[0], ciphertext_len);
return -1;
}
if (!this->readall_(buf, ciphertext_len)) {
return -1;
}
return this->noise_decrypt_(buf, ciphertext_len);
}
/** Blocking read of one frame whose plaintext must be exactly len bytes
* (control units are one unit per frame). buf needs len + noise::MAC_SIZE
* capacity; the plaintext lands at buf[0..len).
*/
bool ESPHomeOTAComponent::noise_readall_(uint8_t *buf, size_t len) {
return this->noise_read_frame_blocking_(buf, len + noise::MAC_SIZE, len + noise::MAC_SIZE) == (ssize_t) len;
}
/** Blocking read of one data-phase frame, decrypted in place; returns the
* plaintext size, or -1 on error. buf is the OTA_BUFFER_SIZE data buffer.
* The ciphertext must fit that buffer and its plaintext must fit what the
* caller accepts (the remaining image bytes).
*/
ssize_t ESPHomeOTAComponent::noise_read_data_(uint8_t *buf, size_t capacity) {
const size_t max_ciphertext = std::min(capacity + noise::MAC_SIZE, OTA_BUFFER_SIZE);
return this->noise_read_frame_blocking_(buf, noise::MAC_SIZE + 1, max_ciphertext);
}
/// Blocking write of one response byte as an encrypted frame.
bool ESPHomeOTAComponent::noise_write_byte_(uint8_t byte) {
uint8_t frame[noise::FRAME_HEADER_SIZE + 1 + noise::MAC_SIZE];
frame[noise::FRAME_HEADER_SIZE] = byte;
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_inout(mbuf, frame + noise::FRAME_HEADER_SIZE, 1, 1 + noise::MAC_SIZE);
int err = noise_cipherstate_encrypt(this->noise_->send_cipher, &mbuf);
if (err != 0) {
ESP_LOGW(TAG, "Encrypt: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
return false;
}
noise::write_frame_header(frame, mbuf.size);
return this->writeall_(frame, noise::FRAME_HEADER_SIZE + mbuf.size);
}
} // namespace esphome
#endif // USE_OTA_ENCRYPTION
#endif // USE_OTA
@@ -196,6 +196,9 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const std::string &url, c
}
container->feed_wdt();
// IDF is the only backend reusing the container across redirect hops;
// drop the previous hop's headers (Arduino/host collect only the final response)
container->response_headers_.clear();
container->content_length = esp_http_client_fetch_headers(client);
container->set_chunked(esp_http_client_is_chunked_response(client));
container->feed_wdt();
@@ -9,7 +9,7 @@
namespace esphome::mitsubishi_cn105 {
template<typename... Ts>
class SetRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
class SetRemoteTemperatureAction final : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
public:
TEMPLATABLE_VALUE(float, temperature)
@@ -17,12 +17,12 @@ class SetRemoteTemperatureAction : public Action<Ts...>, public Parented<Mitsubi
};
template<typename... Ts>
class ClearRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
class ClearRemoteTemperatureAction final : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
public:
void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); }
};
template<typename... Ts> class VaneControlAction : public Action<Ts...> {
template<typename... Ts> class VaneControlAction final : public Action<Ts...> {
public:
using ApplyFn = void (*)(VaneCall &, const std::remove_cvref_t<Ts> &...);
@@ -74,7 +74,7 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() {
traits.add_supported_fan_mode(p.second);
}
traits.set_supported_swing_modes(this->supported_swing_modes_);
traits.set_supported_swing_modes(this->swing_mode_manager_.supported_swing_modes());
const bool use_fahrenheit = this->parent_->get_temperature_mapping().get_use_fahrenheit();
traits.set_temperature_unit(use_fahrenheit ? TemperatureUnit::FAHRENHEIT : TemperatureUnit::CELSIUS);
@@ -109,33 +109,11 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) {
}
if (const auto swing_mode = call.get_swing_mode()) {
auto vane = this->last_non_swing_vane_mode_;
auto wide = this->last_non_swing_wide_vane_mode_;
switch (*swing_mode) {
case climate::CLIMATE_SWING_BOTH:
vane = MitsubishiCN105::VaneMode::SWING;
wide = MitsubishiCN105::WideVaneMode::SWING;
break;
case climate::CLIMATE_SWING_VERTICAL:
vane = MitsubishiCN105::VaneMode::SWING;
break;
case climate::CLIMATE_SWING_HORIZONTAL:
wide = MitsubishiCN105::WideVaneMode::SWING;
break;
case climate::CLIMATE_SWING_OFF:
default:
break;
if (const auto vane = this->swing_mode_manager_.vane_from(*swing_mode)) {
this->parent_->set_vane_mode(*vane);
}
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) {
this->parent_->set_vane_mode(vane);
}
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) {
this->parent_->set_wide_vane_mode(wide);
if (const auto wide = this->swing_mode_manager_.wide_vane_from(*swing_mode)) {
this->parent_->set_wide_vane_mode(*wide);
}
}
@@ -166,64 +144,39 @@ void MitsubishiCN105Climate::apply_values_() {
ESP_LOGD(TAG, "Unable to map fan mode");
}
if (!this->supported_swing_modes_.empty()) {
bool vertical_swinging = false;
bool horizontal_swinging = false;
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) {
if (status.vane_mode == MitsubishiCN105::VaneMode::SWING) {
vertical_swinging = true;
} else if (status.vane_mode != MitsubishiCN105::VaneMode::UNKNOWN) {
this->last_non_swing_vane_mode_ = status.vane_mode;
}
}
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) {
if (status.wide_vane_mode == MitsubishiCN105::WideVaneMode::SWING) {
horizontal_swinging = true;
} else if (status.wide_vane_mode != MitsubishiCN105::WideVaneMode::UNKNOWN) {
this->last_non_swing_wide_vane_mode_ = status.wide_vane_mode;
}
}
if (vertical_swinging && horizontal_swinging) {
this->swing_mode = climate::CLIMATE_SWING_BOTH;
} else if (vertical_swinging) {
this->swing_mode = climate::CLIMATE_SWING_VERTICAL;
} else if (horizontal_swinging) {
this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL;
} else {
this->swing_mode = climate::CLIMATE_SWING_OFF;
}
if (const auto swing_mode =
this->swing_mode_manager_.update_and_get_swing_mode(status.vane_mode, status.wide_vane_mode)) {
this->swing_mode = *swing_mode;
}
this->publish_state();
}
void MitsubishiCN105Climate::set_supported_swing_mode(climate::ClimateSwingMode mode) {
this->supported_swing_modes_.clear();
climate::ClimateSwingModeMask supported_swing_modes;
switch (mode) {
case climate::CLIMATE_SWING_VERTICAL:
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF);
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_VERTICAL);
supported_swing_modes.insert(climate::CLIMATE_SWING_OFF);
supported_swing_modes.insert(climate::CLIMATE_SWING_VERTICAL);
break;
case climate::CLIMATE_SWING_HORIZONTAL:
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF);
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_HORIZONTAL);
supported_swing_modes.insert(climate::CLIMATE_SWING_OFF);
supported_swing_modes.insert(climate::CLIMATE_SWING_HORIZONTAL);
break;
case climate::CLIMATE_SWING_BOTH:
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF);
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_VERTICAL);
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_HORIZONTAL);
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_BOTH);
supported_swing_modes.insert(climate::CLIMATE_SWING_OFF);
supported_swing_modes.insert(climate::CLIMATE_SWING_VERTICAL);
supported_swing_modes.insert(climate::CLIMATE_SWING_HORIZONTAL);
supported_swing_modes.insert(climate::CLIMATE_SWING_BOTH);
break;
case climate::CLIMATE_SWING_OFF:
default:
break;
}
this->swing_mode_manager_.set_supported_swing_modes(supported_swing_modes);
}
} // namespace esphome::mitsubishi_cn105
@@ -6,10 +6,13 @@
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/components/climate/climate.h"
#include "mitsubishi_cn105_swing_mode_manager.h"
namespace esphome::mitsubishi_cn105 {
class MitsubishiCN105Climate : public climate::Climate, public Component, public Parented<MitsubishiCN105Component> {
class MitsubishiCN105Climate final : public climate::Climate,
public Component,
public Parented<MitsubishiCN105Component> {
public:
void setup() override;
void dump_config() override;
@@ -25,14 +28,12 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public
protected:
void apply_values_();
climate::ClimateSwingModeMask supported_swing_modes_{};
MitsubishiCN105::VaneMode last_non_swing_vane_mode_{MitsubishiCN105::VaneMode::AUTO};
MitsubishiCN105::WideVaneMode last_non_swing_wide_vane_mode_{MitsubishiCN105::WideVaneMode::CENTER};
SwingModeManager swing_mode_manager_;
};
// Legacy climate action compatibility. Remove in 2027.2.0.
template<typename... Ts>
class LegacySetRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
class LegacySetRemoteTemperatureAction final : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
public:
TEMPLATABLE_VALUE(float, temperature)
@@ -41,7 +42,7 @@ class LegacySetRemoteTemperatureAction : public Action<Ts...>, public Parented<M
// Legacy climate action compatibility. Remove in 2027.2.0.
template<typename... Ts>
class LegacyClearRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
class LegacyClearRemoteTemperatureAction final : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
public:
void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); }
};
@@ -80,7 +80,7 @@ struct VaneCall {
MitsubishiCN105Component *parent_;
};
class MitsubishiCN105Component : public Component, public uart::UARTDevice {
class MitsubishiCN105Component final : public Component, public uart::UARTDevice {
public:
explicit MitsubishiCN105Component() : hp_(*this) {}
@@ -0,0 +1,86 @@
#pragma once
#include <optional>
#include "esphome/components/climate/climate.h"
#include "mitsubishi_cn105.h"
namespace esphome::mitsubishi_cn105 {
class SwingModeManager final {
public:
const climate::ClimateSwingModeMask &supported_swing_modes() const { return this->supported_swing_modes_; }
void set_supported_swing_modes(const climate::ClimateSwingModeMask &supported_swing_modes) {
this->supported_swing_modes_ = supported_swing_modes;
}
std::optional<MitsubishiCN105::VaneMode> vane_from(climate::ClimateSwingMode swing_mode) const {
if (!this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) {
return std::nullopt;
}
switch (swing_mode) {
case climate::CLIMATE_SWING_BOTH:
case climate::CLIMATE_SWING_VERTICAL:
return MitsubishiCN105::VaneMode::SWING;
default:
return this->last_non_swing_vane_mode_;
}
}
std::optional<MitsubishiCN105::WideVaneMode> wide_vane_from(climate::ClimateSwingMode swing_mode) const {
if (!this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) {
return std::nullopt;
}
switch (swing_mode) {
case climate::CLIMATE_SWING_BOTH:
case climate::CLIMATE_SWING_HORIZONTAL:
return MitsubishiCN105::WideVaneMode::SWING;
default:
return this->last_non_swing_wide_vane_mode_;
}
}
std::optional<climate::ClimateSwingMode> update_and_get_swing_mode(MitsubishiCN105::VaneMode vane_mode,
MitsubishiCN105::WideVaneMode wide_vane_mode) {
if (this->supported_swing_modes_.empty()) {
return std::nullopt;
}
bool vertical_swinging = false;
bool horizontal_swinging = false;
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) {
if (vane_mode == MitsubishiCN105::VaneMode::SWING) {
vertical_swinging = true;
} else if (vane_mode != MitsubishiCN105::VaneMode::UNKNOWN) {
this->last_non_swing_vane_mode_ = vane_mode;
}
}
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) {
if (wide_vane_mode == MitsubishiCN105::WideVaneMode::SWING) {
horizontal_swinging = true;
} else if (wide_vane_mode != MitsubishiCN105::WideVaneMode::UNKNOWN) {
this->last_non_swing_wide_vane_mode_ = wide_vane_mode;
}
}
if (vertical_swinging && horizontal_swinging) {
return climate::CLIMATE_SWING_BOTH;
}
if (vertical_swinging) {
return climate::CLIMATE_SWING_VERTICAL;
}
if (horizontal_swinging) {
return climate::CLIMATE_SWING_HORIZONTAL;
}
return climate::CLIMATE_SWING_OFF;
}
private:
climate::ClimateSwingModeMask supported_swing_modes_{};
MitsubishiCN105::VaneMode last_non_swing_vane_mode_{MitsubishiCN105::VaneMode::AUTO};
MitsubishiCN105::WideVaneMode last_non_swing_wide_vane_mode_{MitsubishiCN105::WideVaneMode::CENTER};
};
} // namespace esphome::mitsubishi_cn105
@@ -7,9 +7,9 @@
namespace esphome::mitsubishi_cn105 {
class MitsubishiCN105VerticalVaneDirectionSelect : public select::Select,
public Component,
public Parented<MitsubishiCN105Component> {
class MitsubishiCN105VerticalVaneDirectionSelect final : public select::Select,
public Component,
public Parented<MitsubishiCN105Component> {
public:
void setup() override;
void publish_vane_state(MitsubishiCN105::VaneMode mode);
+76 -3
View File
@@ -1,17 +1,23 @@
from __future__ import annotations
import logging
from typing import Any, Literal
from typing import Any, Literal, NamedTuple
from esphome import pins
import esphome.codegen as cg
from esphome.components import uart
import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS, CONF_DISABLE_CRC, CONF_FLOW_CONTROL_PIN, CONF_ID
from esphome.const import (
CONF_ADDRESS,
CONF_CONTINUOUS,
CONF_DISABLE_CRC,
CONF_FLOW_CONTROL_PIN,
CONF_ID,
)
from esphome.cpp_generator import MockObj
from esphome.cpp_helpers import gpio_pin_expression
import esphome.final_validate as fv
from esphome.types import ConfigType
from esphome.types import ConfigType, TemplateArgsType
_LOGGER = logging.getLogger(__name__)
@@ -48,6 +54,73 @@ CONF_TURNAROUND_TIME = "turnaround_time"
MODBUS_ROLES = ["client", "server"]
class _CommandOption(NamedTuple):
"""One per-command option forwarded to the hub (modbus::CommandOptions)."""
conf_key: str
field: str # the C++ field, and so the set_<field>() setter name
validator: Any # the static (non-templatable) validator for the key
cpp_type: Any # the C++ type the value is generated as
default: Any
# Per-direction command options. Single-sourcing the schema and the setter generation here keeps
# them from drifting; the C++ side must add the matching field per the rules documented on
# CommandOptions (modbus.h).
_COMMAND_OPTIONS: dict[str, list[_CommandOption]] = {
"read": [_CommandOption(CONF_CONTINUOUS, "continuous", cv.boolean, bool, False)],
"write": [],
}
def _command_options(direction: str) -> list[_CommandOption]:
try:
return _COMMAND_OPTIONS[direction]
except KeyError:
raise ValueError(f"unknown command-options direction {direction!r}") from None
def command_options_schema(
*, direction: Literal["read", "write"], templatable: bool = False
) -> dict[cv.Optional, Any]:
"""Schema fragment for the per-command options a component forwards to the hub
(modbus::CommandOptions). Extend this into any schema that queues commands. Keys are
direction-specific so a schema never offers an option the hub would strip (e.g.
continuous on a write); the write side has no options yet. For actions (templatable=True the
keys also accept lambdas), register the values with register_templatable_command_options().
"""
return {
cv.Optional(option.conf_key, default=option.default): (
cv.templatable(option.validator) if templatable else option.validator
)
for option in _command_options(direction)
}
async def register_templatable_command_options(
var: MockObj, config: ConfigType, args: TemplateArgsType, direction: str
) -> None:
"""Generate the set_<option>() calls for the given direction's command options present in config.
Pass the same direction the action's command_options_schema() used, so the keys generated match
the ones the schema offered - a write action never emits a read option's setter. Options the
schema did not add are simply absent. The consumer's C++ class declares a matching
TEMPLATABLE_VALUE per option (e.g. TEMPLATABLE_VALUE(bool, continuous)).
"""
for option in _command_options(direction):
if option.conf_key not in config:
continue
value = config[option.conf_key]
# Skip codegen when the value is its C++ zero (TemplatableFn::value() returns T{} when
# unset): behaviourally identical, and saves a thunk plus a setup() call per action.
if cg.is_template(value) or value != type(value)():
cg.add(
getattr(var, f"set_{option.field}")(
await cg.templatable(value, args, option.cpp_type)
)
)
CONFIG_SCHEMA = cv.typed_schema(
{
"client": cv.Schema(
+10 -13
View File
@@ -146,7 +146,7 @@ bool ModbusClientHub::tx_buffer_empty() {
// other states are mid-transaction or owed bookkeeping, not queued sends - and a READY continuous
// poll does not count either, since it ranks below every one-shot, so a new send goes out first.
for (const auto &cmd : this->tx_buffer_) {
if (cmd.state == FrameState::READY && !cmd.continuous)
if (cmd.state == FrameState::READY && !cmd.options.continuous)
return false;
}
return true;
@@ -946,7 +946,7 @@ bool ModbusDeviceCommand::notify_retired() {
bool ModbusDeviceCommand::response(std::span<const uint8_t> response_pdu) {
this->state = this->state == FrameState::WAITING_RETIRED ? FrameState::RETIRED : FrameState::RECEIVED_RESPONSE;
// A continuous poll is never consumed by its own response; a one-shot consumes one request here.
if (!this->continuous)
if (!this->options.continuous)
this->decrement_pending();
if (this->device == nullptr)
return false;
@@ -1070,15 +1070,12 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
return false;
}
// Normalize the caller's options in place (the param is a by-value copy) so everything stored or
// merged below carries effective options, never the raw request.
// continuous is ignored for every mutating code (re-writing a value forever is never intended).
const bool mutates = priority == CommandPriority::WRITE;
bool continuous = false;
if (options.continuous) {
if (mutates) {
ESP_LOGV(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address);
} else {
continuous = true;
}
if (options.continuous && priority == CommandPriority::WRITE) {
ESP_LOGW(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address);
options.continuous = false;
}
// A duplicate of a live entry with the same owner is not queued twice; it resolves against that
@@ -1104,10 +1101,10 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
}
return false; // dropped: no entry, no callbacks - the refusal is the return value
}
if (continuous) {
if (options.continuous) {
item.make_continuous(true);
ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", now polled continuously", address);
} else if (item.continuous) {
} else if (item.options.continuous) {
// A one-shot duplicate downgrades the poll to a one-shot: it runs one more cycle to serve this
// request, then stops (mirrors continuous incoming converting a one-shot the other way).
item.make_continuous(false);
@@ -1140,7 +1137,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
#endif
ESP_LOGV(TAG, "Adding frame to tx queue: %" PRIu8 ":%s", address,
format_hex_pretty_to(hex_buf, pdu.data(), pdu.size()));
this->tx_buffer_.emplace_back(device, address, pdu, continuous, this->next_seq_++);
this->tx_buffer_.emplace_back(device, address, pdu, options, this->next_seq_++);
return true;
}
+31 -17
View File
@@ -118,6 +118,14 @@ enum class FrameState : uint8_t {
};
// Per-command send options. Append-only; pass via designated initializers ({.continuous = true}).
// The queue entry stores this struct whole, so a new field arrives at the queue with no plumbing -
// but it arrives inert. Every new field must define three rules before it does anything:
// 1. normalization in queue_pdu() (is it valid for this function code? e.g. continuous is
// stripped for mutating codes),
// 2. a merge rule for when a duplicate send absorbs into a live entry (continuous
// upgrades/downgrades via make_continuous(); a new field needs its own answer),
// 3. teardown: retire() resets the whole struct; silent_retire() leaves it, relying on the sweep
// to erase the entry.
struct CommandOptions {
// A continuous poll lives in the queue until cancelled or failed; ignored for mutating codes.
bool continuous{false};
@@ -126,26 +134,29 @@ struct CommandOptions {
struct ModbusDeviceCommand {
ModbusClientDevice *device;
ModbusFrame frame;
FrameState state{FrameState::READY};
// A continuous poll is a subscription: pending fixed at 1, removed only by cancellation or failure.
bool continuous{false};
// Accepted requests this entry stands for, capped at max_pending(); drains one terminal each.
uint8_t pending{1};
// Place-in-line stamp (hub's free-running counter); selection takes the oldest for round-robin
// fairness within a class. Meant to wrap.
// fairness within a class. Meant to wrap. Declared ahead of the byte fields so the tail packs
// densely and a growing CommandOptions eats trailing padding before enlarging the struct.
uint16_t seq{0};
FrameState state{FrameState::READY};
// Accepted requests this entry stands for, capped at max_pending(); drains one terminal each.
// A continuous poll is a subscription: pending fixed at 1, removed only by cancellation or failure.
uint8_t pending{1};
// The entry's LIVE effective options, not a record of the caller's request: queue_pdu() normalizes
// before storing, duplicate absorption mutates continuous via make_continuous(), and retire() resets
// the struct (silent_retire() leaves it, relying on the sweep to erase the entry). See the
// CommandOptions comment for the rules a new field must define.
CommandOptions options;
// Build a command from a PDU span (caller bounds it to MAX_PDU_SIZE); fully initialized here.
// Build a command from a PDU span (caller bounds it to MAX_PDU_SIZE) and pre-normalized options;
// fully initialized here.
ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, std::span<const uint8_t> pdu,
bool continuous = false, uint16_t seq = 0)
: device(device),
frame(address, pdu.data(), static_cast<uint16_t>(pdu.size())),
continuous(continuous),
seq(seq) {}
CommandOptions options = {}, uint16_t seq = 0)
: device(device), frame(address, pdu.data(), static_cast<uint16_t>(pdu.size())), seq(seq), options(options) {}
// Transmit ordering class, derived (never stored): a continuous poll ranks below every one-shot.
CommandPriority priority() const {
return this->continuous ? CommandPriority::CONTINUOUS : classify(this->frame.pdu()[0]);
return this->options.continuous ? CommandPriority::CONTINUOUS : classify(this->frame.pdu()[0]);
}
// Wire-derived class: mutating codes rank WRITE; exception-flagged codes are excluded.
static CommandPriority classify(uint8_t function_code) {
@@ -161,7 +172,7 @@ struct ModbusDeviceCommand {
uint8_t max_pending() const {
const uint8_t fc = this->frame.pdu()[0];
const bool requeueable = !helpers::is_function_code_exception(fc) && helpers::is_function_code_read_only(fc);
return (requeueable && !this->continuous) ? 2 : 1;
return (requeueable && !this->options.continuous) ? 2 : 1;
}
// Device-scoped clear: detach with no callback (device-less, pending 0). An entry still waiting for
// a response keeps its state as a reply-ignoring shell that resolves silently; any other goes RETIRED.
@@ -196,11 +207,11 @@ struct ModbusDeviceCommand {
// retroactively inflating that no-op.
void make_continuous(bool continuous) {
if (continuous) {
this->continuous = true;
this->options.continuous = true;
this->pending = 1;
} else {
this->increment_pending();
this->continuous = false;
this->options.continuous = false;
}
}
// Address-scoped clear: keep pending and device so the sweep delivers one on_not_sent() per un-run
@@ -218,7 +229,7 @@ struct ModbusDeviceCommand {
} else if (!this->waiting_state()) { // an already-retired shell stays put; off the wire -> RETIRED
this->state = FrameState::RETIRED;
}
this->continuous = false;
this->options = {}; // reset every option so a future field is torn down without editing here
}
// True while the entry is still waiting for a response; the erase pass exempts these even at pending 0.
@@ -253,6 +264,9 @@ struct ModbusDeviceCommand {
bool notify_retired();
/// True if this command carries the same wire frame (address + PDU) as the given one.
/// Cancellation matches the exact frame, not the action instance: a continuous poll whose
/// start_address (or other field) is templated produces one poll per distinct frame, and a later
/// cancel built from different argument values will not reach the polls it does not byte-match.
bool same_frame(uint8_t address, std::span<const uint8_t> pdu) const {
const auto own_pdu = this->frame.pdu();
return own_pdu.size() == pdu.size() && this->frame.address() == address &&
+57 -13
View File
@@ -7,6 +7,7 @@ from esphome.components import modbus
import esphome.config_validation as cv
from esphome.const import (
CONF_ADDRESS,
CONF_CONTINUOUS,
CONF_COUNT,
CONF_ID,
CONF_ON_ERROR,
@@ -156,16 +157,45 @@ _ACTION_BASE_SCHEMA = cv.Schema(
}
)
MODBUS_CLIENT_SEND_SCHEMA = _ACTION_BASE_SCHEMA.extend(
{
cv.Required(CONF_PDU): cv.templatable(
cv.All(
cv.ensure_list(cv.hex_uint8_t),
cv.Length(min=1, max=modbus.MAX_PDU_SIZE),
)
),
cv.Optional(CONF_ON_RESPONSE): _handler_schema(),
}
# The write codes recognised by modbus::helpers::is_function_code_write() - keep in sync. 0x17
# (read/write multiple) is included: it mutates, so the hub treats it as a write despite its read half.
_WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17})
def _no_continuous_on_write(config: ConfigType) -> ConfigType:
"""Reject `continuous: true` on a static write PDU: continuous polling only applies to reads.
Only the fully-static case is decidable here; the hub strips the flag from mutating PDUs at
runtime, so a templated pdu or continuous falls through to that backstop."""
pdu = config[CONF_PDU]
if (
isinstance(pdu, list)
and config.get(CONF_CONTINUOUS) is True
# Masking the exception bit (0x90 -> 0x10) makes this check stricter than the runtime hub,
# whose classify() treats an exception-flagged code as a read and leaves continuous in place.
and pdu[0] & 0x7F in _WRITE_FUNCTION_CODES
):
raise cv.Invalid(
f"'{CONF_CONTINUOUS}: true' does not apply to a write PDU (function code "
f"0x{pdu[0]:02X}); continuous polling only applies to reads",
path=[CONF_CONTINUOUS],
)
return config
MODBUS_CLIENT_SEND_SCHEMA = cv.All(
_ACTION_BASE_SCHEMA.extend(
{
cv.Required(CONF_PDU): cv.templatable(
cv.All(
cv.ensure_list(cv.hex_uint8_t),
cv.Length(min=1, max=modbus.MAX_PDU_SIZE),
)
),
**modbus.command_options_schema(direction="read", templatable=True),
cv.Optional(CONF_ON_RESPONSE): _handler_schema(),
}
),
_no_continuous_on_write,
)
@@ -174,6 +204,7 @@ async def register_client_action(
config: ConfigType,
args: TemplateArgsType,
response_args: TemplateArgsType,
command_direction: str = "read",
) -> cg.MockObj:
"""Wire the shared action plumbing: hub parent, templated device address, outcome triggers.
@@ -235,6 +266,12 @@ async def register_client_action(
await automation.build_automation(
var.get_not_sent_trigger(), [(_PDU_SPAN, "request")], not_sent_conf
)
# Wire any command options the action's schema opted into (e.g. continuous on reads). Pass the
# matching direction so a write action never generates a read option's setter; the write side
# has no options yet, so this is a no-op there.
await modbus.register_templatable_command_options(
var, config, args, command_direction
)
return var
@@ -318,6 +355,7 @@ def _read_schema(max_count: int) -> cv.All:
cv.Optional(CONF_COUNT, default=1): cv.templatable(
cv.int_range(min=1, max=max_count)
),
**modbus.command_options_schema(direction="read", templatable=True),
}
),
_no_address_overflow(CONF_COUNT),
@@ -379,7 +417,9 @@ async def read_input_registers_to_code(config, action_id, template_arg, args):
async def _write_single_to_code(config, action_id, template_arg, args, value_type):
var = cg.new_Pvariable(action_id, template_arg)
cg.add(var.set_value(await cg.templatable(config[CONF_VALUE], args, value_type)))
return await register_client_action(var, config, args, [])
return await register_client_action(
var, config, args, [], command_direction="write"
)
@automation.register_action(
@@ -458,7 +498,9 @@ async def write_multiple_registers_to_code(config, action_id, template_arg, args
arr_id = ID(f"{action_id}_values", is_declaration=True, type=cg.uint16)
arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*values))
cg.add(var.set_values_static(arr, len(values)))
return await register_client_action(var, config, args, [])
return await register_client_action(
var, config, args, [], command_direction="write"
)
@automation.register_action(
@@ -482,7 +524,9 @@ async def write_multiple_coils_to_code(config, action_id, template_arg, args):
arr_id = ID(f"{action_id}_values", is_declaration=True, type=cg.uint8)
arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*packed))
cg.add(var.set_values_static(arr, len(values)))
return await register_client_action(var, config, args, [])
return await register_client_action(
var, config, args, [], command_direction="write"
)
# Read/write multiple registers (FC 0x17) writes one register block and reads another in a single
@@ -68,8 +68,8 @@ template<typename... Ts> class ClientActionBase : public Action<Ts...>, public m
/// resolves through on_sent() alone), so resolve refusals here via on_not_sent.
/// Takes a span, not a PduBuffer: the builders return right-sized buffers (a read PDU is 5 bytes), and
/// a PduBuffer parameter would widen each one to the 253-byte maximum just to cross the call.
void send_or_resolve_(std::span<const uint8_t> pdu) {
if (!this->queue_pdu(pdu))
void send_or_resolve_(std::span<const uint8_t> pdu, modbus::CommandOptions options = {}) {
if (!this->queue_pdu(pdu, options))
this->on_not_sent(pdu);
}
@@ -80,6 +80,26 @@ template<typename... Ts> class ClientActionBase : public Action<Ts...>, public m
retry_func_t retry_func_{nullptr};
};
/// The read-side per-command options (modbus::CommandOptions), declared once for every action that
/// sends a read. Each option is templatable, so it cannot be built in Python the way modbus_controller
/// builds its static struct; declaring the values here instead of per action means a new read option
/// costs one TEMPLATABLE_VALUE plus one field below, and every read action picks it up.
/// The read/write split mirrors _COMMAND_OPTIONS in the modbus component's Python
/// (command_options_schema(direction="read") adds exactly these keys). When a write-side option
/// arrives it gets a WriteCommandOptions twin, so write actions never carry read-only members.
template<typename... Ts> class ReadCommandOptions {
public:
// Poll: re-queue after each success until downgraded (replay with false) or failed. The hub strips
// it for mutating function codes at the door (see modbus::CommandOptions).
TEMPLATABLE_VALUE(bool, continuous)
protected:
/// The options for this send, with every templatable value resolved against the action's arguments.
modbus::CommandOptions command_options_(const Ts &...x) const {
return {.continuous = this->continuous_.value(x...)};
}
};
/// modbus_client.send: fire a raw PDU (function code + data; the hub adds address and CRC). The reply is
/// delivered raw - on_response(request, response) - deliberately bypassing the typed dispatch, so
/// non-standard/custom transactions pass through untouched.
@@ -87,7 +107,8 @@ template<typename... Ts> class ClientActionBase : public Action<Ts...>, public m
/// modbus::helpers::create_*_pdu() builders and return it directly (smaller builder results convert).
/// A PduBuffer drops bytes past modbus::MAX_PDU_SIZE without reporting it (the hub's oversize check
/// cannot fire - that limit is the capacity), so an over-long lambda-built PDU is silently truncated.
template<typename... Ts> class ModbusClientSendAction : public ClientActionBase<Ts...> {
template<typename... Ts>
class ModbusClientSendAction : public ClientActionBase<Ts...>, public ReadCommandOptions<Ts...> {
public:
TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu)
@@ -95,7 +116,7 @@ template<typename... Ts> class ModbusClientSendAction : public ClientActionBase<
return &this->response_trigger_;
}
void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(x...)); }
void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(x...), this->command_options_(x...)); }
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
this->response_trigger_.trigger(request_pdu, response_pdu);
@@ -140,7 +161,8 @@ template<typename... Ts> class TypedClientActionBase : public ClientActionBase<T
/// modbus_client.read_holding_registers / read_input_registers: on_response delivers the registers in
/// host byte order as `values` (only valid for the duration of the trigger).
template<typename... Ts> class ReadRegistersAction : public TypedClientActionBase<Ts...> {
template<typename... Ts>
class ReadRegistersAction : public TypedClientActionBase<Ts...>, public ReadCommandOptions<Ts...> {
public:
explicit ReadRegistersAction(bool holding) : holding_(holding) {}
TEMPLATABLE_VALUE(uint16_t, start_address)
@@ -152,7 +174,8 @@ template<typename... Ts> class ReadRegistersAction : public TypedClientActionBas
const auto function_code =
this->holding_ ? modbus::FunctionCode::READ_HOLDING_REGISTERS : modbus::FunctionCode::READ_INPUT_REGISTERS;
this->send_or_resolve_(
modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...)));
modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...)),
this->command_options_(x...));
}
void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) override {
@@ -167,7 +190,7 @@ template<typename... Ts> class ReadRegistersAction : public TypedClientActionBas
/// modbus_client.read_coils / read_discrete_inputs: on_response delivers the bits as a PackedBits view
/// (bit 0 = the bit at start_address; only valid for the duration of the trigger).
template<typename... Ts> class ReadBitsAction : public TypedClientActionBase<Ts...> {
template<typename... Ts> class ReadBitsAction : public TypedClientActionBase<Ts...>, public ReadCommandOptions<Ts...> {
public:
explicit ReadBitsAction(bool coils) : coils_(coils) {}
TEMPLATABLE_VALUE(uint16_t, start_address)
@@ -179,7 +202,8 @@ template<typename... Ts> class ReadBitsAction : public TypedClientActionBase<Ts.
const auto function_code =
this->coils_ ? modbus::FunctionCode::READ_COILS : modbus::FunctionCode::READ_DISCRETE_INPUTS;
this->send_or_resolve_(
modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...)));
modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...)),
this->command_options_(x...));
}
void on_read_bits(modbus::EntityType entity_type, uint16_t start_address, modbus::PackedBits bits,
modbus::ResponseStatus status) override {
+69
View File
@@ -0,0 +1,69 @@
import base64
import binascii
from typing import Any
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_KEY
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
noise_ns = cg.esphome_ns.namespace("noise")
CONFIG_SCHEMA = cv.Schema({})
def validate_encryption_key(value: Any) -> str:
value = cv.string_strict(value)
try:
decoded = base64.b64decode(value, validate=True)
except ValueError as err:
raise cv.Invalid("Invalid key format, please check it's using base64") from err
if len(decoded) != 32:
raise cv.Invalid("Encryption key must be base64 and 32 bytes long")
# Return original data for roundtrip conversion
return value
def decode_encryption_key(value: str) -> bytes:
"""Decode a base64 encryption key to its 32 raw bytes.
a2b_base64 matches the decode the clients use (aioesphomeapi
decode_noise_psk), so both ends derive the same bytes. The length is
re-checked so a caller cannot turn an unvalidated short decode into a
zero-padded PSK.
"""
try:
decoded = binascii.a2b_base64(value)
except ValueError as err:
raise cv.Invalid("Invalid key format, please check it's using base64") from err
if len(decoded) != 32:
raise cv.Invalid("Encryption key must be base64 and 32 bytes long")
return decoded
ENCRYPTION_SCHEMA = cv.Schema(
{
cv.Optional(CONF_KEY): cv.sensitive(validate_encryption_key),
}
)
def encryption_schema(config: ConfigType | None) -> ConfigType:
# A bare `encryption:` block is valid; a missing key means the consumer
# falls back to its keyless behavior (api provisioning, ota inheriting
# the api key).
if config is None:
config = {}
return ENCRYPTION_SCHEMA(config)
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_NOISE")
cg.add_library("esphome/noise-c", "0.1.21")
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
+88
View File
@@ -0,0 +1,88 @@
#include "noise.h"
#ifdef USE_NOISE
#include "esphome/core/log.h"
#include <algorithm>
#include <cstring>
#include <noise/protocol.h>
#ifdef USE_ESP8266
#include <pgmspace.h>
#endif
namespace esphome::noise {
static const char *const TAG = "noise";
const LogString *noise_err_to_logstr(int err) {
if (err == NOISE_ERROR_NO_MEMORY)
return LOG_STR("NO_MEMORY");
if (err == NOISE_ERROR_UNKNOWN_ID)
return LOG_STR("UNKNOWN_ID");
if (err == NOISE_ERROR_UNKNOWN_NAME)
return LOG_STR("UNKNOWN_NAME");
if (err == NOISE_ERROR_MAC_FAILURE)
return LOG_STR("MAC_FAILURE");
if (err == NOISE_ERROR_NOT_APPLICABLE)
return LOG_STR("NOT_APPLICABLE");
if (err == NOISE_ERROR_SYSTEM)
return LOG_STR("SYSTEM");
if (err == NOISE_ERROR_REMOTE_KEY_REQUIRED)
return LOG_STR("REMOTE_KEY_REQUIRED");
if (err == NOISE_ERROR_LOCAL_KEY_REQUIRED)
return LOG_STR("LOCAL_KEY_REQUIRED");
if (err == NOISE_ERROR_PSK_REQUIRED)
return LOG_STR("PSK_REQUIRED");
if (err == NOISE_ERROR_INVALID_LENGTH)
return LOG_STR("INVALID_LENGTH");
if (err == NOISE_ERROR_INVALID_PARAM)
return LOG_STR("INVALID_PARAM");
if (err == NOISE_ERROR_INVALID_STATE)
return LOG_STR("INVALID_STATE");
if (err == NOISE_ERROR_INVALID_NONCE)
return LOG_STR("INVALID_NONCE");
if (err == NOISE_ERROR_INVALID_PRIVATE_KEY)
return LOG_STR("INVALID_PRIVATE_KEY");
if (err == NOISE_ERROR_INVALID_PUBLIC_KEY)
return LOG_STR("INVALID_PUBLIC_KEY");
if (err == NOISE_ERROR_INVALID_FORMAT)
return LOG_STR("INVALID_FORMAT");
if (err == NOISE_ERROR_INVALID_SIGNATURE)
return LOG_STR("INVALID_SIGNATURE");
return LOG_STR("UNKNOWN");
}
const LogString *reject_reason_for(int err) {
return err == NOISE_ERROR_MAC_FAILURE ? LOG_STR("Handshake MAC failure") : LOG_STR("Handshake error");
}
size_t format_reject_payload(uint8_t *buf, size_t capacity, const LogString *reason) {
if (capacity == 0) {
// A caller bug; the MAC_FAILURE_PAYLOAD_SIZE static_asserts at the call
// sites make this unreachable, kept as cheap memory safety
ESP_LOGVV(TAG, "Reject buffer has no capacity");
return 0;
}
buf[0] = HANDSHAKE_STATUS_REJECT;
#ifdef USE_STORE_LOG_STR_IN_FLASH
// On ESP8266 with flash strings, we need to use PROGMEM-aware functions
size_t reason_len = strlen_P(reinterpret_cast<PGM_P>(reason));
reason_len = std::min(reason_len, capacity - 1);
if (reason_len > 0) {
memcpy_P(buf + 1, reinterpret_cast<PGM_P>(reason), reason_len);
}
#else
const char *reason_str = LOG_STR_ARG(reason);
size_t reason_len = strlen(reason_str);
reason_len = std::min(reason_len, capacity - 1);
if (reason_len > 0) {
// NOLINTNEXTLINE(bugprone-not-null-terminated-result) - binary protocol, not a C string
std::memcpy(buf + 1, reason_str, reason_len);
}
#endif
return reason_len + 1;
}
} // namespace esphome::noise
#endif // USE_NOISE
+74
View File
@@ -0,0 +1,74 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_NOISE
#include <array>
#include <cstddef>
#include <cstdint>
#include "esphome/core/log.h"
namespace esphome::noise {
using psk_t = std::array<uint8_t, 32>;
class NoiseContext {
public:
// The all-zeros PSK is reserved: it marks the device as unprovisioned and
// doubles as the well-known provisioning PSK that unprovisioned devices
// accept for Noise handshakes (passive-sniffing protection only, no
// authentication). It is never a valid real key.
static bool is_all_zeros(const psk_t &psk) {
uint8_t acc = 0;
for (uint8_t b : psk) {
acc |= b;
}
return acc == 0;
}
void set_psk(psk_t psk) {
this->psk_ = psk;
this->has_psk_ = !is_all_zeros(psk);
}
const psk_t &get_psk() const { return this->psk_; }
bool has_psk() const { return this->has_psk_; }
protected:
psk_t psk_{};
bool has_psk_{false};
};
/// Convert a noise error code to a readable error
const LogString *noise_err_to_logstr(int err);
// Shared wire format for the noise transports (api and ota): every frame is
// FRAME_INDICATOR, a 16-bit big-endian payload length, then the payload.
// Handshake payloads start with a status byte; transport payloads end with
// the ChaCha20-Poly1305 MAC.
static constexpr uint8_t FRAME_INDICATOR = 0x01;
static constexpr size_t FRAME_HEADER_SIZE = 3;
static constexpr size_t MAC_SIZE = 16;
static constexpr size_t MAX_HANDSHAKE_SIZE = 128;
static constexpr uint8_t HANDSHAKE_STATUS_OK = 0x00;
static constexpr uint8_t HANDSHAKE_STATUS_REJECT = 0x01;
inline void write_frame_header(uint8_t *buf, uint16_t payload_len) {
buf[0] = FRAME_INDICATOR;
buf[1] = (uint8_t) (payload_len >> 8);
buf[2] = (uint8_t) payload_len;
}
/// Fill buf with a handshake reject payload (status byte plus the reason
/// text, PROGMEM aware); returns the payload length. buf needs capacity for
/// the status byte plus the truncated reason.
size_t format_reject_payload(uint8_t *buf, size_t capacity, const LogString *reason);
/// Reject reason for a failed handshake read. The MAC failure string is a
/// wire contract: clients match it to report a wrong key.
const LogString *reject_reason_for(int err);
/// Payload size of the MAC failure reject, the one reason string that is a
/// wire contract (sizeof's NUL stands in for the status byte). static_assert
/// reject buffers against this so a wrong key report can never truncate;
/// longer caller-supplied reasons are informational and sized by the caller.
static constexpr size_t MAC_FAILURE_PAYLOAD_SIZE = sizeof("Handshake MAC failure");
} // namespace esphome::noise
#endif // USE_NOISE
@@ -0,0 +1,139 @@
#include "noise_handshake.h"
#ifdef USE_NOISE
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::noise {
static const char *const TAG = "noise";
// Log the failing noise-c call at the same verbosity the api helper used
// before this class existed; callers only see one collapsed error code.
#define HANDSHAKE_STEP_LOG(func_name, err_code) \
ESP_LOGVV(TAG, "%s failed: %s", LOG_STR_ARG(LOG_STR(func_name)), LOG_STR_ARG(noise_err_to_logstr(err_code)))
NoiseResponderHandshake::~NoiseResponderHandshake() {
if (this->handshake_ != nullptr) {
noise_handshakestate_free(this->handshake_);
this->handshake_ = nullptr;
}
}
int NoiseResponderHandshake::init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len) {
if (this->handshake_ != nullptr) {
noise_handshakestate_free(this->handshake_);
this->handshake_ = nullptr;
}
// Noise_NNpsk0_25519_ChaChaPoly_SHA256, built on the stack:
// noise_handshakestate_new_by_id copies it, so a member would waste
// 104 bytes per connection, and a static const would sit in RAM on
// ESP8266 (.rodata is DRAM there).
const NoiseProtocolId nid = {
.prefix_id = NOISE_PREFIX_STANDARD,
.pattern_id = NOISE_PATTERN_NN,
.modifier_ids = {NOISE_MODIFIER_PSK0},
.dh_id = NOISE_DH_CURVE25519,
.cipher_id = NOISE_CIPHER_CHACHAPOLY,
.hash_id = NOISE_HASH_SHA256,
.hybrid_id = NOISE_DH_NONE,
};
int err = noise_handshakestate_new_by_id(&this->handshake_, &nid, NOISE_ROLE_RESPONDER);
if (err != 0) {
HANDSHAKE_STEP_LOG("noise_handshakestate_new_by_id", err);
return err;
}
err = noise_handshakestate_set_pre_shared_key(this->handshake_, psk.data(), psk.size());
if (err != 0) {
HANDSHAKE_STEP_LOG("noise_handshakestate_set_pre_shared_key", err);
return this->fail_init_(err);
}
err = noise_handshakestate_set_prologue(this->handshake_, prologue, prologue_len);
if (err != 0) {
HANDSHAKE_STEP_LOG("noise_handshakestate_set_prologue", err);
return this->fail_init_(err);
}
err = noise_handshakestate_start(this->handshake_);
if (err != 0) {
HANDSHAKE_STEP_LOG("noise_handshakestate_start", err);
return this->fail_init_(err);
}
return 0;
}
/// Release a half-initialized state so a failed init() leaves the object as
/// if init() was never called.
int NoiseResponderHandshake::fail_init_(int err) {
noise_handshakestate_free(this->handshake_);
this->handshake_ = nullptr;
return err;
}
NoiseResponderHandshake::Action NoiseResponderHandshake::action() const {
if (this->handshake_ == nullptr) {
// A caller bug: init() was never called, or split() already released the state
ESP_LOGVV(TAG, "action() on uninitialized or split handshake");
return Action::ACTION_FAILED;
}
int raw = noise_handshakestate_get_action(this->handshake_);
switch (raw) {
case NOISE_ACTION_READ_MESSAGE:
return Action::ACTION_READ;
case NOISE_ACTION_WRITE_MESSAGE:
return Action::ACTION_WRITE;
case NOISE_ACTION_SPLIT:
return Action::ACTION_SPLIT;
default:
// Preserve the raw code in debug logs; callers only see the collapsed enum
ESP_LOGVV(TAG, "Unexpected noise action %d", raw);
return Action::ACTION_FAILED;
}
}
int NoiseResponderHandshake::read_message(uint8_t *data, size_t len) {
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_input(mbuf, data, len);
return noise_handshakestate_read_message(this->handshake_, &mbuf, nullptr);
}
int NoiseResponderHandshake::write_message(uint8_t *out, size_t capacity, size_t &out_len) {
out_len = 0;
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_output(mbuf, out, capacity);
int err = noise_handshakestate_write_message(this->handshake_, &mbuf, nullptr);
if (err == 0)
out_len = mbuf.size;
return err;
}
int NoiseResponderHandshake::split(NoiseCipherState *&send_cipher, NoiseCipherState *&recv_cipher) {
// Defined error postcondition: noise-c leaves the out-params unwritten on
// its early error returns, so a caller passing uninitialized locals must
// never see garbage to free
send_cipher = nullptr;
recv_cipher = nullptr;
int err = noise_handshakestate_split(this->handshake_, &send_cipher, &recv_cipher);
if (err != 0)
return err;
noise_handshakestate_free(this->handshake_);
this->handshake_ = nullptr;
return 0;
}
extern "C" {
// noise-c's only randomness source (the vendored library compiles no rand of
// its own); HWRNG backed. Lives in this TU so every handshake consumer links
// it and the definition can never be dropped from the archive.
void noise_rand_bytes(void *output, size_t len) {
if (!esphome::random_bytes(reinterpret_cast<uint8_t *>(output), len)) {
ESP_LOGE(TAG, "Acquiring random bytes failed; rebooting");
arch_restart();
}
}
}
} // namespace esphome::noise
#endif // USE_NOISE
@@ -0,0 +1,63 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_NOISE
#include <cstddef>
#include <cstdint>
#include <noise/protocol.h>
#include "noise.h"
namespace esphome::noise {
/** Sans-IO responder side of a Noise_NNpsk0_25519_ChaChaPoly_SHA256 handshake.
*
* Owns only the noise-c handshake state; the caller moves the raw handshake
* messages (no framing) over its own transport, driven by action():
* read_message() while READ, write_message() while WRITE, then split() to
* take ownership of the transport ciphers. All methods return a noise-c
* error code, 0 on success. Called outside their action() step (before
* init(), after split()) the message methods return a noise-c error rather
* than crashing; the library checks its state argument.
*
* Methods are deliberately small separate functions so callers on tight
* stacks (RP2040 core0 scratch bank) never pay for more than one branch;
* the curve25519 step alone needs ~2KB of stack.
*/
class NoiseResponderHandshake {
public:
// The ACTION_ prefix is macro-collision safety: SDK headers #define bare
// names like READ/WRITE, and macros expand even inside an enum class.
enum class Action : uint8_t { ACTION_READ, ACTION_WRITE, ACTION_SPLIT, ACTION_FAILED };
NoiseResponderHandshake() = default;
~NoiseResponderHandshake();
// Owns a raw noise-c handshake state; copying would double free it
NoiseResponderHandshake(const NoiseResponderHandshake &) = delete;
NoiseResponderHandshake &operator=(const NoiseResponderHandshake &) = delete;
/// Create and start the handshake with the given PSK and prologue. A
/// repeated call frees the previous handshake state and starts over.
[[nodiscard]] int init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len);
/// ACTION_FAILED is the catch-all: returned before init(), after split()
/// has released the state, and when noise-c reports a failed handshake.
[[nodiscard]] Action action() const;
/// Process one received handshake message. The buffer is consumed in
/// place: noise-c decrypts into it and zeroes it before returning.
[[nodiscard]] int read_message(uint8_t *data, size_t len);
/// Produce the next handshake message into out; out_len receives its size
/// and is zero on error.
[[nodiscard]] int write_message(uint8_t *out, size_t capacity, size_t &out_len);
/// Hand out the transport ciphers and free the handshake state. The caller
/// owns both cipher states and must free them with noise_cipherstate_free();
/// both are set to nullptr on error.
[[nodiscard]] int split(NoiseCipherState *&send_cipher, NoiseCipherState *&recv_cipher);
protected:
int fail_init_(int err);
NoiseHandshakeState *handshake_{nullptr};
};
} // namespace esphome::noise
#endif // USE_NOISE
+11 -2
View File
@@ -4,8 +4,16 @@ from esphome.components import runtime_image
from esphome.components.const import CONF_REQUEST_HEADERS
from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestComponent
from esphome.components.image import CONF_TRANSPARENCY, add_metadata
from esphome.components.runtime_image import IMAGE_FORMATS
import esphome.config_validation as cv
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_ON_ERROR, CONF_TYPE, CONF_URL
from esphome.const import (
CONF_BUFFER_SIZE,
CONF_FORMAT,
CONF_ID,
CONF_ON_ERROR,
CONF_TYPE,
CONF_URL,
)
from esphome.core import ID, Lambda
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType
@@ -31,7 +39,6 @@ ReleaseImageAction = online_image_ns.class_(
"OnlineImageReleaseAction", automation.Action, cg.Parented.template(OnlineImage)
)
ONLINE_IMAGE_SCHEMA = (
runtime_image.runtime_image_schema(OnlineImage)
.extend(
@@ -39,6 +46,8 @@ ONLINE_IMAGE_SCHEMA = (
# Online Image specific options
cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent),
cv.Required(CONF_URL): cv.url,
# AUTO (Content-Type detection) is online_image specific; not in the shared registry
cv.Required(CONF_FORMAT): cv.one_of(*IMAGE_FORMATS, "AUTO", upper=True),
cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536),
cv.Optional(CONF_REQUEST_HEADERS): cv.All(
cv.Schema({cv.string: cv.templatable(cv.string)})
@@ -1,9 +1,11 @@
#include "online_image.h"
#include "esphome/components/runtime_image/image_decoder.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
static const char *const TAG = "online_image";
static const char *const CONTENT_TYPE_HEADER_NAME = "content-type";
static const char *const ETAG_HEADER_NAME = "etag";
static const char *const IF_NONE_MATCH_HEADER_NAME = "if-none-match";
static const char *const LAST_MODIFIED_HEADER_NAME = "last-modified";
@@ -62,7 +64,8 @@ void OnlineImage::update() {
// Add Accept header based on image format
const char *accept_mime_type;
switch (this->get_format()) {
runtime_image::ImageFormat format = this->get_format();
switch (format) {
#ifdef USE_RUNTIME_IMAGE_BMP
case runtime_image::BMP:
accept_mime_type = "image/bmp,*/*;q=0.8";
@@ -89,8 +92,8 @@ void OnlineImage::update() {
headers.push_back(http_request::Header{header.first, header.second.value()});
}
this->downloader_ = this->parent_->get(this->url_, headers, {ETAG_HEADER_NAME, LAST_MODIFIED_HEADER_NAME});
this->downloader_ =
this->parent_->get(this->url_, headers, {ETAG_HEADER_NAME, LAST_MODIFIED_HEADER_NAME, CONTENT_TYPE_HEADER_NAME});
if (this->downloader_ == nullptr) {
ESP_LOGE(TAG, "Download failed.");
this->end_connection_();
@@ -115,17 +118,54 @@ void OnlineImage::update() {
ESP_LOGD(TAG, "Starting download");
size_t total_size = this->downloader_->content_length;
ESP_LOGV(TAG, "Content-Length: %zu", total_size);
if (format == runtime_image::AUTO) {
// Try to auto-detect format from Content-Type header
auto content_type_header = this->downloader_->get_response_header(CONTENT_TYPE_HEADER_NAME);
const char *content_type = content_type_header.c_str();
ESP_LOGV(TAG, "Content-Type: %s", content_type);
// Includes aliases seen from real servers (older IIS, CDNs, S3)
if (str_contains_ignore_case(content_type, "image/bmp") ||
str_contains_ignore_case(content_type, "image/x-ms-bmp") ||
str_contains_ignore_case(content_type, "image/x-bmp")) {
format = runtime_image::BMP;
} else if (str_contains_ignore_case(content_type, "image/jpeg") ||
str_contains_ignore_case(content_type, "image/jpg")) {
format = runtime_image::JPEG;
} else if (str_contains_ignore_case(content_type, "image/png") ||
str_contains_ignore_case(content_type, "image/x-png")) {
format = runtime_image::PNG;
} else if (str_contains_ignore_case(content_type, "image/")) {
ESP_LOGW(TAG, "Unsupported image type: '%s'", content_type);
this->end_connection_();
this->download_error_callback_.call();
return;
} else {
// TODO: implement auto-detection in runtime_image by sniffing the first few bytes of the image data
if (content_type_header.empty()) {
ESP_LOGW(TAG, "Server sent no Content-Type header; cannot determine image format. Set `format:` explicitly");
} else {
ESP_LOGE(TAG, "Could not determine image format from Content-Type: '%s'. Set `format:` explicitly",
content_type);
}
this->end_connection_();
this->download_error_callback_.call();
return;
}
}
ESP_LOGD(TAG, "Using image format: %d", format);
// Initialize decoder with the known format
if (!this->begin_decode(total_size)) {
ESP_LOGE(TAG, "Failed to initialize decoder for format %d", this->get_format());
if (!this->begin_decode(total_size, format)) {
ESP_LOGE(TAG, "Failed to initialize decoder for format %d", format);
this->end_connection_();
this->download_error_callback_.call();
return;
}
// JPEG requires the complete image in the download buffer before decoding
if (this->get_format() == runtime_image::JPEG && total_size > this->download_buffer_.size()) {
if (format == runtime_image::JPEG && total_size > this->download_buffer_.size()) {
this->download_buffer_.resize(total_size);
}
+1
View File
@@ -49,6 +49,7 @@ enum OTAResponseTypes {
OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY = 0x91,
OTA_RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92,
OTA_RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93,
OTA_RESPONSE_ERROR_ENCRYPTION_REQUIRED = 0x94,
OTA_RESPONSE_ERROR_UNKNOWN = 0xFF,
};
+23 -4
View File
@@ -58,6 +58,18 @@ class Format:
"""Add defines and libraries needed for this format."""
class AUTOFormat(Format):
"""AUTO format - detect from MIME type."""
def __init__(self):
super().__init__("AUTO", None)
def actions(self) -> None:
# dict.fromkeys dedupes the JPG/JPEG alias so each format runs once
for image_format in dict.fromkeys(IMAGE_FORMATS.values()):
image_format.actions()
class BMPFormat(Format):
"""BMP format decoder configuration."""
@@ -102,18 +114,25 @@ class PNGFormat(Format):
cg.add_library("pngle", "1.1.0")
# Registry of available formats
# Decodable formats only; platforms that support runtime detection accept
# "AUTO" in their own schema and get_format() resolves it
_JPEG_FORMAT = JPEGFormat()
IMAGE_FORMATS = {
"BMP": BMPFormat(),
"JPEG": JPEGFormat(),
"JPEG": _JPEG_FORMAT,
"JPG": _JPEG_FORMAT, # Alias for JPEG
"PNG": PNGFormat(),
"JPG": JPEGFormat(), # Alias for JPEG
}
AUTO_FORMAT = AUTOFormat()
def get_format(format_name: str) -> Format | None:
"""Get a format instance by name."""
return IMAGE_FORMATS.get(format_name.upper())
name = format_name.upper()
if name == "AUTO":
return AUTO_FORMAT
return IMAGE_FORMATS.get(name)
def enable_format(format_name: str) -> Format | None:
@@ -6,7 +6,8 @@ namespace esphome::runtime_image {
* @brief Image format types that can be decoded dynamically.
*/
enum ImageFormat {
/** Automatically detect from data. Not implemented yet. */
/** Format is supplied per decode, e.g. detected from the Content-Type header
* by online_image; sniffing the image data is not implemented. */
AUTO,
/** JPEG format. */
JPEG,
@@ -171,22 +171,27 @@ void RuntimeImage::draw(int x, int y, display::Display *display, Color color_on,
// If no image is loaded and no placeholder, nothing to draw
}
bool RuntimeImage::begin_decode(size_t expected_size) {
bool RuntimeImage::begin_decode(size_t expected_size, ImageFormat format) {
if (this->is_decoding()) {
ESP_LOGW(TAG, "Decoding already in progress");
return false;
}
if (format == AUTO && this->format_ != AUTO) {
// Fall back to the configured format before the reuse check below
format = this->format_;
}
// An idle decoder for a different format cannot be reused
if (this->decoder_ != nullptr && this->decoder_->get_format() != this->format_) {
ESP_LOGD(TAG, "Decoder format mismatch: current: %d, new: %d", this->decoder_->get_format(), this->format_);
if (this->decoder_ != nullptr && this->decoder_->get_format() != format) {
ESP_LOGD(TAG, "Decoder format mismatch: current: %d, new: %d", this->decoder_->get_format(), format);
this->decoder_ = nullptr;
}
if (!this->decoder_) {
this->decoder_ = this->create_decoder_(this->format_);
this->decoder_ = this->create_decoder_(format);
if (!this->decoder_) {
ESP_LOGE(TAG, "Failed to create decoder for format %d", this->format_);
ESP_LOGE(TAG, "Failed to create decoder for format %d", format);
return false;
}
}
@@ -364,6 +369,9 @@ std::unique_ptr<ImageDecoder> RuntimeImage::create_decoder_(ImageFormat format)
case PNG:
return make_unique<PngDecoder>(this);
#endif
case AUTO:
ESP_LOGE(TAG, "Image format could not be determined; set `format:` explicitly in the configuration");
return nullptr;
default:
ESP_LOGE(TAG, "Unsupported image format: %d", format);
return nullptr;
@@ -62,9 +62,10 @@ class RuntimeImage : public image::Image {
* @brief Begin decoding an image.
*
* @param expected_size Optional hint about the expected data size.
* @param format The image format to decode (defaults to AUTO, which uses the value set at construction).
* @return true if decoder was successfully initialized.
*/
bool begin_decode(size_t expected_size = 0);
bool begin_decode(size_t expected_size = 0, ImageFormat format = AUTO);
/**
* @brief Feed data to the decoder.
@@ -103,6 +104,7 @@ class RuntimeImage : public image::Image {
/**
* @brief Get the image format.
*/
/// Configured format; a format resolved per decode lives on the active decoder
ImageFormat get_format() const { return this->format_; }
/**
+2 -15
View File
@@ -5,10 +5,7 @@ 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_defines,
filter_source_files_from_platform,
)
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import (
CONF_AFTER,
@@ -524,7 +521,7 @@ async def final_step():
cg.add_define("USE_UART_WAKE_LOOP_ON_RX")
_platform_filter = filter_source_files_from_platform(
FILTER_SOURCE_FILES = filter_source_files_from_platform(
{
"uart_component_esp_idf.cpp": {
PlatformFramework.ESP32_IDF,
@@ -540,13 +537,3 @@ _platform_filter = 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,7 +35,6 @@ class ListEntitiesIterator final : public ComponentIterator {
#undef ENTITY_TYPE_
#undef ENTITY_CONTROLLER_TYPE_
// NOLINTEND(bugprone-macro-parentheses)
bool completed() { return this->state_ == IteratorState::NONE; }
protected:
const WebServer *web_server_;
+2 -8
View File
@@ -214,8 +214,8 @@ void DeferredUpdateEventSource::process_deferred_queue_() {
void DeferredUpdateEventSource::loop() {
process_deferred_queue_();
if (!this->entities_iterator_.completed())
this->entities_iterator_.advance();
// One step per loop; refusals retry next pass
this->entities_iterator_.try_advance(1);
}
void DeferredUpdateEventSource::deferrable_send_state(void *source, const char *event_type,
@@ -321,12 +321,6 @@ void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource
#endif
source->entities_iterator_.begin(ws->include_internal_);
// just dump them all up-front and take advantage of the deferred queue
// on second thought that takes too long, but leaving the commented code here for debug purposes
// while(!source->entities_iterator_.completed()) {
// source->entities_iterator_.advance();
//}
});
}
@@ -935,8 +935,8 @@ void AsyncEventSourceResponse::process_buffer_() {
void AsyncEventSourceResponse::loop() {
process_buffer_();
process_deferred_queue_();
if (!this->entities_iterator_.completed())
this->entities_iterator_.advance();
// One step per loop; refusals retry next pass
this->entities_iterator_.try_advance(1);
}
bool AsyncEventSourceResponse::try_send_nodefer(const char *message, size_t message_len, const char *event, uint32_t id,
+14 -11
View File
@@ -22,23 +22,23 @@ void ComponentIterator::advance_platform_() {
this->at_ = 0;
}
void ComponentIterator::advance() {
bool ComponentIterator::advance_step_() {
switch (this->state_) {
case IteratorState::NONE:
// not started
return;
return false;
case IteratorState::BEGIN:
if (this->on_begin()) {
advance_platform_();
return true;
}
break;
return false;
// Entity iterator cases (generated from entity_types.h)
// NOLINTBEGIN(bugprone-macro-parentheses)
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
case IteratorState::upper: \
this->process_platform_item_(App.get_##plural(), &ComponentIterator::on_##singular); \
break;
return this->process_platform_item_(App.get_##plural(), &ComponentIterator::on_##singular);
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
ENTITY_TYPE_(type, singular, plural, count, upper)
#include "esphome/core/entity_types.h"
@@ -48,26 +48,29 @@ void ComponentIterator::advance() {
#ifdef USE_API_USER_DEFINED_ACTIONS
case IteratorState::SERVICE:
this->process_platform_item_(api::global_api_server->get_user_services(), &ComponentIterator::on_service);
break;
return this->process_platform_item_(api::global_api_server->get_user_services(), &ComponentIterator::on_service);
#endif
#ifdef USE_CAMERA
case IteratorState::CAMERA: {
camera::Camera *camera_instance = camera::Camera::instance();
if (camera_instance != nullptr && (!camera_instance->is_internal() || this->include_internal_)) {
this->on_camera(camera_instance);
if (camera_instance != nullptr && (!camera_instance->is_internal() || this->include_internal_) &&
!this->on_camera(camera_instance)) {
return false;
}
advance_platform_();
} break;
return true;
}
#endif
case IteratorState::MAX:
if (this->on_end()) {
this->state_ = IteratorState::NONE;
return true;
}
return;
return false;
}
return false;
}
bool ComponentIterator::on_end() { return true; }
+35 -8
View File
@@ -30,7 +30,23 @@ class RadioFrequency;
class ComponentIterator {
public:
void begin(bool include_internal = false);
void advance();
/// Run up to max_steps iteration steps; stops early when iteration
/// completes or a callback refuses (that step is retried on the next
/// call). Inline so an idle (completed) iterator costs one compare, no call.
ESPHOME_ALWAYS_INLINE void try_advance(size_t max_steps) {
size_t steps = 0;
while (steps < max_steps && !this->completed()) {
this->yield_requested_ = false;
if (!this->advance_step_())
break;
steps++;
if (this->yield_requested_)
break;
}
}
// Remove before 2027.3.0
ESPDEPRECATED("Use try_advance() instead. Removed in 2027.3.0", "2026.8.1")
void advance() { this->try_advance(1); }
bool completed() const { return this->state_ == IteratorState::NONE; }
virtual bool on_begin();
// Pure virtual entity callbacks (generated from entity_types.h)
@@ -73,23 +89,34 @@ class ComponentIterator {
#endif
MAX,
};
/// End the current try_advance() pass after this step; lets callbacks
/// that write directly to the socket cap direct writes per pass.
void yield_after_step_() { this->yield_requested_ = true; }
uint16_t at_{0}; // Supports up to 65,535 entities per type
IteratorState state_{IteratorState::NONE};
bool include_internal_{false};
bool yield_requested_ : 1 {false};
bool include_internal_ : 1 {false};
template<typename Container>
void process_platform_item_(const Container &items,
bool process_platform_item_(const Container &items,
bool (ComponentIterator::*on_item)(typename Container::value_type)) {
if (this->at_ >= items.size()) {
this->advance_platform_();
} else {
typename Container::value_type item = items[this->at_];
if ((item->is_internal() && !this->include_internal_) || (this->*on_item)(item)) {
this->at_++;
}
return true;
}
typename Container::value_type item = items[this->at_];
if ((item->is_internal() && !this->include_internal_) || (this->*on_item)(item)) {
this->at_++;
return true;
}
return false;
}
/// One iteration step; false if no progress was made (callback refused
/// or iterator not running).
bool advance_step_();
void advance_platform_();
};
+2
View File
@@ -222,6 +222,7 @@
#define API_MAX_SEND_QUEUE 8
#define MAX_API_CONNECTIONS 6
#define USE_MD5
#define USE_NOISE
#define USE_SHA256
#ifndef USE_RP2 // no MQTT backend or esp_wireguard library on RP2
#define USE_MQTT
@@ -234,6 +235,7 @@
#define USE_RUNTIME_IMAGE_JPEG
#define USE_RUNTIME_STATS
#define USE_OTA
#define USE_OTA_ENCRYPTION
#define USE_OTA_PASSWORD
#define USE_OTA_VERSION 2
#define USE_TIME_TIMEZONE
+197 -2
View File
@@ -53,6 +53,7 @@ RESPONSE_ERROR_PARTITION_TABLE_UPDATE = 0x90
RESPONSE_ERROR_BOOTLOADER_VERIFY = 0x91
RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92
RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93
RESPONSE_ERROR_ENCRYPTION_REQUIRED = 0x94
RESPONSE_ERROR_UNKNOWN = 0xFF
OTA_VERSION_1_0 = 1
@@ -63,8 +64,17 @@ MAGIC_BYTES = [0x6C, 0x26, 0xF7, 0x5C, 0x45]
CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01
CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02
CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04
CLIENT_FEATURE_SUPPORTS_NOISE = 0x08
SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01
SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02
SERVER_FEATURE_SUPPORTS_NOISE = 0x04
NOISE_FRAME_INDICATOR = 0x01
NOISE_HANDSHAKE_OK = 0x00
# The device decrypts frames in its 1024-byte transfer buffer; the 16-byte
# ChaCha20-Poly1305 MAC leaves this much plaintext per frame.
NOISE_MAX_PLAINTEXT = 1008
NOISE_PROLOGUE_INIT = b"NoiseOTAInit"
# OTA types this client knows how to send. Future PRs that add bootloader/partition
# updates extend this set. Anything outside the set is rejected up front so callers
@@ -171,6 +181,12 @@ _ERROR_MESSAGES: dict[int, str] = {
"enabled: the new firmware's version must be newer than the version the "
"device is currently running."
),
RESPONSE_ERROR_ENCRYPTION_REQUIRED: (
"The device requires an encrypted OTA connection but this upload has no "
"encryption key. Add 'encryption:' to the 'ota: platform: esphome' section "
"of the YAML this upload uses, or update your esphome installation if it "
"predates OTA encryption."
),
RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP",
}
@@ -305,12 +321,155 @@ def send_check(
raise OTANetworkError(f"sending {msg}: {err}") from err
class NoiseSocketWrapper:
"""Runs the OTA session inside a Noise (ChaCha20-Poly1305) transport.
Presents the small subset of the socket API perform_ota uses (recv,
sendall, and the settimeout/setsockopt/close pass-throughs), so the rest
of the upload flow works unchanged. Frames on the wire are
indicator 0x01, 16-bit big-endian length, ciphertext; recv() serves the
decrypted stream from an internal buffer one frame at a time. Writes keep
each sendall() unit within one frame when it fits (the device expects
control units one per frame) and split larger data blocks at
NOISE_MAX_PLAINTEXT.
"""
def __init__(self, sock: socket.socket, psk: str, prologue: bytes) -> None:
# Deliberately lazy: the noise stack (noiseprotocol, cryptography) is
# only imported when an encrypted upload actually runs.
try:
from aioesphomeapi.noise import NoiseHandshake
except ImportError as err:
raise OTAError(
"OTA encryption requires a newer aioesphomeapi; update your "
"esphome installation (pip install -U esphome) and retry"
) from err
from cryptography.exceptions import InvalidTag
self._invalid_tag = InvalidTag
self._sock = sock
try:
self._handshake = NoiseHandshake(psk, prologue)
except ValueError as err:
raise OTAError(f"Invalid OTA encryption key: {err}") from err
self._encrypt = None
self._decrypt = None
self._buffer = b""
# Only harmless socket controls pass through; anything that moves bytes
# must go through the encrypted recv/sendall. Byte-moving socket methods
# (send, recv_into, ...) are deliberately not defined, so reaching for
# one raises AttributeError instead of leaking plaintext.
def settimeout(self, timeout: float | None) -> None:
self._sock.settimeout(timeout)
def setsockopt(self, level: int, optname: int, value: int) -> None:
self._sock.setsockopt(level, optname, value)
def close(self) -> None:
self._sock.close()
def do_handshake(self) -> None:
"""Run the two-message NNpsk0 handshake and set up the transport ciphers."""
try:
self._send_frame(
bytes([NOISE_HANDSHAKE_OK]) + self._handshake.write_message()
)
payload = self._recv_frame()
except OSError as err:
raise OTANetworkError(f"noise handshake: {err}") from err
if not payload:
raise OTANetworkError("Device closed connection during the noise handshake")
if payload[0] != NOISE_HANDSHAKE_OK:
reason = payload[1:].decode("utf-8", "replace")
if reason == "Handshake MAC failure":
raise OTAError(
"Device rejected the handshake; is the OTA encryption key correct?"
)
raise OTAError(f"Device rejected the noise handshake: {reason}")
try:
self._handshake.read_message(payload[1:])
except (ValueError, self._invalid_tag) as err:
# InvalidTag is a wrong key; ValueError covers a device sending an
# invalid curve point, which cryptography rejects during the DH
raise OTAError(
"Noise handshake failed; is the OTA encryption key correct?"
) from err
self._encrypt, self._decrypt = self._handshake.get_ciphers()
def sendall(self, data: bytes) -> None:
frames: list[bytes] = []
for offset in range(0, len(data), NOISE_MAX_PLAINTEXT):
ciphertext = self._encrypt.encrypt(
data[offset : offset + NOISE_MAX_PLAINTEXT]
)
frames.append(self._frame_header(len(ciphertext)))
frames.append(ciphertext)
self._sock.sendall(b"".join(frames))
def recv(self, amount: int) -> bytes:
if not self._buffer:
ciphertext = self._recv_frame()
if not ciphertext:
return b"" # connection closed at a frame boundary
try:
self._buffer = self._decrypt.decrypt(ciphertext)
except self._invalid_tag as err:
# A fresh connection renegotiates the session, so this is
# retryable like other transport failures. The message names
# the MAC so repeated failures read as tampering or a cipher
# desync, not a flaky link.
raise OTANetworkError(
"Noise decryption failed (MAC mismatch); frame corrupted or tampered"
) from err
if not self._buffer:
# A MAC-only frame decrypts to nothing; reject it so recv's
# b"" always means the peer closed
raise OTANetworkError("Device sent an empty noise frame")
data = self._buffer[:amount]
self._buffer = self._buffer[amount:]
return data
@staticmethod
def _frame_header(length: int) -> bytes:
return bytes([NOISE_FRAME_INDICATOR, (length >> 8) & 0xFF, length & 0xFF])
def _send_frame(self, payload: bytes) -> None:
self._sock.sendall(self._frame_header(len(payload)) + payload)
def _recv_frame(self) -> bytes:
header = self._recv_exact(3, closed_ok=True)
if not header:
return b"" # connection closed at a frame boundary
# A malformed frame is a broken transport, not a device-reported
# error; raise the retryable class so the send-failure probe keeps its
# semantics and a fresh session is tried
if header[0] != NOISE_FRAME_INDICATOR:
raise OTANetworkError(f"Bad noise frame indicator 0x{header[0]:02X}")
length = (header[1] << 8) | header[2]
if length == 0:
raise OTANetworkError("Device sent an empty noise frame")
return self._recv_exact(length)
def _recv_exact(self, amount: int, closed_ok: bool = False) -> bytes:
data = b""
while len(data) < amount:
chunk = self._sock.recv(amount - len(data))
if not chunk:
if closed_ok and not data:
return b""
raise OSError("connection closed inside a noise frame")
data += chunk
return data
def perform_ota(
sock: socket.socket,
password: str | None,
file_handle: io.IOBase,
filename: Path,
ota_type: int = OTA_TYPE_UPDATE_APP,
noise_psk: str | None = None,
) -> None:
# Validate ota_type up front. It travels as a single byte on the wire, and
# passing an out-of-range value would only surface as a ValueError from
@@ -325,6 +484,11 @@ def perform_ota(
f"Unsupported OTA type 0x{ota_type:02X}; this ESPHome supports: {supported}"
)
if noise_psk is not None and not noise_psk:
raise OTAError(
"An empty OTA encryption key was provided; refusing to upload in plaintext"
)
file_contents = file_handle.read()
file_size = len(file_contents)
_LOGGER.info("Uploading %s (%s bytes)", filename, file_size)
@@ -347,6 +511,8 @@ def perform_ota(
| CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
)
if noise_psk:
features_to_send |= CLIENT_FEATURE_SUPPORTS_NOISE
send_check(sock, features_to_send, "features")
features = receive_exactly(
sock,
@@ -369,6 +535,31 @@ def perform_ota(
else:
features = 0
if noise_psk:
# Fail closed: never fall back to a plaintext upload when an
# encryption key is configured, an active attacker could otherwise
# strip the feature flag and capture the image (it contains the wifi
# credentials and the api encryption key).
if not (extended_proto and features & SERVER_FEATURE_SUPPORTS_NOISE):
raise OTAError(
"An OTA encryption key is configured but the device did not "
"offer encryption; refusing to send the image in plaintext. "
"If the running firmware predates OTA encryption, first update "
"it without the 'ota: encryption:' block (over a trusted "
"network or via USB), then restore the block and upload again."
)
# The prologue binds every negotiation byte both sides saw, so any
# tampering with the plaintext preamble breaks the handshake.
prologue = (
NOISE_PROLOGUE_INIT
+ bytes(MAGIC_BYTES)
+ bytes([RESPONSE_OK, version, features_to_send])
+ bytes([RESPONSE_FEATURE_FLAGS, features])
)
sock = NoiseSocketWrapper(sock, noise_psk, prologue)
sock.do_handshake()
_LOGGER.info("Encrypted connection established")
if ota_type != OTA_TYPE_UPDATE_APP:
# Any non-app OTA type requires the extended protocol and the
# partition-access server feature. Reject up front so the user gets
@@ -572,6 +763,7 @@ def run_ota_impl_(
password: str | None,
filename: Path,
ota_type: int = OTA_TYPE_UPDATE_APP,
noise_psk: str | None = None,
) -> tuple[int, str | None]:
from esphome.core import CORE
@@ -636,7 +828,7 @@ def run_ota_impl_(
reached_device = True
with contextlib.closing(sock), Path(filename).open("rb") as file_handle:
try:
perform_ota(sock, password, file_handle, filename, ota_type)
perform_ota(sock, password, file_handle, filename, ota_type, noise_psk)
except OTANetworkError as err:
# Transient network failure; retry
last_error = str(err)
@@ -661,9 +853,12 @@ def run_ota(
password: str | None,
filename: Path,
ota_type: int = OTA_TYPE_UPDATE_APP,
noise_psk: str | None = None,
) -> tuple[int, str | None]:
try:
return run_ota_impl_(remote_host, remote_port, password, filename, ota_type)
return run_ota_impl_(
remote_host, remote_port, password, filename, ota_type, noise_psk
)
except OTAError as err:
_LOGGER.error(err)
return 1, None
+17 -4
View File
@@ -16,6 +16,7 @@ from esphome.const import CONF_FILE, CONF_TYPE, CONF_URL, __version__
from esphome.core import CORE, EsphomeError, TimePeriodSeconds
from esphome.happy_eyeballs import ensure_happy_eyeballs
from esphome.helpers import write_file
from esphome.net_retry import fetch_with_retry
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
@@ -157,8 +158,17 @@ def has_remote_file_changed(
}
if etag := _read_etag(local_file_path):
headers[IF_NONE_MATCH] = etag
response = requests.head(
url, headers=headers, timeout=timeout, allow_redirects=True
# Retried so allow_stale=False consumers don't hard-fail on a
# healed flake. Only connection-level failures retry: HEAD
# never raises on HTTP status (servers rejecting HEAD with
# 405/501 must fall through to the GET), so 5xx is handled by
# the GET's own retry.
response = fetch_with_retry(
url,
lambda: requests.head(
url, headers=headers, timeout=timeout, allow_redirects=True
),
what="Revalidation",
)
_LOGGER.debug(
@@ -293,7 +303,7 @@ def download_content(
_LOGGER.info("Downloading %s", url)
_LOGGER.debug("Saving to %s", path)
try:
def _fetch() -> tuple[requests.Response, bytes]:
req = requests.get(
url,
timeout=timeout,
@@ -304,7 +314,10 @@ def download_content(
# and mid-stream connection errors all surface here as
# RequestException subclasses, so this needs the same fall-back
# treatment as the request itself.
data = req.content
return req, req.content
try:
req, data = fetch_with_retry(url, _fetch)
except requests.exceptions.RequestException as e:
if path.exists():
# Memoized so a flaky host warns once per run, not per consumer.
+5 -27
View File
@@ -15,6 +15,7 @@ from typing import IO, TYPE_CHECKING
from esphome.happy_eyeballs import ensure_happy_eyeballs
from esphome.helpers import ProgressBar, rmtree
from esphome.net_retry import NETWORK_MAX_ATTEMPTS, is_transient_download_error
if TYPE_CHECKING:
import requests
@@ -29,8 +30,9 @@ _LOGGER = logging.getLogger(__name__)
_MIRROR_ATTEMPTS = 3
# Passes over the whole mirror list when a transient network error is in
# the mix; matches git.py's _NETWORK_MAX_ATTEMPTS (3 tries, 2s/4s backoff).
_MIRROR_SWEEP_ATTEMPTS = 3
# the mix; shares net_retry's policy (3 tries, 2s/4s backoff), which in
# turn matches git.py's _NETWORK_MAX_ATTEMPTS.
_MIRROR_SWEEP_ATTEMPTS = NETWORK_MAX_ATTEMPTS
def get_project_link_flags() -> list[str]:
@@ -903,30 +905,6 @@ def _spent_attempts_error(e: Exception, attempts: int) -> Exception:
return err
def _is_transient_download_error(e: Exception) -> bool:
"""Return True when a download failure is worth retrying.
Connection-level failures and HTTP 429/5xx are transient. Other HTTP
errors, local errors, and exhausted-attempts EsphomeError wrappers
(their per-mirror retries are already spent) are permanent.
"""
# Imported lazily: requests is a heavy import (~85ms) and is only
# needed when actually downloading, never during config validation.
import requests
if isinstance(e, requests.exceptions.HTTPError):
resp = e.response
return resp is not None and (resp.status_code == 429 or resp.status_code >= 500)
return isinstance(
e,
(
requests.exceptions.ConnectionError,
requests.exceptions.Timeout,
requests.exceptions.ChunkedEncodingError,
),
)
def _try_mirrors_once(
urls: list[str],
path_target: Path | None,
@@ -1131,7 +1109,7 @@ def download_from_mirrors(
# Permanent failures (404, verification mismatch) won't heal;
# only retry when a transient error is in the mix (as git.py does).
transient = next(
((u, e) for u, e in sweep_failures if _is_transient_download_error(e)),
((u, e) for u, e in sweep_failures if is_transient_download_error(e)),
None,
)
if transient is None:
+114
View File
@@ -0,0 +1,114 @@
"""Retry policy for HTTP downloads.
Kept import-light on purpose: this module is imported at config time, so it
must not pull in requests (a heavy import, ~85ms) at module scope.
"""
from __future__ import annotations
from collections.abc import Callable
import logging
import time
_LOGGER = logging.getLogger(__name__)
# 3 tries with 2s/4s backoff, matching git.py's _NETWORK_MAX_ATTEMPTS.
# Callers memoize failures so a flaky host pays this once per file per run.
NETWORK_MAX_ATTEMPTS = 3
def _is_permanent_dns_failure(e: BaseException) -> bool:
"""Whether a hard socket.gaierror hides in ``e``'s exception chain.
EAI_AGAIN (flaky resolver) stays retryable; anything else is permanent
so offline builds fall back to their cache without sleeping first.
Narrower than git.py, which retries NXDOMAIN too.
Walks ``__cause__``, ``args`` (requests wraps MaxRetryError without
``from``) and MaxRetryError's ``reason``, but not implicit
``__context__``: an unrelated earlier attempt's resolution failure
must not reclassify an error it did not cause.
"""
import socket
seen: set[int] = set()
stack: list[BaseException] = [e]
while stack:
exc = stack.pop()
if id(exc) in seen:
continue
if (
isinstance(exc, socket.gaierror)
and exc.errno is not None
and exc.errno != socket.EAI_AGAIN
):
return True
seen.add(id(exc))
stack.extend(
nxt
for nxt in (
exc.__cause__,
getattr(exc, "reason", None), # urllib3 MaxRetryError
*exc.args,
)
if isinstance(nxt, BaseException)
)
return False
def is_transient_download_error(e: Exception) -> bool:
"""Return True when a download failure is worth retrying.
Connection-level failures and HTTP 429/5xx are transient; hard DNS
failures, other HTTP errors, and local errors are permanent.
"""
# Imported lazily: requests is a heavy import (~85ms) and is only
# needed when actually downloading, never during config validation.
import requests
if isinstance(e, requests.exceptions.HTTPError):
resp = e.response
return resp is not None and (resp.status_code == 429 or resp.status_code >= 500)
if isinstance(e, requests.exceptions.ConnectionError) and _is_permanent_dns_failure(
e
):
return False
# SSLError (a ConnectionError subclass) stays transient on purpose: it
# also covers mid-handshake connection drops, not just bad certificates.
return isinstance(
e,
(
requests.exceptions.ConnectionError,
requests.exceptions.Timeout,
requests.exceptions.ChunkedEncodingError,
requests.exceptions.ContentDecodingError,
),
)
def fetch_with_retry[T](url: str, fetch: Callable[[], T], what: str = "Download") -> T:
"""Run ``fetch``, retrying transient failures with 2s/4s backoff.
Permanent failures and the final attempt propagate to the caller;
``what`` names the operation in the retry warning.
"""
import requests
for attempt in range(1, NETWORK_MAX_ATTEMPTS):
try:
return fetch()
except requests.exceptions.RequestException as e:
if not is_transient_download_error(e):
raise
delay = 2**attempt
_LOGGER.warning(
"%s of %s failed: %s. Retrying in %d seconds... (attempt %d/%d)",
what,
url,
e,
delay,
attempt + 1,
NETWORK_MAX_ATTEMPTS,
)
time.sleep(delay)
return fetch()
+3 -3
View File
@@ -45,7 +45,7 @@ lib_deps_base =
lib_deps =
${common.lib_deps_base}
https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea
esphome/noise-c@0.1.21 ; api
esphome/noise-c@0.1.21 ; noise (api, ota)
improv/Improv@1.2.6 ; improv_serial / esp32_improv
kikuchan98/pngle@1.1.0 ; online_image
; Using the repository directly, otherwise ESP-IDF can't use the library
@@ -244,7 +244,7 @@ lib_deps =
${common:idf-component-libs.lib_deps}
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
droscy/esp_wireguard@0.4.5 ; wireguard
esphome/noise-c@0.1.21 ; api
esphome/noise-c@0.1.21 ; noise (api, ota)
ESP32Async/AsyncTCP@3.4.5 ; async_tcp
DNSServer ; captive_portal
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
@@ -641,7 +641,7 @@ build_unflags =
extends = common
platform = platformio/native
lib_deps =
esphome/noise-c@0.1.21 ; used by api
esphome/noise-c@0.1.21 ; used by noise (api, ota)
lvgl/lvgl@9.5.0 ; lvgl
build_flags =
${common.build_flags}
+1 -1
View File
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.1.19
esptool==5.3.1
click==8.3.3
aioesphomeapi==45.13.1
aioesphomeapi==46.0.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.150.0
puremagic==2.2.0
+3 -2
View File
@@ -3,8 +3,9 @@ from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# api must run its to_code to define USE_API, USE_API_PLAINTEXT,
# and add the noise-c library dependency.
# api must run its to_code to define USE_API and USE_API_NOISE. The
# AUTO_LOADed noise component runs its own to_code via the override in
# tests/benchmarks/components/noise/__init__.py.
manifest.enable_codegen()
original_to_code = manifest.to_code
@@ -0,0 +1,7 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# to_code must run: it defines USE_NOISE and adds the noise-c library
# the api benchmark sources need.
manifest.enable_codegen()
@@ -0,0 +1,19 @@
esphome:
name: scan-window-explicit
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: MySSID
esp32_ble_tracker:
scan_parameters:
window: 30ms
bluetooth_proxy:
active: true
api:
@@ -0,0 +1,17 @@
esphome:
name: scan-window-raised
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: MySSID
esp32_ble_tracker:
bluetooth_proxy:
active: true
api:
@@ -0,0 +1,12 @@
esphome:
name: scan-window-scan-only
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: MySSID
esp32_ble_tracker:
@@ -0,0 +1,14 @@
esphome:
name: scan-window-user-scan-only
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: MySSID
esp32_ble_tracker:
scan_parameters:
connection_scan_window: 20ms
@@ -12,11 +12,12 @@ arbiter a full-duty scan would starve wifi, so the 30 ms default is kept.
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome import config_validation as cv
from esphome.components.ble_device_base import to_ble_units
from esphome.components.ble_device_base import CONF_CONNECTION_SCAN_WINDOW, to_ble_units
from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW
from esphome.components.esp32 import KEY_IDF_VERSION
from esphome.components.esp32_ble_tracker import (
@@ -120,3 +121,103 @@ def test_short_interval_without_window_still_rejected(
stage_esp32("5.5.5", wifi=True)
with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"):
_scan_params({"scan_parameters": {"interval": "20ms"}})
# The connection-time fallback window: while a GATT connection is active the
# scanner drops from a raised full-duty window back to this value so the
# connection gets guaranteed airtime.
def test_raise_arms_connection_scan_window_default(
stage_esp32: Callable[..., None],
) -> None:
stage_esp32("5.5.5", wifi=True)
params = _scan_params({})
assert params[CONF_WINDOW] == params[CONF_INTERVAL]
assert to_ble_units(params[CONF_CONNECTION_SCAN_WINDOW]) == 48
def test_user_connection_scan_window_survives_raise(
stage_esp32: Callable[..., None],
) -> None:
stage_esp32("5.5.5", wifi=True)
params = _scan_params({"scan_parameters": {"connection_scan_window": "60ms"}})
assert params[CONF_WINDOW] == params[CONF_INTERVAL]
assert to_ble_units(params[CONF_CONNECTION_SCAN_WINDOW]) == 96
def test_unraised_window_gets_no_connection_scan_window_default(
stage_esp32: Callable[..., None],
) -> None:
stage_esp32("5.5.4", wifi=True)
assert CONF_CONNECTION_SCAN_WINDOW not in _scan_params({})
def test_connection_scan_window_above_interval_rejected(
stage_esp32: Callable[..., None],
) -> None:
stage_esp32("5.5.5", wifi=True)
with pytest.raises(
cv.Invalid, match="connection_scan_window .* needs to be smaller"
):
_scan_params({"scan_parameters": {"connection_scan_window": "400ms"}})
def test_connection_scan_window_above_window_rejected(
stage_esp32: Callable[..., None],
) -> None:
"""A connection window above the (post-raise) window would widen the scan
during connections; the reject runs after the raise so a fallback below a
raised window still validates (covered by the survives-raise test)."""
stage_esp32("5.5.5", wifi=True)
with pytest.raises(
cv.Invalid, match="connection_scan_window .* needs to be smaller"
):
_scan_params(
{"scan_parameters": {"window": "30ms", "connection_scan_window": "300ms"}}
)
def test_connection_scan_window_truncation_collapse_rejected(
stage_esp32: Callable[..., None],
) -> None:
"""A connection window that truncates into the interval's 0.625 ms unit
would silently program a full-duty scan during connections."""
stage_esp32("5.5.5", wifi=True)
with pytest.raises(cv.Invalid, match="connection_scan_window .* both truncate"):
_scan_params(
{
"scan_parameters": {
"interval": "320.5ms",
"connection_scan_window": "320.2ms",
}
}
)
@pytest.mark.parametrize(
("config_file", "window_call", "connection_call", "warns"),
[
# Raised window with GATT clients: the injected fallback is emitted.
("scan_window_raised.yaml", "set_scan_window(512)", True, False),
# Explicit window: nothing injected.
("scan_window_explicit.yaml", "set_scan_window(48)", False, False),
# Scan-only build compiles the path out: the injected default is
# dropped silently, a user-set value warns.
("scan_window_scan_only.yaml", "set_scan_window(512)", False, False),
("scan_window_user_set_scan_only.yaml", "set_scan_window(512)", False, True),
],
)
def test_connection_scan_window_codegen(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
caplog: pytest.LogCaptureFixture,
config_file: str,
window_call: str,
connection_call: bool,
warns: bool,
) -> None:
main_cpp = generate_main(component_config_path(config_file))
assert window_call in main_cpp
assert ("set_connection_scan_window(48)" in main_cpp) == connection_call
assert ("'connection_scan_window' has no effect" in caplog.text) == warns
@@ -16,7 +16,13 @@ from esphome.components.modbus_client import (
CONFIG_SCHEMA,
MODBUS_CLIENT_SEND_SCHEMA,
)
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_ON_ERROR, CONF_ON_RESPONSE
from esphome.const import (
CONF_ADDRESS,
CONF_CONTINUOUS,
CONF_ID,
CONF_ON_ERROR,
CONF_ON_RESPONSE,
)
from esphome.core import Lambda
from esphome.types import ConfigType
@@ -118,6 +124,29 @@ def test_on_no_response_retry_lambda_accepted() -> None:
)
def test_continuous_on_write_pdu_rejected() -> None:
"""A literal write-code PDU with continuous: true is rejected at config time (reads only)."""
with pytest.raises(cv.Invalid, match="does not apply to a write PDU"):
MODBUS_CLIENT_SEND_SCHEMA(
{
CONF_ADDRESS: 0x01,
CONF_PDU: [0x06, 0x00, 0x01, 0x00, 0x0A],
CONF_CONTINUOUS: True,
}
)
def test_continuous_on_read_pdu_accepted() -> None:
"""A literal read-code PDU with continuous: true is fine - continuous polling applies to reads."""
MODBUS_CLIENT_SEND_SCHEMA(
{
CONF_ADDRESS: 0x01,
CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01],
CONF_CONTINUOUS: True,
}
)
# The standalone component block. The compile fixtures cover the accepted shapes end to end; these pin
# the parts a fixture cannot express - a rejection, and a module flag whose absence breaks other
# components rather than this one.
@@ -0,0 +1,37 @@
"""Tests for the shared noise encryption key helpers."""
from __future__ import annotations
import pytest
from esphome import config_validation as cv
from esphome.components.noise import decode_encryption_key, validate_encryption_key
KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
def test_validate_encryption_key_roundtrips() -> None:
assert validate_encryption_key(KEY) == KEY
@pytest.mark.parametrize("value", ["not-base64!!!", "AAECAw=="])
def test_validate_encryption_key_rejects_bad_input(value: str) -> None:
with pytest.raises(cv.Invalid):
validate_encryption_key(value)
def test_decode_encryption_key_returns_32_bytes() -> None:
assert decode_encryption_key(KEY) == bytes(range(32))
def test_decode_encryption_key_rejects_invalid_base64() -> None:
"""The shared helper raises cv.Invalid, not binascii.Error."""
with pytest.raises(cv.Invalid, match="base64"):
decode_encryption_key("A")
def test_decode_encryption_key_rejects_short_decode() -> None:
"""a2b_base64 stops at embedded padding; a short decode must not become
a zero padded PSK on the device."""
with pytest.raises(cv.Invalid, match="32 bytes"):
decode_encryption_key("AAECAw==")
+308 -2
View File
@@ -8,17 +8,25 @@ from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.esphome.ota import ota_esphome_final_validate
from esphome.components.esphome.ota import (
AUTO_LOAD,
FILTER_SOURCE_FILES,
_validate_no_password_with_encryption,
ota_esphome_final_validate,
)
from esphome.const import (
CONF_API,
CONF_ENCRYPTION,
CONF_ESPHOME,
CONF_ID,
CONF_KEY,
CONF_OTA,
CONF_PASSWORD,
CONF_PLATFORM,
CONF_PORT,
CONF_VERSION,
)
from esphome.core import ID
from esphome.core import CORE, ID
import esphome.final_validate as fv
@@ -103,3 +111,301 @@ def test_non_esphome_ota_unaffected() -> None:
assert len(updated[CONF_OTA]) == 3
finally:
fv.full_config.reset(token)
API_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
OTHER_KEY = "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA="
ZEROS_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
def test_encryption_key_inherited_from_api() -> None:
"""A bare encryption block resolves to the api encryption key."""
full_conf = {
CONF_API: {CONF_ENCRYPTION: {CONF_KEY: API_KEY}},
CONF_OTA: [_make_ota_config(port=3232, **{CONF_ENCRYPTION: {}})],
}
token = fv.full_config.set(full_conf)
try:
ota_esphome_final_validate({})
updated = fv.full_config.get()
assert updated[CONF_OTA][0][CONF_ENCRYPTION][CONF_KEY] == API_KEY
finally:
fv.full_config.reset(token)
def test_encryption_explicit_key_matching_api_accepted() -> None:
"""An explicit ota key equal to the api key validates."""
full_conf = {
CONF_API: {CONF_ENCRYPTION: {CONF_KEY: API_KEY}},
CONF_OTA: [
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: API_KEY}})
],
}
token = fv.full_config.set(full_conf)
try:
ota_esphome_final_validate({})
updated = fv.full_config.get()
assert updated[CONF_OTA][0][CONF_ENCRYPTION][CONF_KEY] == API_KEY
finally:
fv.full_config.reset(token)
def test_encryption_key_differing_from_api_rejected() -> None:
"""There is one key per device; an ota key differing from the api key raises."""
full_conf = {
CONF_API: {CONF_ENCRYPTION: {CONF_KEY: API_KEY}},
CONF_OTA: [
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}})
],
}
token = fv.full_config.set(full_conf)
try:
with pytest.raises(cv.Invalid, match="must match the 'api' encryption key"):
ota_esphome_final_validate({})
finally:
fv.full_config.reset(token)
def test_encryption_explicit_key_without_api_encryption_accepted() -> None:
"""An explicit ota key with a plaintext api has nothing to match; it stands."""
full_conf = {
CONF_API: {},
CONF_OTA: [
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}})
],
}
token = fv.full_config.set(full_conf)
try:
ota_esphome_final_validate({})
updated = fv.full_config.get()
assert updated[CONF_OTA][0][CONF_ENCRYPTION][CONF_KEY] == OTHER_KEY
finally:
fv.full_config.reset(token)
def test_encryption_without_any_key_rejected() -> None:
"""A bare encryption block with no api key to inherit raises."""
full_conf = {
CONF_API: {},
CONF_OTA: [_make_ota_config(port=3232, **{CONF_ENCRYPTION: {}})],
}
token = fv.full_config.set(full_conf)
try:
with pytest.raises(cv.Invalid, match="no 'api' encryption key to inherit"):
ota_esphome_final_validate({})
finally:
fv.full_config.reset(token)
def test_encryption_explicit_all_zeros_key_rejected() -> None:
"""The all-zeros key is the provisioning sentinel; the device would treat
it as no PSK and accept plaintext, so it must fail validation."""
full_conf = {
CONF_OTA: [
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: ZEROS_KEY}})
],
}
token = fv.full_config.set(full_conf)
try:
with pytest.raises(cv.Invalid, match="all-zeros key is reserved"):
ota_esphome_final_validate({})
finally:
fv.full_config.reset(token)
def test_encryption_inherited_all_zeros_key_rejected() -> None:
"""An all-zeros api key must not silently disable ota encryption either."""
full_conf = {
CONF_API: {CONF_ENCRYPTION: {CONF_KEY: ZEROS_KEY}},
CONF_OTA: [_make_ota_config(port=3232, **{CONF_ENCRYPTION: {}})],
}
token = fv.full_config.set(full_conf)
try:
with pytest.raises(cv.Invalid, match="all-zeros key is reserved"):
ota_esphome_final_validate({})
finally:
fv.full_config.reset(token)
def test_encryption_key_mismatch_between_merged_configs_rejected() -> None:
"""Same-port configs with different encryption keys raise."""
full_conf = {
CONF_OTA: [
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: API_KEY}}),
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}),
]
}
token = fv.full_config.set(full_conf)
try:
with pytest.raises(cv.Invalid, match="encryption is inconsistent"):
ota_esphome_final_validate({})
finally:
fv.full_config.reset(token)
@pytest.mark.parametrize("keyed_first", [True, False])
def test_encryption_bare_and_keyed_blocks_merge(keyed_first: bool) -> None:
"""A bare encryption block (package/device split) is compatible with a
keyed one on the same port; the merge resolves to the keyed result."""
keyed = _make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}})
bare = _make_ota_config(port=3232, **{CONF_ENCRYPTION: {}})
full_conf = {
CONF_OTA: [keyed, bare] if keyed_first else [bare, keyed],
}
token = fv.full_config.set(full_conf)
try:
ota_esphome_final_validate({})
updated = fv.full_config.get()
assert len(updated[CONF_OTA]) == 1
assert updated[CONF_OTA][0][CONF_ENCRYPTION][CONF_KEY] == OTHER_KEY
finally:
fv.full_config.reset(token)
def test_encryption_runtime_provisioned_api_key_not_inheritable() -> None:
"""A keyless api encryption block provisions its key at runtime; a bare
ota encryption block cannot inherit it and the message says so."""
full_conf = {
CONF_API: {CONF_ENCRYPTION: {}},
CONF_OTA: [_make_ota_config(port=3232, **{CONF_ENCRYPTION: {}})],
}
token = fv.full_config.set(full_conf)
try:
with pytest.raises(cv.Invalid, match="provisioned at runtime"):
ota_esphome_final_validate({})
finally:
fv.full_config.reset(token)
def test_encryption_explicit_key_with_runtime_provisioned_api_accepted() -> None:
"""The documented remedy for a runtime-provisioned api key: set an
explicit ota key."""
full_conf = {
CONF_API: {CONF_ENCRYPTION: {}},
CONF_OTA: [
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}})
],
}
token = fv.full_config.set(full_conf)
try:
ota_esphome_final_validate({})
updated = fv.full_config.get()
assert updated[CONF_OTA][0][CONF_ENCRYPTION][CONF_KEY] == OTHER_KEY
finally:
fv.full_config.reset(token)
def test_encryption_with_web_server_ota_rejected() -> None:
"""With the web_server component the plaintext /update endpoint is always
on, a full bypass of the encryption; the combination fails closed."""
full_conf = {
"web_server": {},
CONF_OTA: [
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}),
{CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)},
],
}
token = fv.full_config.set(full_conf)
try:
with pytest.raises(cv.Invalid, match="plaintext HTTP"):
ota_esphome_final_validate({})
finally:
fv.full_config.reset(token)
def test_encryption_with_captive_portal_web_server_ota_warns(
caplog: pytest.LogCaptureFixture,
) -> None:
"""captive_portal auto-loads the web_server ota platform without the
web_server component; encryption stays usable and only warns, so the
fallback AP recovery path is not lost."""
full_conf = {
CONF_OTA: [
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}),
{CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)},
],
}
token = fv.full_config.set(full_conf)
try:
with caplog.at_level(logging.WARNING):
ota_esphome_final_validate({})
assert any("captive_portal" in record.message for record in caplog.records)
esphome_conf = next(
conf
for conf in fv.full_config.get()[CONF_OTA]
if conf.get(CONF_PLATFORM) == CONF_ESPHOME
)
assert esphome_conf[CONF_ENCRYPTION][CONF_KEY] == OTHER_KEY
finally:
fv.full_config.reset(token)
def test_web_server_ota_without_encryption_unaffected() -> None:
"""web_server ota stays valid alongside an unencrypted esphome entry."""
full_conf = {
CONF_OTA: [
_make_ota_config(port=3232),
{CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)},
],
}
token = fv.full_config.set(full_conf)
try:
ota_esphome_final_validate({})
assert len(fv.full_config.get()[CONF_OTA]) == 2
finally:
fv.full_config.reset(token)
def test_auto_load_pulls_noise_only_for_encryption() -> None:
"""A plain ota entry must never pull noise-c into the build."""
assert AUTO_LOAD({CONF_PORT: 3232}) == ["sha256", "socket"]
assert "noise" in AUTO_LOAD({CONF_ENCRYPTION: {}})
# Tooling probes must get the maximal set: None from dependency
# resolution, {} from the components-graph platform probe
assert "noise" in AUTO_LOAD(None)
assert "noise" in AUTO_LOAD({})
def test_filter_source_files_excludes_noise_without_encryption() -> None:
"""The noise transport source compiles only for encrypted builds."""
old_config = CORE.config
try:
CORE.config = {CONF_OTA: [_make_ota_config(port=3232)]}
assert FILTER_SOURCE_FILES() == ["ota_esphome_noise.cpp"]
CORE.config = {
CONF_OTA: [
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: API_KEY}})
]
}
assert FILTER_SOURCE_FILES() == []
finally:
CORE.config = old_config
def test_password_with_encryption_rejected() -> None:
"""The password and encryption options are mutually exclusive."""
config = {CONF_PASSWORD: "pw", CONF_ENCRYPTION: {CONF_KEY: API_KEY}}
with pytest.raises(cv.Invalid, match="cannot be combined"):
_validate_no_password_with_encryption(config)
def test_password_alone_accepted() -> None:
"""A password without encryption still validates."""
config = {CONF_PASSWORD: "pw"}
assert _validate_no_password_with_encryption(config) is config
def test_merged_password_and_encryption_rejected() -> None:
"""A password block and an encryption block merged on one port raise."""
full_conf = {
CONF_OTA: [
_make_ota_config(port=3232, **{CONF_PASSWORD: "pw"}),
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: API_KEY}}),
]
}
token = fv.full_config.set(full_conf)
try:
with pytest.raises(cv.Invalid, match="cannot be combined"):
ota_esphome_final_validate({})
finally:
fv.full_config.reset(token)
+11
View File
@@ -0,0 +1,11 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# No host camera platform exists to emit USE_CAMERA; define it here so
# the iterator CAMERA state compiles into the test binary.
async def to_code_testing(config):
cg.add_define("USE_CAMERA")
manifest.to_code = to_code_testing
@@ -0,0 +1,79 @@
#include <gtest/gtest.h>
#include "esphome/core/component_iterator.h"
#ifdef USE_CAMERA
#include "esphome/components/camera/camera.h"
namespace esphome::testing {
class StubCamera : public camera::Camera {
public:
void add_listener(camera::CameraListener *listener) override {}
camera::CameraImageReader *create_image_reader() override { return nullptr; }
void request_image(camera::CameraRequester requester) override {}
void start_stream(camera::CameraRequester requester) override {}
void stop_stream(camera::CameraRequester requester) override {}
};
// Iterator that accepts everything except the camera, which can refuse a
// configurable number of times. The CAMERA state is a singleton path
// distinct from process_platform_item_; this pins the same contract:
// a refused camera is re-offered, never skipped.
class CameraRefusingIterator : public ComponentIterator {
public:
// NOLINTBEGIN(bugprone-macro-parentheses)
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
bool on_##singular(type *obj) override { return true; }
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
ENTITY_TYPE_(type, singular, plural, count, upper)
#include "esphome/core/entity_types.h"
#undef ENTITY_TYPE_
#undef ENTITY_CONTROLLER_TYPE_
// NOLINTEND(bugprone-macro-parentheses)
bool on_camera(camera::Camera *obj) override {
this->camera_calls++;
if (this->camera_refusals > 0) {
this->camera_refusals--;
return false;
}
return true;
}
int camera_calls{0};
int camera_refusals{0};
};
// Far above the fixed number of iterator states
static constexpr size_t BIG_BUDGET = 1000;
class ComponentIteratorCameraTest : public ::testing::Test {
protected:
void SetUp() override {
// Constructing a Camera installs the process-wide singleton
static StubCamera stub_camera;
ASSERT_EQ(camera::Camera::instance(), &stub_camera);
}
};
TEST_F(ComponentIteratorCameraTest, RefusedCameraIsReofferedNotSkipped) {
CameraRefusingIterator it;
it.camera_refusals = 2;
it.begin();
// Runs until the camera refuses, which stops the pass
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.camera_calls, 1);
EXPECT_FALSE(it.completed());
// The camera is re-offered once per call, not skipped
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.camera_calls, 2);
EXPECT_FALSE(it.completed());
// Once accepted, the iteration completes
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_EQ(it.camera_calls, 3);
}
} // namespace esphome::testing
#endif // USE_CAMERA
+11
View File
@@ -0,0 +1,11 @@
# Pulls in sensor so entity iteration paths compile (USE_SENSOR);
# tests register their own instances. Plain yaml.safe_load, no ESPHome tags.
# An alphabetically-earlier component's sensor: block shadows this one in
# combined builds; the tests' sensor-count ASSERT catches a capacity drop.
sensor:
- platform: template
id: bench_sensor_a
name: "Bench A"
- platform: template
id: bench_sensor_b
name: "Bench B"
@@ -0,0 +1,195 @@
#include <gtest/gtest.h>
#include "esphome/core/component_iterator.h"
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/application.h"
#endif
namespace esphome::testing {
// Iterator whose begin/end callbacks can refuse a configurable number of
// times; all entity callbacks accept (any registered entities are accepted).
class RefusingIterator : public ComponentIterator {
public:
// NOLINTBEGIN(bugprone-macro-parentheses)
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
bool on_##singular(type *obj) override { return true; }
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
ENTITY_TYPE_(type, singular, plural, count, upper)
#include "esphome/core/entity_types.h"
#undef ENTITY_TYPE_
#undef ENTITY_CONTROLLER_TYPE_
// NOLINTEND(bugprone-macro-parentheses)
bool on_begin() override { return step(this->begin_calls, this->begin_refusals); }
bool on_end() override { return step(this->end_calls, this->end_refusals); }
int begin_calls{0};
int end_calls{0};
int begin_refusals{0};
int end_refusals{0};
protected:
static bool step(int &calls, int &refusals) {
calls++;
if (refusals > 0) {
refusals--;
return false;
}
return true;
}
};
// Far above the fixed number of iterator states
static constexpr size_t BIG_BUDGET = 1000;
TEST(ComponentIterator, NotRunningMakesNoProgress) {
RefusingIterator it;
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_EQ(it.begin_calls, 0);
EXPECT_EQ(it.end_calls, 0);
}
TEST(ComponentIterator, CompletesInOneCallWithoutRefusals) {
RefusingIterator it;
it.begin();
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_EQ(it.begin_calls, 1);
EXPECT_EQ(it.end_calls, 1);
}
TEST(ComponentIterator, StepBudgetIsHonored) {
RefusingIterator it;
it.begin();
it.try_advance(1);
EXPECT_EQ(it.begin_calls, 1);
EXPECT_EQ(it.end_calls, 0);
EXPECT_FALSE(it.completed());
}
TEST(ComponentIterator, RefusedStepStopsBatchAndRetriesSameStep) {
RefusingIterator it;
it.end_refusals = 3;
it.begin();
// First call runs until the refused end step, which stops the pass
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.end_calls, 1);
EXPECT_FALSE(it.completed());
// The refused step is retried once per call, not skipped
it.try_advance(BIG_BUDGET);
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.end_calls, 3);
EXPECT_FALSE(it.completed());
// Once accepted, the iteration completes
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_EQ(it.end_calls, 4);
}
TEST(ComponentIterator, RefusedBeginStopsBatchAndRetries) {
RefusingIterator it;
it.begin_refusals = 2;
it.begin();
it.try_advance(BIG_BUDGET);
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.begin_calls, 2);
EXPECT_FALSE(it.completed());
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_EQ(it.begin_calls, 3);
}
// The deprecated advance() wrapper must keep the legacy once-per-loop
// pattern working during the deprecation window.
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
TEST(ComponentIterator, DeprecatedAdvanceKeepsLegacyPatternWorking) {
RefusingIterator it;
it.end_refusals = 2;
it.begin();
size_t guard = 0;
while (!it.completed() && guard++ < BIG_BUDGET) {
it.advance();
}
EXPECT_TRUE(it.completed());
// Two refused end steps were retried, then accepted
EXPECT_EQ(it.end_calls, 3);
}
#pragma GCC diagnostic pop
#ifdef USE_SENSOR
// Iterator whose sensor callback can refuse or yield; pins the per-item
// contract: a refused item is re-offered with at_ unchanged, never skipped.
class ItemRefusingIterator : public RefusingIterator {
public:
bool on_sensor(sensor::Sensor *obj) override {
this->last_sensor = obj;
if (!step(this->sensor_calls, this->sensor_refusals))
return false;
if (this->yield_on_sensor)
this->yield_after_step_();
return true;
}
sensor::Sensor *last_sensor{nullptr};
int sensor_calls{0};
int sensor_refusals{0};
bool yield_on_sensor{false};
};
class ComponentIteratorSensorTest : public ::testing::Test {
protected:
void SetUp() override {
static sensor::Sensor sensor_a;
static sensor::Sensor sensor_b;
static bool registered = false;
if (!registered) {
App.register_sensor(&sensor_a);
App.register_sensor(&sensor_b);
registered = true;
}
// StaticVector drops silently when full; fail the fixture, not the contract
ASSERT_EQ(App.get_sensors().size(), 2u) << "benchmark.yaml sensor count too small";
}
};
TEST_F(ComponentIteratorSensorTest, RefusedItemIsReofferedNotSkipped) {
ItemRefusingIterator it;
it.sensor_refusals = 2;
it.begin();
// Runs until the first sensor refuses
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.sensor_calls, 1);
EXPECT_FALSE(it.completed());
// The refused item is re-offered, not skipped
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.sensor_calls, 2);
sensor::Sensor *refused = it.last_sensor;
// Once accepted, iteration continues through the second sensor to the end
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_NE(it.last_sensor, refused);
EXPECT_EQ(it.sensor_calls, 4);
}
TEST_F(ComponentIteratorSensorTest, YieldAfterStepEndsPassAndResumes) {
ItemRefusingIterator it;
it.yield_on_sensor = true;
it.begin();
// The pass ends right after the first sensor despite a big budget
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.sensor_calls, 1);
EXPECT_FALSE(it.completed());
// The next pass ends after the second sensor
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.sensor_calls, 2);
// Remaining states then run to completion in one pass
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
}
#endif // USE_SENSOR
} // namespace esphome::testing
@@ -2,10 +2,19 @@
#include <utility>
#include "../common.h"
#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h"
namespace esphome::mitsubishi_cn105::testing {
struct MitsubishiCN105ClimateTestContext {
MitsubishiCN105Component component;
MitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext() { this->sut.set_parent(&this->component); }
};
TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpectedValues) {
TestableMitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext context;
const auto mapping = TemperatureMapping();
for (int temperature = 16; temperature <= 31; ++temperature) {
@@ -13,7 +22,7 @@ TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpecte
EXPECT_EQ(mapping.from_mitsubishi(temperature), temperature);
}
const auto traits = sut.traits();
const auto traits = context.sut.traits();
EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::CELSIUS);
EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 16.0f);
EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 31.0f);
@@ -22,10 +31,10 @@ TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpecte
}
TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingAndTraitsMatchExpectedValues) {
TestableMitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext context;
auto mapping = TemperatureMapping();
mapping.set_use_fahrenheit(true);
sut.set_use_fahrenheit(true);
context.component.set_use_fahrenheit(true);
const std::array cases{
std::pair{61, 16.0f}, std::pair{62, 16.5f}, std::pair{63, 17.0f}, std::pair{64, 17.5f}, std::pair{65, 18.0f},
@@ -40,7 +49,7 @@ TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingAndTraitsMatchExpe
EXPECT_FLOAT_EQ(mapping.to_mitsubishi(fahrenheit), mitsubishi_celsius);
EXPECT_FLOAT_EQ(mapping.from_mitsubishi(mitsubishi_celsius), fahrenheit);
}
const auto traits = sut.traits();
const auto traits = context.sut.traits();
EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::FAHRENHEIT);
EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 61.0f);
EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 88.0f);
@@ -63,163 +72,44 @@ TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingUsesLinearConversi
}
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeOffLeavesTraitsEmpty) {
TestableMitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext context;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_OFF);
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_OFF);
EXPECT_FALSE(sut.traits().get_supports_swing_modes());
EXPECT_FALSE(context.sut.traits().get_supports_swing_modes());
}
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeVerticalExposesOffAndVertical) {
TestableMitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext context;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
}
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeHorizontalExposesOffAndHorizontal) {
TestableMitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext context;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
}
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeBothExposesAllExpectedModes) {
TestableMitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext context;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsVerticalSwingWhenSupported) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_VERTICAL);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsHorizontalSwingWhenSupported) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
sut.status().vane_mode = MitsubishiCN105::VaneMode::AUTO;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_HORIZONTAL);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsBothSwingWhenSupported) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_BOTH);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsSwingOffWhenNoSwingActive) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
sut.status().vane_mode = MitsubishiCN105::VaneMode::POSITION_3;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesRemembersLastNonSwingPositions) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
sut.status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::RIGHT;
sut.apply_values_();
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_4);
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::RIGHT);
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
sut.apply_values_();
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_4);
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::RIGHT);
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_BOTH);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesDoesNotOverwriteRememberedPositionWithUnknownValues) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
sut.last_non_swing_vane_mode_ = MitsubishiCN105::VaneMode::POSITION_2;
sut.last_non_swing_wide_vane_mode_ = MitsubishiCN105::WideVaneMode::LEFT;
sut.status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::UNKNOWN;
sut.apply_values_();
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_2);
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::LEFT);
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesIgnoresUnsupportedVerticalSwingState) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesIgnoresUnsupportedHorizontalSwingState) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
sut.status().vane_mode = MitsubishiCN105::VaneMode::AUTO;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
}
} // namespace esphome::mitsubishi_cn105::testing
@@ -0,0 +1,99 @@
#include "../common.h"
#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_swing_mode_manager.h"
namespace esphome::mitsubishi_cn105::testing {
static SwingModeManager make_swing_mode_manager(std::initializer_list<climate::ClimateSwingMode> supported_modes) {
SwingModeManager manager;
climate::ClimateSwingModeMask supported_swing_modes;
for (const auto mode : supported_modes)
supported_swing_modes.insert(mode);
manager.set_supported_swing_modes(supported_swing_modes);
return manager;
}
TEST(SwingModeManagerTests, StatusMapsVerticalSwingWhenSupported) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL});
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::CENTER),
std::optional{climate::CLIMATE_SWING_VERTICAL});
}
TEST(SwingModeManagerTests, StatusMapsHorizontalSwingWhenSupported) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL});
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::WideVaneMode::SWING),
std::optional{climate::CLIMATE_SWING_HORIZONTAL});
}
TEST(SwingModeManagerTests, StatusMapsBothSwingWhenSupported) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::SWING),
std::optional{climate::CLIMATE_SWING_BOTH});
}
TEST(SwingModeManagerTests, StatusMapsSwingOffWhenNoSwingActive) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
EXPECT_EQ(
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::POSITION_3, MitsubishiCN105::WideVaneMode::CENTER),
std::optional{climate::CLIMATE_SWING_OFF});
}
TEST(SwingModeManagerTests, RemembersLastNonSwingPositions) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::WideVaneMode::RIGHT);
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::SWING);
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::VaneMode::POSITION_4});
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::WideVaneMode::RIGHT});
}
TEST(SwingModeManagerTests, UnknownValuesDoNotOverwriteRememberedPositions) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::POSITION_2, MitsubishiCN105::WideVaneMode::LEFT);
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::UNKNOWN, MitsubishiCN105::WideVaneMode::UNKNOWN);
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::VaneMode::POSITION_2});
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::WideVaneMode::LEFT});
}
TEST(SwingModeManagerTests, UnsupportedVerticalSwingStateIsIgnored) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL});
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::CENTER),
std::optional{climate::CLIMATE_SWING_OFF});
}
TEST(SwingModeManagerTests, UnsupportedHorizontalSwingStateIsIgnored) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL});
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::WideVaneMode::SWING),
std::optional{climate::CLIMATE_SWING_OFF});
}
TEST(SwingModeManagerTests, SwingModeFromReturnsNulloptWhenNoSwingModesSupported) {
auto manager = make_swing_mode_manager({});
EXPECT_FALSE(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::SWING)
.has_value());
}
TEST(SwingModeManagerTests, VaneFromSwingModeReturnsNulloptWhenVerticalUnsupported) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL});
EXPECT_FALSE(manager.vane_from(climate::CLIMATE_SWING_VERTICAL).has_value());
}
TEST(SwingModeManagerTests, WideVaneFromSwingModeReturnsNulloptWhenHorizontalUnsupported) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL});
EXPECT_FALSE(manager.wide_vane_from(climate::CLIMATE_SWING_HORIZONTAL).has_value());
}
TEST(SwingModeManagerTests, VaneAndWideVaneFromSwingModeMapSwingModes) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_VERTICAL), std::optional{MitsubishiCN105::VaneMode::SWING});
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_BOTH), std::optional{MitsubishiCN105::VaneMode::SWING});
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_HORIZONTAL),
std::optional{MitsubishiCN105::WideVaneMode::SWING});
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_BOTH), std::optional{MitsubishiCN105::WideVaneMode::SWING});
}
} // namespace esphome::mitsubishi_cn105::testing
@@ -64,26 +64,4 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 {
void set_current_time(uint32_t ms) { test_loop_time_ms = ms; }
};
class TestableMitsubishiCN105Climate : public MitsubishiCN105Climate {
public:
TestableMitsubishiCN105Climate() { this->set_parent(&this->component_); }
using MitsubishiCN105Climate::apply_values_;
using MitsubishiCN105Climate::last_non_swing_vane_mode_;
using MitsubishiCN105Climate::last_non_swing_wide_vane_mode_;
MitsubishiCN105::Status &status() { return const_cast<MitsubishiCN105::Status &>(this->component_.status()); }
void set_use_fahrenheit(bool value) { this->component_.set_use_fahrenheit(value); }
protected:
MitsubishiCN105Component component_;
};
class TestableMitsubishiCN105Component : public MitsubishiCN105Component {
public:
MitsubishiCN105::Status &mutable_status() { return const_cast<MitsubishiCN105::Status &>(this->status()); }
void notify_status() { this->status_callback_.call(); }
};
} // namespace esphome::mitsubishi_cn105::testing
@@ -3,7 +3,7 @@
namespace esphome::mitsubishi_cn105::testing {
TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) {
TestableMitsubishiCN105Component hub;
MitsubishiCN105Component hub;
size_t callback_count = 0;
std::optional<VerticalVaneMode> callback_direction;
hub.add_on_vane_state_callback([&](const VaneState &state) {
@@ -11,8 +11,9 @@ TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) {
callback_direction = state.vertical.direction;
});
hub.mutable_status().room_temperature = 20.0f;
hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4;
hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
hub.set_target_temperature(20.0f);
hub.set_vane_mode(MitsubishiCN105::VaneMode::POSITION_4);
hub.publish_status();
EXPECT_EQ(callback_count, 1);
@@ -25,7 +26,7 @@ TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) {
}
TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) {
TestableMitsubishiCN105Component hub;
MitsubishiCN105Component hub;
size_t status_callback_count = 0;
size_t vane_callback_count = 0;
std::optional<VerticalVaneMode> callback_direction;
@@ -35,15 +36,16 @@ TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) {
callback_direction = state.vertical.direction;
});
hub.mutable_status().room_temperature = 20.0f;
hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN;
hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
hub.set_target_temperature(20.0f);
ASSERT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::UNKNOWN);
hub.publish_status();
EXPECT_EQ(status_callback_count, 1);
EXPECT_EQ(vane_callback_count, 1);
EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_UNKNOWN});
hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4;
hub.set_vane_mode(MitsubishiCN105::VaneMode::POSITION_4);
hub.publish_status();
EXPECT_EQ(status_callback_count, 2);
@@ -52,7 +54,7 @@ TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) {
}
TEST(MitsubishiCN105ComponentTests, VaneCallAppliesVerticalDirection) {
TestableMitsubishiCN105Component hub;
MitsubishiCN105Component hub;
auto call = hub.make_vane_call();
call.vertical.set_direction(VERTICAL_VANE_MODE_POSITION_5);
@@ -62,12 +64,11 @@ TEST(MitsubishiCN105ComponentTests, VaneCallAppliesVerticalDirection) {
}
TEST(MitsubishiCN105ComponentTests, VaneControlActionAppliesConfiguredFields) {
TestableMitsubishiCN105Component hub;
MitsubishiCN105Component hub;
VaneControlAction<> action(&hub, [](VaneCall &call) { call.vertical.set_direction(VERTICAL_VANE_MODE_SWING); });
action.play();
EXPECT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::SWING);
}
} // namespace esphome::mitsubishi_cn105::testing
@@ -3,14 +3,9 @@
namespace esphome::mitsubishi_cn105::testing {
class TestableMitsubishiCN105VerticalVaneDirectionSelect : public MitsubishiCN105VerticalVaneDirectionSelect {
public:
using MitsubishiCN105VerticalVaneDirectionSelect::control;
};
struct VerticalVaneDirectionSelectTestContext {
TestableMitsubishiCN105Component hub;
TestableMitsubishiCN105VerticalVaneDirectionSelect select;
MitsubishiCN105Component hub;
MitsubishiCN105VerticalVaneDirectionSelect select;
VerticalVaneDirectionSelectTestContext() {
this->select.traits.set_options({"Auto", "1", "2", "3", "4", "5", "Swing"});
@@ -31,13 +26,15 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, MapsIndexesToVaneModes) {
for (size_t i = 0; i < expected_modes.size(); ++i) {
SCOPED_TRACE(i);
ctx.select.control(i);
ctx.select.make_call().set_index(i).perform();
EXPECT_EQ(ctx.hub.status().vane_mode, expected_modes[i]);
}
}
TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, PublishesIncomingVaneModes) {
VerticalVaneDirectionSelectTestContext ctx;
ctx.hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
ctx.hub.set_target_temperature(20.0f);
constexpr std::array modes{
MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1,
@@ -48,13 +45,12 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, PublishesIncomingVaneModes
for (size_t i = 0; i < modes.size(); ++i) {
SCOPED_TRACE(i);
ctx.hub.mutable_status().vane_mode = modes[i];
ctx.hub.notify_status();
ctx.hub.set_vane_mode(modes[i]);
ctx.hub.publish_status();
EXPECT_EQ(ctx.select.active_index(), std::optional{i});
}
ctx.hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN;
ctx.hub.notify_status();
ctx.select.publish_vane_state(MitsubishiCN105::VaneMode::UNKNOWN);
EXPECT_EQ(ctx.select.active_index(), std::optional{modes.size() - 1});
}
@@ -64,14 +60,15 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ControlPublishesSelectAndC
climate_entity.set_parent(&ctx.hub);
climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
ctx.hub.mutable_status().room_temperature = 20.0f;
ctx.hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
ctx.hub.set_target_temperature(20.0f);
climate_entity.setup();
ctx.select.control(6);
ctx.select.make_call().set_index(6).perform();
EXPECT_EQ(ctx.select.active_index(), std::optional<size_t>{6});
EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_VERTICAL);
ctx.select.control(3);
ctx.select.make_call().set_index(3).perform();
EXPECT_EQ(ctx.select.active_index(), std::optional<size_t>{3});
EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_OFF);
}
@@ -82,7 +79,8 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ClimateControlPublishesSel
climate_entity.set_parent(&ctx.hub);
climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
ctx.hub.mutable_status().room_temperature = 20.0f;
ctx.hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
ctx.hub.set_target_temperature(20.0f);
climate_entity.setup();
climate_entity.make_call().set_swing_mode(climate::CLIMATE_SWING_VERTICAL).perform();
@@ -95,10 +93,9 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ClimateControlPublishesSel
TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, BeforeInitializationDoesNotPublishSelectState) {
VerticalVaneDirectionSelectTestContext ctx;
ctx.select.control(3);
ctx.select.make_call().set_index(3).perform();
EXPECT_EQ(ctx.hub.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_3);
EXPECT_FALSE(ctx.select.has_state());
}
} // namespace esphome::mitsubishi_cn105::testing
@@ -322,14 +322,14 @@ TEST(ModbusClientHubPriority, ContinuousReadRequeuesOnSuccessOnly) {
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
EXPECT_TRUE(hub.queued(0).options.continuous);
hub.force_send_next();
// A matching successful response cycles the continuous entry back to READY.
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
EXPECT_TRUE(hub.queued(0).options.continuous);
// An exception response ends the poll.
hub.force_send_next();
@@ -346,13 +346,13 @@ TEST(ModbusClientHubPriority, RetriedContinuousReadStaysContinuous) {
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_TRUE(hub.queued(0).continuous);
ASSERT_TRUE(hub.queued(0).options.continuous);
hub.force_send_next();
hub.timeout_waiting(); // no response -> device requests retry
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous); // the retried poll stays continuous
EXPECT_TRUE(hub.queued(0).options.continuous); // the retried poll stays continuous
}
// A one-shot duplicate downgrades a continuous poll to a one-shot (the mirror of a continuous
@@ -363,16 +363,16 @@ TEST(ModbusClientHubPriority, DuplicateSendDowngradesContinuous) {
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_TRUE(hub.queued(0).continuous);
ASSERT_TRUE(hub.queued(0).options.continuous);
device.read_holding_registers(0x100, 2); // one-shot duplicate downgrades the poll
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_FALSE(hub.queued(0).continuous);
EXPECT_FALSE(hub.queued(0).options.continuous);
EXPECT_EQ(hub.queued(0).pending, 1u);
// It runs one more cycle to serve the request, then stops - not re-queued as a poll.
hub.force_send_next();
EXPECT_FALSE(hub.waiting_command().continuous);
EXPECT_FALSE(hub.waiting_command().options.continuous);
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response);
EXPECT_EQ(hub.queued_frames(), 0u);
@@ -407,16 +407,16 @@ TEST(ModbusClientHubPriority, DowngradeAfterTerminalKeepsRequestAlive) {
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_TRUE(hub.queued(0).continuous);
ASSERT_TRUE(hub.queued(0).options.continuous);
hub.force_send_next();
const uint8_t exception_response[] = {0x83, 0x02};
hub.receive_frame_for_test(0x02, exception_response); // exception ends the poll; on_error re-sends
EXPECT_EQ(device.error_count_, 1); // one terminal delivered so far
ASSERT_EQ(hub.queued_frames(), 1u); // the re-send survived the sweep instead of being erased
EXPECT_FALSE(hub.queued(0).continuous); // downgraded to a one-shot
EXPECT_EQ(hub.queued(0).pending, 1u); // debt restored so the request runs
EXPECT_EQ(device.error_count_, 1); // one terminal delivered so far
ASSERT_EQ(hub.queued_frames(), 1u); // the re-send survived the sweep instead of being erased
EXPECT_FALSE(hub.queued(0).options.continuous); // downgraded to a one-shot
EXPECT_EQ(hub.queued(0).pending, 1u); // debt restored so the request runs
// And it runs to its own terminal - a good response this time - then the entry is gone.
hub.force_send_next();
@@ -434,18 +434,18 @@ TEST(ModbusClientHubPriority, ContinuousRequestUpgradesQueuedDuplicate) {
device.read_holding_registers(0x100, 2);
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_FALSE(hub.queued(0).continuous);
ASSERT_FALSE(hub.queued(0).options.continuous);
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
EXPECT_TRUE(hub.queued(0).options.continuous);
// And it behaves as a poll from here: success cycles it back to READY.
hub.force_send_next();
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
EXPECT_TRUE(hub.queued(0).options.continuous);
}
// The transmit order is one key with three levels: writes, then one-shot reads, then continuous
@@ -473,7 +473,7 @@ TEST(ModbusClientHubPriority, WritesThenOneShotReadsThenContinuousPolls) {
EXPECT_EQ(hub.waiting_command().frame.pdu()[1], 0x02); // then the one-shot read
hub.timeout_waiting();
hub.force_send_next();
EXPECT_TRUE(hub.waiting_command().continuous); // and the poll takes what is left
EXPECT_TRUE(hub.waiting_command().options.continuous); // and the poll takes what is left
}
// continuous is ignored for writes: the frame still sends at WRITE priority, once.
@@ -485,7 +485,7 @@ TEST(ModbusClientHubPriority, ContinuousIgnoredForWrites) {
device.queue_pdu(write_pdu, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE);
EXPECT_FALSE(hub.queued(0).continuous);
EXPECT_FALSE(hub.queued(0).options.continuous);
}
// A queued continuous poll does not count against immediate-send readiness: it ranks below every
@@ -496,7 +496,7 @@ TEST(ModbusClientHubPriority, ContinuousPollDoesNotBlockImmediateSend) {
EXPECT_TRUE(hub.tx_buffer_empty()); // nothing queued
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_TRUE(hub.queued(0).continuous);
ASSERT_TRUE(hub.queued(0).options.continuous);
EXPECT_TRUE(hub.tx_buffer_empty()); // a READY continuous poll still leaves room to send now
device.read_holding_registers(0x200, 2); // a one-shot does count
@@ -1878,8 +1878,8 @@ TEST(ModbusClientHubPriority, ResendFromOnResponseAbsorbsIntoCompletingCommand)
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response); // handler re-sends the identical frame mid-completion
ASSERT_EQ(hub.queued_frames(), 1u); // absorbed into the same entry, not a fresh twin
EXPECT_FALSE(hub.queued(0).continuous); // the one-shot re-send downgraded the poll
ASSERT_EQ(hub.queued_frames(), 1u); // absorbed into the same entry, not a fresh twin
EXPECT_FALSE(hub.queued(0).options.continuous); // the one-shot re-send downgraded the poll
}
// An exception-flagged function code is never silently re-sendable, even though the read check
@@ -51,6 +51,7 @@ button:
# A pdu lambda can hand-assemble bytes or return a modbus::helpers::create_*_pdu() builder result.
- modbus_client.send:
address: 0x01
continuous: true
pdu: !lambda "return modbus::helpers::create_read_pdu(modbus::FunctionCode::READ_HOLDING_REGISTERS, 0x0010, 1);"
- modbus_client.send:
address: !lambda "return 1;"
@@ -91,6 +92,7 @@ button:
address: !lambda "return 1;"
start_address: 0x10
count: 2
continuous: true
on_response:
then:
- lambda: 'ESP_LOGI("modbus_client.test", "first=%u n=%u", values[0], (unsigned) values.size());'
@@ -98,6 +100,7 @@ button:
then:
- logger.log: "typed read timeout"
- modbus_client.read_input_registers:
continuous: !lambda "return false;"
address: 0x01
start_address: 0x20
on_custom_response:
@@ -113,12 +116,14 @@ button:
address: 0x01
start_address: 0x03
count: 16
continuous: true
on_response:
then:
- lambda: 'ESP_LOGI("modbus_client.test", "coil0=%d n=%u", bits[0], (unsigned) bits.size());'
- modbus_client.read_discrete_inputs:
address: 0x01
start_address: 0x00
continuous: true
on_error:
then:
- lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);'
+7
View File
@@ -0,0 +1,7 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# to_code must run: it defines USE_NOISE and adds the noise-c library
# the component sources under test need.
manifest.enable_codegen()
+1
View File
@@ -0,0 +1 @@
noise:
@@ -0,0 +1,2 @@
packages:
noise: !include common.yaml
@@ -0,0 +1,2 @@
packages:
noise: !include common.yaml
+2
View File
@@ -0,0 +1,2 @@
packages:
noise: !include common.yaml
@@ -0,0 +1,2 @@
packages:
noise: !include common.yaml
@@ -0,0 +1,199 @@
#include <gtest/gtest.h>
#include <cstring>
#include <noise/protocol.h>
#include "esphome/components/noise/noise.h"
#include "esphome/components/noise/noise_handshake.h"
namespace esphome::noise::testing {
using Action = NoiseResponderHandshake::Action;
// A raw noise-c initiator driving the same Noise_NNpsk0_25519_ChaChaPoly_SHA256
// pattern the responder class implements, so the tests exercise a real
// two-message handshake rather than mirrored calls into the class under test.
class Initiator {
public:
Initiator(const psk_t &psk, const uint8_t *prologue, size_t prologue_len) {
const NoiseProtocolId nid = {
.prefix_id = NOISE_PREFIX_STANDARD,
.pattern_id = NOISE_PATTERN_NN,
.modifier_ids = {NOISE_MODIFIER_PSK0},
.dh_id = NOISE_DH_CURVE25519,
.cipher_id = NOISE_CIPHER_CHACHAPOLY,
.hash_id = NOISE_HASH_SHA256,
.hybrid_id = NOISE_DH_NONE,
};
EXPECT_EQ(noise_handshakestate_new_by_id(&this->state_, &nid, NOISE_ROLE_INITIATOR), 0);
EXPECT_EQ(noise_handshakestate_set_pre_shared_key(this->state_, psk.data(), psk.size()), 0);
EXPECT_EQ(noise_handshakestate_set_prologue(this->state_, prologue, prologue_len), 0);
EXPECT_EQ(noise_handshakestate_start(this->state_), 0);
}
~Initiator() {
if (this->state_ != nullptr)
noise_handshakestate_free(this->state_);
if (this->send_ != nullptr)
noise_cipherstate_free(this->send_);
if (this->recv_ != nullptr)
noise_cipherstate_free(this->recv_);
}
Initiator(const Initiator &) = delete;
Initiator &operator=(const Initiator &) = delete;
size_t write_message(uint8_t *out, size_t capacity) {
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_output(mbuf, out, capacity);
EXPECT_EQ(noise_handshakestate_write_message(this->state_, &mbuf, nullptr), 0);
return mbuf.size;
}
int read_message(uint8_t *data, size_t len) {
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_input(mbuf, data, len);
return noise_handshakestate_read_message(this->state_, &mbuf, nullptr);
}
void split() { EXPECT_EQ(noise_handshakestate_split(this->state_, &this->send_, &this->recv_), 0); }
NoiseCipherState *send_{nullptr};
NoiseCipherState *recv_{nullptr};
private:
NoiseHandshakeState *state_{nullptr};
};
static const uint8_t PROLOGUE[] = {'t', 'e', 's', 't', 'p', 'r', 'o', 'l', 'o', 'g', 'u', 'e'};
static psk_t make_psk(uint8_t seed) {
psk_t psk;
for (size_t i = 0; i < psk.size(); i++) {
psk[i] = static_cast<uint8_t>(seed + i);
}
return psk;
}
TEST(NoiseResponderHandshakeTest, ActionFailedBeforeInit) {
NoiseResponderHandshake handshake;
EXPECT_EQ(handshake.action(), Action::ACTION_FAILED);
}
TEST(NoiseResponderHandshakeTest, MessageMethodsErrorBeforeInit) {
// The class doc promises a noise-c error, not a crash, when the message
// methods run outside their action() step; pin the library's null check
NoiseResponderHandshake handshake;
uint8_t buf[MAX_HANDSHAKE_SIZE] = {};
size_t out_len = 0;
EXPECT_NE(handshake.read_message(buf, sizeof(buf)), 0);
EXPECT_NE(handshake.write_message(buf, sizeof(buf), out_len), 0);
// Deliberately non-null: split() documents a nullptr postcondition on
// error, so a caller's uninitialized locals never hold garbage to free
auto *sentinel = reinterpret_cast<NoiseCipherState *>(0x1);
NoiseCipherState *send_cipher = sentinel;
NoiseCipherState *recv_cipher = sentinel;
EXPECT_NE(handshake.split(send_cipher, recv_cipher), 0);
EXPECT_EQ(send_cipher, nullptr);
EXPECT_EQ(recv_cipher, nullptr);
}
TEST(NoiseResponderHandshakeTest, FullHandshakeAndTransportRoundTrip) {
const psk_t psk = make_psk(7);
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0);
EXPECT_EQ(responder.action(), Action::ACTION_READ);
Initiator initiator(psk, PROLOGUE, sizeof(PROLOGUE));
uint8_t msg[MAX_HANDSHAKE_SIZE];
size_t msg_len = initiator.write_message(msg, sizeof(msg));
ASSERT_GT(msg_len, 0u);
ASSERT_EQ(responder.read_message(msg, msg_len), 0);
ASSERT_EQ(responder.action(), Action::ACTION_WRITE);
size_t reply_len = 0;
ASSERT_EQ(responder.write_message(msg, sizeof(msg), reply_len), 0);
ASSERT_GT(reply_len, 0u);
ASSERT_EQ(responder.action(), Action::ACTION_SPLIT);
ASSERT_EQ(initiator.read_message(msg, reply_len), 0);
initiator.split();
NoiseCipherState *send_cipher = nullptr;
NoiseCipherState *recv_cipher = nullptr;
ASSERT_EQ(responder.split(send_cipher, recv_cipher), 0);
ASSERT_NE(send_cipher, nullptr);
ASSERT_NE(recv_cipher, nullptr);
// The handshake state is released by split(); the class reports FAILED after
EXPECT_EQ(responder.action(), Action::ACTION_FAILED);
EXPECT_EQ(static_cast<size_t>(noise_cipherstate_get_mac_length(send_cipher)), MAC_SIZE);
// Responder encrypts, initiator decrypts
uint8_t frame[64];
static constexpr char PLAINTEXT[] = "encrypted ota";
std::memcpy(frame, PLAINTEXT, sizeof(PLAINTEXT));
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_inout(mbuf, frame, sizeof(PLAINTEXT), sizeof(frame));
ASSERT_EQ(noise_cipherstate_encrypt(send_cipher, &mbuf), 0);
EXPECT_EQ(mbuf.size, sizeof(PLAINTEXT) + MAC_SIZE);
noise_buffer_set_inout(mbuf, frame, mbuf.size, sizeof(frame));
ASSERT_EQ(noise_cipherstate_decrypt(initiator.recv_, &mbuf), 0);
ASSERT_EQ(mbuf.size, sizeof(PLAINTEXT));
EXPECT_EQ(std::memcmp(frame, PLAINTEXT, sizeof(PLAINTEXT)), 0);
noise_cipherstate_free(send_cipher);
noise_cipherstate_free(recv_cipher);
}
TEST(NoiseResponderHandshakeTest, ReInitRestartsHandshake) {
// The documented retry shape: a repeated init() frees the previous state
// and starts over. The first message under the new key authenticating
// proves the restart took effect; the old state surviving would fail the
// MAC here.
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0);
ASSERT_EQ(responder.init(make_psk(9), PROLOGUE, sizeof(PROLOGUE)), 0);
EXPECT_EQ(responder.action(), Action::ACTION_READ);
Initiator initiator(make_psk(9), PROLOGUE, sizeof(PROLOGUE));
uint8_t msg[MAX_HANDSHAKE_SIZE];
size_t msg_len = initiator.write_message(msg, sizeof(msg));
ASSERT_GT(msg_len, 0u);
EXPECT_EQ(responder.read_message(msg, msg_len), 0);
}
TEST(NoiseResponderHandshakeTest, WrongPskFailsWithMacFailure) {
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0);
Initiator initiator(make_psk(200), PROLOGUE, sizeof(PROLOGUE));
uint8_t msg[MAX_HANDSHAKE_SIZE];
size_t msg_len = initiator.write_message(msg, sizeof(msg));
ASSERT_GT(msg_len, 0u);
int err = responder.read_message(msg, msg_len);
EXPECT_EQ(err, NOISE_ERROR_MAC_FAILURE);
EXPECT_EQ(responder.action(), Action::ACTION_FAILED);
}
TEST(NoiseResponderHandshakeTest, MismatchedPrologueFailsWithMacFailure) {
// The prologue binds the plaintext preamble for downgrade resistance; a
// tampered preamble must fail even with the right key.
const psk_t psk = make_psk(7);
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0);
static const uint8_t TAMPERED[] = {'x'};
Initiator initiator(psk, TAMPERED, sizeof(TAMPERED));
uint8_t msg[MAX_HANDSHAKE_SIZE];
size_t msg_len = initiator.write_message(msg, sizeof(msg));
ASSERT_GT(msg_len, 0u);
EXPECT_EQ(responder.read_message(msg, msg_len), NOISE_ERROR_MAC_FAILURE);
}
} // namespace esphome::noise::testing
@@ -0,0 +1,74 @@
#include <gtest/gtest.h>
#include <cstring>
#include <noise/protocol.h>
#include "esphome/components/noise/noise.h"
namespace esphome::noise::testing {
TEST(NoiseContextTest, AllZerosPskIsReserved) {
psk_t zeros{};
EXPECT_TRUE(NoiseContext::is_all_zeros(zeros));
psk_t psk{};
psk[31] = 1;
EXPECT_FALSE(NoiseContext::is_all_zeros(psk));
NoiseContext ctx;
EXPECT_FALSE(ctx.has_psk());
ctx.set_psk(zeros);
EXPECT_FALSE(ctx.has_psk());
ctx.set_psk(psk);
EXPECT_TRUE(ctx.has_psk());
EXPECT_EQ(ctx.get_psk(), psk);
}
TEST(WireFormatTest, FrameHeaderIsIndicatorPlusBigEndianLength) {
uint8_t header[FRAME_HEADER_SIZE];
write_frame_header(header, 0x1234);
EXPECT_EQ(header[0], FRAME_INDICATOR);
EXPECT_EQ(header[1], 0x12);
EXPECT_EQ(header[2], 0x34);
}
TEST(WireFormatTest, RejectPayloadCarriesStatusByteAndMacFailureContract) {
// The MAC failure string is a wire contract: clients match it to report a
// wrong key. Format the payload exactly the way the handshake read path does.
uint8_t buf[64];
size_t len = format_reject_payload(buf, sizeof(buf), reject_reason_for(NOISE_ERROR_MAC_FAILURE));
static constexpr char EXPECTED[] = "Handshake MAC failure";
ASSERT_EQ(len, 1 + strlen(EXPECTED));
EXPECT_EQ(buf[0], HANDSHAKE_STATUS_REJECT);
EXPECT_EQ(memcmp(buf + 1, EXPECTED, strlen(EXPECTED)), 0);
// The exported floor covers the full MAC failure payload exactly
EXPECT_EQ(MAC_FAILURE_PAYLOAD_SIZE, 1 + strlen(EXPECTED));
// Any other error maps to the generic reason
len = format_reject_payload(buf, sizeof(buf), reject_reason_for(NOISE_ERROR_INVALID_STATE));
static constexpr char GENERIC[] = "Handshake error";
ASSERT_EQ(len, 1 + strlen(GENERIC));
EXPECT_EQ(memcmp(buf + 1, GENERIC, strlen(GENERIC)), 0);
}
TEST(WireFormatTest, RejectPayloadTruncatesToCapacity) {
uint8_t buf[8];
size_t len = format_reject_payload(buf, sizeof(buf), reject_reason_for(NOISE_ERROR_MAC_FAILURE));
ASSERT_EQ(len, sizeof(buf));
EXPECT_EQ(buf[0], HANDSHAKE_STATUS_REJECT);
EXPECT_EQ(memcmp(buf + 1, "Handsha", 7), 0);
// A one-byte buffer still carries the status byte
uint8_t tiny[1];
len = format_reject_payload(tiny, sizeof(tiny), reject_reason_for(NOISE_ERROR_MAC_FAILURE));
ASSERT_EQ(len, 1u);
EXPECT_EQ(tiny[0], HANDSHAKE_STATUS_REJECT);
// A zero-capacity buffer yields no payload and stays untouched
uint8_t none[1] = {0xAA};
EXPECT_EQ(format_reject_payload(none, 0, reject_reason_for(NOISE_ERROR_MAC_FAILURE)), 0u);
EXPECT_EQ(none[0], 0xAA);
}
} // namespace esphome::noise::testing
@@ -57,6 +57,11 @@ image:
url: http://www.faqs.org/images/library.jpg
format: JPG
type: RGB565
- platform: online_image
id: online_auto_image
url: http://www.faqs.org/images/library.jpg
format: AUTO
type: RGB565
# Check the set_url action
esphome:
+9
View File
@@ -0,0 +1,9 @@
wifi:
ssid: MySSID
password: password1
ota:
- platform: esphome
port: 3288
encryption:
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
@@ -0,0 +1,12 @@
wifi:
ssid: MySSID
password: password1
api:
encryption:
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
ota:
- platform: esphome
port: 3289
encryption:
@@ -0,0 +1,2 @@
packages:
ota: !include encryption.yaml
@@ -0,0 +1,2 @@
packages:
ota: !include encryption.yaml
@@ -0,0 +1,2 @@
packages:
ota: !include encryption.yaml
@@ -0,0 +1,2 @@
packages:
ota: !include encryption_inherit.yaml
@@ -77,18 +77,12 @@ class TestableRuntimeImage : public RuntimeImage {
: RuntimeImage(format, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE, nullptr, false, 0, 0) {}
ImageDecoder *decoder() { return this->decoder_.get(); }
/// Simulates the state a dynamic-format producer (PR #16337) would leave behind:
/// a cached decoder whose format no longer matches the image's format.
/// TODO: once #16337 adds a public way to change the format, drive the mismatch
/// through it and delete this seam.
void plant_decoder(ImageFormat format) { this->decoder_ = this->create_decoder_(format); }
};
/// Runs one full decode session. Returns true when every stage succeeded.
static bool decode_all(TestableRuntimeImage &img, const uint8_t *data, size_t len) {
static bool decode_all(TestableRuntimeImage &img, const uint8_t *data, size_t len, ImageFormat format = AUTO) {
std::vector<uint8_t> buffer(data, data + len); // feed_data needs mutable bytes
if (!img.begin_decode(len)) {
if (!img.begin_decode(len, format)) {
return false;
}
size_t offset = 0;
@@ -203,25 +197,51 @@ TEST(RuntimeImageDecoder, ChunkedFeedDecodesLikeDownloadLoop) {
}
TEST(RuntimeImageDecoder, FormatSwitchEvictsMismatchedDecoder) {
// PNG image holding a stale BMP decoder: begin_decode must evict and recreate.
TestableRuntimeImage png_img(PNG);
png_img.plant_decoder(BMP);
ASSERT_NE(png_img.decoder(), nullptr);
ASSERT_EQ(png_img.decoder()->get_format(), BMP);
// Drive the format switch through begin_decode()'s format parameter, the way
// a dynamic-format producer (online_image MIME detection) does.
TestableRuntimeImage img(AUTO);
ASSERT_TRUE(decode_all(png_img, PNG_RGB, sizeof(PNG_RGB)));
EXPECT_EQ(png_img.decoder()->get_format(), PNG);
expect_pixels(png_img, PNG_RGB_EXPECTED);
ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP), BMP));
ASSERT_NE(img.decoder(), nullptr);
ASSERT_EQ(img.decoder()->get_format(), BMP);
expect_pixels(img, BMP_24BPP_EXPECTED);
// And the other direction: BMP image holding a stale PNG decoder.
TestableRuntimeImage bmp_img(BMP);
bmp_img.plant_decoder(PNG);
ASSERT_NE(bmp_img.decoder(), nullptr);
ASSERT_EQ(bmp_img.decoder()->get_format(), PNG);
// Same explicit format again: the decoder must stay warm.
ImageDecoder *bmp_decoder = img.decoder();
ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP), BMP));
expect_pixels(img, BMP_8BPP_EXPECTED);
EXPECT_EQ(img.decoder(), bmp_decoder);
ASSERT_TRUE(decode_all(bmp_img, BMP_24BPP, sizeof(BMP_24BPP)));
EXPECT_EQ(bmp_img.decoder()->get_format(), BMP);
expect_pixels(bmp_img, BMP_24BPP_EXPECTED);
// Different format: the stale decoder must be evicted and recreated.
ASSERT_TRUE(decode_all(img, PNG_RGB, sizeof(PNG_RGB), PNG));
EXPECT_EQ(img.decoder()->get_format(), PNG);
expect_pixels(img, PNG_RGB_EXPECTED);
// And back again.
ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP), BMP));
EXPECT_EQ(img.decoder()->get_format(), BMP);
expect_pixels(img, BMP_24BPP_EXPECTED);
}
TEST(RuntimeImageDecoder, AutoFormatFallsBackToConfiguredAndKeepsDecoderWarm) {
// With a configured format, an AUTO begin_decode() must resolve to the
// configured format before the reuse check instead of evicting the decoder.
TestableRuntimeImage img(BMP);
ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP), AUTO));
ImageDecoder *first = img.decoder();
ASSERT_NE(first, nullptr);
EXPECT_EQ(first->get_format(), BMP);
ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP), AUTO));
expect_pixels(img, BMP_24BPP_EXPECTED);
EXPECT_EQ(img.decoder(), first) << "AUTO must not evict the configured-format decoder";
}
TEST(RuntimeImageDecoder, AutoWithoutConfiguredFormatFails) {
// Neither a configured format nor an explicit one: there is nothing to decode with.
TestableRuntimeImage img(AUTO);
EXPECT_FALSE(img.begin_decode(64));
}
TEST(RuntimeImageDecoder, ReleaseKeepsDecoderWarm) {
+2
View File
@@ -7,6 +7,7 @@ This directory contains end-to-end integration tests for ESPHome, focusing on te
- `conftest.py` - Common fixtures and utilities
- `const.py` - Constants used throughout the integration tests
- `types.py` - Type definitions for fixtures and functions
- `raw_api_client.py` - Minimal plaintext api client whose reads happen only on request (for backpressure tests)
- `state_utils.py` - State handling utilities (e.g., `InitialStateHelper`, `find_entity`, `require_entity`)
- `fixtures/` - YAML configuration files for tests
- `test_*.py` - Individual test files
@@ -347,6 +348,7 @@ Create C++ components in `fixtures/external_components/` for:
- Custom entity behaviors
- Scheduler testing
- Memory management tests
- Deterministic network backpressure (`sndbuf_pin_component` pins socket send buffers; assert on its log line to prove the pin took effect)
##### Log Line Monitoring
```python
@@ -0,0 +1,23 @@
esphome:
name: api-backpressure-test
host:
api:
# Smallest queue so a non-draining client blocks the send path quickly
max_send_queue: 1
actions:
# GENERATED_ACTIONS
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
components: [sndbuf_pin_component]
# Pins the device's socket send buffers for deterministic TCP backpressure
sndbuf_pin_component:
buffer_size: SERVER_SNDBUF
logger:
level: DEBUG
@@ -0,0 +1,20 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_BUFFER_SIZE, CONF_ID
DEPENDENCIES = ["api"]
sndbuf_pin_ns = cg.esphome_ns.namespace("sndbuf_pin")
SndbufPinComponent = sndbuf_pin_ns.class_("SndbufPinComponent", cg.Component)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(SndbufPinComponent),
cv.Required(CONF_BUFFER_SIZE): cv.int_range(min=1),
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID], config[CONF_BUFFER_SIZE])
await cg.register_component(var, config)
@@ -0,0 +1,55 @@
#include "sndbuf_pin_component.h"
#include <netinet/in.h>
#include <sys/socket.h>
#include <cerrno>
#include "esphome/components/api/api_server.h"
#include "esphome/core/log.h"
namespace esphome::sndbuf_pin {
static const char *const TAG = "sndbuf_pin";
// Skip stdio; scan the low fd range where the listeners land
static constexpr int FIRST_USER_FD = 3;
static constexpr int MAX_FD_SCAN = 128;
void SndbufPinComponent::setup() {
int pinned = 0;
for (int fd = FIRST_USER_FD; fd < MAX_FD_SCAN; fd++) {
int type = 0;
socklen_t len = sizeof(type);
if (::getsockopt(fd, SOL_SOCKET, SO_TYPE, &type, &len) != 0 || type != SOCK_STREAM)
continue;
struct sockaddr_in addr {};
socklen_t addr_len = sizeof(addr);
if (::getsockname(fd, reinterpret_cast<struct sockaddr *>(&addr), &addr_len) != 0) {
ESP_LOGW(TAG, "fd %d: getsockname failed, errno %d", fd, errno);
continue;
}
if (ntohs(addr.sin_port) != api::global_api_server->get_port())
continue;
if (::setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &this->buffer_size_, sizeof(this->buffer_size_)) != 0) {
ESP_LOGW(TAG, "fd %d: SO_SNDBUF pin failed, errno %d", fd, errno);
continue;
}
int applied = 0;
len = sizeof(applied);
if (::getsockopt(fd, SOL_SOCKET, SO_SNDBUF, &applied, &len) != 0 || applied < this->buffer_size_) {
// Linux doubles the requested value; anything below it means clamped
ESP_LOGW(TAG, "fd %d: SO_SNDBUF readback %d below requested %d", fd, applied, this->buffer_size_);
continue;
}
// Tests assert on this line; accepted sockets inherit the pinned size
ESP_LOGD(TAG, "fd %d port %d: SO_SNDBUF pinned to %d (effective %d)", fd, ntohs(addr.sin_port), this->buffer_size_,
applied);
pinned++;
}
if (pinned == 0) {
ESP_LOGE(TAG, "api listener socket was not pinned");
this->mark_failed();
}
}
} // namespace esphome::sndbuf_pin

Some files were not shown because too many files have changed in this diff Show More