mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 14:46:20 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd10dca6cf | ||
|
|
aff4b89470 | ||
|
|
c25a17e840 | ||
|
|
f162fce638 | ||
|
|
3730f9137c | ||
|
|
3a350484c1 | ||
|
|
04e2977609 | ||
|
|
08231c91d4 | ||
|
|
cf31c08a5c | ||
|
|
e7574a574b | ||
|
|
33484108a9 | ||
|
|
e697a40fda | ||
|
|
d1f065671e | ||
|
|
02da5c6484 | ||
|
|
b2440cb655 | ||
|
|
160d8b8f0c | ||
|
|
8899713ef9 | ||
|
|
f3cdefce21 | ||
|
|
b115813fbe | ||
|
|
ab45ab316a | ||
|
|
5b3a6c05bf | ||
|
|
cd53681787 |
@@ -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
|
||||
|
||||
+26
-1
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ from esphome.automation import Condition, maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import mqtt, web_server, zigbee
|
||||
from esphome.components.const import CONF_ON_STATE_CHANGE
|
||||
from esphome.config_helpers import filter_source_files_from_defines
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_DELAY,
|
||||
@@ -560,6 +561,11 @@ _CALLBACK_AUTOMATIONS = (
|
||||
async def _build_binary_sensor_automations(var, config):
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
|
||||
if config.get(CONF_ON_CLICK) or config.get(CONF_ON_DOUBLE_CLICK):
|
||||
cg.add_define("USE_BINARY_SENSOR_CLICK_TRIGGER")
|
||||
if config.get(CONF_ON_MULTI_CLICK):
|
||||
cg.add_define("USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER")
|
||||
|
||||
for conf in config.get(CONF_ON_CLICK, []):
|
||||
trigger = cg.new_Pvariable(
|
||||
conf[CONF_TRIGGER_ID], var, conf[CONF_MIN_LENGTH], conf[CONF_MAX_LENGTH]
|
||||
@@ -673,3 +679,15 @@ async def to_code(config):
|
||||
async def binary_sensor_invalidate_state_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
return cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
|
||||
# automation.cpp only implements the click/double_click/multi_click triggers
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_defines(
|
||||
{
|
||||
"automation.cpp": (
|
||||
"USE_BINARY_SENSOR_CLICK_TRIGGER",
|
||||
"USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER",
|
||||
),
|
||||
"filter.cpp": "USE_BINARY_SENSOR_FILTER",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
#include "esphome/core/defines.h"
|
||||
#if defined(USE_BINARY_SENSOR_CLICK_TRIGGER) || defined(USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER)
|
||||
|
||||
#include "automation.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::binary_sensor {
|
||||
|
||||
#ifdef USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER
|
||||
|
||||
static const char *const TAG = "binary_sensor.automation";
|
||||
|
||||
// MultiClickTrigger timeout IDs.
|
||||
@@ -120,6 +125,9 @@ void MultiClickTriggerBase::trigger_() {
|
||||
this->trigger();
|
||||
}
|
||||
|
||||
#endif // USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER
|
||||
|
||||
#ifdef USE_BINARY_SENSOR_CLICK_TRIGGER
|
||||
bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length) {
|
||||
if (max_length == 0) {
|
||||
return length >= min_length;
|
||||
@@ -127,4 +135,8 @@ bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length) {
|
||||
return length >= min_length && length <= max_length;
|
||||
}
|
||||
}
|
||||
#endif // USE_BINARY_SENSOR_CLICK_TRIGGER
|
||||
|
||||
} // namespace esphome::binary_sensor
|
||||
|
||||
#endif // USE_BINARY_SENSOR_CLICK_TRIGGER || USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
from esphome import yaml_util
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_ENABLE_OTA_DOWNGRADE_PROTECTION
|
||||
from esphome.config_helpers import filter_source_files_from_defines
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ADVANCED,
|
||||
@@ -3451,3 +3452,10 @@ def process_stacktrace(config, line, backtrace_state):
|
||||
_decode_pc(config, addr.group())
|
||||
|
||||
return backtrace_state
|
||||
|
||||
|
||||
# gpio.cpp only implements ESP32InternalGPIOPin and its ISR helpers, which
|
||||
# are instantiated solely by the pin schema codegen (esp32_pin_to_code)
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_defines(
|
||||
{"gpio.cpp": "USE_ESP32_INTERNAL_GPIO"}
|
||||
)
|
||||
|
||||
@@ -124,6 +124,15 @@ static uint8_t IRAM_ATTR capture_riscv_backtrace(RvExcFrame *frame, uint32_t *ou
|
||||
// Version is uint32_t because it would be padded to 4 bytes anyway before the next
|
||||
// uint32_t field, so we use the full width rather than wasting 3 bytes of padding.
|
||||
static constexpr uint32_t CRASH_DATA_VERSION = 4;
|
||||
#if CONFIG_IDF_TARGET_ARCH_XTENSA
|
||||
// EXCCAUSE is a 6-bit register; larger recorded values mean the frame's
|
||||
// cause/vaddr slots were never written (not a real exception frame).
|
||||
static constexpr uint32_t XTENSA_EXCCAUSE_COUNT = XCHAL_EXCCAUSE_NUM;
|
||||
#elif CONFIG_IDF_TARGET_ARCH_RISCV
|
||||
// Synchronous mcause exception codes are small and have no interrupt bit;
|
||||
// anything else in a non-pseudo record is a stale slot.
|
||||
static constexpr uint32_t RISCV_EXCEPTION_CAUSE_COUNT = 32;
|
||||
#endif
|
||||
struct RawCrashData {
|
||||
uint32_t version;
|
||||
uint32_t magic;
|
||||
@@ -198,10 +207,28 @@ void crash_handler_clear() {
|
||||
s_raw_crash_data.magic = 0;
|
||||
}
|
||||
|
||||
// Whether the cause slot was written by a real exception frame.
|
||||
static bool cause_slot_was_written() {
|
||||
#if CONFIG_IDF_TARGET_ARCH_XTENSA
|
||||
return s_raw_crash_data.cause < XTENSA_EXCCAUSE_COUNT;
|
||||
#else
|
||||
return s_raw_crash_data.cause < RISCV_EXCEPTION_CAUSE_COUNT;
|
||||
#endif
|
||||
}
|
||||
|
||||
// Look up the exception cause as a human-readable string.
|
||||
// Tables mirror ESP-IDF's panic_arch_fill_info() which uses local static arrays
|
||||
// not exposed via any public API.
|
||||
static const char *get_exception_reason() {
|
||||
uint8_t exception = s_raw_crash_data.exception;
|
||||
if (exception == PANIC_EXCEPTION_ABORT || exception == PANIC_EXCEPTION_TWDT) {
|
||||
// Abort-class panics carry no cause register
|
||||
return nullptr;
|
||||
}
|
||||
if (!cause_slot_was_written()) {
|
||||
// Garbage from old-build or corrupt records; report just the type
|
||||
return nullptr;
|
||||
}
|
||||
#if CONFIG_IDF_TARGET_ARCH_XTENSA
|
||||
if (s_raw_crash_data.pseudo_excause) {
|
||||
// SoC-level panic: watchdog, cache error, etc.
|
||||
@@ -354,10 +381,11 @@ static const char *const FAULT_ADDR_REG = "MTVAL";
|
||||
static const char *const FAULT_ADDR_REG_LOWER = "mtval";
|
||||
#endif
|
||||
|
||||
// Whether the fault address is meaningful — real CPU faults only, not
|
||||
// aborts/watchdogs or SoC-level pseudo exceptions.
|
||||
// Whether the fault address is meaningful: real CPU faults with a validly
|
||||
// written frame only.
|
||||
static bool has_fault_addr() {
|
||||
return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause;
|
||||
return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause &&
|
||||
cause_slot_was_written();
|
||||
}
|
||||
|
||||
// The record was captured by a different firmware build (it survives soft
|
||||
@@ -458,6 +486,10 @@ void crash_handler_log() {
|
||||
// into NOINIT memory before the normal panic handler runs.
|
||||
//
|
||||
extern "C" {
|
||||
// Set by IDF's task watchdog (task_wdt.c, no header) before it simulates an
|
||||
// abort; weak so builds without the task watchdog still link.
|
||||
extern bool g_twdt_isr __attribute__((weak));
|
||||
|
||||
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||
// Names are mandated by the --wrap linker mechanism
|
||||
extern void __real_esp_panic_handler(panic_info_t *info);
|
||||
@@ -470,6 +502,14 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) {
|
||||
s_raw_crash_data.exception = (uint8_t) info->exception;
|
||||
s_raw_crash_data.pseudo_excause = info->pseudo_excause ? 1 : 0;
|
||||
s_raw_crash_data.crashed_core = (uint8_t) info->core;
|
||||
if (g_panic_abort) {
|
||||
// IDF reclassifies to ABORT only inside esp_panic_handler(), after this
|
||||
// wrapper captured info->exception; correct it here. TWDT is our own
|
||||
// distinction (IDF never assigns PANIC_EXCEPTION_TWDT). The abort text is
|
||||
// not stored; the symbolized backtrace already identifies the site.
|
||||
bool is_twdt = &g_twdt_isr != nullptr && g_twdt_isr;
|
||||
s_raw_crash_data.exception = (uint8_t) (is_twdt ? PANIC_EXCEPTION_TWDT : PANIC_EXCEPTION_ABORT);
|
||||
}
|
||||
// Zero unconditionally so a null frame doesn't leave stale .noinit data from a previous boot
|
||||
s_raw_crash_data.cause = 0;
|
||||
s_raw_crash_data.fault_addr = 0;
|
||||
@@ -487,8 +527,12 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) {
|
||||
// Xtensa: walk the backtrace using the public API
|
||||
if (info->frame != nullptr) {
|
||||
auto *xt_frame = (XtExcFrame *) info->frame;
|
||||
s_raw_crash_data.cause = xt_frame->exccause;
|
||||
s_raw_crash_data.fault_addr = xt_frame->excvaddr;
|
||||
if (!g_panic_abort) {
|
||||
// Abort-class frames carry no useful cause/vaddr: TWDT task snapshots
|
||||
// never wrote them and abort() traps describe only the synthetic trap.
|
||||
s_raw_crash_data.cause = xt_frame->exccause;
|
||||
s_raw_crash_data.fault_addr = xt_frame->excvaddr;
|
||||
}
|
||||
s_raw_crash_data.backtrace_count = walk_xtensa_backtrace(xt_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE);
|
||||
}
|
||||
|
||||
@@ -510,8 +554,11 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) {
|
||||
// RISC-V: capture MEPC + RA, then scan stack for code addresses
|
||||
if (info->frame != nullptr) {
|
||||
auto *rv_frame = (RvExcFrame *) info->frame;
|
||||
s_raw_crash_data.cause = rv_frame->mcause;
|
||||
s_raw_crash_data.fault_addr = rv_frame->mtval;
|
||||
if (!g_panic_abort) {
|
||||
// See the Xtensa branch: abort-class frames carry no valid cause/vaddr.
|
||||
s_raw_crash_data.cause = rv_frame->mcause;
|
||||
s_raw_crash_data.fault_addr = rv_frame->mtval;
|
||||
}
|
||||
s_raw_crash_data.backtrace_count =
|
||||
capture_riscv_backtrace(rv_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE, &s_raw_crash_data.reg_frame_count);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#ifdef USE_ESP32
|
||||
#include "esphome/core/defines.h"
|
||||
// Also defines the core ISRInternalGPIOPin methods; those are only reachable
|
||||
// via ESP32InternalGPIOPin::to_isr(), so the same define gates both safely.
|
||||
#if defined(USE_ESP32) && defined(USE_ESP32_INTERNAL_GPIO)
|
||||
|
||||
#include "gpio.h"
|
||||
#include "esphome/core/log.h"
|
||||
@@ -204,4 +207,4 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) {
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
#endif // USE_ESP32
|
||||
#endif // USE_ESP32 && USE_ESP32_INTERNAL_GPIO
|
||||
|
||||
@@ -257,6 +257,7 @@ ESP32_PIN_SCHEMA = cv.All(
|
||||
|
||||
@pins.PIN_SCHEMA_REGISTRY.register(PLATFORM_ESP32, ESP32_PIN_SCHEMA)
|
||||
async def esp32_pin_to_code(config):
|
||||
cg.add_define("USE_ESP32_INTERNAL_GPIO")
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
num = config[CONF_NUMBER]
|
||||
cg.add(var.set_pin(getattr(gpio_num_t, f"GPIO_NUM_{num}")))
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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) |
|
||||
@@ -398,17 +446,18 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
this->notify_state_(ota::OTA_STARTED, 0.0f, 0);
|
||||
#endif
|
||||
|
||||
// begin() may block for a few seconds while it locks flash.
|
||||
// begin() returns quickly; flash sectors are erased incrementally during write().
|
||||
error_code = this->backend_->begin(ota_size, ota_type);
|
||||
if (error_code != ota::OTA_RESPONSE_OK)
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -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
|
||||
@@ -159,9 +159,6 @@ class EthernetComponent final : public Component {
|
||||
const char *get_use_address() const { return this->use_address_; }
|
||||
void set_use_address(const char *use_address) { this->use_address_ = use_address; }
|
||||
void get_eth_mac_address_raw(uint8_t *mac);
|
||||
// Remove before 2026.9.0
|
||||
ESPDEPRECATED("Use get_eth_mac_address_pretty_into_buffer() instead. Removed in 2026.9.0", "2026.3.0")
|
||||
std::string get_eth_mac_address_pretty();
|
||||
const char *get_eth_mac_address_pretty_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf);
|
||||
eth_duplex_t get_duplex_mode();
|
||||
eth_speed_t get_link_speed();
|
||||
|
||||
@@ -928,11 +928,6 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) {
|
||||
ESPHL_ERROR_CHECK(err, "ETH_CMD_G_MAC error");
|
||||
}
|
||||
|
||||
std::string EthernetComponent::get_eth_mac_address_pretty() {
|
||||
char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
return std::string(this->get_eth_mac_address_pretty_into_buffer(buf));
|
||||
}
|
||||
|
||||
const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer(
|
||||
std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) {
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
|
||||
@@ -249,11 +249,6 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) {
|
||||
}
|
||||
}
|
||||
|
||||
std::string EthernetComponent::get_eth_mac_address_pretty() {
|
||||
char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
return std::string(this->get_eth_mac_address_pretty_into_buffer(buf));
|
||||
}
|
||||
|
||||
const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer(
|
||||
std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) {
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
|
||||
@@ -64,8 +64,9 @@ void OtaHttpRequestComponent::flash() {
|
||||
}
|
||||
}
|
||||
|
||||
void OtaHttpRequestComponent::cleanup_(ota::OTABackendPtr backend, const std::shared_ptr<HttpContainer> &container) {
|
||||
if (this->update_started_) {
|
||||
void OtaHttpRequestComponent::cleanup_(ota::OTABackendPtr backend, const std::shared_ptr<HttpContainer> &container,
|
||||
bool abort_backend) {
|
||||
if (abort_backend) {
|
||||
ESP_LOGV(TAG, "Aborting OTA backend");
|
||||
backend->abort();
|
||||
}
|
||||
@@ -106,7 +107,8 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
|
||||
auto error_code = backend->begin(container->content_length);
|
||||
if (error_code != ota::OTA_RESPONSE_OK) {
|
||||
ESP_LOGW(TAG, "backend->begin error: %d", error_code);
|
||||
this->cleanup_(std::move(backend), container);
|
||||
// Nothing to abort: begin() failed, so no OTA handle was opened
|
||||
this->cleanup_(std::move(backend), container, /*abort_backend=*/false);
|
||||
return error_code;
|
||||
}
|
||||
|
||||
@@ -140,7 +142,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Error reading data: %d", bufsize_or_error);
|
||||
}
|
||||
this->cleanup_(std::move(backend), container);
|
||||
this->cleanup_(std::move(backend), container, /*abort_backend=*/true);
|
||||
return OTA_CONNECTION_ERROR;
|
||||
}
|
||||
|
||||
@@ -150,14 +152,13 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
|
||||
md5_receive.add(buf, bufsize_or_error);
|
||||
|
||||
// write bytes to OTA backend
|
||||
this->update_started_ = true;
|
||||
error_code = backend->write(buf, bufsize_or_error);
|
||||
if (error_code != ota::OTA_RESPONSE_OK) {
|
||||
// error code explanation available at
|
||||
// https://github.com/esphome/esphome/blob/dev/esphome/components/ota/ota_backend.h
|
||||
ESP_LOGE(TAG, "Error code (%02X) writing binary data to flash at offset %d and size %d", error_code,
|
||||
container->get_bytes_read() - bufsize_or_error, container->content_length);
|
||||
this->cleanup_(std::move(backend), container);
|
||||
this->cleanup_(std::move(backend), container, /*abort_backend=*/true);
|
||||
return error_code;
|
||||
}
|
||||
}
|
||||
@@ -181,7 +182,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
|
||||
this->md5_computed_ = md5_receive_str;
|
||||
if (strncmp(this->md5_computed_.c_str(), this->md5_expected_.c_str(), MD5_SIZE) != 0) {
|
||||
ESP_LOGE(TAG, "MD5 computed: %s - Aborting due to MD5 mismatch", this->md5_computed_.c_str());
|
||||
this->cleanup_(std::move(backend), container);
|
||||
this->cleanup_(std::move(backend), container, /*abort_backend=*/true);
|
||||
return ota::OTA_RESPONSE_ERROR_MD5_MISMATCH;
|
||||
} else {
|
||||
backend->set_update_md5(md5_receive_str);
|
||||
@@ -197,7 +198,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
|
||||
error_code = backend->end();
|
||||
if (error_code != ota::OTA_RESPONSE_OK) {
|
||||
ESP_LOGW(TAG, "Error ending update! error_code: %d", error_code);
|
||||
this->cleanup_(std::move(backend), container);
|
||||
this->cleanup_(std::move(backend), container, /*abort_backend=*/true);
|
||||
return error_code;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented<
|
||||
void flash();
|
||||
|
||||
protected:
|
||||
void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr<HttpContainer> &container);
|
||||
void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr<HttpContainer> &container, bool abort_backend);
|
||||
uint8_t do_ota_();
|
||||
std::string get_url_with_auth_(const std::string &url);
|
||||
bool http_get_md5_();
|
||||
@@ -51,7 +51,6 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented<
|
||||
std::string username_{};
|
||||
std::string url_{};
|
||||
int status_ = -1;
|
||||
bool update_started_ = false;
|
||||
static const uint16_t HTTP_RECV_BUFFER = 256; // the firmware GET chunk size
|
||||
};
|
||||
|
||||
|
||||
@@ -618,9 +618,6 @@ class ModbusClientDevice {
|
||||
inline void clear_tx_queue_for_address() { this->parent_->clear_tx_queue_for_address(this->address_); }
|
||||
inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); }
|
||||
|
||||
// If more than one device is connected block sending a new command before a response is received
|
||||
ESPDEPRECATED("Use ready_for_immediate_send() instead. Removed in 2026.9.0", "2026.3.0")
|
||||
bool waiting_for_response() { return !this->ready_for_immediate_send(); }
|
||||
bool ready_for_immediate_send() { return this->parent_->tx_buffer_empty() && !this->parent_->tx_blocked(); }
|
||||
|
||||
protected:
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_NOISE
|
||||
#include <array>
|
||||
#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
|
||||
@@ -1,6 +1,9 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
from esphome.config_helpers import (
|
||||
filter_source_files_from_defines,
|
||||
filter_source_files_from_platform,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ESPHOME,
|
||||
@@ -171,24 +174,17 @@ _filter_backend_source_files = filter_source_files_from_platform(
|
||||
)
|
||||
|
||||
|
||||
# USE_OTA_SIGNED_VERIFICATION_MULTI_KEY is set only on ESP32/IDF;
|
||||
# USE_OTA_PARTITIONS is set by the esphome OTA platform when
|
||||
# allow_partition_access is enabled.
|
||||
_filter_define_source_files = filter_source_files_from_defines(
|
||||
{
|
||||
"ota_signature_esp_idf.cpp": "USE_OTA_SIGNED_VERIFICATION_MULTI_KEY",
|
||||
"ota_bootloader_esp_idf.cpp": "USE_OTA_PARTITIONS",
|
||||
"ota_partitions_esp_idf.cpp": "USE_OTA_PARTITIONS",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def FILTER_SOURCE_FILES() -> list[str]:
|
||||
files = _filter_backend_source_files()
|
||||
# ota_signature_esp_idf.cpp implements multi-key OTA signature verification,
|
||||
# compiled only when the esp32 component enables it (external RSA signed
|
||||
# OTA sets USE_OTA_SIGNED_VERIFICATION_MULTI_KEY). The define is set only on
|
||||
# ESP32/IDF, so this also excludes the file on every other platform. Filter
|
||||
# it out otherwise so the (otherwise fully #ifdef'd-out) file isn't opened
|
||||
# and parsed on every build.
|
||||
if not any(
|
||||
define.name == "USE_OTA_SIGNED_VERIFICATION_MULTI_KEY"
|
||||
for define in CORE.defines
|
||||
):
|
||||
files.append("ota_signature_esp_idf.cpp")
|
||||
# ota_bootloader_esp_idf.cpp and ota_partitions_esp_idf.cpp are fully
|
||||
# #ifdef'd on USE_OTA_PARTITIONS (set by the esphome OTA platform when
|
||||
# allow_partition_access is enabled). Filter them out otherwise for the
|
||||
# same reason as above.
|
||||
if not any(define.name == "USE_OTA_PARTITIONS" for define in CORE.defines):
|
||||
files.append("ota_bootloader_esp_idf.cpp")
|
||||
files.append("ota_partitions_esp_idf.cpp")
|
||||
return files
|
||||
return _filter_backend_source_files() + _filter_define_source_files()
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -66,6 +67,19 @@ enum OTAResponseTypes {
|
||||
*/
|
||||
bool version_is_older(const char *candidate, const char *reference);
|
||||
|
||||
// 64 KiB flash block; the erase granularity the ESP-IDF backend erases ahead with.
|
||||
static constexpr size_t OTA_BLOCK_ERASE_SIZE = 64 * 1024;
|
||||
|
||||
/** Target erased watermark for lazy block erase-ahead.
|
||||
*
|
||||
* Rounds the write end offset up to a block boundary, clamped to the partition
|
||||
* size. Platform-independent so the arithmetic is host-testable.
|
||||
*/
|
||||
constexpr size_t next_erase_end(size_t write_end, size_t partition_size) {
|
||||
const size_t rounded = (write_end + OTA_BLOCK_ERASE_SIZE - 1) & ~(OTA_BLOCK_ERASE_SIZE - 1);
|
||||
return rounded < partition_size ? rounded : partition_size;
|
||||
}
|
||||
|
||||
enum OTAState {
|
||||
OTA_COMPLETED = 0,
|
||||
OTA_STARTED,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <esp_ota_ops.h>
|
||||
#include <esp_task_wdt.h>
|
||||
#include <sdkconfig.h>
|
||||
#include <spi_flash_mmap.h>
|
||||
#ifdef USE_OTA_DOWNGRADE_PROTECTION
|
||||
#include <esp_app_desc.h>
|
||||
@@ -60,27 +60,38 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type)
|
||||
return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION;
|
||||
}
|
||||
|
||||
// esp_ota_begin() erases the destination region, which blocks loopTask and
|
||||
// scales with the erase size -- a fixed watchdog overruns on large OTA slots.
|
||||
// An unknown size (0, e.g. web_server uploads) erases the whole partition, so
|
||||
// budget against the bytes actually erased. ~10ms/KiB (conservative
|
||||
// ~100 KiB/s erase) over a 15s floor; panic stays on so a stuck erase still
|
||||
// resets rather than hanging forever.
|
||||
size_t erase_size = image_size;
|
||||
if (erase_size == 0 || erase_size > this->partition_->size) {
|
||||
erase_size = this->partition_->size;
|
||||
// Both lazy-erase paths below replace esp_ota_begin()'s blocking full erase.
|
||||
// Size check replaces the one that erase performed (0 = unknown size,
|
||||
// e.g. web_server uploads).
|
||||
if (image_size != 0 && image_size > this->partition_->size) {
|
||||
return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE;
|
||||
}
|
||||
const uint32_t erase_budget_ms = 15000 + (erase_size >> 10) * 10;
|
||||
watchdog::WatchdogManager watchdog(erase_budget_ms);
|
||||
esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_);
|
||||
this->written_ = 0;
|
||||
esp_err_t err;
|
||||
#ifdef USE_OTA_BLOCK_ERASE_AHEAD
|
||||
this->erased_end_ = 0;
|
||||
// Unlike esp_ota_begin(), esp_ota_resume() does not reject a running app in
|
||||
// ESP_OTA_IMG_PENDING_VERIFY; that state is unreachable here because the app
|
||||
// was marked valid at boot (esp32/hal.cpp) or just above under USE_OTA_ROLLBACK.
|
||||
// erase_size 0 (!= OTA_WITH_SEQUENTIAL_WRITES) means no erase; erase_ahead_() handles it
|
||||
err = esp_ota_resume(this->partition_, 0, 0, &this->update_handle_);
|
||||
#if defined(CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
|
||||
// esp_ota_begin() does this on IDF 5.5+; esp_ota_resume() does not. Prevents
|
||||
// booting a half-written slot after a crash mid-OTA. Not available on the
|
||||
// 5.3.3/5.4.2 backports, whose esp_ota_begin() did not invalidate either.
|
||||
if (err == ESP_OK) {
|
||||
esp_ota_invalidate_inactive_ota_data_slot();
|
||||
}
|
||||
#endif
|
||||
#else
|
||||
err = esp_ota_begin(this->partition_, OTA_WITH_SEQUENTIAL_WRITES, &this->update_handle_);
|
||||
#endif
|
||||
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X)", err);
|
||||
ESP_LOGE(TAG, "OTA begin failed (err=0x%X)", err);
|
||||
esp_ota_abort(this->update_handle_);
|
||||
this->update_handle_ = 0;
|
||||
if (err == ESP_ERR_INVALID_SIZE) {
|
||||
return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE;
|
||||
} else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) {
|
||||
if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) {
|
||||
return OTA_RESPONSE_ERROR_WRITING_FLASH;
|
||||
} else if (err == ESP_ERR_OTA_PARTITION_CONFLICT) {
|
||||
// This error appears with 1 factory and 1 ota partition
|
||||
@@ -120,6 +131,17 @@ OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) {
|
||||
if (!this->is_app_or_bootloader_update_()) {
|
||||
return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE;
|
||||
}
|
||||
#endif
|
||||
// Overflow can only happen on unknown-size uploads (web_server); known
|
||||
// sizes were rejected in begin().
|
||||
if (this->written_ + len > this->partition_->size) {
|
||||
return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE;
|
||||
}
|
||||
#ifdef USE_OTA_BLOCK_ERASE_AHEAD
|
||||
OTAResponseTypes erase_result = this->erase_ahead_(len);
|
||||
if (erase_result != OTA_RESPONSE_OK) {
|
||||
return erase_result;
|
||||
}
|
||||
#endif
|
||||
esp_err_t err = esp_ota_write(this->update_handle_, data, len);
|
||||
this->md5_.add(data, len);
|
||||
@@ -127,14 +149,40 @@ OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) {
|
||||
ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X)", err);
|
||||
if (err == ESP_ERR_OTA_VALIDATE_FAILED) {
|
||||
return OTA_RESPONSE_ERROR_MAGIC;
|
||||
} else if (err == ESP_ERR_INVALID_SIZE) {
|
||||
// Sequential-writes fallback: IDF's lazy erase reports overflow here
|
||||
return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE;
|
||||
} else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) {
|
||||
return OTA_RESPONSE_ERROR_WRITING_FLASH;
|
||||
}
|
||||
return OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
}
|
||||
this->written_ += len;
|
||||
return OTA_RESPONSE_OK;
|
||||
}
|
||||
|
||||
#ifdef USE_OTA_BLOCK_ERASE_AHEAD
|
||||
OTAResponseTypes IDFOTABackend::erase_ahead_(size_t len) {
|
||||
const size_t end = this->written_ + len;
|
||||
if (this->erased_end_ >= end) {
|
||||
return OTA_RESPONSE_OK;
|
||||
}
|
||||
// Round up to a block boundary, clamped to the partition end; IDF splits the
|
||||
// range into 64 KiB block erases where aligned, sector erases elsewhere.
|
||||
const size_t erase_to = next_erase_end(end, this->partition_->size);
|
||||
// A block erase is one uninterruptible flash op (typically ~150 ms, seconds
|
||||
// on aged flash) and the transfer loop may not have fed the WDT for ~1s.
|
||||
watchdog::WatchdogManager watchdog(15000);
|
||||
esp_err_t err = esp_partition_erase_range(this->partition_, this->erased_end_, erase_to - this->erased_end_);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_partition_erase_range failed (err=0x%X)", err);
|
||||
return err == ESP_ERR_INVALID_SIZE ? OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE : OTA_RESPONSE_ERROR_WRITING_FLASH;
|
||||
}
|
||||
this->erased_end_ = erase_to;
|
||||
return OTA_RESPONSE_OK;
|
||||
}
|
||||
#endif
|
||||
|
||||
OTAResponseTypes IDFOTABackend::end() {
|
||||
if (this->md5_set_) {
|
||||
this->md5_.calculate();
|
||||
@@ -226,6 +274,10 @@ void IDFOTABackend::abort() {
|
||||
// or not an update is in flight.
|
||||
esp_ota_abort(this->update_handle_);
|
||||
this->update_handle_ = 0;
|
||||
this->written_ = 0;
|
||||
#ifdef USE_OTA_BLOCK_ERASE_AHEAD
|
||||
this->erased_end_ = 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace esphome::ota
|
||||
|
||||
@@ -5,8 +5,18 @@
|
||||
#include "esphome/components/md5/md5.h"
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#include <esp_idf_version.h>
|
||||
#include <esp_ota_ops.h>
|
||||
|
||||
// esp_ota_resume() (IDF 5.4.2+, backported to 5.3.3) provides a no-erase OTA
|
||||
// handle, letting write() block-erase 64 KiB ahead of the write cursor
|
||||
// (~4x faster than the per-sector lazy erase of OTA_WITH_SEQUENTIAL_WRITES,
|
||||
// used as fallback on older IDF).
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 2) || \
|
||||
(ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 3) && ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 0))
|
||||
#define USE_OTA_BLOCK_ERASE_AHEAD
|
||||
#endif
|
||||
|
||||
namespace esphome::ota {
|
||||
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
@@ -54,6 +64,9 @@ class IDFOTABackend final {
|
||||
#endif
|
||||
|
||||
private:
|
||||
#ifdef USE_OTA_BLOCK_ERASE_AHEAD
|
||||
OTAResponseTypes erase_ahead_(size_t len);
|
||||
#endif
|
||||
#ifdef USE_OTA_SIGNED_VERIFICATION_MULTI_KEY
|
||||
// Accept an image signed by any key the running app trusts (up to 3 blocks),
|
||||
// so rotation and backup keys work. Fails closed. Covers app and bootloader.
|
||||
@@ -62,7 +75,11 @@ class IDFOTABackend final {
|
||||
// Keep md5_ first since its digest_ is alignas(32) on DMA-SHA variants; md5_set_ stays last so buf_ packs tightly.
|
||||
md5::MD5Digest md5_{};
|
||||
esp_ota_handle_t update_handle_{0};
|
||||
const esp_partition_t *partition_;
|
||||
const esp_partition_t *partition_{nullptr};
|
||||
size_t written_{0}; // Bytes handed to esp_ota_write()
|
||||
#ifdef USE_OTA_BLOCK_ERASE_AHEAD
|
||||
size_t erased_end_{0}; // Erased up to this partition offset; must stay >= written_
|
||||
#endif
|
||||
char expected_bin_md5_[32];
|
||||
bool md5_set_{false};
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#ifdef USE_ESP32
|
||||
#include "ota_backend_esp_idf.h"
|
||||
|
||||
#include "esphome/components/watchdog/watchdog.h"
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
@@ -69,12 +70,20 @@ OTAResponseTypes IDFOTABackend::setup_bootloader_staging_() {
|
||||
return OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY;
|
||||
}
|
||||
// Erase full size of the bootloader partition in the staging partition
|
||||
// to avoid copying old data to the bootloader partition later
|
||||
// to avoid copying old data to the bootloader partition later. Up to
|
||||
// ESP_BOOTLOADER_SIZE of blocking erase; widen the WDT for its duration.
|
||||
watchdog::WatchdogManager watchdog(15000);
|
||||
esp_err_t err = esp_partition_erase_range(this->partition_, 0, this->bootloader_part_->size);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "esp_partition_erase_range failed (err=0x%X)", err);
|
||||
// No critical error, don't return
|
||||
}
|
||||
#ifdef USE_OTA_BLOCK_ERASE_AHEAD
|
||||
if (err == ESP_OK) {
|
||||
// Skip re-erasing the pre-erased staging region in erase_ahead_()
|
||||
this->erased_end_ = this->bootloader_part_->size;
|
||||
}
|
||||
#endif
|
||||
err = esp_ota_set_final_partition(this->update_handle_, this->bootloader_part_, false);
|
||||
if (err != ESP_OK) {
|
||||
esp_ota_abort(this->update_handle_);
|
||||
|
||||
@@ -211,7 +211,7 @@ bool rsa_pss_verify(uint8_t *block, const uint8_t *digest) {
|
||||
bool IDFOTABackend::verify_signed_image_(const esp_partition_t *incoming) {
|
||||
// Verification re-hashes the full image (after esp_ota_end already did one
|
||||
// pass), which can approach the task WDT budget on a large app. Extend it for
|
||||
// the duration, mirroring the erase budget in begin().
|
||||
// the duration, scaled to the image size over a 15 s floor.
|
||||
const uint32_t verify_budget_ms = 15000 + (incoming->size >> 10) * 10;
|
||||
watchdog::WatchdogManager watchdog(verify_budget_ms);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import mqtt, web_server, zigbee
|
||||
from esphome.components.const import CONF_B_CONSTANT
|
||||
from esphome.config_helpers import filter_source_files_from_defines
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ABOVE,
|
||||
@@ -1303,3 +1304,8 @@ def _lstsq(a, b):
|
||||
@coroutine_with_priority(CoroPriority.CORE)
|
||||
async def to_code(config):
|
||||
cg.add_global(sensor_ns.using)
|
||||
|
||||
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_defines(
|
||||
{"filter.cpp": "USE_SENSOR_FILTER"}
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import mqtt, web_server
|
||||
from esphome.config_helpers import filter_source_files_from_defines
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_DEVICE_CLASS,
|
||||
@@ -256,3 +257,8 @@ async def text_sensor_state_to_code(config, condition_id, template_arg, args):
|
||||
templ = await cg.templatable(config[CONF_STATE], args, cg.std_string)
|
||||
cg.add(var.set_state(templ))
|
||||
return var
|
||||
|
||||
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_defines(
|
||||
{"filter.cpp": "USE_TEXT_SENSOR_FILTER"}
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor, time
|
||||
from esphome.config_helpers import filter_source_files_from_defines
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_TIME_ID,
|
||||
@@ -10,7 +11,6 @@ from esphome.const import (
|
||||
STATE_CLASS_TOTAL_INCREASING,
|
||||
UNIT_SECOND,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
|
||||
uptime_ns = cg.esphome_ns.namespace("uptime")
|
||||
UptimeSecondsSensor = uptime_ns.class_(
|
||||
@@ -62,9 +62,6 @@ async def to_code(config):
|
||||
cg.add(var.set_time(time_id))
|
||||
|
||||
|
||||
def FILTER_SOURCE_FILES() -> list[str]:
|
||||
# uptime_timestamp_sensor.cpp is fully #ifdef'd on USE_TIME; skip it
|
||||
# when no time component is configured.
|
||||
if not any(define.name == "USE_TIME" for define in CORE.defines):
|
||||
return ["uptime_timestamp_sensor.cpp"]
|
||||
return []
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_defines(
|
||||
{"uptime_timestamp_sensor.cpp": "USE_TIME"}
|
||||
)
|
||||
|
||||
@@ -117,12 +117,6 @@ class AsyncWebServerRequest {
|
||||
/// Write URL (without query string) to buffer, returns StringRef pointing to buffer.
|
||||
/// URL is decoded (e.g., %20 -> space).
|
||||
StringRef url_to(std::span<char, URL_BUF_SIZE> buffer) const;
|
||||
// Remove before 2026.9.0
|
||||
ESPDEPRECATED("Use url_to() instead. Removed in 2026.9.0", "2026.3.0")
|
||||
std::string url() const {
|
||||
char buffer[URL_BUF_SIZE];
|
||||
return std::string(this->url_to(buffer));
|
||||
}
|
||||
// NOLINTNEXTLINE(readability-identifier-naming)
|
||||
size_t contentLength() const { return this->req_->content_len; }
|
||||
|
||||
|
||||
@@ -618,8 +618,6 @@ static const char *eap_phase2_to_str(esp_eap_ttls_phase2_types type) {
|
||||
}
|
||||
#endif
|
||||
|
||||
float WiFiComponent::get_setup_priority() const { return setup_priority::WIFI; }
|
||||
|
||||
void WiFiComponent::setup() {
|
||||
this->wifi_pre_setup_();
|
||||
|
||||
@@ -931,10 +929,6 @@ void WiFiComponent::loop() {
|
||||
|
||||
WiFiComponent::WiFiComponent() { global_wifi_component = this; }
|
||||
|
||||
#ifdef USE_WIFI_11KV_SUPPORT
|
||||
void WiFiComponent::set_btm(bool btm) { this->btm_ = btm; }
|
||||
void WiFiComponent::set_rrm(bool rrm) { this->rrm_ = rrm; }
|
||||
#endif
|
||||
network::IPAddresses WiFiComponent::get_ip_addresses() {
|
||||
if (this->has_sta())
|
||||
return this->wifi_sta_ip_addresses();
|
||||
@@ -1327,8 +1321,6 @@ void WiFiComponent::disable() {
|
||||
this->wifi_mode_(false, false);
|
||||
}
|
||||
|
||||
bool WiFiComponent::is_disabled() { return this->state_ == WIFI_COMPONENT_STATE_DISABLED; }
|
||||
|
||||
void WiFiComponent::start_scanning() {
|
||||
this->action_started_ = millis();
|
||||
ESP_LOGD(TAG, "Starting scan");
|
||||
@@ -2196,7 +2188,6 @@ void WiFiComponent::retry_connect() {
|
||||
}
|
||||
}
|
||||
|
||||
void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; }
|
||||
void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) {
|
||||
this->power_save_ = power_save;
|
||||
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
|
||||
@@ -2204,8 +2195,6 @@ void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) {
|
||||
#endif
|
||||
}
|
||||
|
||||
void WiFiComponent::set_passive_scan(bool passive) { this->passive_scan_ = passive; }
|
||||
|
||||
bool WiFiComponent::is_captive_portal_active_() {
|
||||
#ifdef USE_CAPTIVE_PORTAL
|
||||
return captive_portal::global_captive_portal != nullptr && captive_portal::global_captive_portal->is_active();
|
||||
@@ -2324,33 +2313,6 @@ void WiFiComponent::save_fast_connect_settings_(const bssid_t &bssid, uint8_t ch
|
||||
}
|
||||
#endif
|
||||
|
||||
void WiFiAP::set_ssid(const std::string &ssid) { this->ssid_ = CompactString(ssid.c_str(), ssid.size()); }
|
||||
void WiFiAP::set_ssid(const char *ssid) { this->ssid_ = CompactString(ssid, strlen(ssid)); }
|
||||
void WiFiAP::set_bssid(const bssid_t &bssid) { this->bssid_ = bssid; }
|
||||
void WiFiAP::clear_bssid() { this->bssid_ = {}; }
|
||||
void WiFiAP::set_password(const std::string &password) {
|
||||
this->password_ = CompactString(password.c_str(), password.size());
|
||||
}
|
||||
void WiFiAP::set_password(const char *password) { this->password_ = CompactString(password, strlen(password)); }
|
||||
#ifdef USE_WIFI_WPA2_EAP
|
||||
void WiFiAP::set_eap(optional<EAPAuth> eap_auth) { this->eap_ = std::move(eap_auth); }
|
||||
#endif
|
||||
void WiFiAP::set_channel(uint8_t channel) { this->channel_ = channel; }
|
||||
void WiFiAP::clear_channel() { this->channel_ = 0; }
|
||||
#ifdef USE_WIFI_MANUAL_IP
|
||||
void WiFiAP::set_manual_ip(optional<ManualIP> manual_ip) { this->manual_ip_ = manual_ip; }
|
||||
#endif
|
||||
void WiFiAP::set_hidden(bool hidden) { this->hidden_ = hidden; }
|
||||
const bssid_t &WiFiAP::get_bssid() const { return this->bssid_; }
|
||||
bool WiFiAP::has_bssid() const { return this->bssid_ != bssid_t{}; }
|
||||
#ifdef USE_WIFI_WPA2_EAP
|
||||
const optional<EAPAuth> &WiFiAP::get_eap() const { return this->eap_; }
|
||||
#endif
|
||||
#ifdef USE_WIFI_MANUAL_IP
|
||||
const optional<ManualIP> &WiFiAP::get_manual_ip() const { return this->manual_ip_; }
|
||||
#endif
|
||||
bool WiFiAP::get_hidden() const { return this->hidden_; }
|
||||
|
||||
WiFiScanResult::WiFiScanResult(const bssid_t &bssid, const char *ssid, size_t ssid_len, uint8_t channel, int8_t rssi,
|
||||
bool with_auth, bool is_hidden)
|
||||
: bssid_(bssid),
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#ifdef USE_LIBRETINY
|
||||
@@ -261,38 +262,38 @@ class WiFiAP {
|
||||
friend class WiFiScanResult;
|
||||
|
||||
public:
|
||||
void set_ssid(const std::string &ssid);
|
||||
void set_ssid(const char *ssid);
|
||||
void set_ssid(const std::string &ssid) { this->ssid_ = CompactString(ssid.c_str(), ssid.size()); }
|
||||
void set_ssid(const char *ssid) { this->set_ssid(StringRef(ssid)); }
|
||||
void set_ssid(StringRef ssid) { this->ssid_ = CompactString(ssid.c_str(), ssid.size()); }
|
||||
void set_bssid(const bssid_t &bssid);
|
||||
void clear_bssid();
|
||||
void set_password(const std::string &password);
|
||||
void set_password(const char *password);
|
||||
void set_bssid(const bssid_t &bssid) { this->bssid_ = bssid; }
|
||||
void clear_bssid() { this->bssid_ = {}; }
|
||||
void set_password(const std::string &password) { this->password_ = CompactString(password.c_str(), password.size()); }
|
||||
void set_password(const char *password) { this->set_password(StringRef(password)); }
|
||||
void set_password(StringRef password) { this->password_ = CompactString(password.c_str(), password.size()); }
|
||||
#ifdef USE_WIFI_WPA2_EAP
|
||||
void set_eap(optional<EAPAuth> eap_auth);
|
||||
void set_eap(optional<EAPAuth> eap_auth) { this->eap_ = std::move(eap_auth); }
|
||||
#endif // USE_WIFI_WPA2_EAP
|
||||
void set_channel(uint8_t channel);
|
||||
void clear_channel();
|
||||
void set_channel(uint8_t channel) { this->channel_ = channel; }
|
||||
void clear_channel() { this->channel_ = 0; }
|
||||
void set_priority(int8_t priority) { priority_ = priority; }
|
||||
#ifdef USE_WIFI_MANUAL_IP
|
||||
void set_manual_ip(optional<ManualIP> manual_ip);
|
||||
void set_manual_ip(optional<ManualIP> manual_ip) { this->manual_ip_ = manual_ip; }
|
||||
#endif
|
||||
void set_hidden(bool hidden);
|
||||
void set_hidden(bool hidden) { this->hidden_ = hidden; }
|
||||
StringRef get_ssid() const { return this->ssid_.ref(); }
|
||||
StringRef get_password() const { return this->password_.ref(); }
|
||||
const bssid_t &get_bssid() const;
|
||||
bool has_bssid() const;
|
||||
const bssid_t &get_bssid() const { return this->bssid_; }
|
||||
bool has_bssid() const { return this->bssid_ != bssid_t{}; }
|
||||
#ifdef USE_WIFI_WPA2_EAP
|
||||
const optional<EAPAuth> &get_eap() const;
|
||||
const optional<EAPAuth> &get_eap() const { return this->eap_; }
|
||||
#endif // USE_WIFI_WPA2_EAP
|
||||
uint8_t get_channel() const { return this->channel_; }
|
||||
bool has_channel() const { return this->channel_ != 0; }
|
||||
int8_t get_priority() const { return priority_; }
|
||||
#ifdef USE_WIFI_MANUAL_IP
|
||||
const optional<ManualIP> &get_manual_ip() const;
|
||||
const optional<ManualIP> &get_manual_ip() const { return this->manual_ip_; }
|
||||
#endif
|
||||
bool get_hidden() const;
|
||||
bool get_hidden() const { return this->hidden_; }
|
||||
|
||||
protected:
|
||||
CompactString ssid_;
|
||||
@@ -442,6 +443,7 @@ class WiFiComponent final : public Component {
|
||||
void set_sta(const WiFiAP &ap);
|
||||
// Returns a copy of the currently selected AP configuration
|
||||
WiFiAP get_sta() const;
|
||||
// init_sta/add_sta kept out of line: inlining them into the generated setup() grows flash
|
||||
void init_sta(size_t count);
|
||||
void add_sta(const WiFiAP &ap);
|
||||
void clear_sta();
|
||||
@@ -461,7 +463,7 @@ class WiFiComponent final : public Component {
|
||||
|
||||
void enable();
|
||||
void disable();
|
||||
bool is_disabled();
|
||||
bool is_disabled() { return this->state_ == WIFI_COMPONENT_STATE_DISABLED; }
|
||||
void start_scanning();
|
||||
void check_scanning_finished();
|
||||
void start_connecting(const WiFiAP &ap);
|
||||
@@ -472,7 +474,7 @@ class WiFiComponent final : public Component {
|
||||
|
||||
void retry_connect();
|
||||
|
||||
void set_reboot_timeout(uint32_t reboot_timeout);
|
||||
void set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; }
|
||||
|
||||
bool is_connected() const { return this->connected_; }
|
||||
|
||||
@@ -492,7 +494,7 @@ class WiFiComponent final : public Component {
|
||||
void set_phy_mode(WiFi8266PhyMode phy_mode) { this->phy_mode_ = phy_mode; }
|
||||
#endif
|
||||
|
||||
void set_passive_scan(bool passive);
|
||||
void set_passive_scan(bool passive) { this->passive_scan_ = passive; }
|
||||
|
||||
void save_wifi_sta(const std::string &ssid, const std::string &password);
|
||||
void save_wifi_sta(const char *ssid, const char *password);
|
||||
@@ -506,7 +508,7 @@ class WiFiComponent final : public Component {
|
||||
void dump_config() override;
|
||||
void restart_adapter();
|
||||
/// WIFI setup_priority.
|
||||
float get_setup_priority() const override;
|
||||
float get_setup_priority() const override { return setup_priority::WIFI; }
|
||||
/// Reconnect WiFi if required.
|
||||
void loop() override;
|
||||
|
||||
@@ -515,8 +517,8 @@ class WiFiComponent final : public Component {
|
||||
bool is_ap_active() const { return this->ap_started_; }
|
||||
|
||||
#ifdef USE_WIFI_11KV_SUPPORT
|
||||
void set_btm(bool btm);
|
||||
void set_rrm(bool rrm);
|
||||
void set_btm(bool btm) { this->btm_ = btm; }
|
||||
void set_rrm(bool rrm) { this->rrm_ = rrm; }
|
||||
#endif
|
||||
|
||||
network::IPAddress get_dns_address(int num);
|
||||
@@ -550,9 +552,6 @@ class WiFiComponent final : public Component {
|
||||
void set_sta_priority(bssid_t bssid, int8_t priority);
|
||||
|
||||
network::IPAddresses wifi_sta_ip_addresses();
|
||||
// Remove before 2026.9.0
|
||||
ESPDEPRECATED("Use wifi_ssid_to() instead. Removed in 2026.9.0", "2026.3.0")
|
||||
std::string wifi_ssid();
|
||||
/// Write SSID to buffer without heap allocation.
|
||||
/// Returns pointer to buffer, or empty string if not connected.
|
||||
const char *wifi_ssid_to(std::span<char, SSID_BUFFER_SIZE> buffer);
|
||||
|
||||
@@ -944,16 +944,6 @@ bssid_t WiFiComponent::wifi_bssid() {
|
||||
}
|
||||
return bssid;
|
||||
}
|
||||
std::string WiFiComponent::wifi_ssid() {
|
||||
struct station_config conf {};
|
||||
if (!wifi_station_get_config(&conf)) {
|
||||
return "";
|
||||
}
|
||||
// conf.ssid is uint8[32], not null-terminated if full
|
||||
auto *ssid_s = reinterpret_cast<const char *>(conf.ssid);
|
||||
size_t len = strnlen(ssid_s, sizeof(conf.ssid));
|
||||
return {ssid_s, len};
|
||||
}
|
||||
const char *WiFiComponent::wifi_ssid_to(std::span<char, SSID_BUFFER_SIZE> buffer) {
|
||||
struct station_config conf {};
|
||||
if (!wifi_station_get_config(&conf)) {
|
||||
|
||||
@@ -1237,18 +1237,6 @@ bssid_t WiFiComponent::wifi_bssid() {
|
||||
std::copy(info.bssid, info.bssid + 6, bssid.begin());
|
||||
return bssid;
|
||||
}
|
||||
std::string WiFiComponent::wifi_ssid() {
|
||||
wifi_ap_record_t info{};
|
||||
esp_err_t err = esp_wifi_sta_get_ap_info(&info);
|
||||
if (err != ESP_OK) {
|
||||
// Very verbose only: this is expected during dump_config() before connection is established (PR #9823)
|
||||
ESP_LOGVV(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err));
|
||||
return "";
|
||||
}
|
||||
auto *ssid_s = reinterpret_cast<const char *>(info.ssid);
|
||||
size_t len = strnlen(ssid_s, sizeof(info.ssid));
|
||||
return {ssid_s, len};
|
||||
}
|
||||
const char *WiFiComponent::wifi_ssid_to(std::span<char, SSID_BUFFER_SIZE> buffer) {
|
||||
wifi_ap_record_t info{};
|
||||
esp_err_t err = esp_wifi_sta_get_ap_info(&info);
|
||||
|
||||
@@ -762,7 +762,6 @@ bssid_t WiFiComponent::wifi_bssid() {
|
||||
}
|
||||
return bssid;
|
||||
}
|
||||
std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); }
|
||||
const char *WiFiComponent::wifi_ssid_to(std::span<char, SSID_BUFFER_SIZE> buffer) {
|
||||
#ifdef USE_BK72XX
|
||||
LinkStatusTypeDef link_status{};
|
||||
|
||||
@@ -265,7 +265,6 @@ bssid_t WiFiComponent::wifi_bssid() {
|
||||
bssid[i] = raw_bssid[i];
|
||||
return bssid;
|
||||
}
|
||||
std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); }
|
||||
const char *WiFiComponent::wifi_ssid_to(std::span<char, SSID_BUFFER_SIZE> buffer) {
|
||||
// TODO: Find direct CYW43 API to avoid Arduino String allocation
|
||||
String ssid = WiFi.SSID();
|
||||
|
||||
@@ -151,6 +151,31 @@ def filter_source_files_from_platform(
|
||||
return filter_source_files
|
||||
|
||||
|
||||
def filter_source_files_from_defines(
|
||||
files_map: dict[str, str | tuple[str, ...]],
|
||||
) -> Callable[[], list[str]]:
|
||||
"""Helper to build a FILTER_SOURCE_FILES function from a define mapping.
|
||||
|
||||
Args:
|
||||
files_map: Dict mapping filename to the define name (or tuple of
|
||||
define names) that keeps the file in the build; the file is
|
||||
excluded when none of its defines is set for the current config.
|
||||
|
||||
Returns:
|
||||
Function that returns the files to exclude for the current config.
|
||||
"""
|
||||
|
||||
def filter_source_files() -> list[str]:
|
||||
defines = {define.name for define in CORE.defines}
|
||||
return [
|
||||
filename
|
||||
for filename, needed in files_map.items()
|
||||
if defines.isdisjoint((needed,) if isinstance(needed, str) else needed)
|
||||
]
|
||||
|
||||
return filter_source_files
|
||||
|
||||
|
||||
def get_logger_level() -> str:
|
||||
"""Get the configured logger level.
|
||||
|
||||
|
||||
@@ -43,7 +43,9 @@
|
||||
#define USE_ALARM_CONTROL_PANEL
|
||||
#define USE_AREAS
|
||||
#define USE_BINARY_SENSOR
|
||||
#define USE_BINARY_SENSOR_CLICK_TRIGGER
|
||||
#define USE_BINARY_SENSOR_FILTER
|
||||
#define USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER
|
||||
#define USE_BLE_DEVICE_IRK
|
||||
#define USE_BUTTON
|
||||
#define USE_CAMERA
|
||||
@@ -220,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
|
||||
@@ -232,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
|
||||
@@ -281,6 +285,7 @@
|
||||
// ESP32-specific feature flags
|
||||
#ifdef USE_ESP32
|
||||
#define USE_ESP32_CRASH_HANDLER
|
||||
#define USE_ESP32_INTERNAL_GPIO
|
||||
#define USE_MQTT_IDF_ENQUEUE
|
||||
#define USE_ESPHOME_TASK_LOG_BUFFER
|
||||
#define ESPHOME_TASK_LOG_BUFFER_SIZE 768
|
||||
|
||||
@@ -80,24 +80,6 @@ const char *EntityBase::get_device_class_to([[maybe_unused]] std::span<char, MAX
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifndef USE_ESP8266
|
||||
// Deprecated device class accessors — not available on ESP8266 (rodata is RAM)
|
||||
StringRef EntityBase::get_device_class_ref() const {
|
||||
#ifdef USE_ENTITY_DEVICE_CLASS
|
||||
return StringRef(entity_device_class_lookup(this->device_class_idx_));
|
||||
#else
|
||||
return StringRef(entity_device_class_lookup(0));
|
||||
#endif
|
||||
}
|
||||
std::string EntityBase::get_device_class() const {
|
||||
#ifdef USE_ENTITY_DEVICE_CLASS
|
||||
return std::string(entity_device_class_lookup(this->device_class_idx_));
|
||||
#else
|
||||
return std::string(entity_device_class_lookup(0));
|
||||
#endif
|
||||
}
|
||||
#endif // !USE_ESP8266
|
||||
|
||||
// Entity unit of measurement (from index)
|
||||
StringRef EntityBase::get_unit_of_measurement_ref() const {
|
||||
#ifdef USE_ENTITY_UNIT_OF_MEASUREMENT
|
||||
@@ -106,10 +88,6 @@ StringRef EntityBase::get_unit_of_measurement_ref() const {
|
||||
return StringRef(entity_uom_lookup(0));
|
||||
#endif
|
||||
}
|
||||
std::string EntityBase::get_unit_of_measurement() const {
|
||||
return std::string(this->get_unit_of_measurement_ref().c_str());
|
||||
}
|
||||
|
||||
// Entity icon — buffer-based API for PROGMEM safety on ESP8266
|
||||
const char *EntityBase::get_icon_to([[maybe_unused]] std::span<char, MAX_ICON_LENGTH> buffer) const {
|
||||
#ifdef USE_ENTITY_ICON
|
||||
@@ -129,24 +107,6 @@ const char *EntityBase::get_icon_to([[maybe_unused]] std::span<char, MAX_ICON_LE
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifndef USE_ESP8266
|
||||
// Deprecated icon accessors — not available on ESP8266 (rodata is RAM)
|
||||
StringRef EntityBase::get_icon_ref() const {
|
||||
#ifdef USE_ENTITY_ICON
|
||||
return StringRef(entity_icon_lookup(this->icon_idx_));
|
||||
#else
|
||||
return StringRef(entity_icon_lookup(0));
|
||||
#endif
|
||||
}
|
||||
std::string EntityBase::get_icon() const {
|
||||
#ifdef USE_ENTITY_ICON
|
||||
return std::string(entity_icon_lookup(this->icon_idx_));
|
||||
#else
|
||||
return std::string(entity_icon_lookup(0));
|
||||
#endif
|
||||
}
|
||||
#endif // !USE_ESP8266
|
||||
|
||||
// Calculate Object ID Hash directly from name using snake_case + sanitize
|
||||
void EntityBase::calc_object_id_() {
|
||||
this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size());
|
||||
|
||||
@@ -109,60 +109,14 @@ class EntityBase {
|
||||
// On ESP8266: copies from PROGMEM to buffer, returns buffer pointer.
|
||||
const char *get_device_class_to(std::span<char, MAX_DEVICE_CLASS_LENGTH> buffer) const;
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
// On ESP8266, rodata is RAM. Device classes are in PROGMEM and cannot be accessed
|
||||
// directly as const char*. Use get_device_class_to() with a stack buffer instead.
|
||||
template<typename T = int> StringRef get_device_class_ref() const {
|
||||
static_assert(sizeof(T) == 0, "get_device_class_ref() unavailable on ESP8266 (rodata is RAM). "
|
||||
"Use get_device_class_to() with a stack buffer.");
|
||||
return StringRef("");
|
||||
}
|
||||
template<typename T = int> std::string get_device_class() const {
|
||||
static_assert(sizeof(T) == 0, "get_device_class() unavailable on ESP8266 (rodata is RAM). "
|
||||
"Use get_device_class_to() with a stack buffer.");
|
||||
return "";
|
||||
}
|
||||
#else
|
||||
// Deprecated: use get_device_class_to() instead. Device classes are in PROGMEM.
|
||||
ESPDEPRECATED("Use get_device_class_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0")
|
||||
StringRef get_device_class_ref() const;
|
||||
ESPDEPRECATED("Use get_device_class_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0")
|
||||
std::string get_device_class() const;
|
||||
#endif
|
||||
// Get unit of measurement as StringRef (from packed index)
|
||||
StringRef get_unit_of_measurement_ref() const;
|
||||
/// Get the unit of measurement as std::string (deprecated, prefer get_unit_of_measurement_ref())
|
||||
ESPDEPRECATED("Use get_unit_of_measurement_ref() instead for better performance (avoids string copy). Will be "
|
||||
"removed in ESPHome 2026.9.0",
|
||||
"2026.3.0")
|
||||
std::string get_unit_of_measurement() const;
|
||||
|
||||
// Get this entity's icon into a stack buffer.
|
||||
// On ESP32: returns pointer to PROGMEM string directly (buffer unused).
|
||||
// On ESP8266: copies from PROGMEM to buffer, returns buffer pointer.
|
||||
const char *get_icon_to(std::span<char, MAX_ICON_LENGTH> buffer) const;
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
// On ESP8266, rodata is RAM. Icons are in PROGMEM and cannot be accessed
|
||||
// directly as const char*. Use get_icon_to() with a stack buffer instead.
|
||||
template<typename T = int> StringRef get_icon_ref() const {
|
||||
static_assert(sizeof(T) == 0,
|
||||
"get_icon_ref() unavailable on ESP8266 (rodata is RAM). Use get_icon_to() with a stack buffer.");
|
||||
return StringRef("");
|
||||
}
|
||||
template<typename T = int> std::string get_icon() const {
|
||||
static_assert(sizeof(T) == 0,
|
||||
"get_icon() unavailable on ESP8266 (rodata is RAM). Use get_icon_to() with a stack buffer.");
|
||||
return "";
|
||||
}
|
||||
#else
|
||||
// Deprecated: use get_icon_to() instead. Icons are in PROGMEM.
|
||||
ESPDEPRECATED("Use get_icon_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0")
|
||||
StringRef get_icon_ref() const;
|
||||
ESPDEPRECATED("Use get_icon_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0")
|
||||
std::string get_icon() const;
|
||||
#endif
|
||||
|
||||
#ifdef USE_DEVICES
|
||||
// Get this entity's device id
|
||||
uint32_t get_device_id() const {
|
||||
|
||||
@@ -723,23 +723,6 @@ bool base64_decode_int32_vector(const std::string &base64, std::vector<int32_t>
|
||||
|
||||
// Colors
|
||||
|
||||
float gamma_correct(float value, float gamma) {
|
||||
if (value <= 0.0f)
|
||||
return 0.0f;
|
||||
if (gamma <= 0.0f)
|
||||
return value;
|
||||
|
||||
return powf(value, gamma); // NOLINT - deprecated, removal 2026.9.0
|
||||
}
|
||||
float gamma_uncorrect(float value, float gamma) {
|
||||
if (value <= 0.0f)
|
||||
return 0.0f;
|
||||
if (gamma <= 0.0f)
|
||||
return value;
|
||||
|
||||
return powf(value, 1 / gamma); // NOLINT - deprecated, removal 2026.9.0
|
||||
}
|
||||
|
||||
void rgb_to_hsv(float red, float green, float blue, int &hue, float &saturation, float &value) {
|
||||
float max_color_value = std::max({red, green, blue});
|
||||
float min_color_value = std::min({red, green, blue});
|
||||
|
||||
@@ -1646,15 +1646,6 @@ bool base64_decode_int32_vector(const std::string &base64, std::vector<int32_t>
|
||||
/// @name Colors
|
||||
///@{
|
||||
|
||||
/// Applies gamma correction of \p gamma to \p value.
|
||||
// Remove before 2026.9.0
|
||||
ESPDEPRECATED("Use LightState::gamma_correct_lut() instead. Removed in 2026.9.0.", "2026.3.0")
|
||||
float gamma_correct(float value, float gamma);
|
||||
/// Reverts gamma correction of \p gamma to \p value.
|
||||
// Remove before 2026.9.0
|
||||
ESPDEPRECATED("Use LightState::gamma_uncorrect_lut() instead. Removed in 2026.9.0.", "2026.3.0")
|
||||
float gamma_uncorrect(float value, float gamma);
|
||||
|
||||
/// Convert \p red, \p green and \p blue (all 0-1) values to \p hue (0-360), \p saturation (0-1) and \p value (0-1).
|
||||
void rgb_to_hsv(float red, float green, float blue, int &hue, float &saturation, float &value);
|
||||
/// Convert \p hue (0-360), \p saturation (0-1) and \p value (0-1) to \p red, \p green and \p blue (all 0-1).
|
||||
|
||||
@@ -60,16 +60,6 @@ void HOT esp_log_vprintf_(int level, const char *tag, int line, const char *form
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_STORE_LOG_STR_IN_FLASH
|
||||
// Remove before 2026.9.0
|
||||
void HOT esp_log_vprintf_(int level, const char *tag, int line, const __FlashStringHelper *format, va_list args) {
|
||||
#ifdef USE_LOGGER
|
||||
ESPHOME_DEBUG_ASSERT(logger::global_logger != nullptr);
|
||||
logger::global_logger->log_vprintf_(static_cast<uint8_t>(level), tag, line, format, args);
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_ESP32
|
||||
int HOT esp_idf_log_vprintf_(const char *format, va_list args) { // NOLINT
|
||||
#ifdef USE_LOGGER
|
||||
|
||||
@@ -68,11 +68,6 @@ void esp_log_printf_(int level, const char *tag, int line, const char *format, .
|
||||
void esp_log_printf_(int level, const char *tag, int line, const __FlashStringHelper *format, ...);
|
||||
#endif
|
||||
void esp_log_vprintf_(int level, const char *tag, int line, const char *format, va_list args); // NOLINT
|
||||
#ifdef USE_STORE_LOG_STR_IN_FLASH
|
||||
// Remove before 2026.9.0
|
||||
__attribute__((deprecated("Use esp_log_printf_() instead. Removed in 2026.9.0."))) void esp_log_vprintf_(
|
||||
int level, const char *tag, int line, const __FlashStringHelper *format, va_list args);
|
||||
#endif
|
||||
#if defined(USE_ESP32)
|
||||
int esp_idf_log_vprintf_(const char *format, va_list args); // NOLINT
|
||||
#endif
|
||||
|
||||
+197
-2
@@ -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
|
||||
|
||||
+12
-1
@@ -552,7 +552,18 @@ def write_file_if_changed(path: Path, text: str) -> bool:
|
||||
"""
|
||||
src_content = None
|
||||
if path.is_file():
|
||||
src_content = read_file(path)
|
||||
try:
|
||||
src_content = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError as err:
|
||||
# Replace a damaged file rather than abort the regeneration that
|
||||
# fixes it; an OSError may hide an intact file, so it still raises
|
||||
_LOGGER.warning("Replacing damaged file %s: %s", path, err)
|
||||
with suppress(OSError):
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError as err:
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
raise EsphomeError(f"Error reading file {path}: {err}") from err
|
||||
if src_content == text:
|
||||
return False
|
||||
write_file(path, text)
|
||||
|
||||
+3
-3
@@ -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}
|
||||
|
||||
@@ -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,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==")
|
||||
@@ -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)
|
||||
|
||||
@@ -136,3 +136,19 @@ binary_sensor:
|
||||
invalid_cooldown: 2s
|
||||
then:
|
||||
- logger.log: "Click with custom cooldown"
|
||||
|
||||
# Test on_click and on_double_click (compiles match_interval via
|
||||
# USE_BINARY_SENSOR_CLICK_TRIGGER)
|
||||
- platform: template
|
||||
id: click_triggers
|
||||
name: "Click Triggers"
|
||||
on_click:
|
||||
min_length: 50ms
|
||||
max_length: 350ms
|
||||
then:
|
||||
- logger.log: "Clicked"
|
||||
on_double_click:
|
||||
min_length: 50ms
|
||||
max_length: 350ms
|
||||
then:
|
||||
- logger.log: "Double clicked"
|
||||
|
||||
@@ -0,0 +1,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()
|
||||
@@ -0,0 +1 @@
|
||||
noise:
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
noise: !include common.yaml
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
noise: !include common.yaml
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,41 @@
|
||||
// Pins the lazy erase-ahead arithmetic used by the ESP-IDF OTA backend: the
|
||||
// erased watermark must always cover the write end, stay 64 KiB block-aligned
|
||||
// until the clamp, and never exceed the partition.
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/components/ota/ota_backend.h"
|
||||
|
||||
namespace esphome::ota::testing {
|
||||
|
||||
static constexpr size_t BLOCK = 64 * 1024;
|
||||
static constexpr size_t PART = 1835008; // 0x1C0000, a real app slot size
|
||||
|
||||
TEST(NextEraseEnd, FirstWriteRoundsUpToOneBlock) { EXPECT_EQ(next_erase_end(1024, PART), BLOCK); }
|
||||
|
||||
TEST(NextEraseEnd, ExactBlockBoundaryDoesNotOverErase) { EXPECT_EQ(next_erase_end(BLOCK, PART), BLOCK); }
|
||||
|
||||
TEST(NextEraseEnd, StraddlingWriteCoversNextBlock) { EXPECT_EQ(next_erase_end(BLOCK + 1, PART), 2 * BLOCK); }
|
||||
|
||||
TEST(NextEraseEnd, ClampsToPartitionEnd) {
|
||||
// Partition sizes are sector multiples but not always block multiples
|
||||
constexpr size_t part = 27 * BLOCK + 4096;
|
||||
EXPECT_EQ(next_erase_end(27 * BLOCK + 1, part), part);
|
||||
EXPECT_EQ(next_erase_end(part, part), part);
|
||||
}
|
||||
|
||||
// Bootloader staging seeds erased_end_ mid-block (e.g. 0x8000); the target for
|
||||
// a write past that seed must still cover the write end.
|
||||
TEST(NextEraseEnd, MidBlockSeedStillCovered) { EXPECT_EQ(next_erase_end(0x8000 + 1024, PART), BLOCK); }
|
||||
|
||||
TEST(NextEraseEnd, SweepAlwaysCoversWriteEndWithinPartition) {
|
||||
for (size_t end = 1; end <= PART; end += 4093) {
|
||||
const size_t erased = next_erase_end(end, PART);
|
||||
ASSERT_GE(erased, end);
|
||||
ASSERT_LE(erased, PART);
|
||||
// Block-aligned unless clamped at the partition end
|
||||
ASSERT_TRUE(erased == PART || erased % BLOCK == 0);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::ota::testing
|
||||
@@ -29,6 +29,7 @@ void setup() {
|
||||
|
||||
auto *ota = new esphome::ESPHomeOTAComponent(); // NOLINT
|
||||
ota->set_port(8266);
|
||||
App.register_component_(ota);
|
||||
|
||||
App.setup();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: host-ota-test
|
||||
host:
|
||||
api:
|
||||
ota:
|
||||
- platform: esphome
|
||||
port: __OTA_PORT__
|
||||
encryption:
|
||||
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
logger:
|
||||
level: DEBUG
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
import functools
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
@@ -111,6 +112,62 @@ async def test_host_ota_self_update(
|
||||
assert proc.pid == pid_before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_ota_encrypted(
|
||||
yaml_config: str,
|
||||
write_yaml_config: ConfigWriter,
|
||||
compile_esphome: CompileFunction,
|
||||
reserved_tcp_port: tuple[int, socket.socket],
|
||||
) -> None:
|
||||
"""Encrypted self-OTA succeeds; a plaintext upload to the same device fails."""
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
noise_psk = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
api_port, api_socket = reserved_tcp_port
|
||||
with _reserve_port() as (ota_port, ota_socket):
|
||||
yaml_config = yaml_config.replace("__OTA_PORT__", str(ota_port))
|
||||
config_path = await write_yaml_config(yaml_config)
|
||||
binary_path = await compile_esphome(config_path)
|
||||
api_socket.close()
|
||||
ota_socket.close()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
rebooted = loop.create_future()
|
||||
|
||||
def on_log(line: str) -> None:
|
||||
if not rebooted.done() and "Rebooting safely" in line:
|
||||
rebooted.set_result(True)
|
||||
|
||||
async with run_binary(binary_path, line_callback=on_log) as (proc, _lines):
|
||||
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
|
||||
pid_before = proc.pid
|
||||
|
||||
# A plaintext upload must be refused with the device unharmed
|
||||
rc, _ = await loop.run_in_executor(
|
||||
None, espota2.run_ota, LOCALHOST, ota_port, None, binary_path
|
||||
)
|
||||
assert rc == 1, "plaintext upload to an encrypted device must fail"
|
||||
await asyncio.sleep(0.5)
|
||||
assert proc.returncode is None, "process died on rejected plaintext OTA"
|
||||
|
||||
# The encrypted upload goes through and the device re-execs
|
||||
rc, _ = await loop.run_in_executor(
|
||||
None,
|
||||
functools.partial(
|
||||
espota2.run_ota,
|
||||
LOCALHOST,
|
||||
ota_port,
|
||||
None,
|
||||
binary_path,
|
||||
noise_psk=noise_psk,
|
||||
),
|
||||
)
|
||||
assert rc == 0, "encrypted OTA reported failure"
|
||||
await asyncio.wait_for(rebooted, timeout=10.0)
|
||||
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
|
||||
assert proc.returncode is None, "process exited instead of execing"
|
||||
assert proc.pid == pid_before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_ota_rejects_garbage(
|
||||
yaml_config: str,
|
||||
|
||||
@@ -6,6 +6,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from esphome.config_helpers import (
|
||||
filter_source_files_from_defines,
|
||||
filter_source_files_from_platform,
|
||||
frameworks_for_platforms,
|
||||
get_logger_level,
|
||||
@@ -18,6 +19,7 @@ from esphome.const import (
|
||||
KEY_TARGET_PLATFORM,
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.core import Define
|
||||
|
||||
|
||||
def test_filter_source_files_from_platform_esp32() -> None:
|
||||
@@ -148,3 +150,25 @@ def test_frameworks_for_platforms_derives_and_rejects_unknown() -> None:
|
||||
}
|
||||
with pytest.raises(ValueError, match="unknown platform"):
|
||||
frameworks_for_platforms(["esp32", "not_a_platform"])
|
||||
|
||||
|
||||
def test_filter_source_files_from_defines() -> None:
|
||||
"""Files are excluded unless one of their defines is set."""
|
||||
files_map: dict[str, str | tuple[str, ...]] = {
|
||||
"filter.cpp": "USE_SENSOR_FILTER",
|
||||
"automation.cpp": ("USE_CLICK", "USE_MULTI_CLICK"),
|
||||
}
|
||||
filter_func: Callable[[], list[str]] = filter_source_files_from_defines(files_map)
|
||||
|
||||
with patch("esphome.config_helpers.CORE") as mock_core:
|
||||
mock_core.defines = {Define("USE_SENSOR_FILTER")}
|
||||
assert filter_func() == ["automation.cpp"]
|
||||
|
||||
mock_core.defines = {Define("USE_MULTI_CLICK")}
|
||||
assert filter_func() == ["filter.cpp"]
|
||||
|
||||
mock_core.defines = {Define("USE_SENSOR_FILTER"), Define("USE_CLICK")}
|
||||
assert filter_func() == []
|
||||
|
||||
mock_core.defines = set()
|
||||
assert sorted(filter_func()) == ["automation.cpp", "filter.cpp"]
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
"""Unit tests for encrypted OTA uploads in esphome.espota2.
|
||||
|
||||
A fake device implementing the responder side of the wire protocol (via
|
||||
noiseprotocol, which esphome already has through aioesphomeapi) serves a real
|
||||
TCP loopback connection, so these exercise the actual handshake, framing, and
|
||||
cipher interop of the client code. Tests that need the client-side crypto skip
|
||||
when the installed aioesphomeapi predates the noise module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import espota2
|
||||
|
||||
PSK = base64.b64encode(bytes(range(32))).decode()
|
||||
OTHER_PSK = base64.b64encode(bytes(range(1, 33))).decode()
|
||||
|
||||
MAGIC = bytes(espota2.MAGIC_BYTES)
|
||||
|
||||
|
||||
def _recv_exact(sock: socket.socket, amount: int) -> bytes:
|
||||
data = b""
|
||||
while len(data) < amount:
|
||||
chunk = sock.recv(amount - len(data))
|
||||
if not chunk:
|
||||
raise ConnectionError("client closed")
|
||||
data += chunk
|
||||
return data
|
||||
|
||||
|
||||
def _frame(payload: bytes) -> bytes:
|
||||
return (
|
||||
bytes([espota2.NOISE_FRAME_INDICATOR, len(payload) >> 8, len(payload) & 0xFF])
|
||||
+ payload
|
||||
)
|
||||
|
||||
|
||||
def _send_frame(sock: socket.socket, payload: bytes) -> None:
|
||||
sock.sendall(_frame(payload))
|
||||
|
||||
|
||||
def _recv_frame(sock: socket.socket) -> bytes:
|
||||
header = _recv_exact(sock, 3)
|
||||
assert header[0] == 0x01
|
||||
return _recv_exact(sock, (header[1] << 8) | header[2])
|
||||
|
||||
|
||||
class FakeEncryptedDevice(threading.Thread):
|
||||
"""Responder side of the encrypted OTA wire protocol."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
psk: str = PSK,
|
||||
version: int = 2,
|
||||
offer_noise: bool = True,
|
||||
require_noise: bool = True,
|
||||
prologue_features_override: int | None = None,
|
||||
) -> None:
|
||||
super().__init__(daemon=True)
|
||||
self.psk = psk
|
||||
self.version = version
|
||||
self.offer_noise = offer_noise
|
||||
self.require_noise = require_noise
|
||||
self.prologue_features_override = prologue_features_override
|
||||
self.received: bytes | None = None
|
||||
self.error: Exception | None = None
|
||||
self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.listener.bind(("127.0.0.1", 0))
|
||||
self.listener.listen(1)
|
||||
self.port = self.listener.getsockname()[1]
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
sock, _ = self.listener.accept()
|
||||
sock.settimeout(10)
|
||||
with sock:
|
||||
self._serve(sock)
|
||||
except Exception as err: # noqa: BLE001 - surfaced via join_and_check
|
||||
self.error = err
|
||||
finally:
|
||||
self.listener.close()
|
||||
|
||||
def join_and_check(self) -> None:
|
||||
self.join(timeout=10)
|
||||
assert not self.is_alive(), "fake device did not finish"
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
|
||||
def _serve(self, sock: socket.socket) -> None:
|
||||
assert _recv_exact(sock, 5) == MAGIC
|
||||
sock.sendall(bytes([espota2.RESPONSE_OK, self.version]))
|
||||
features = _recv_exact(sock, 1)[0]
|
||||
noise_negotiated = bool(
|
||||
features & espota2.CLIENT_FEATURE_SUPPORTS_NOISE
|
||||
and features & espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
|
||||
)
|
||||
if self.require_noise and not noise_negotiated:
|
||||
sock.sendall(bytes([espota2.RESPONSE_ERROR_ENCRYPTION_REQUIRED]))
|
||||
return
|
||||
server_flags = espota2.SERVER_FEATURE_SUPPORTS_NOISE if self.offer_noise else 0
|
||||
sock.sendall(bytes([espota2.RESPONSE_FEATURE_FLAGS, server_flags]))
|
||||
if not (self.offer_noise and noise_negotiated):
|
||||
return # the client fails closed; nothing further arrives
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from noise.connection import NoiseConnection
|
||||
|
||||
prologue_features = (
|
||||
features
|
||||
if self.prologue_features_override is None
|
||||
else self.prologue_features_override
|
||||
)
|
||||
prologue = (
|
||||
espota2.NOISE_PROLOGUE_INIT
|
||||
+ MAGIC
|
||||
+ bytes([espota2.RESPONSE_OK, self.version, prologue_features])
|
||||
+ bytes([espota2.RESPONSE_FEATURE_FLAGS, server_flags])
|
||||
)
|
||||
proto = NoiseConnection.from_name(b"Noise_NNpsk0_25519_ChaChaPoly_SHA256")
|
||||
proto.set_as_responder()
|
||||
proto.set_psks(base64.b64decode(self.psk))
|
||||
proto.set_prologue(prologue)
|
||||
proto.start_handshake()
|
||||
|
||||
msg1 = _recv_frame(sock)
|
||||
assert msg1[0] == 0x00
|
||||
try:
|
||||
proto.read_message(msg1[1:])
|
||||
except InvalidTag:
|
||||
_send_frame(sock, b"\x01Handshake MAC failure")
|
||||
return
|
||||
_send_frame(sock, b"\x00" + bytes(proto.write_message()))
|
||||
|
||||
def send_byte(byte: int) -> None:
|
||||
_send_frame(sock, proto.encrypt(bytes([byte])))
|
||||
|
||||
def recv_unit(length: int) -> bytes:
|
||||
plaintext = proto.decrypt(_recv_frame(sock))
|
||||
assert len(plaintext) == length, "control units must be one per frame"
|
||||
return plaintext
|
||||
|
||||
send_byte(espota2.RESPONSE_AUTH_OK)
|
||||
recv_unit(1) # ota type
|
||||
size = int.from_bytes(recv_unit(4), "big")
|
||||
send_byte(espota2.RESPONSE_UPDATE_PREPARE_OK)
|
||||
md5_hex = recv_unit(32)
|
||||
send_byte(espota2.RESPONSE_BIN_MD5_OK)
|
||||
|
||||
received = b""
|
||||
acked = 0
|
||||
while len(received) < size:
|
||||
plaintext = proto.decrypt(_recv_frame(sock))
|
||||
assert 0 < len(plaintext) <= espota2.NOISE_MAX_PLAINTEXT
|
||||
received += plaintext
|
||||
if self.version >= espota2.OTA_VERSION_2_0:
|
||||
while acked + espota2.UPLOAD_BLOCK_SIZE <= len(received) or (
|
||||
len(received) == size and acked < size
|
||||
):
|
||||
send_byte(espota2.RESPONSE_CHUNK_OK)
|
||||
acked += espota2.UPLOAD_BLOCK_SIZE
|
||||
assert hashlib.md5(received).hexdigest().encode() == md5_hex
|
||||
send_byte(espota2.RESPONSE_RECEIVE_OK)
|
||||
send_byte(espota2.RESPONSE_UPDATE_END_OK)
|
||||
assert recv_unit(1) == bytes([espota2.RESPONSE_OK])
|
||||
self.received = received
|
||||
|
||||
|
||||
def _upload(
|
||||
device: FakeEncryptedDevice, firmware: bytes, noise_psk: str | None
|
||||
) -> None:
|
||||
device.start()
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(10)
|
||||
sock.connect(("127.0.0.1", device.port))
|
||||
try:
|
||||
espota2.perform_ota(
|
||||
sock, None, io.BytesIO(firmware), Path("firmware.bin"), noise_psk=noise_psk
|
||||
)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def test_encrypted_upload_success() -> None:
|
||||
"""A full encrypted v2 upload spanning several 8192-byte blocks."""
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
firmware = bytes(range(256)) * 80 # 20480 bytes, crosses chunk-ack boundaries
|
||||
device = FakeEncryptedDevice()
|
||||
with patch("time.sleep"):
|
||||
_upload(device, firmware, PSK)
|
||||
device.join_and_check()
|
||||
assert device.received == firmware
|
||||
|
||||
|
||||
def test_encrypted_upload_version_1() -> None:
|
||||
"""Version 1 protocol (no chunk acks) works through the noise transport."""
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
firmware = b"v1 firmware image" * 100
|
||||
device = FakeEncryptedDevice(version=1)
|
||||
with patch("time.sleep"):
|
||||
_upload(device, firmware, PSK)
|
||||
device.join_and_check()
|
||||
assert device.received == firmware
|
||||
|
||||
|
||||
def test_wrong_key_fails_with_clear_error() -> None:
|
||||
"""A key mismatch surfaces the device's handshake reject readably."""
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
device = FakeEncryptedDevice(psk=OTHER_PSK)
|
||||
with pytest.raises(espota2.OTAError, match="encryption key correct"):
|
||||
_upload(device, b"firmware", PSK)
|
||||
device.join_and_check()
|
||||
|
||||
|
||||
def test_tampered_negotiation_breaks_handshake() -> None:
|
||||
"""A negotiation byte differing between the sides breaks the prologue MAC."""
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
device = FakeEncryptedDevice(
|
||||
prologue_features_override=espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
|
||||
)
|
||||
with pytest.raises(espota2.OTAError, match="encryption key correct"):
|
||||
_upload(device, b"firmware", PSK)
|
||||
device.join_and_check()
|
||||
|
||||
|
||||
def test_client_fails_closed_when_device_lacks_encryption() -> None:
|
||||
"""With a key configured, a device not offering noise aborts the upload."""
|
||||
device = FakeEncryptedDevice(offer_noise=False, require_noise=False)
|
||||
with pytest.raises(espota2.OTAError, match="refusing to send the image"):
|
||||
_upload(device, b"firmware", PSK)
|
||||
device.join_and_check()
|
||||
|
||||
|
||||
def test_plaintext_client_gets_encryption_required_error() -> None:
|
||||
"""A client without a key gets the device's 0x94 error message."""
|
||||
device = FakeEncryptedDevice()
|
||||
with pytest.raises(espota2.OTAError, match="requires an encrypted OTA"):
|
||||
_upload(device, b"firmware", None)
|
||||
device.join_and_check()
|
||||
|
||||
|
||||
def test_missing_aioesphomeapi_noise_module_message() -> None:
|
||||
"""An aioesphomeapi without the noise module produces a clear error."""
|
||||
with (
|
||||
patch.dict(sys.modules, {"aioesphomeapi.noise": None}),
|
||||
pytest.raises(espota2.OTAError, match="requires a newer aioesphomeapi"),
|
||||
):
|
||||
espota2.NoiseSocketWrapper(Mock(), PSK, b"prologue")
|
||||
|
||||
|
||||
class ScriptedSocket:
|
||||
"""Serves scripted recv chunks; b"" means the peer closed."""
|
||||
|
||||
def __init__(self, *chunks: bytes | Exception) -> None:
|
||||
self.chunks = list(chunks)
|
||||
self.sent: list[bytes] = []
|
||||
|
||||
def sendall(self, data: bytes) -> None:
|
||||
self.sent.append(data)
|
||||
|
||||
def settimeout(self, timeout: float) -> None:
|
||||
pass
|
||||
|
||||
def recv(self, amount: int) -> bytes:
|
||||
if not self.chunks:
|
||||
return b""
|
||||
chunk = self.chunks[0]
|
||||
if isinstance(chunk, Exception):
|
||||
self.chunks.pop(0)
|
||||
raise chunk
|
||||
take, rest = chunk[:amount], chunk[amount:]
|
||||
if rest:
|
||||
self.chunks[0] = rest
|
||||
else:
|
||||
self.chunks.pop(0)
|
||||
return take
|
||||
|
||||
|
||||
def _wrapper(*chunks: bytes | Exception) -> espota2.NoiseSocketWrapper:
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
return espota2.NoiseSocketWrapper(ScriptedSocket(*chunks), PSK, b"prologue")
|
||||
|
||||
|
||||
def test_wrapper_rejects_malformed_psk() -> None:
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
with pytest.raises(espota2.OTAError, match="Invalid OTA encryption key"):
|
||||
espota2.NoiseSocketWrapper(ScriptedSocket(), "not-base64!!!", b"prologue")
|
||||
|
||||
|
||||
def test_handshake_socket_error_is_network_error() -> None:
|
||||
wrapper = _wrapper(OSError("boom"))
|
||||
with pytest.raises(espota2.OTANetworkError, match="noise handshake"):
|
||||
wrapper.do_handshake()
|
||||
|
||||
|
||||
def test_handshake_closed_at_frame_boundary() -> None:
|
||||
wrapper = _wrapper()
|
||||
with pytest.raises(espota2.OTANetworkError, match="closed connection during"):
|
||||
wrapper.do_handshake()
|
||||
|
||||
|
||||
def test_handshake_reject_with_other_reason() -> None:
|
||||
wrapper = _wrapper(_frame(b"\x01Handshake error"))
|
||||
with pytest.raises(
|
||||
espota2.OTAError, match="rejected the noise handshake: Handshake error"
|
||||
):
|
||||
wrapper.do_handshake()
|
||||
|
||||
|
||||
def test_handshake_garbage_second_message() -> None:
|
||||
"""A valid-looking point with a garbage MAC fails cleanly."""
|
||||
wrapper = _wrapper(_frame(b"\x00" + bytes(range(48))))
|
||||
with pytest.raises(
|
||||
espota2.OTAError, match="handshake failed; is the OTA encryption key"
|
||||
):
|
||||
wrapper.do_handshake()
|
||||
|
||||
|
||||
def test_handshake_invalid_curve_point() -> None:
|
||||
"""An all-zero x25519 point is rejected as a clean error, not a crash."""
|
||||
wrapper = _wrapper(_frame(b"\x00" + bytes(48)))
|
||||
with pytest.raises(
|
||||
espota2.OTAError, match="handshake failed; is the OTA encryption key"
|
||||
):
|
||||
wrapper.do_handshake()
|
||||
|
||||
|
||||
def test_recv_closed_at_frame_boundary_returns_empty() -> None:
|
||||
wrapper = _wrapper()
|
||||
assert wrapper.recv(1) == b""
|
||||
|
||||
|
||||
def test_recv_corrupt_frame_is_retryable_network_error() -> None:
|
||||
from cryptography.exceptions import InvalidTag
|
||||
|
||||
wrapper = _wrapper(_frame(b"ciphertext"))
|
||||
wrapper._decrypt = Mock(decrypt=Mock(side_effect=InvalidTag()))
|
||||
with pytest.raises(espota2.OTANetworkError, match="decryption failed"):
|
||||
wrapper.recv(1)
|
||||
|
||||
|
||||
def test_wrapper_blocks_unencrypted_socket_methods() -> None:
|
||||
"""Byte-moving socket methods must not bypass the encrypted transport."""
|
||||
wrapper = _wrapper()
|
||||
# The harmless socket controls pass through to the wrapped socket
|
||||
wrapper._sock = Mock()
|
||||
wrapper.settimeout(1)
|
||||
wrapper._sock.settimeout.assert_called_once_with(1)
|
||||
wrapper.setsockopt(6, 1, 1)
|
||||
wrapper._sock.setsockopt.assert_called_once_with(6, 1, 1)
|
||||
wrapper.close()
|
||||
wrapper._sock.close.assert_called_once_with()
|
||||
with pytest.raises(AttributeError):
|
||||
_ = wrapper.send
|
||||
with pytest.raises(AttributeError):
|
||||
_ = wrapper.recv_into
|
||||
|
||||
|
||||
def test_recv_empty_plaintext_frame_is_protocol_error() -> None:
|
||||
"""A MAC-only frame decrypts to nothing; b'' from recv must mean close."""
|
||||
wrapper = _wrapper(_frame(bytes(16)))
|
||||
wrapper._decrypt = Mock(decrypt=Mock(return_value=b""))
|
||||
with pytest.raises(espota2.OTANetworkError, match="empty noise frame"):
|
||||
wrapper.recv(1)
|
||||
|
||||
|
||||
def test_recv_frame_bad_indicator_is_retryable() -> None:
|
||||
wrapper = _wrapper(b"\x02\x00\x01x")
|
||||
with pytest.raises(espota2.OTANetworkError, match="Bad noise frame indicator"):
|
||||
wrapper._recv_frame()
|
||||
|
||||
|
||||
def test_recv_frame_zero_length_is_retryable() -> None:
|
||||
wrapper = _wrapper(bytes([espota2.NOISE_FRAME_INDICATOR, 0, 0]))
|
||||
with pytest.raises(espota2.OTANetworkError, match="empty noise frame"):
|
||||
wrapper._recv_frame()
|
||||
|
||||
|
||||
def test_perform_ota_blank_key_refuses_plaintext() -> None:
|
||||
with pytest.raises(espota2.OTAError, match="empty OTA encryption key"):
|
||||
espota2.perform_ota(
|
||||
ScriptedSocket(), None, io.BytesIO(b"x"), Path("f.bin"), noise_psk=""
|
||||
)
|
||||
|
||||
|
||||
def test_recv_exact_closed_mid_frame() -> None:
|
||||
wrapper = _wrapper(_frame(b"partial")[:5])
|
||||
with pytest.raises(OSError, match="closed inside a noise frame"):
|
||||
wrapper._recv_frame()
|
||||
|
||||
|
||||
def test_recv_serves_buffered_plaintext_without_new_frame() -> None:
|
||||
"""A second recv drains the decrypted buffer without reading another frame."""
|
||||
wrapper = _wrapper(_frame(b"ciphertext"))
|
||||
wrapper._decrypt = Mock(decrypt=Mock(return_value=b"AB"))
|
||||
assert wrapper.recv(1) == b"A" # reads and decrypts one frame
|
||||
assert wrapper.recv(1) == b"B" # served from the buffer, no new frame
|
||||
wrapper._decrypt.decrypt.assert_called_once()
|
||||
@@ -253,6 +253,31 @@ class Test_write_file_if_changed:
|
||||
|
||||
assert dst.read_text() == text
|
||||
|
||||
def test_damaged_existing_file_is_replaced(
|
||||
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
"""A non-UTF-8 existing file is logged and overwritten."""
|
||||
dst = tmp_path / "generated.txt"
|
||||
dst.write_bytes(b"\xff\xfe")
|
||||
|
||||
assert helpers.write_file_if_changed(dst, "fresh content") is True
|
||||
|
||||
assert dst.read_text(encoding="utf-8") == "fresh content"
|
||||
assert "Replacing damaged file" in caplog.text
|
||||
|
||||
def test_unreadable_existing_file_still_raises(self, tmp_path: Path):
|
||||
"""An OSError on the comparison read still raises EsphomeError."""
|
||||
dst = tmp_path / "generated.txt"
|
||||
dst.write_text("intact")
|
||||
|
||||
with (
|
||||
patch.object(Path, "read_text", side_effect=OSError("permission denied")),
|
||||
pytest.raises(EsphomeError, match="Error reading file"),
|
||||
):
|
||||
helpers.write_file_if_changed(dst, "fresh content")
|
||||
|
||||
assert dst.exists()
|
||||
|
||||
def test_dst_does_not_exist(self, tmp_path: Path):
|
||||
text = "A files are unique.\n"
|
||||
dst = tmp_path / "file-a.txt"
|
||||
|
||||
@@ -82,7 +82,9 @@ from esphome.const import (
|
||||
CONF_BAUD_RATE,
|
||||
CONF_BROKER,
|
||||
CONF_DISABLED,
|
||||
CONF_ENCRYPTION,
|
||||
CONF_ESPHOME,
|
||||
CONF_KEY,
|
||||
CONF_LEVEL,
|
||||
CONF_LOG,
|
||||
CONF_LOG_TOPIC,
|
||||
@@ -2104,10 +2106,65 @@ def test_upload_program_ota_success(
|
||||
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
|
||||
)
|
||||
mock_run_ota.assert_called_once_with(
|
||||
["192.168.1.100"], 3232, "secret", expected_firmware, OTA_TYPE_UPDATE_APP
|
||||
["192.168.1.100"], 3232, "secret", expected_firmware, OTA_TYPE_UPDATE_APP, None
|
||||
)
|
||||
|
||||
|
||||
def test_upload_program_ota_encryption_key(
|
||||
mock_run_ota: Mock,
|
||||
mock_get_port_type: Mock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The resolved encryption key is passed through to run_ota."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
|
||||
mock_get_port_type.return_value = "NETWORK"
|
||||
mock_run_ota.return_value = (0, "192.168.1.100")
|
||||
|
||||
key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
config = {
|
||||
CONF_OTA: [
|
||||
{
|
||||
CONF_PLATFORM: CONF_ESPHOME,
|
||||
CONF_PORT: 3232,
|
||||
CONF_ENCRYPTION: {CONF_KEY: key},
|
||||
}
|
||||
]
|
||||
}
|
||||
exit_code, host = upload_program(config, MockArgs(), ["192.168.1.100"])
|
||||
|
||||
assert exit_code == 0
|
||||
assert host == "192.168.1.100"
|
||||
expected_firmware = (
|
||||
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
|
||||
)
|
||||
mock_run_ota.assert_called_once_with(
|
||||
["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP, key
|
||||
)
|
||||
|
||||
|
||||
def test_upload_program_ota_encryption_without_key_fails_closed(
|
||||
mock_run_ota: Mock,
|
||||
mock_get_port_type: Mock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""An encryption block with no resolved key must never upload plaintext."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
|
||||
mock_get_port_type.return_value = "NETWORK"
|
||||
|
||||
config = {
|
||||
CONF_OTA: [
|
||||
{
|
||||
CONF_PLATFORM: CONF_ESPHOME,
|
||||
CONF_PORT: 3232,
|
||||
CONF_ENCRYPTION: {},
|
||||
}
|
||||
]
|
||||
}
|
||||
with pytest.raises(EsphomeError, match="no key was resolved"):
|
||||
upload_program(config, MockArgs(), ["192.168.1.100"])
|
||||
mock_run_ota.assert_not_called()
|
||||
|
||||
|
||||
def test_upload_program_ota_with_file_arg(
|
||||
mock_run_ota: Mock,
|
||||
mock_get_port_type: Mock,
|
||||
@@ -2135,7 +2192,7 @@ def test_upload_program_ota_with_file_arg(
|
||||
assert exit_code == 0
|
||||
assert host == "192.168.1.100"
|
||||
mock_run_ota.assert_called_once_with(
|
||||
["192.168.1.100"], 3232, None, Path("custom.bin"), OTA_TYPE_UPDATE_APP
|
||||
["192.168.1.100"], 3232, None, Path("custom.bin"), OTA_TYPE_UPDATE_APP, None
|
||||
)
|
||||
|
||||
|
||||
@@ -2190,6 +2247,7 @@ def test_upload_program_ota_partition_table_with_file_arg(
|
||||
None,
|
||||
partition_file,
|
||||
OTA_TYPE_UPDATE_PARTITION_TABLE,
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@@ -2251,6 +2309,7 @@ def test_upload_program_ota_partition_table_mqttip(
|
||||
None,
|
||||
partition_file,
|
||||
OTA_TYPE_UPDATE_PARTITION_TABLE,
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@@ -2438,6 +2497,7 @@ def test_upload_program_ota_bootloader_with_file_arg(
|
||||
None,
|
||||
bootloader_file,
|
||||
OTA_TYPE_UPDATE_BOOTLOADER,
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@@ -2600,6 +2660,42 @@ def test_has_web_server_logging_respects_log_disabled() -> None:
|
||||
assert has_web_server_logging() is False
|
||||
|
||||
|
||||
def test_upload_program_web_server_warns_when_encryption_configured(
|
||||
mock_run_web_server_ota: Mock,
|
||||
mock_run_ota: Mock,
|
||||
mock_get_port_type: Mock,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Explicitly picking web_server OTA on an encrypted config warns about
|
||||
the plaintext upload path."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
|
||||
mock_get_port_type.return_value = "NETWORK"
|
||||
mock_run_web_server_ota.return_value = (0, "192.168.1.100")
|
||||
|
||||
config = {
|
||||
CONF_OTA: [
|
||||
{
|
||||
CONF_PLATFORM: CONF_ESPHOME,
|
||||
CONF_PORT: 3232,
|
||||
CONF_ENCRYPTION: {CONF_KEY: "test_key"},
|
||||
},
|
||||
{CONF_PLATFORM: CONF_WEB_SERVER},
|
||||
],
|
||||
CONF_WEB_SERVER: {
|
||||
CONF_PORT: 80,
|
||||
CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "pw"},
|
||||
},
|
||||
}
|
||||
args = MockArgs(ota_platform=CONF_WEB_SERVER)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
exit_code, _ = upload_program(config, args, ["192.168.1.100"])
|
||||
|
||||
assert exit_code == 0
|
||||
assert any("plaintext HTTP" in record.message for record in caplog.records)
|
||||
mock_run_ota.assert_not_called()
|
||||
|
||||
|
||||
def test_upload_program_web_server_only_auto_dispatches(
|
||||
mock_run_web_server_ota: Mock,
|
||||
mock_run_ota: Mock,
|
||||
@@ -2890,7 +2986,7 @@ def test_upload_program_ota_with_mqtt_resolution(
|
||||
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
|
||||
)
|
||||
mock_run_ota.assert_called_once_with(
|
||||
["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP
|
||||
["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP, None
|
||||
)
|
||||
|
||||
|
||||
@@ -2940,7 +3036,7 @@ def test_upload_program_ota_with_mqtt_empty_broker(
|
||||
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
|
||||
)
|
||||
mock_run_ota.assert_called_once_with(
|
||||
["192.168.1.50"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP
|
||||
["192.168.1.50"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP, None
|
||||
)
|
||||
# Verify warning was logged
|
||||
assert "MQTT IP discovery failed" in caplog.text
|
||||
@@ -5115,6 +5211,7 @@ def test_upload_program_ota_static_ip_with_mqttip(
|
||||
None,
|
||||
expected_firmware,
|
||||
OTA_TYPE_UPDATE_APP,
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@@ -5164,6 +5261,7 @@ def test_upload_program_ota_multiple_mqttip_resolves_once(
|
||||
None,
|
||||
expected_firmware,
|
||||
OTA_TYPE_UPDATE_APP,
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@@ -5341,7 +5439,7 @@ def test_upload_program_ota_mqtt_timeout_fallback(
|
||||
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
|
||||
)
|
||||
mock_run_ota.assert_called_once_with(
|
||||
["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP
|
||||
["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP, None
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user