mirror of
https://github.com/esphome/esphome.git
synced 2026-10-01 17:00:21 +00:00
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.9.0
|
||||
PROJECT_NUMBER = 2026.9.1
|
||||
|
||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
||||
# for a project that appears at the top of each page and should give viewer a
|
||||
|
||||
@@ -29,6 +29,29 @@ static const char *const TAG = "api";
|
||||
// APIServer
|
||||
APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
static constexpr uint32_t NOISE_PSK_PREF_HASH = 88491486UL;
|
||||
#endif
|
||||
|
||||
#if defined(USE_API_NOISE) && defined(USE_OTA_ENCRYPTION_PROVISIONED)
|
||||
bool load_saved_noise_psk(noise::psk_t &out) {
|
||||
SavedNoisePsk saved;
|
||||
#ifdef USE_PREFERENCE_KEY_LOOKUP
|
||||
const bool loaded =
|
||||
global_preferences->load_from_key(NOISE_PSK_PREF_HASH, reinterpret_cast<uint8_t *>(&saved), sizeof(saved));
|
||||
#else
|
||||
// Slot backends need the reservation walk; it only lands on the record when the reservations before
|
||||
// it match a normal boot, otherwise the type checked checksum fails the load
|
||||
const bool loaded = global_preferences->make_preference<SavedNoisePsk>(NOISE_PSK_PREF_HASH, true).load(&saved);
|
||||
#endif
|
||||
// The all-zeros record means no key
|
||||
if (!loaded || noise::NoiseContext::is_all_zeros(saved.psk))
|
||||
return false;
|
||||
out = saved.psk;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
APIServer::APIServer() { global_api_server = this; }
|
||||
|
||||
void APIServer::socket_failed_(const LogString *msg) {
|
||||
@@ -43,8 +66,7 @@ void APIServer::setup() {
|
||||
#ifdef USE_API_NOISE
|
||||
// Always reserve the slot: flash preferences are positional on esp8266, so
|
||||
// a yaml key build must keep the layout of a runtime key build
|
||||
uint32_t hash = 88491486UL;
|
||||
this->noise_pref_ = global_preferences->make_preference<SavedNoisePsk>(hash, true);
|
||||
this->noise_pref_ = global_preferences->make_preference<SavedNoisePsk>(NOISE_PSK_PREF_HASH, true);
|
||||
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
||||
// A cleared record loads fine but holds no key
|
||||
if (this->load_and_apply_noise_psk_() && this->noise_ctx_.has_psk()) {
|
||||
|
||||
@@ -43,6 +43,11 @@ struct SavedNoisePsk {
|
||||
noise::psk_t psk;
|
||||
} PACKED; // NOLINT
|
||||
#endif
|
||||
#if defined(USE_API_NOISE) && defined(USE_OTA_ENCRYPTION_PROVISIONED)
|
||||
/// One-shot read of the provisioned key for a boot without an api server (safe mode); false when
|
||||
/// there is no key
|
||||
bool load_saved_noise_psk(noise::psk_t &out);
|
||||
#endif
|
||||
|
||||
class APIServer final : public Component,
|
||||
public Controller
|
||||
|
||||
@@ -254,7 +254,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = (
|
||||
"esp_lcd", # LCD controller drivers - only needed by display component
|
||||
"esp_local_ctrl", # Local control over HTTPS/BLE - ESPHome has native API
|
||||
"esp_phy", # RF PHY - re-included by internal_temperature on the original ESP32; esp_wifi/bt/ieee802154 pull it back
|
||||
"esp_wifi", # WiFi stack - re-included by request_wifi(), espnow; bt pulls it back for BLE builds
|
||||
"esp_wifi", # WiFi stack - re-included by request_wifi(), espnow, esp32_hosted; bt pulls it back for BLE builds
|
||||
"espcoredump", # Core dump support - ESPHome has its own debug component
|
||||
"fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage
|
||||
"ieee802154", # 802.15.4 radio - IDF openthread and the Zigbee libs pull it back
|
||||
@@ -2628,6 +2628,11 @@ async def to_code(config):
|
||||
config.get(CONF_ENGINEERING_SAMPLE, False),
|
||||
)
|
||||
|
||||
# ESP32-C2 defaults to the ROM's newlib "nano" printf, which does not
|
||||
# understand %zu or %lld and crashes on any %s that follows one.
|
||||
if variant == VARIANT_ESP32C2:
|
||||
add_idf_sdkconfig_option("CONFIG_LIBC_NEWLIB_NANO_FORMAT", False)
|
||||
|
||||
# Set minimum chip revision for ESP32 variant
|
||||
# Setting this to 3.0 or higher reduces flash size by excluding workaround code,
|
||||
# and for PSRAM users saves significant IRAM by keeping C library functions in ROM.
|
||||
|
||||
@@ -173,7 +173,10 @@ static const char *const TAG = "esp32.crash";
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static uint32_t s_current_build_time = static_cast<uint32_t>(ESPHOME_BUILD_TIME);
|
||||
|
||||
void crash_handler_read_and_clear() {
|
||||
// Validate the NOINIT record. Runs on every has_data() call; re-running is
|
||||
// harmless and the magic is left alone so the record survives an OTA
|
||||
// rollback reboot, crash_handler_clear() drops it once an API client has it.
|
||||
static void read_crash_data() {
|
||||
if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) {
|
||||
s_crash_data_valid = true;
|
||||
// Clamp counts to prevent out-of-bounds reads from corrupt .noinit data
|
||||
@@ -194,11 +197,12 @@ void crash_handler_read_and_clear() {
|
||||
s_raw_crash_data.other_reg_frame_count = s_raw_crash_data.other_backtrace_count;
|
||||
#endif
|
||||
}
|
||||
// Don't clear magic here — crash data must survive OTA rollback reboots.
|
||||
// Magic is cleared by crash_handler_clear() after an API client receives the data.
|
||||
}
|
||||
|
||||
bool crash_handler_has_data() { return s_crash_data_valid; }
|
||||
bool crash_handler_has_data() {
|
||||
read_crash_data();
|
||||
return s_crash_data_valid;
|
||||
}
|
||||
|
||||
void crash_handler_clear() {
|
||||
// Only clear the magic so data doesn't survive the next reboot.
|
||||
@@ -426,7 +430,7 @@ static void log_foreign_addresses() {
|
||||
// crashes again during boot, and allowing the CLI's process_stacktrace to match
|
||||
// and decode each address individually.
|
||||
void crash_handler_log() {
|
||||
if (!s_crash_data_valid)
|
||||
if (!crash_handler_has_data())
|
||||
return;
|
||||
|
||||
ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***");
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
|
||||
namespace esphome::esp32 {
|
||||
|
||||
/// Read and validate crash data from NOINIT memory.
|
||||
/// Does not clear the magic marker — call crash_handler_clear() after
|
||||
/// the data has been delivered to an API client so it survives OTA rollback reboots.
|
||||
void crash_handler_read_and_clear();
|
||||
|
||||
/// Log crash data if a crash was detected on previous boot.
|
||||
void crash_handler_log();
|
||||
|
||||
@@ -16,7 +11,7 @@ void crash_handler_log();
|
||||
/// Call after the data has been delivered to an API client.
|
||||
void crash_handler_clear();
|
||||
|
||||
/// Returns true if crash data was found this boot.
|
||||
/// Returns true if crash data was found this boot, reading it first if needed.
|
||||
bool crash_handler_has_data();
|
||||
|
||||
} // namespace esphome::esp32
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
#ifdef USE_ESP32
|
||||
|
||||
// defines.h must come before crash_handler.h so USE_ESP32_CRASH_HANDLER is set
|
||||
// before crash_handler.h's #ifdef-guarded namespace block is parsed.
|
||||
#include "esphome/core/defines.h"
|
||||
#include "crash_handler.h"
|
||||
#include "esphome/core/hal.h"
|
||||
|
||||
#include <esp_clk_tree.h>
|
||||
@@ -45,11 +42,6 @@ void arch_restart() {
|
||||
}
|
||||
|
||||
void arch_init() {
|
||||
#ifdef USE_ESP32_CRASH_HANDLER
|
||||
// Read crash data from previous boot before anything else
|
||||
esp32::crash_handler_read_and_clear();
|
||||
#endif
|
||||
|
||||
// Enable the task watchdog only on the loop task (from which we're currently running)
|
||||
esp_task_wdt_add(nullptr);
|
||||
|
||||
|
||||
@@ -290,6 +290,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
# symbols are simply unused and never register a callback at runtime.
|
||||
if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4:
|
||||
add_define("USE_ESP_NOW_HOSTED")
|
||||
# esp_now_hosted.cpp includes esp_now.h, which esp_wifi provides
|
||||
esp32.include_builtin_idf_component("esp_wifi")
|
||||
# esp-hosted's CustomRpc ("peer data transfer") path — off by default.
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
"CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER", True
|
||||
|
||||
@@ -166,9 +166,17 @@ def ota_esphome_final_validate(config: ConfigType) -> None:
|
||||
CONF_PASSWORD,
|
||||
)
|
||||
# web_server and prometheus keep the shared listener up; the captive
|
||||
# portal's copy only exists on the fallback AP and is the recovery path
|
||||
# portal's copy only exists on the fallback AP and is the recovery path.
|
||||
# web_server `ota: false` gates /update behind the captive portal on
|
||||
# every listener
|
||||
web_server_conf = full_conf.get(CONF_WEB_SERVER)
|
||||
plaintext_update_reachable = (
|
||||
web_server_conf.get(CONF_OTA) is not False
|
||||
if web_server_conf is not None
|
||||
else "prometheus" in full_conf
|
||||
)
|
||||
if (
|
||||
(CONF_WEB_SERVER in full_conf or "prometheus" in full_conf)
|
||||
plaintext_update_reachable
|
||||
and any(conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf)
|
||||
and any(
|
||||
CONF_ENCRYPTION in conf
|
||||
@@ -308,20 +316,16 @@ async def to_code(config: ConfigType) -> None:
|
||||
# One key per device: an api encryption block supplies it (static or
|
||||
# runtime) and offers; the ota block only adds the requirement
|
||||
api_conf = CORE.config.get(CONF_API) or {}
|
||||
encryption_conf = config.get(CONF_ENCRYPTION)
|
||||
own_key = None
|
||||
if encryption_conf is not None and static_encryption_key(api_conf) is None:
|
||||
own_key = encryption_conf[CONF_KEY]
|
||||
if own_key is not None:
|
||||
if key := static_encryption_key(config) or static_encryption_key(api_conf):
|
||||
# Build time key: the ota keeps its own pointer so safe mode, which
|
||||
# has no api server, still has it
|
||||
cg.add_define("USE_OTA_ENCRYPTION")
|
||||
cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], own_key)))
|
||||
cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key)))
|
||||
elif CONF_ENCRYPTION in api_conf:
|
||||
# Runtime key: found in the api server, or in preferences in safe mode
|
||||
cg.add_define("USE_OTA_ENCRYPTION")
|
||||
cg.add_define("USE_OTA_ENCRYPTION_FROM_API")
|
||||
if static_encryption_key(api_conf) is None:
|
||||
# The key arrives at runtime, so the offer has to look for it
|
||||
cg.add_define("USE_OTA_ENCRYPTION_PROVISIONED")
|
||||
if encryption_conf is not None:
|
||||
cg.add_define("USE_OTA_ENCRYPTION_PROVISIONED")
|
||||
if CONF_ENCRYPTION in config:
|
||||
cg.add_define("USE_OTA_ENCRYPTION_REQUIRED")
|
||||
|
||||
# Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#include "ota_esphome.h"
|
||||
#ifdef USE_OTA_ENCRYPTION_FROM_API
|
||||
#ifdef USE_OTA_ENCRYPTION_PROVISIONED
|
||||
#include "esphome/components/api/api_server.h"
|
||||
#endif
|
||||
#ifdef USE_OTA
|
||||
@@ -32,11 +32,13 @@ static const char *const TAG = "esphome.ota";
|
||||
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const {
|
||||
#ifdef USE_OTA_ENCRYPTION_FROM_API
|
||||
return api::global_api_server->get_noise_ctx();
|
||||
#else
|
||||
return this->noise_ctx_;
|
||||
#ifdef USE_OTA_ENCRYPTION_PROVISIONED
|
||||
// The api server holds the live key; safe mode never constructs it, and then
|
||||
// noise_ctx_ holds the saved key setup() found, if any
|
||||
if (api::global_api_server != nullptr)
|
||||
return api::global_api_server->get_noise_ctx();
|
||||
#endif
|
||||
return this->noise_ctx_;
|
||||
}
|
||||
#endif
|
||||
static constexpr uint16_t OTA_BLOCK_SIZE = 8192;
|
||||
@@ -58,6 +60,16 @@ extern "C" void esphome_wake_ota_component_any_context() {
|
||||
}
|
||||
|
||||
void ESPHomeOTAComponent::setup() {
|
||||
#ifdef USE_OTA_ENCRYPTION_PROVISIONED
|
||||
// Safe mode never constructs the api server, so read the key it saved
|
||||
noise::psk_t psk;
|
||||
if (api::global_api_server == nullptr && api::load_saved_noise_psk(psk)) {
|
||||
this->saved_psk_ = RAMAllocator<noise::psk_t>().make_unique(psk);
|
||||
if (this->saved_psk_ != nullptr) {
|
||||
this->noise_ctx_.set_psk(this->saved_psk_->data());
|
||||
}
|
||||
}
|
||||
#endif
|
||||
this->server_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections
|
||||
if (this->server_ == nullptr) {
|
||||
this->server_failed_(LOG_STR("creation"));
|
||||
|
||||
@@ -44,7 +44,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
}
|
||||
#endif // USE_OTA_PASSWORD
|
||||
|
||||
#if defined(USE_OTA_ENCRYPTION) && !defined(USE_OTA_ENCRYPTION_FROM_API)
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
/// psk points at 32 bytes that live in flash for the life of the program
|
||||
void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); }
|
||||
#endif
|
||||
@@ -86,7 +86,8 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
bool writing{false}; // a produced handshake frame is still being flushed
|
||||
uint8_t frame_buf[noise::FRAME_HEADER_SIZE + 1 + noise::MAX_HANDSHAKE_SIZE];
|
||||
};
|
||||
// The api server's live context when the api has encryption, else our own
|
||||
// The api server's live context when it exists, otherwise our own (a build
|
||||
// time key, or the saved key loaded in safe mode)
|
||||
const noise::NoiseContext &noise_context_() const;
|
||||
bool noise_start_session_(uint8_t server_feature_flags);
|
||||
bool handle_noise_handshake_();
|
||||
@@ -148,8 +149,10 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
RAMUniquePtr<uint8_t[]> auth_buf_;
|
||||
#endif // USE_OTA_PASSWORD
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
#ifndef USE_OTA_ENCRYPTION_FROM_API
|
||||
noise::NoiseContext noise_ctx_;
|
||||
#ifdef USE_OTA_ENCRYPTION_PROVISIONED
|
||||
// Backs noise_ctx_ in safe mode, where no api server holds the saved key
|
||||
RAMUniquePtr<noise::psk_t> saved_psk_;
|
||||
#endif
|
||||
RAMUniquePtr<NoiseSession> noise_;
|
||||
#endif // USE_OTA_ENCRYPTION
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
#include <lwip/dns.h>
|
||||
#include <cinttypes>
|
||||
#include "esp_event.h"
|
||||
#ifdef USE_PSRAM
|
||||
#include <esp_psram.h>
|
||||
#endif
|
||||
|
||||
// IDF 6.0 moved per-chip PHY/MAC drivers to the Espressif Component Registry;
|
||||
// they are no longer included via esp_eth.h and need explicit includes.
|
||||
@@ -71,6 +74,32 @@ static const char *const TAG = "ethernet";
|
||||
// PHY register size for hex logging
|
||||
static constexpr size_t PHY_REG_SIZE = 2;
|
||||
|
||||
// Dual wifi + ethernet SPI builds: the one place internal RAM is short and lwip's other buffers are
|
||||
// already in PSRAM. Not with L2 TAP, whose filter lives in the glue's input path this replaces.
|
||||
#if defined(USE_PSRAM) && defined(USE_ETHERNET_SPI) && defined(USE_WIFI) && !defined(CONFIG_ESP_NETIF_L2_TAP)
|
||||
#define USE_ETHERNET_RX_PSRAM
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - reported by dump_config()
|
||||
static bool rx_psram_installed = false;
|
||||
|
||||
// ESP-IDF ethernet drivers malloc() every received frame in internal RAM, where it stays until lwIP
|
||||
// hands it to the application. Move it to PSRAM; if that fails the frame is passed on where it is.
|
||||
static esp_err_t eth_input_to_psram(esp_eth_handle_t handle, uint8_t *buffer, uint32_t length, void *priv) {
|
||||
auto *copy = static_cast<uint8_t *>(heap_caps_malloc(length, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
|
||||
if (copy != nullptr) {
|
||||
memcpy(copy, buffer, length);
|
||||
free(buffer); // NOLINT(cppcoreguidelines-no-malloc) - allocated by the driver with malloc()
|
||||
buffer = copy;
|
||||
} else {
|
||||
static bool warned = false; // once, this runs per frame in the driver's task
|
||||
if (!warned) {
|
||||
warned = true;
|
||||
ESP_LOGW(TAG, "PSRAM allocation failed, frame kept in internal RAM (reported once)");
|
||||
}
|
||||
}
|
||||
return esp_netif_receive(static_cast<esp_netif_t *>(priv), buffer, length, nullptr);
|
||||
}
|
||||
#endif
|
||||
|
||||
void EthernetComponent::log_error_and_mark_failed_(esp_err_t err, const char *message) {
|
||||
ESP_LOGE(TAG, "%s: (%d) %s", message, err, esp_err_to_name(err));
|
||||
this->mark_failed();
|
||||
@@ -453,6 +482,16 @@ void EthernetComponent::ethernet_lazy_init_() {
|
||||
/* attach Ethernet driver to TCP/IP stack */
|
||||
err = esp_netif_attach(this->eth_netif_, esp_eth_new_netif_glue(this->eth_handle_));
|
||||
ESPHL_ERROR_CHECK(err, "ETH netif attach error");
|
||||
#ifdef USE_ETHERNET_RX_PSRAM
|
||||
// The glue frees every receive buffer with free(), so the replacement buffer must come from the heap
|
||||
if (esp_psram_is_initialized()) {
|
||||
err = esp_eth_update_input_path(this->eth_handle_, eth_input_to_psram, this->eth_netif_);
|
||||
rx_psram_installed = err == ESP_OK;
|
||||
if (!rx_psram_installed) {
|
||||
ESP_LOGW(TAG, "PSRAM RX path not installed: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Register user defined event handers
|
||||
err = esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, &EthernetComponent::eth_event_handler, nullptr);
|
||||
@@ -630,6 +669,13 @@ void EthernetComponent::dump_config() {
|
||||
this->clk_pin_, this->mdc_pin_, this->mdio_pin_, this->phy_addr_);
|
||||
#endif
|
||||
ESP_LOGCONFIG(TAG, " Type: %s", eth_type);
|
||||
#ifdef USE_ETHERNET_RX_PSRAM
|
||||
// Only known once the driver is up; with enable_on_boot: false that is after this dump
|
||||
if (this->ethernet_initialized_) {
|
||||
ESP_LOGCONFIG(TAG, " RX frames: %s",
|
||||
rx_psram_installed ? LOG_STR_LITERAL("PSRAM") : LOG_STR_LITERAL("internal RAM"));
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
network::IPAddresses EthernetComponent::get_ip_addresses() {
|
||||
|
||||
@@ -31,6 +31,7 @@ from esphome.components.psram import DOMAIN as PSRAM_DOMAIN
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BUFFER_SIZE,
|
||||
CONF_BUILD_FLAGS,
|
||||
CONF_ESPHOME,
|
||||
CONF_GROUP,
|
||||
CONF_ID,
|
||||
@@ -169,11 +170,17 @@ def generate_lv_conf_h():
|
||||
all_defines = set(
|
||||
df.LV_DEFINES + tuple(f"LV_USE_{w.upper()}" for w in WIDGET_TYPES)
|
||||
)
|
||||
build_flags = (
|
||||
CORE.config[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS).get("build_flags", [])
|
||||
esphome_config = CORE.config[CONF_ESPHOME]
|
||||
# User build flags come from esphome->build_flags and from the deprecated
|
||||
# esphome->platformio_options->build_flags (a string or a list).
|
||||
# Remove before 2026.12.0
|
||||
|
||||
pio_build_flags = esphome_config.get(CONF_PLATFORMIO_OPTIONS, {}).get(
|
||||
CONF_BUILD_FLAGS, []
|
||||
)
|
||||
if not isinstance(build_flags, list):
|
||||
build_flags = [build_flags]
|
||||
if not isinstance(pio_build_flags, list):
|
||||
pio_build_flags = [pio_build_flags]
|
||||
build_flags = [*esphome_config.get(CONF_BUILD_FLAGS, []), *pio_build_flags]
|
||||
# Extract define names from build flags like '-DLV_USE_CHART=1', '-D LV_USE_CHART',
|
||||
# or multiple defines in one string.
|
||||
define_pattern = r'-D\s*([A-Z_][A-Z0-9_]*)(?:=[^\s\'"\]]*)?'
|
||||
|
||||
@@ -35,8 +35,8 @@ void MipiDsi::setup() {
|
||||
.bus_id = 0, // index from 0, specify the DSI host to use
|
||||
.num_data_lanes =
|
||||
this->lanes_, // Number of data lanes to use, can't set a value that exceeds the chip's capability
|
||||
.phy_clk_src = MIPI_DSI_PHY_CLK_SRC_DEFAULT, // Clock source for the DPHY
|
||||
.lane_bit_rate_mbps = this->lane_bit_rate_, // Bit rate of the data lanes, in Mbps
|
||||
// phy_clk_src left at 0 to enable runtime auto-select.
|
||||
.lane_bit_rate_mbps = this->lane_bit_rate_, // Bit rate of the data lanes, in Mbps
|
||||
};
|
||||
auto err = esp_lcd_new_dsi_bus(&bus_config, &this->bus_handle_);
|
||||
if (err != ESP_OK) {
|
||||
|
||||
@@ -246,20 +246,18 @@ class MipiSpi : public display::Display,
|
||||
this->write_cmd_addr_data(8, 0x02, 24, cmd << 8, bytes, len);
|
||||
this->disable();
|
||||
} else if constexpr (BUS_TYPE == BUS_TYPE_OCTAL) {
|
||||
// Toggle D/C only while holding the bus; on boards where D/C doubles as
|
||||
// another bus signal, driving it while another device owns the bus
|
||||
// corrupts that device's transfer.
|
||||
this->enable();
|
||||
this->dc_pin_->digital_write(false);
|
||||
this->write_cmd_addr_data(0, 0, 0, 0, &cmd, 1, 8);
|
||||
this->dc_pin_->digital_write(true);
|
||||
this->disable();
|
||||
// hold the bus between command and data to avoid a glitch on the D/C line
|
||||
if (len != 0) {
|
||||
this->enable();
|
||||
this->write_cmd_addr_data(0, 0, 0, 0, bytes, len, 8);
|
||||
this->disable();
|
||||
}
|
||||
this->disable();
|
||||
} else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE) {
|
||||
// Toggle D/C only while holding the bus; works around a quirk in the CoreS3 and W5500 ethernet combination.
|
||||
// See https://github.com/esphome/esphome/pull/18529
|
||||
this->enable();
|
||||
this->dc_pin_->digital_write(false);
|
||||
this->write_byte(cmd);
|
||||
@@ -271,12 +269,14 @@ class MipiSpi : public display::Display,
|
||||
this->disable();
|
||||
}
|
||||
} else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE_16) {
|
||||
this->enable();
|
||||
// DC must be stable before CS as the clock is gated by CS
|
||||
this->dc_pin_->digital_write(false);
|
||||
this->enable();
|
||||
this->write_byte(cmd);
|
||||
this->dc_pin_->digital_write(true);
|
||||
this->disable();
|
||||
this->dc_pin_->digital_write(true);
|
||||
for (size_t i = 0; i != len; i++) {
|
||||
// must enable and disable for each byte based on empirical testing
|
||||
this->enable();
|
||||
this->write_byte(0);
|
||||
this->write_byte(bytes[i]);
|
||||
|
||||
@@ -385,7 +385,8 @@ void MixerSpeaker::loop() {
|
||||
// Retries on a subsequent loop if the task is still running on the other core
|
||||
if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) {
|
||||
ESP_LOGD(TAG, "Stopped");
|
||||
xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS);
|
||||
// Keep a start request that arrived while the task was stopping, otherwise it is lost for good
|
||||
xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS & ~MIXER_TASK_COMMAND_START);
|
||||
this->all_stopped_since_ms_ = 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@ from typing import Any
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ENCRYPTION, CONF_KEY
|
||||
from esphome.core import ID
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
DOMAIN = "noise"
|
||||
|
||||
noise_ns = cg.esphome_ns.namespace("noise")
|
||||
|
||||
@@ -70,11 +71,16 @@ def static_encryption_key(conf: ConfigType) -> str | None:
|
||||
|
||||
def new_psk_progmem(parent_id: ID, key: str) -> MockObj:
|
||||
"""Emit the decoded key as a PROGMEM array; the component keeps a pointer
|
||||
so the key never occupies RAM."""
|
||||
return cg.progmem_array(
|
||||
ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8),
|
||||
list(decode_encryption_key(key)),
|
||||
)
|
||||
so the key never occupies RAM. Components sharing one key (api and ota)
|
||||
share the array."""
|
||||
decoded = decode_encryption_key(key)
|
||||
arrays: dict[bytes, MockObj] = CORE.data.setdefault(DOMAIN, {})
|
||||
if (array := arrays.get(decoded)) is None:
|
||||
array = arrays[decoded] = cg.progmem_array(
|
||||
ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8),
|
||||
list(decoded),
|
||||
)
|
||||
return array
|
||||
|
||||
|
||||
def encryption_schema(config: ConfigType | None) -> ConfigType:
|
||||
|
||||
@@ -28,6 +28,7 @@ void DeviceFirmwareUpdate::setup() {
|
||||
this->reset_pin_->digital_write(true);
|
||||
} else {
|
||||
NRF_POWER->GPREGRET = DFU_MAGIC_UF2_RESET;
|
||||
arch_feed_wdt();
|
||||
App.reboot();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,8 +55,7 @@ namespace esphome::rp2 {
|
||||
|
||||
static const char *const TAG = "rp2.crash";
|
||||
|
||||
// Placed in .noinit so BSS zero-init cannot race with crash_handler_read_and_clear().
|
||||
// The valid field is explicitly cleared in crash_handler_read_and_clear() instead.
|
||||
// Filled from the watchdog scratch registers on the first read.
|
||||
static struct CrashData {
|
||||
bool valid;
|
||||
uint32_t pc;
|
||||
@@ -64,11 +63,24 @@ static struct CrashData {
|
||||
uint32_t sp;
|
||||
uint32_t backtrace[MAX_BACKTRACE];
|
||||
uint8_t backtrace_count;
|
||||
} s_crash_data __attribute__((section(".noinit"))); // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
} s_crash_data; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
bool crash_handler_has_data() { return s_crash_data.valid; }
|
||||
// Logger::pre_setup() logs the record before App.pre_setup() reaches
|
||||
// arch_init(), so the first caller reads it and later calls are no-ops.
|
||||
// The read clears the scratch registers, so it must not run twice, and
|
||||
// arch_init() keeps its call so the read precedes watchdog_enable(), which
|
||||
// overwrites scratch[4].
|
||||
static bool s_crash_data_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
bool crash_handler_has_data() {
|
||||
crash_handler_read_and_clear();
|
||||
return s_crash_data.valid;
|
||||
}
|
||||
|
||||
void crash_handler_read_and_clear() {
|
||||
if (s_crash_data_read)
|
||||
return;
|
||||
s_crash_data_read = true;
|
||||
s_crash_data.valid = false;
|
||||
uint32_t magic = watchdog_hw->scratch[0];
|
||||
if ((magic & 0xFFFF0000) == CRASH_MAGIC_SENTINEL && (magic & 0xFFFF) == CRASH_DATA_VERSION) {
|
||||
@@ -97,7 +109,7 @@ void crash_handler_read_and_clear() {
|
||||
// the device crashes again during boot, and allowing the CLI's process_stacktrace
|
||||
// to match and decode each address individually.
|
||||
void crash_handler_log() {
|
||||
if (!s_crash_data.valid)
|
||||
if (!crash_handler_has_data())
|
||||
return;
|
||||
|
||||
ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***");
|
||||
|
||||
@@ -9,12 +9,13 @@
|
||||
namespace esphome::rp2 {
|
||||
|
||||
/// Read crash data from watchdog scratch registers and clear them.
|
||||
/// Only the first call reads; later calls are no-ops.
|
||||
void crash_handler_read_and_clear();
|
||||
|
||||
/// Log crash data if a crash was detected on previous boot.
|
||||
void crash_handler_log();
|
||||
|
||||
/// Returns true if crash data was found this boot.
|
||||
/// Returns true if crash data was found this boot, reading it first if needed.
|
||||
bool crash_handler_has_data();
|
||||
|
||||
} // namespace esphome::rp2
|
||||
|
||||
@@ -1412,8 +1412,11 @@ json::SerializationBuffer<> WebServer::text_json_(text::Text *obj, const std::st
|
||||
json::JsonBuilder builder;
|
||||
JsonObject root = builder.root();
|
||||
|
||||
const char *state = obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD ? "********" : value.c_str();
|
||||
set_json_icon_state_value(root, obj, "text", state, value.c_str(), start_config);
|
||||
// A password entity shows the mask and prefills the input with nothing, so the secret never
|
||||
// reaches the JSON and the mask cannot be written back as the value
|
||||
const bool password = obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD;
|
||||
set_json_icon_state_value(root, obj, "text", password ? "********" : value.c_str(), password ? "" : value.c_str(),
|
||||
start_config);
|
||||
root[ESPHOME_F("min_length")] = obj->traits.get_min_length();
|
||||
root[ESPHOME_F("max_length")] = obj->traits.get_max_length();
|
||||
root[ESPHOME_F("pattern")] = obj->traits.get_pattern_c_str();
|
||||
|
||||
@@ -27,7 +27,11 @@ void arch_init() {
|
||||
if (device_is_ready(WDT)) {
|
||||
static wdt_timeout_cfg wdt_config{};
|
||||
wdt_config.flags = WDT_FLAG_RESET_SOC;
|
||||
#ifdef USE_ZIGBEE
|
||||
#ifndef USE_BOOTLOADER_MCUBOOT
|
||||
// Adafruit bootloader doesn't feed the WDT while
|
||||
// erasing flash during a firmware update, so a shorter timeout can break the update.
|
||||
wdt_config.window.max = 30000;
|
||||
#elif defined(USE_ZIGBEE)
|
||||
// zboss thread uses a lot of CPU cycles during startup
|
||||
wdt_config.window.max = 10000;
|
||||
#else
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
|
||||
from esphome.enum import StrEnum
|
||||
|
||||
__version__ = "2026.9.0"
|
||||
__version__ = "2026.9.1"
|
||||
|
||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||
|
||||
@@ -67,7 +67,7 @@ static constexpr uint32_t TEARDOWN_TIMEOUT_REBOOT_MS = 1000; // 1 second for qu
|
||||
class Application {
|
||||
public:
|
||||
#ifdef ESPHOME_NAME_ADD_MAC_SUFFIX
|
||||
// Called before Logger::pre_setup() — must not log (global_logger is not yet set).
|
||||
// Runs after Logger::pre_setup() (emitted at EARLY_INIT priority), so the app name is not set yet there.
|
||||
/// Pre-setup with MAC suffix: overwrites placeholder in mutable static buffers with actual MAC.
|
||||
void pre_setup(char *name, size_t name_len, char *friendly_name, size_t friendly_name_len) {
|
||||
arch_init();
|
||||
@@ -87,7 +87,7 @@ class Application {
|
||||
this->friendly_name_ = StringRef(friendly_name, friendly_name_len);
|
||||
}
|
||||
#else
|
||||
// Called before Logger::pre_setup() — must not log (global_logger is not yet set).
|
||||
// Runs after Logger::pre_setup() (emitted at EARLY_INIT priority), so the app name is not set yet there.
|
||||
/// Pre-setup without MAC suffix: StringRef points directly at const string literals in flash.
|
||||
void pre_setup(const char *name, size_t name_len, const char *friendly_name, size_t friendly_name_len) {
|
||||
arch_init();
|
||||
|
||||
@@ -245,7 +245,6 @@
|
||||
#define USE_RUNTIME_STATS
|
||||
#define USE_OTA
|
||||
#define USE_OTA_ENCRYPTION
|
||||
#define USE_OTA_ENCRYPTION_FROM_API
|
||||
#define USE_OTA_ENCRYPTION_PROVISIONED
|
||||
#define USE_OTA_ENCRYPTION_REQUIRED
|
||||
#define USE_OTA_PASSWORD
|
||||
|
||||
+85
-14
@@ -74,6 +74,64 @@ FILTER_IDF_LINES: list[str] = [
|
||||
r"Stopping at filesystem boundary",
|
||||
]
|
||||
|
||||
# Windows code page identifier for UTF-8, as used by ``chcp 65001``.
|
||||
UTF8_CODEPAGE = 65001
|
||||
|
||||
|
||||
def _get_kernel32():
|
||||
"""Return the Windows kernel32 module, or None on any other platform."""
|
||||
if sys.platform != "win32":
|
||||
return None
|
||||
import ctypes
|
||||
|
||||
return ctypes.windll.kernel32
|
||||
|
||||
|
||||
class _Utf8Console:
|
||||
"""Keep an attached Windows console on UTF-8 for the length of the build.
|
||||
|
||||
The build tree runs in UTF-8 mode, so esp_idf_size draws its table with
|
||||
Unicode box characters. ``idf.py size`` reaches it through ``cmake -P``,
|
||||
and CMake re-decodes the child's output with the console code page before
|
||||
printing it, which turns the table into mojibake on any code page but
|
||||
UTF-8. Every process in the build shares this console, so switching it
|
||||
here covers CMake too. The old code pages go back on exit so the user's
|
||||
terminal is left as it was.
|
||||
|
||||
A console that is already on UTF-8 is left alone. The code page belongs to
|
||||
the console, not to this process, so a build that overlaps another one
|
||||
must not save UTF-8 as the page to go back to.
|
||||
"""
|
||||
|
||||
def __init__(self, kernel32) -> None:
|
||||
self._kernel32 = kernel32
|
||||
self._codepages: tuple[int, int] | None = None
|
||||
|
||||
def __enter__(self) -> None:
|
||||
kernel32 = self._kernel32
|
||||
if kernel32 is None:
|
||||
return
|
||||
old_in = kernel32.GetConsoleCP()
|
||||
old_out = kernel32.GetConsoleOutputCP()
|
||||
# Both calls return 0 when no console is attached.
|
||||
if not old_in or not old_out:
|
||||
return
|
||||
if old_in == UTF8_CODEPAGE and old_out == UTF8_CODEPAGE:
|
||||
return
|
||||
# Record the old pages first so a switch that fails part way through
|
||||
# still gets put back on exit.
|
||||
self._codepages = (old_in, old_out)
|
||||
kernel32.SetConsoleCP(UTF8_CODEPAGE)
|
||||
kernel32.SetConsoleOutputCP(UTF8_CODEPAGE)
|
||||
|
||||
def __exit__(self, *exc_info: object) -> None:
|
||||
if self._codepages is None:
|
||||
return
|
||||
old_in, old_out = self._codepages
|
||||
self._codepages = None
|
||||
self._kernel32.SetConsoleCP(old_in)
|
||||
self._kernel32.SetConsoleOutputCP(old_out)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# ---- sys.path fix-up ---------------------------------------------------
|
||||
@@ -269,8 +327,29 @@ def main() -> int:
|
||||
is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[2:])
|
||||
filter_lines = None if is_verbose else FILTER_IDF_LINES or None
|
||||
|
||||
stdout_shim = sys.stdout = _FilteringTTYStream(sys.stdout, filter_lines) # type: ignore[assignment]
|
||||
stderr_shim = sys.stderr = _FilteringTTYStream(sys.stderr, filter_lines) # type: ignore[assignment]
|
||||
class _FilteredStreams:
|
||||
"""Route ``sys.stdout`` and ``sys.stderr`` through the filtering shims.
|
||||
|
||||
On exit each shim releases a last line that never got its
|
||||
terminator. The shims made here are drained rather than whatever
|
||||
``sys.stdout`` holds by then, which the script is free to replace.
|
||||
"""
|
||||
|
||||
def __init__(self, filter_lines: list[str] | None) -> None:
|
||||
self._stdout = _FilteringTTYStream(sys.stdout, filter_lines)
|
||||
self._stderr = _FilteringTTYStream(sys.stderr, filter_lines)
|
||||
|
||||
def __enter__(self) -> None:
|
||||
sys.stdout = self._stdout # type: ignore[assignment]
|
||||
sys.stderr = self._stderr # type: ignore[assignment]
|
||||
|
||||
def __exit__(self, *exc_info: object) -> None:
|
||||
# Drain stderr from a finally so a surprise from the first one
|
||||
# cannot strand the second.
|
||||
try:
|
||||
self._stdout.drain()
|
||||
finally:
|
||||
self._stderr.drain()
|
||||
|
||||
# Shift argv so the target script sees its own path as argv[0] and
|
||||
# its own arguments starting at argv[1]. runpy.run_path does not
|
||||
@@ -288,19 +367,11 @@ def main() -> int:
|
||||
|
||||
# If idf.py calls sys.exit(), SystemExit propagates out of run_path
|
||||
# and carries the exit code back to our caller. For normal returns,
|
||||
# fall through and exit with 0. Either way the streams get a chance to
|
||||
# release a last line that never got its terminator. Drain the shims we
|
||||
# made rather than sys.stdout, which the script is free to replace, and
|
||||
# report instead of raising so cleanup cannot bury the real exit code.
|
||||
try:
|
||||
# fall through and exit with 0. Either way the context managers drain
|
||||
# the streams and put the console back on the way out, and they report
|
||||
# instead of raising so cleanup cannot bury the real exit code.
|
||||
with _FilteredStreams(filter_lines), _Utf8Console(_get_kernel32()):
|
||||
runpy.run_path(script_path, run_name="__main__")
|
||||
finally:
|
||||
# Drain stderr from a finally so a surprise from the first one cannot
|
||||
# strand the second.
|
||||
try:
|
||||
stdout_shim.drain()
|
||||
finally:
|
||||
stderr_shim.drain()
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""``generate_lv_conf_h()`` writes ``#define LV_... 0`` for every LVGL option
|
||||
the configuration does not use, so ``lv_conf.h`` must leave alone the options
|
||||
the user defines in build flags. Otherwise the header, which is included after
|
||||
the compiler ``-D`` flags, turns the option off again.
|
||||
|
||||
Build flags can come from ``esphome: build_flags:`` or from the deprecated
|
||||
``esphome: platformio_options: build_flags:``; both must be honoured.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.lvgl import defines as df, generate_lv_conf_h
|
||||
from esphome.const import CONF_BUILD_FLAGS, CONF_ESPHOME, CONF_PLATFORMIO_OPTIONS
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
def _set_esphome_config(
|
||||
build_flags: list[str] | None = None,
|
||||
pio_build_flags: list[str] | str | None = None,
|
||||
) -> None:
|
||||
pio_options = {} if pio_build_flags is None else {"build_flags": pio_build_flags}
|
||||
CORE.config = {
|
||||
CONF_ESPHOME: {
|
||||
CONF_PLATFORMIO_OPTIONS: pio_options,
|
||||
CONF_BUILD_FLAGS: build_flags or [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_unused_define_is_disabled_without_build_flag() -> None:
|
||||
_set_esphome_config()
|
||||
assert "#define LV_USE_OBSERVER 0" in generate_lv_conf_h().splitlines()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"flags",
|
||||
[
|
||||
{"build_flags": ["-DLV_USE_OBSERVER=1"]},
|
||||
{"build_flags": ["-D LV_USE_OBSERVER"]},
|
||||
{"pio_build_flags": ["-DLV_USE_OBSERVER=1"]},
|
||||
{"pio_build_flags": "-DLV_USE_OBSERVER=1"},
|
||||
],
|
||||
ids=["esphome", "esphome-spaced", "platformio_options", "platformio_options-str"],
|
||||
)
|
||||
def test_build_flag_define_is_not_disabled(flags: dict) -> None:
|
||||
_set_esphome_config(**flags)
|
||||
assert "#define LV_USE_OBSERVER 0" not in generate_lv_conf_h().splitlines()
|
||||
|
||||
|
||||
def test_esphome_build_flag_clash_warns(caplog: pytest.LogCaptureFixture) -> None:
|
||||
_set_esphome_config(build_flags=["-DLV_USE_ARC=1"])
|
||||
df.add_define("LV_USE_ARC")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
generate_lv_conf_h()
|
||||
assert "LV_USE_ARC" in caplog.text
|
||||
@@ -319,6 +319,32 @@ def test_encryption_with_captive_portal_does_not_warn(
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("extra", [{}, {"prometheus": {}}])
|
||||
def test_encryption_with_web_server_ota_disabled_does_not_warn(
|
||||
caplog: pytest.LogCaptureFixture, extra: dict[str, Any]
|
||||
) -> None:
|
||||
"""web_server `ota: false` only serves /update while the captive portal is
|
||||
active, on every listener, so there is no plaintext endpoint to warn about."""
|
||||
full_conf = {
|
||||
"web_server": {CONF_OTA: False},
|
||||
**extra,
|
||||
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 not any(
|
||||
"OTA encryption does not cover" in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_password_with_api_key_warns(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A static api key makes the device offer encryption and the CLI take
|
||||
it, so the password is dead weight; the config validates with a warning."""
|
||||
@@ -450,43 +476,36 @@ def test_static_encryption_key() -> None:
|
||||
("yaml_name", "defines_present", "defines_absent"),
|
||||
[
|
||||
# An api key alone compiles the transport in without requiring it;
|
||||
# the device uses the api server's key, not a copy
|
||||
# the ota keeps its own pointer to the key so safe mode, which never
|
||||
# constructs the api server, can still use it
|
||||
(
|
||||
"api_key_offer",
|
||||
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API"},
|
||||
{"USE_OTA_ENCRYPTION"},
|
||||
{"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"},
|
||||
),
|
||||
# A password still guards plaintext uploads on an offering device
|
||||
(
|
||||
"api_key_offer_password",
|
||||
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_PASSWORD"},
|
||||
{"USE_OTA_ENCRYPTION", "USE_OTA_PASSWORD"},
|
||||
{"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"},
|
||||
),
|
||||
# The ota encryption block is what makes the device refuse plaintext
|
||||
(
|
||||
"encryption_required",
|
||||
{
|
||||
"USE_OTA_ENCRYPTION",
|
||||
"USE_OTA_ENCRYPTION_REQUIRED",
|
||||
"USE_OTA_ENCRYPTION_FROM_API",
|
||||
},
|
||||
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"},
|
||||
{"USE_OTA_ENCRYPTION_PROVISIONED"},
|
||||
),
|
||||
# Without api encryption the ota key is the device's own
|
||||
(
|
||||
"own_key",
|
||||
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"},
|
||||
{"USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_ENCRYPTION_PROVISIONED"},
|
||||
{"USE_OTA_ENCRYPTION_PROVISIONED"},
|
||||
),
|
||||
# A key provisioned at runtime lives in the api server; the device
|
||||
# offers with it once provisioned and never requires it
|
||||
(
|
||||
"runtime_api_key",
|
||||
{
|
||||
"USE_OTA_ENCRYPTION",
|
||||
"USE_OTA_ENCRYPTION_FROM_API",
|
||||
"USE_OTA_ENCRYPTION_PROVISIONED",
|
||||
},
|
||||
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_PROVISIONED"},
|
||||
{"USE_OTA_ENCRYPTION_REQUIRED"},
|
||||
),
|
||||
# No api encryption at all keeps the noise glue out of the build
|
||||
@@ -496,7 +515,6 @@ def test_static_encryption_key() -> None:
|
||||
{
|
||||
"USE_OTA_ENCRYPTION",
|
||||
"USE_OTA_ENCRYPTION_REQUIRED",
|
||||
"USE_OTA_ENCRYPTION_FROM_API",
|
||||
"USE_OTA_ENCRYPTION_PROVISIONED",
|
||||
},
|
||||
),
|
||||
@@ -515,8 +533,10 @@ def test_encryption_offer_codegen(
|
||||
assert defines_present <= defines
|
||||
assert not (defines_absent & defines)
|
||||
encrypted = "USE_OTA_ENCRYPTION" in defines_present
|
||||
own_key = encrypted and "USE_OTA_ENCRYPTION_FROM_API" not in defines_present
|
||||
own_key = encrypted and "USE_OTA_ENCRYPTION_PROVISIONED" not in defines_present
|
||||
assert ("esphome_esphomeotacomponent_id->set_noise_psk(" in main_cpp) is own_key
|
||||
# The api shares the ota's array instead of emitting the same key twice
|
||||
assert main_cpp.count("_psk[] PROGMEM") == (1 if own_key else 0)
|
||||
assert ("set_auth_password(" in main_cpp) is ("USE_OTA_PASSWORD" in defines_present)
|
||||
# The noise transport source compiles only when the define is set
|
||||
assert FILTER_SOURCE_FILES() == ([] if encrypted else ["ota_esphome_noise.cpp"])
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# No wifi, espnow or BLE: nothing else re-includes esp_wifi for the ESP-NOW shim.
|
||||
esp32_hosted:
|
||||
variant: ESP32C6
|
||||
slot: 1
|
||||
active_high: true
|
||||
reset_pin: GPIO15
|
||||
cmd_pin: GPIO13
|
||||
clk_pin: GPIO12
|
||||
d0_pin: GPIO11
|
||||
d1_pin: GPIO10
|
||||
d2_pin: GPIO9
|
||||
d3_pin: GPIO8
|
||||
@@ -0,0 +1,15 @@
|
||||
# W5500 next to wifi with PSRAM: compiles the input path that moves received frames to PSRAM
|
||||
packages:
|
||||
ethernet: !include common-w5500.yaml
|
||||
|
||||
psram:
|
||||
mode: quad
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
network:
|
||||
priority:
|
||||
- ethernet
|
||||
- wifi
|
||||
@@ -0,0 +1,13 @@
|
||||
esphome:
|
||||
name: host-ota-test
|
||||
host:
|
||||
api:
|
||||
encryption:
|
||||
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
ota:
|
||||
- platform: esphome
|
||||
port: __OTA_PORT__
|
||||
encryption:
|
||||
safe_mode:
|
||||
logger:
|
||||
level: DEBUG
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: host-ota-test
|
||||
host:
|
||||
api:
|
||||
encryption:
|
||||
ota:
|
||||
- platform: esphome
|
||||
port: __OTA_PORT__
|
||||
safe_mode:
|
||||
logger:
|
||||
level: DEBUG
|
||||
@@ -14,6 +14,12 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import struct
|
||||
|
||||
_ENTRY = struct.Struct("<IB") # key, data length
|
||||
# Must match esphome::safe_mode::RTC_KEY in safe_mode.h
|
||||
_SAFE_MODE_RTC_KEY = 233825507
|
||||
# Must match esphome::safe_mode::SafeModeComponent::ENTER_SAFE_MODE_MAGIC
|
||||
_ENTER_SAFE_MODE_MAGIC = 0x5AFE5AFE
|
||||
|
||||
|
||||
def host_prefs_path(device_name: str) -> Path:
|
||||
"""Return the on-disk prefs file path for a host-platform device."""
|
||||
@@ -34,16 +40,33 @@ def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path:
|
||||
for key, data in entries.items():
|
||||
if len(data) > 255:
|
||||
raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)")
|
||||
payload += struct.pack("<IB", key, len(data)) + data
|
||||
payload += _ENTRY.pack(key, len(data)) + data
|
||||
path = host_prefs_path(device_name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(payload)
|
||||
return path
|
||||
|
||||
|
||||
def write_host_pref(device_name: str, key: int, data: bytes) -> Path:
|
||||
"""Write a single preference entry, replacing the file's contents.
|
||||
def read_host_prefs(device_name: str) -> dict[int, bytes]:
|
||||
"""Read the preference entries of a host-platform device; empty when
|
||||
the file does not exist."""
|
||||
path = host_prefs_path(device_name)
|
||||
if not path.exists():
|
||||
return {}
|
||||
payload = path.read_bytes()
|
||||
entries: dict[int, bytes] = {}
|
||||
pos = 0
|
||||
while pos < len(payload):
|
||||
key, length = _ENTRY.unpack_from(payload, pos)
|
||||
pos += _ENTRY.size
|
||||
entries[key] = payload[pos : pos + length]
|
||||
pos += length
|
||||
return entries
|
||||
|
||||
Returns the path that was written.
|
||||
"""
|
||||
return write_host_prefs(device_name, {key: data})
|
||||
|
||||
def force_safe_mode(device_name: str) -> None:
|
||||
"""Make the next boot of a host-platform device enter safe mode; other
|
||||
saved preferences are kept."""
|
||||
entries = read_host_prefs(device_name)
|
||||
entries[_SAFE_MODE_RTC_KEY] = struct.pack("<I", _ENTER_SAFE_MODE_MAGIC)
|
||||
write_host_prefs(device_name, entries)
|
||||
|
||||
@@ -29,6 +29,7 @@ from .const import (
|
||||
PROVISIONING_PSK,
|
||||
ZERO_PSK,
|
||||
)
|
||||
from .host_prefs import force_safe_mode
|
||||
from .types import APIClientConnectedFactory, CompileFunction, ConfigWriter
|
||||
|
||||
DEVICE_NAME = "host-ota-test"
|
||||
@@ -166,6 +167,15 @@ class _Device:
|
||||
assert self.proc.returncode is None, "process died on rejected OTA"
|
||||
|
||||
|
||||
async def _provision_key(
|
||||
dev: _Device, api_client_connected: APIClientConnectedFactory
|
||||
) -> None:
|
||||
"""Provision PROVISIONING_PSK over the api and wait for it to activate."""
|
||||
async with api_client_connected(port=dev.api_port, noise_psk=ZERO_PSK) as client:
|
||||
assert await client.noise_encryption_set_key(PROVISIONING_PSK) is True
|
||||
await asyncio.sleep(KEY_ACTIVATION_DELAY)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_ota_self_update(
|
||||
yaml_config: str,
|
||||
@@ -227,6 +237,31 @@ async def test_host_ota_encrypted(
|
||||
await dev.ota(None, API_KEY, "encrypted OTA reported failure")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_ota_encrypted_safe_mode(
|
||||
yaml_config: str,
|
||||
write_yaml_config: ConfigWriter,
|
||||
compile_esphome: CompileFunction,
|
||||
reserved_tcp_port: tuple[int, socket.socket],
|
||||
) -> None:
|
||||
"""Safe mode never constructs the api server, so an encrypted OTA with the
|
||||
api key has to run on the ota component's own copy of that key."""
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
dev = _Device(
|
||||
*await _build(
|
||||
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
|
||||
)
|
||||
)
|
||||
# The api port never opens in safe mode, so wait for the log line instead
|
||||
force_safe_mode(DEVICE_NAME)
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines):
|
||||
dev.proc = proc
|
||||
await _wait_for_line(lines, "SAFE MODE IS ACTIVE", PORT_WAIT_TIMEOUT)
|
||||
await _wait_for_port(LOCALHOST, dev.ota_port, PORT_WAIT_TIMEOUT)
|
||||
# The safe mode boot clears the counter, so the re-exec boots normally
|
||||
await dev.ota(None, API_KEY, "encrypted OTA in safe mode reported failure")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_ota_api_key_offer_with_password(
|
||||
yaml_config: str,
|
||||
@@ -305,11 +340,7 @@ async def test_host_ota_provisioned_api_key(
|
||||
None, None, "plaintext upload to an unprovisioned device must succeed"
|
||||
)
|
||||
|
||||
async with api_client_connected(
|
||||
port=dev.api_port, noise_psk=ZERO_PSK
|
||||
) as client:
|
||||
assert await client.noise_encryption_set_key(PROVISIONING_PSK) is True
|
||||
await asyncio.sleep(KEY_ACTIVATION_DELAY)
|
||||
await _provision_key(dev, api_client_connected)
|
||||
|
||||
key = PROVISIONING_PSK.decode()
|
||||
await dev.ota(
|
||||
@@ -319,6 +350,45 @@ async def test_host_ota_provisioned_api_key(
|
||||
await dev.ota(None, None, "plaintext must stay accepted on an offering device")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_ota_provisioned_api_key_safe_mode(
|
||||
yaml_config: str,
|
||||
write_yaml_config: ConfigWriter,
|
||||
compile_esphome: CompileFunction,
|
||||
reserved_tcp_port: tuple[int, socket.socket],
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Safe mode never constructs the api server, so the OTA has to load the
|
||||
provisioned key from preferences itself to keep encrypting there."""
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
dev = _Device(
|
||||
*await _build(
|
||||
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
|
||||
)
|
||||
)
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, _lines):
|
||||
dev.proc = proc
|
||||
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
|
||||
await _provision_key(dev, api_client_connected)
|
||||
|
||||
# The saved key is already on disk; a host reboot outside an OTA just
|
||||
# exits, so safe mode takes a second start
|
||||
force_safe_mode(DEVICE_NAME)
|
||||
key = PROVISIONING_PSK.decode()
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines):
|
||||
dev.proc = proc
|
||||
await _wait_for_line(lines, "SAFE MODE IS ACTIVE", PORT_WAIT_TIMEOUT)
|
||||
await _wait_for_port(LOCALHOST, dev.ota_port, PORT_WAIT_TIMEOUT)
|
||||
await dev.ota(
|
||||
None,
|
||||
key,
|
||||
"encrypted upload with the provisioned key must succeed in safe mode",
|
||||
)
|
||||
# The re-exec boots normally and the api reads the same record
|
||||
async with api_client_connected(port=dev.api_port, noise_psk=key):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_ota_rejects_garbage(
|
||||
yaml_config: str,
|
||||
|
||||
@@ -25,19 +25,13 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import run_binary
|
||||
from .host_prefs import clear_host_prefs, write_host_pref
|
||||
from .host_prefs import clear_host_prefs, force_safe_mode
|
||||
from .types import CompileFunction, ConfigWriter
|
||||
|
||||
# Must match esphome::safe_mode::RTC_KEY in safe_mode.h
|
||||
SAFE_MODE_RTC_KEY = 233825507
|
||||
# Must match esphome::safe_mode::SafeModeComponent::ENTER_SAFE_MODE_MAGIC
|
||||
ENTER_SAFE_MODE_MAGIC = 0x5AFE5AFE
|
||||
|
||||
DEVICE_NAME = "safe-mode-loop-runs"
|
||||
THREAD_LOG_MARKER = "looping component ran in safe mode"
|
||||
|
||||
@@ -56,9 +50,7 @@ async def test_safe_mode_loop_runs(
|
||||
|
||||
# Compile finished successfully; pre-populate prefs so the *next* run
|
||||
# enters safe mode immediately.
|
||||
write_host_pref(
|
||||
DEVICE_NAME, SAFE_MODE_RTC_KEY, struct.pack("<I", ENTER_SAFE_MODE_MAGIC)
|
||||
)
|
||||
force_safe_mode(DEVICE_NAME)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
@@ -209,3 +209,107 @@ def test_runner_streams_output_before_the_build_finishes(
|
||||
# Join before leaving the block, so the reader is done rather than
|
||||
# racing ``Popen`` closing the pipe under it.
|
||||
reader.join(1.0)
|
||||
|
||||
|
||||
class _FakeKernel32:
|
||||
"""Stand-in for the Windows kernel32 console code page calls."""
|
||||
|
||||
def __init__(self, input_cp: int, output_cp: int) -> None:
|
||||
self.input_cp = input_cp
|
||||
self.output_cp = output_cp
|
||||
self.calls: list[tuple[str, int]] = []
|
||||
|
||||
def GetConsoleCP(self) -> int: # noqa: N802
|
||||
return self.input_cp
|
||||
|
||||
def GetConsoleOutputCP(self) -> int: # noqa: N802
|
||||
return self.output_cp
|
||||
|
||||
def SetConsoleCP(self, codepage: int) -> int: # noqa: N802
|
||||
self.calls.append(("SetConsoleCP", codepage))
|
||||
self.input_cp = codepage
|
||||
return 1
|
||||
|
||||
def SetConsoleOutputCP(self, codepage: int) -> int: # noqa: N802
|
||||
self.calls.append(("SetConsoleOutputCP", codepage))
|
||||
self.output_cp = codepage
|
||||
return 1
|
||||
|
||||
|
||||
def test_main_runs_the_build_with_a_utf8_console(
|
||||
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
|
||||
) -> None:
|
||||
"""An attached console is switched to UTF-8 and then put back."""
|
||||
kernel32 = _FakeKernel32(850, 850)
|
||||
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
|
||||
|
||||
_run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py")
|
||||
|
||||
assert kernel32.calls == [
|
||||
("SetConsoleCP", runner.UTF8_CODEPAGE),
|
||||
("SetConsoleOutputCP", runner.UTF8_CODEPAGE),
|
||||
("SetConsoleCP", 850),
|
||||
("SetConsoleOutputCP", 850),
|
||||
]
|
||||
|
||||
|
||||
def test_main_restores_the_console_when_the_build_dies(
|
||||
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
|
||||
) -> None:
|
||||
"""A failing build must not leave the user's console on UTF-8."""
|
||||
kernel32 = _FakeKernel32(437, 437)
|
||||
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
|
||||
_prepare_main(monkeypatch, fixture_path / "espidf" / "crashing_probe.py")
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
runner.main()
|
||||
|
||||
assert (kernel32.input_cp, kernel32.output_cp) == (437, 437)
|
||||
|
||||
|
||||
def test_main_restores_the_console_when_the_switch_fails_part_way(
|
||||
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
|
||||
) -> None:
|
||||
"""A failed output page switch must not strand the changed input page."""
|
||||
kernel32 = _FakeKernel32(850, 850)
|
||||
|
||||
def _refuse(codepage: int) -> int:
|
||||
kernel32.calls.append(("SetConsoleOutputCP", codepage))
|
||||
return 0
|
||||
|
||||
kernel32.SetConsoleOutputCP = _refuse # type: ignore[method-assign]
|
||||
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
|
||||
|
||||
_run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py")
|
||||
|
||||
assert kernel32.input_cp == 850
|
||||
assert kernel32.calls[-2:] == [("SetConsoleCP", 850), ("SetConsoleOutputCP", 850)]
|
||||
|
||||
|
||||
def test_main_leaves_the_console_alone_when_there_is_none(
|
||||
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
|
||||
) -> None:
|
||||
"""Without a console the code page calls return 0 and nothing is set."""
|
||||
kernel32 = _FakeKernel32(0, 0)
|
||||
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
|
||||
|
||||
_run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py")
|
||||
|
||||
assert kernel32.calls == []
|
||||
|
||||
|
||||
def test_main_leaves_a_console_already_on_utf8_alone(
|
||||
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
|
||||
) -> None:
|
||||
"""An overlapping build must not save UTF-8 as the page to restore."""
|
||||
kernel32 = _FakeKernel32(runner.UTF8_CODEPAGE, runner.UTF8_CODEPAGE)
|
||||
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
|
||||
|
||||
_run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py")
|
||||
|
||||
assert kernel32.calls == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="kernel32 exists on Windows")
|
||||
def test_get_kernel32_is_none_off_windows() -> None:
|
||||
assert runner._get_kernel32() is None
|
||||
|
||||
Reference in New Issue
Block a user