Compare commits

...
32 Commits
Author SHA1 Message Date
Jesse Hills 0b2b16e664 Merge pull request #19872 from esphome/bump-2026.9.1
2026.9.1
2026-09-29 20:12:00 +01:00
Jesse Hills 7a4ffa98f6 Bump version to 2026.9.1 2026-09-29 12:57:36 +13:00
J. Nick Koston eca4c2d0a5 [ethernet] Move received frames to PSRAM on SPI ethernet chips (#19377) 2026-09-29 12:57:35 +13:00
Suliman AbdulrazzaqandClyde Stubbs 43b6b41d16 [lvgl] Honor esphome build_flags when generating lv_conf.h (#19743)
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
2026-09-29 12:57:35 +13:00
Clyde Stubbs b6d70d4e34 [mipi_spi] Fix reversion in dc/cs sequence for spi_16 (#19745) 2026-09-29 12:57:35 +13:00
tomaszduda23andJ. Nick Koston 4af2db7e8a [nrf52] set WDT timeout to 30sec for Adafruit bootloader (#19596)
Co-authored-by: J. Nick Koston <nick+github@koston.org>
2026-09-29 12:57:35 +13:00
J. Nick Koston 92059abf6c [web_server] Mask the value of a password text entity, not only its state (#19385) 2026-09-29 12:57:35 +13:00
Jonathan SwobodaandClaude Fable 5.1 e6d124d229 [esp32] Disable newlib nano printf formatting on ESP32-C2 (#19418)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 12:57:35 +13:00
J. Nick Koston 0575eb14f2 [esphome] Keep the OTA encryption key without the api server so safe mode uploads work (#19349) 2026-09-29 12:57:35 +13:00
J. Nick Koston c732903390 [esp32_hosted] Fix ESP32-P4 build without wifi, espnow or BLE (#19374) 2026-09-29 12:57:35 +13:00
J. Nick Koston eaee1cdd86 [espidf] Switch the Windows console to UTF-8 while idf.py runs (#19352) 2026-09-29 12:57:35 +13:00
J. Nick Koston 964a54f7a7 [esp32][rp2] Print the previous boot crash report before the logger reads it (#19351) 2026-09-29 12:57:34 +13:00
rexmoriarty 3e5797d153 [mixer] Don't discard a start request while reaping a stopped task (#19368) 2026-09-29 12:57:34 +13:00
J. Nick Koston 7e99301644 [ota] Skip the web_server plaintext warning when web_server ota is disabled (#19348) 2026-09-29 12:57:34 +13:00
08387ad289 [mipi_dsi] Let IDF pick the DPHY PLL reference clock (#18984)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
2026-09-29 12:57:34 +13:00
Jesse Hills c6e4c87e52 Merge pull request #19338 from esphome/bump-2026.9.0
2026.9.0
2026-09-16 20:47:05 +12:00
Jesse Hills 160bc29c7c Bump version to 2026.9.0 2026-09-16 17:01:34 +12:00
Jesse Hills b0cc913884 Merge pull request #19334 from esphome/bump-2026.9.0b5
2026.9.0b5
2026-09-16 13:37:44 +12:00
Jesse Hills 3f7725a684 Bump version to 2026.9.0b5 2026-09-16 12:00:05 +12:00
Christopher PruijsenandJ. Nick Koston 0f500628dd [file] Keep resolved image paths as Path so config-hash normalizes them (#19267)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-09-16 12:00:03 +12:00
Bonne Eggleston fe0f04b2e4 [modbus] Add allow_broadcast_read and expect_broadcast_write_response options (#19304) 2026-09-16 12:00:03 +12:00
Edvard Filistovič 45362dbc5b [bk72xx_ble] Keep wifi power save off while BLE is compiled in (#19317) 2026-09-16 12:00:03 +12:00
J. Nick Koston a1ad794d03 [esp8266_pwm] Skip the frequency setter when it matches the default (#19224) 2026-09-16 12:00:03 +12:00
J. Nick Koston f8bda9fbad [logger] Skip the hardware UART setter when it matches the default (#19230) 2026-09-16 12:00:03 +12:00
J. Nick Koston 808b7210db [wifi] Skip setters that pass the default priority, timeouts, power save and auth mode (#19229) 2026-09-16 12:00:03 +12:00
J. Nick Koston 81be397056 [light] Skip the flash transition setter and the empty effect list (#19228) 2026-09-16 12:00:03 +12:00
J. Nick Koston eae6af437b [output] Skip the power limit setters when they match the defaults (#19225) 2026-09-16 12:00:03 +12:00
J. Nick Koston 6582c618f1 [web_server] Skip setters that pass the default port, log and include internal values (#19226) 2026-09-16 12:00:03 +12:00
J. Nick Koston 8c999d3152 [number] Fix the default mode check so mode auto is no longer emitted (#19231) 2026-09-16 12:00:03 +12:00
Jesse Hills 076dc017ab [core] Mark filters, manual_ip and interlock as advanced (#19272) 2026-09-16 12:00:03 +12:00
tronikos b7acd9c0dc [template] Stop water heater republishing when a temperature is unknown (#19013) 2026-09-16 12:00:03 +12:00
Jeff Brown 20d199ae20 [pmsa003i] Fix read from uninitialized stack memory (#19053) 2026-09-16 12:00:03 +12:00
114 changed files with 2458 additions and 321 deletions
+1 -1
View File
@@ -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.0b4
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
+24 -2
View File
@@ -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()) {
+5
View File
@@ -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
+3 -1
View File
@@ -452,7 +452,9 @@ _BINARY_SENSOR_SCHEMA = (
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
cv.Optional(CONF_FILTERS): validate_filters,
cv.Optional(
CONF_FILTERS, visibility=cv.Visibility.ADVANCED
): validate_filters,
cv.Optional(CONF_ON_PRESS): automation.validate_automation({}),
cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}),
cv.Optional(CONF_ON_CLICK): cv.All(
+10 -1
View File
@@ -23,7 +23,7 @@ public ble_api.h.
import logging
import esphome.codegen as cg
from esphome.components import libretiny
from esphome.components import libretiny, wifi
from esphome.components.libretiny.const import (
FAMILY_BK7231N,
FAMILY_BK7231Q,
@@ -84,6 +84,15 @@ def _final_validate(config: ConfigType) -> None:
# which run on a BLE 4.2 board. The hard error is raised at codegen.
if msg := _unsupported_family_message(libretiny.get_libretiny_family()):
_LOGGER.warning("%s (this configuration cannot compile)", msg)
# Any wifi power_save_mode other than NONE also arms the Beken SDK's MCU
# sleep. With the BLE controller running, that sleep never wakes up once the
# station is stopped (adapter restart after failed roams, wifi.disable): the
# device is dead until a power cycle (esphome#18592). Keep power save off
# until LibreTiny ships the SDK-side fix (libretiny-eu/libretiny#414).
wifi.force_power_save_off(
"with BLE running, the Beken SDK's MCU sleep halts the device once the "
"station is stopped (https://github.com/esphome/esphome/issues/18592)"
)
FINAL_VALIDATE_SCHEMA = _final_validate
+6 -1
View File
@@ -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.
+9 -5
View File
@@ -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 ***");
+1 -6
View File
@@ -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
-8
View File
@@ -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
+1 -1
View File
@@ -29,7 +29,7 @@ class ESP8266PWM final : public output::FloatOutput, public Component {
void write_state(float state) override;
InternalGPIOPin *pin_;
float frequency_{1000.0};
float frequency_{1000.0}; // Keep in sync with DEFAULT_FREQUENCY in output.py
/// Cache last output level for dynamic frequency updating
float last_output_{0.0};
};
+8 -2
View File
@@ -22,6 +22,10 @@ ESP8266PWM = esp8266_pwm_ns.class_("ESP8266PWM", output.FloatOutput, cg.Componen
SetFrequencyAction = esp8266_pwm_ns.class_("SetFrequencyAction", automation.Action)
validate_frequency = cv.All(cv.frequency, cv.float_range(min=1.0e-6))
# Schema default that also matches the C++ initializer in esp8266_pwm.h; codegen
# skips the setter when the config equals it.
DEFAULT_FREQUENCY = 1000.0
CONFIG_SCHEMA = cv.All(
output.FLOAT_OUTPUT_SCHEMA.extend(
{
@@ -29,7 +33,7 @@ CONFIG_SCHEMA = cv.All(
cv.Required(CONF_PIN): cv.All(
pins.internal_gpio_output_pin_schema, valid_pwm_pin
),
cv.Optional(CONF_FREQUENCY, default="1kHz"): validate_frequency,
cv.Optional(CONF_FREQUENCY, default=DEFAULT_FREQUENCY): validate_frequency,
}
).extend(cv.COMPONENT_SCHEMA),
cv.require_framework_version(
@@ -48,7 +52,9 @@ async def to_code(config: ConfigType) -> None:
pin = await cg.gpio_pin_expression(config[CONF_PIN])
cg.add(var.set_pin(pin))
cg.add(var.set_frequency(config[CONF_FREQUENCY]))
# Skip the setter when the config matches the C++ initializer (DEFAULT_FREQUENCY).
if (frequency := config[CONF_FREQUENCY]) != DEFAULT_FREQUENCY:
cg.add(var.set_frequency(frequency))
@automation.register_action(
+17 -13
View File
@@ -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.
+17 -5
View File
@@ -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"));
+6 -3
View File
@@ -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
+3 -1
View File
@@ -420,7 +420,9 @@ def _validate(config: ConfigType) -> ConfigType:
BASE_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(EthernetComponent),
cv.Optional(CONF_MANUAL_IP): MANUAL_IP_SCHEMA,
cv.Optional(
CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED
): MANUAL_IP_SCHEMA,
cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name,
cv.Optional(CONF_USE_ADDRESS): cv.string_strict,
cv.Optional(CONF_MAC_ADDRESS): cv.mac_address,
@@ -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() {
+12 -10
View File
@@ -42,7 +42,7 @@ from esphome.const import (
CONF_TYPE,
CONF_URL,
)
from esphome.core import CORE, HexInt
from esphome.core import HexInt
from esphome.cpp_generator import MockObj, MockObjClass
from esphome.external_files import RemoteFile
from esphome.types import ConfigType
@@ -76,16 +76,18 @@ def compute_local_image_path(value: str | ConfigType) -> Path:
return external_files.compute_local_file_path(DOMAIN, url)
def local_path(value: str | ConfigType) -> str:
value = value[CONF_PATH] if isinstance(value, dict) else value
return str(CORE.relative_config_path(value))
def local_path(value: Path | ConfigType) -> Path:
# cv.file_ has already resolved the path against the config dir.
return value[CONF_PATH] if isinstance(value, dict) else value
def download_file(url: str, path: Path) -> str:
def download_file(url: str, path: Path) -> Path:
# The shared NETWORK_TIMEOUT applies; a per-caller timeout would be
# silently ignored on a per-run memo hit anyway (memos key by path).
external_files.download_content(url, path)
return str(path)
# Keep the Path: config-hash normalizes Path values under the data dir,
# which a str would dump verbatim and break the CLI/add-on comparison.
return path
def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]:
@@ -93,13 +95,13 @@ def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]:
return MDI_SOURCES[source] + mdi_id + ".svg", base_dir / f"{mdi_id}.svg"
def download_gh_svg(value: str | ConfigType, source: str) -> str:
def download_gh_svg(value: str | ConfigType, source: str) -> Path:
mdi_id = value[CONF_ICON] if isinstance(value, dict) else value
url, path = _gh_svg_url_path(mdi_id, source)
return download_file(url, path)
def download_image(value: str | ConfigType) -> str:
def download_image(value: str | ConfigType) -> Path:
value = value[CONF_URL] if isinstance(value, dict) else value
return download_file(value, compute_local_image_path(value))
@@ -147,7 +149,7 @@ def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None:
PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref)
def validate_file_shorthand(value: Any) -> str:
def validate_file_shorthand(value: Any) -> Path:
value = cv.string_strict(value)
if (remote := _parse_remote_shorthand(value)) is not None:
return download_file(remote.url, remote.path)
@@ -165,7 +167,7 @@ LOCAL_SCHEMA = cv.All(
def mdi_schema(source: str) -> cv.All:
def validate_mdi(value: ConfigType) -> str:
def validate_mdi(value: ConfigType) -> Path:
return download_gh_svg(value, source)
return cv.All(
+6 -2
View File
@@ -15,9 +15,13 @@ CONFIG_SCHEMA = (
.extend(
{
cv.Required(CONF_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_INTERLOCK): cv.ensure_list(cv.use_id(switch.Switch)),
cv.Optional(
CONF_INTERLOCK_WAIT_TIME, default="0ms"
CONF_INTERLOCK, visibility=cv.Visibility.ADVANCED
): cv.ensure_list(cv.use_id(switch.Switch)),
cv.Optional(
CONF_INTERLOCK_WAIT_TIME,
default="0ms",
visibility=cv.Visibility.ADVANCED,
): cv.positive_time_period_milliseconds,
}
)
+11 -3
View File
@@ -340,6 +340,10 @@ RESTORE_MODES = {
"RESTORE_AND_ON": LightRestoreMode.LIGHT_RESTORE_AND_ON,
}
# Schema default that also matches the C++ initializer in light_state.h; codegen
# skips the setter when the config equals it.
DEFAULT_FLASH_TRANSITION_LENGTH = "0s"
LIGHT_SCHEMA = (
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
.extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA)
@@ -387,7 +391,7 @@ BRIGHTNESS_ONLY_LIGHT_SCHEMA = LIGHT_SCHEMA.extend(
CONF_DEFAULT_TRANSITION_LENGTH, default="1s"
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_FLASH_TRANSITION_LENGTH, default="0s"
CONF_FLASH_TRANSITION_LENGTH, default=DEFAULT_FLASH_TRANSITION_LENGTH
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_EFFECTS): validate_effects(MONOCHROMATIC_EFFECTS),
}
@@ -502,9 +506,12 @@ async def setup_light_core_(light_var, config, output_var):
default_transition_length := config.get(CONF_DEFAULT_TRANSITION_LENGTH)
) is not None:
cg.add(light_var.set_default_transition_length(default_transition_length))
# Skip the setter when the config matches the C++ initializer.
if (
flash_transition_length := config.get(CONF_FLASH_TRANSITION_LENGTH)
) is not None:
) is not None and flash_transition_length != cv.time_period(
DEFAULT_FLASH_TRANSITION_LENGTH
):
cg.add(light_var.set_flash_transition_length(flash_transition_length))
if (gamma_correct := config.get(CONF_GAMMA_CORRECT)) is not None:
cg.add(light_var.set_gamma_correct(gamma_correct))
@@ -514,7 +521,8 @@ async def setup_light_core_(light_var, config, output_var):
effects = await cg.build_registry_list(
EFFECTS_REGISTRY, config.get(CONF_EFFECTS, [])
)
cg.add(light_var.add_effects(effects))
if effects:
cg.add(light_var.add_effects(effects))
for conf in config.get(CONF_ON_TURN_ON, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], light_var)
+1 -1
View File
@@ -356,7 +356,7 @@ class LightState : public EntityBase, public Component {
/// Default transition length for all transitions in ms.
uint32_t default_transition_length_{};
/// Transition length to use for flash transitions.
uint32_t flash_transition_length_{};
uint32_t flash_transition_length_{}; // Keep in sync with DEFAULT_FLASH_TRANSITION_LENGTH in __init__.py
/// Gamma correction factor for the light.
float gamma_correct_{};
#ifdef USE_LIGHT_GAMMA_LUT
+7 -6
View File
@@ -362,12 +362,13 @@ async def to_code(config: ConfigType) -> None:
# pre_setup() switches on uart_ to decide which hardware to initialize
# (e.g. UART0 vs USB_SERIAL_JTAG). Without this, uart_ is still the
# default UART_SELECTION_UART0 and the wrong hardware gets initialized.
if CONF_HARDWARE_UART in config:
cg.add(
log.set_uart_selection(
HARDWARE_UART_TO_UART_SELECTION[config[CONF_HARDWARE_UART]]
)
)
# uart_ is UART0 in C++ except on LibreTiny where it is DEFAULT; skip the
# setter when the config matches it.
cpp_default_uart = DEFAULT if CORE.is_libretiny else UART0
if (
hardware_uart := config.get(CONF_HARDWARE_UART)
) is not None and hardware_uart != cpp_default_uart:
cg.add(log.set_uart_selection(HARDWARE_UART_TO_UART_SELECTION[hardware_uart]))
# pre_setup() sets global_logger and must run before any other code
# that may call ESP_LOG* (e.g. setup_preferences contains ESP_LOGVV).
cg.add(log.pre_setup())
+2 -2
View File
@@ -352,10 +352,10 @@ class Logger final : public Component {
// Group smaller types together at the end
uint8_t current_level_{ESPHOME_LOG_LEVEL_VERY_VERBOSE};
#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_ZEPHYR)
UARTSelection uart_{UART_SELECTION_UART0};
UARTSelection uart_{UART_SELECTION_UART0}; // Must match cpp_default_uart in __init__.py
#endif
#ifdef USE_LIBRETINY
UARTSelection uart_{UART_SELECTION_DEFAULT};
UARTSelection uart_{UART_SELECTION_DEFAULT}; // Must match cpp_default_uart in __init__.py
#endif
#if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR)
bool main_task_recursion_guard_{false};
+11 -4
View File
@@ -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\'"\]]*)?'
+2 -2
View File
@@ -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) {
+8 -8
View File
@@ -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;
}
+139 -19
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import Callable
import logging
from typing import Any, Literal, NamedTuple
@@ -48,6 +49,8 @@ ModbusServerDevice = modbus_ns.class_("ModbusServerDevice")
CommandOptions = modbus_ns.struct("CommandOptions")
MULTI_CONF = True
CONF_ALLOW_BROADCAST_READ = "allow_broadcast_read"
CONF_EXPECT_BROADCAST_WRITE_RESPONSE = "expect_broadcast_write_response"
CONF_ROLE = "role"
CONF_MODBUS_ID = "modbus_id"
CONF_SEND_WAIT_TIME = "send_wait_time"
@@ -56,6 +59,28 @@ CONF_TURNAROUND_TIME = "turnaround_time"
MODBUS_ROLES = ["client", "server"]
# The write (mutating) function codes, matching modbus::helpers::is_function_code_write(). 0x17
# (read/write multiple) is included: it mutates, so the hub treats it as a write despite its read half.
_WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17})
# Codes the hub refuses at address 0; keep in sync with modbus::helpers::is_function_code_broadcastable().
_NON_BROADCASTABLE_FUNCTION_CODES = frozenset(
{0x01, 0x02, 0x03, 0x04, 0x14, 0x15, 0x17, 0x18}
)
def is_function_code_write(function_code: int) -> bool:
"""True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first,
so an exception-flagged code still classifies by its base code (the runtime hub never queues one:
queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write()."""
return function_code & 0x7F in _WRITE_FUNCTION_CODES
def is_function_code_broadcastable(function_code: int) -> bool:
"""True if the hub accepts the function code at address 0 without allow_broadcast_read."""
return function_code & 0x7F not in _NON_BROADCASTABLE_FUNCTION_CODES
class _CommandOption(NamedTuple):
"""One per-command option forwarded to the hub (modbus::CommandOptions)."""
@@ -64,14 +89,47 @@ class _CommandOption(NamedTuple):
validator: Any # the static (non-templatable) validator for the key
cpp_type: Any # the C++ type the value is generated as
default: Any
# Function codes the hub honours the option on; it is stripped from any other.
applies_to: Callable[[int], bool]
requires_broadcast_address: bool = False
# Per-direction command options. Single-sourcing the schema and the setter generation here keeps
# them from drifting; the C++ side must add the matching field per the rules documented on
# CommandOptions (modbus.h).
def _not_write(function_code: int) -> bool:
return not is_function_code_write(function_code)
def _not_broadcastable(function_code: int) -> bool:
return not is_function_code_broadcastable(function_code)
# Per-direction command options, single-sourced so the schema, setters and applicability rule cannot
# drift; the C++ side adds the matching field per the rules on CommandOptions (modbus.h).
_COMMAND_OPTIONS: dict[str, list[_CommandOption]] = {
"read": [_CommandOption(CONF_CONTINUOUS, "continuous", cv.boolean, bool, False)],
"write": [],
"read": [
_CommandOption(
CONF_CONTINUOUS, "continuous", cv.boolean, bool, False, _not_write
),
_CommandOption(
CONF_ALLOW_BROADCAST_READ,
"allow_broadcast_read",
cv.boolean,
bool,
False,
_not_broadcastable,
requires_broadcast_address=True,
),
],
"write": [
_CommandOption(
CONF_EXPECT_BROADCAST_WRITE_RESPONSE,
"expect_broadcast_write_response",
cv.boolean,
bool,
False,
is_function_code_broadcastable,
requires_broadcast_address=True,
),
],
}
@@ -82,32 +140,75 @@ def _command_options(direction: str) -> list[_CommandOption]:
raise ValueError(f"unknown command-options direction {direction!r}") from None
# The write (mutating) function codes, matching modbus::helpers::is_function_code_write(). 0x17
# (read/write multiple) is included: it mutates, so the hub treats it as a write despite its read half.
_WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17})
def broadcast_only_option_keys() -> list[str]:
return [
option.conf_key
for options in _COMMAND_OPTIONS.values()
for option in options
if option.requires_broadcast_address
]
def is_function_code_write(function_code: int) -> bool:
"""True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first,
so an exception-flagged code still classifies by its base code (the runtime hub never queues one:
queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write()."""
return function_code & 0x7F in _WRITE_FUNCTION_CODES
def reject_broadcast_options_for_unicast(
address_key: str,
) -> Callable[[ConfigType], ConfigType]:
"""Reject a broadcast-only option set true on a literal address other than 0."""
def validator(config: ConfigType) -> ConfigType:
address = config.get(address_key)
if not isinstance(address, int) or address == BROADCAST_ADDRESS:
return config
for key in broadcast_only_option_keys():
if config.get(key) is True:
raise cv.Invalid(
f"'{key}' only applies to the broadcast address; set '{address_key}: 0' or "
f"remove the option.",
path=[key],
)
return config
return validator
def reject_inapplicable_command_options(
pdu_key: str,
) -> Callable[[ConfigType], ConfigType]:
"""Reject an option set true that the hub would strip from a literal PDU's function code."""
def validator(config: ConfigType) -> ConfigType:
pdu = config[pdu_key]
if not isinstance(pdu, list):
return config
for direction in _COMMAND_OPTIONS:
for option in _command_options(direction):
if config.get(option.conf_key) is True and not option.applies_to(
pdu[0]
):
raise cv.Invalid(
f"'{option.conf_key}: true' does not apply to function code "
f"0x{pdu[0]:02X}",
path=[option.conf_key],
)
return config
return validator
def command_options_schema(
*, direction: Literal["read", "write"], templatable: bool = False
*,
direction: Literal["read", "write"],
templatable: bool = False,
function_code: int | None = None,
) -> dict[cv.Optional, Any]:
"""Schema fragment for the per-command options a component forwards to the hub
(modbus::CommandOptions). Extend this into any schema that queues commands. Keys are
direction-specific so a schema never offers an option the hub would strip (e.g.
continuous on a write); the write side has no options yet. For actions (templatable=True the
keys also accept lambdas), register the values with register_templatable_command_options().
"""Schema fragment for the per-command options of one direction; `function_code` (a typed
action's fixed code) leaves out the options that do not apply to it.
"""
return {
cv.Optional(option.conf_key, default=option.default): (
cv.templatable(option.validator) if templatable else option.validator
)
for option in _command_options(direction)
if function_code is None or option.applies_to(function_code)
}
@@ -130,6 +231,25 @@ def command_options_expression(
)
def add_command_options(
var: MockObj,
setter: str,
config: ConfigType,
*,
direction: Literal["read", "write"],
) -> None:
"""Emit `var.<setter>(<options>)` for a config validated with command_options_schema() of the
same direction, skipped when every option is at its C++ default."""
if all(
config.get(option.conf_key, option.default) == option.default
for option in _command_options(direction)
):
return
cg.add(
getattr(var, setter)(command_options_expression(config, direction=direction))
)
async def register_templatable_command_options(
var: MockObj, config: ConfigType, args: TemplateArgsType, direction: str
) -> None:
+20 -6
View File
@@ -832,7 +832,7 @@ void ModbusClientHub::send_next_frame_() {
}
cmd->sent();
if (cmd->frame.address() == BROADCAST_ADDRESS) {
if (cmd->fire_and_forget()) {
// A broadcast (address 0) is never answered (Modbus 4.1), so it is fire-and-forget: on_sent above
// reports the transmission, and the entry then retires with no terminal callback instead of
// occupying the waiting slot until the send-wait timeout expires. The turnaround delay already
@@ -1074,11 +1074,6 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
return false;
}
if (address == BROADCAST_ADDRESS && !helpers::is_function_code_broadcastable(pdu[0])) {
ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]);
return false;
}
// Normalize the caller's options in place (the param is a by-value copy) so everything stored or
// merged below carries effective options, never the raw request.
// continuous is ignored for every mutating code (re-writing a value forever is never intended).
@@ -1086,6 +1081,24 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
ESP_LOGW(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address);
options.continuous = false;
}
if (address != BROADCAST_ADDRESS) {
options.allow_broadcast_read = false;
options.expect_broadcast_write_response = false;
} else {
const bool broadcastable = helpers::is_function_code_broadcastable(pdu[0]);
if (options.allow_broadcast_read && broadcastable) {
ESP_LOGV(TAG, "allow_broadcast_read is ignored for function 0x%X: it is broadcastable", pdu[0]);
options.allow_broadcast_read = false;
}
if (options.expect_broadcast_write_response && !broadcastable) {
ESP_LOGV(TAG, "expect_broadcast_write_response is ignored for function 0x%X: it is not broadcastable", pdu[0]);
options.expect_broadcast_write_response = false;
}
if (!broadcastable && !options.allow_broadcast_read) {
ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]);
return false;
}
}
// A duplicate of a live entry with the same owner is not queued twice; it resolves against that
// entry: anonymous -> dropped; continuous incoming -> convert the entry to a poll; one-shot onto a
@@ -1126,6 +1139,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", request absorbed (pending %" PRIu8 ")", address,
item.pending);
}
item.options.expect_broadcast_write_response |= options.expect_broadcast_write_response;
return true;
}
+23 -14
View File
@@ -111,11 +111,15 @@ enum class FrameState : uint8_t {
// Per-command send options. Append-only; pass via designated initializers ({.continuous = true}).
// A new field reaches the queue with no plumbing but arrives inert until it defines three rules:
// normalization in queue_pdu(), a merge rule for duplicate absorption, and teardown in
// retire()/silent_retire().
// retire()/silent_retire(). Bit-packed: stored per entry, controller and writer entity, passed by value.
struct CommandOptions {
// A continuous poll lives in the queue until cancelled or failed; ignored for mutating codes.
bool continuous{false};
bool continuous : 1 {false};
// Wait for the reply to a read sent to address 0, for a device that answers the broadcast address.
bool allow_broadcast_read : 1 {false};
bool expect_broadcast_write_response : 1 {false};
};
static_assert(sizeof(CommandOptions) == 1, "CommandOptions must stay one byte");
struct ModbusDeviceCommand {
ModbusClientDevice *device;
@@ -158,6 +162,10 @@ struct ModbusDeviceCommand {
this->pending = 0;
this->device = nullptr;
}
bool fire_and_forget() const {
return this->frame.address() == BROADCAST_ADDRESS && !this->options.allow_broadcast_read &&
!this->options.expect_broadcast_write_response;
}
// Fire-and-forget completion for a broadcast (address 0): the frame was transmitted (on_sent already
// fired), but a broadcast is never answered (Modbus 4.1), so the entry retires with no terminal callback.
void complete_broadcast() {
@@ -191,7 +199,8 @@ struct ModbusDeviceCommand {
} else if (!this->waiting_state()) { // an already-retired shell stays put; off the wire -> RETIRED
this->state = FrameState::RETIRED;
}
this->options = {}; // reset every option
// Only continuous ends with the clear; the delivery flags must survive for a granted retry.
this->options.continuous = false;
}
// True while the entry is still waiting for a response
@@ -534,27 +543,27 @@ class ModbusClientDevice {
return this->queue_pdu(
helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs), options);
}
bool write_single_register(uint16_t start_address, uint16_t value) {
return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value));
bool write_single_register(uint16_t start_address, uint16_t value, CommandOptions options = {}) {
return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value), options);
}
bool write_single_coil(uint16_t address, bool value) {
return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value));
bool write_single_coil(uint16_t address, bool value, CommandOptions options = {}) {
return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value), options);
}
bool write_multiple_registers(uint16_t start_address, std::span<const uint16_t> values) {
bool write_multiple_registers(uint16_t start_address, std::span<const uint16_t> values, CommandOptions options = {}) {
// Empty goes to the full-size builder so the rejection log names this method's limit, not the small one's.
if (!values.empty() && values.size() <= helpers::MAX_FEW_REGISTERS)
return this->queue_pdu(helpers::create_write_few_registers_pdu(start_address, values));
return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values));
return this->queue_pdu(helpers::create_write_few_registers_pdu(start_address, values), options);
return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values), options);
}
/// Note: std::vector<bool> cannot bind to std::span<const bool>; use a contiguous bool container or the packed
/// overload.
bool write_multiple_coils(uint16_t start_address, std::span<const bool> values) {
return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values));
bool write_multiple_coils(uint16_t start_address, std::span<const bool> values, CommandOptions options = {}) {
return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values), options);
}
/// Packed variant: a PackedBits view (the same layout on_read_coils() delivers), so
/// read-modify-write needs no unpack/repack.
bool write_multiple_coils(uint16_t start_address, PackedBits bits) {
return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits));
bool write_multiple_coils(uint16_t start_address, PackedBits bits, CommandOptions options = {}) {
return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits), options);
}
/// FC 0x17: the read-back is delivered through on_read_holding_registers(), and a device exception
/// (typically a rejected write half) arrives there too via its status - one callback handles both
+30 -26
View File
@@ -7,7 +7,6 @@ from esphome.components import modbus
import esphome.config_validation as cv
from esphome.const import (
CONF_ADDRESS,
CONF_CONTINUOUS,
CONF_COUNT,
CONF_ID,
CONF_ON_ERROR,
@@ -158,24 +157,6 @@ _ACTION_BASE_SCHEMA = cv.Schema(
)
def _no_continuous_on_write(config: ConfigType) -> ConfigType:
"""Reject `continuous: true` on a static write PDU: continuous polling only applies to reads.
Only the fully-static case is decidable here; the hub strips the flag from mutating PDUs at
runtime, so a templated pdu or continuous falls through to that backstop."""
pdu = config[CONF_PDU]
if (
isinstance(pdu, list)
and config.get(CONF_CONTINUOUS) is True
and modbus.is_function_code_write(pdu[0])
):
raise cv.Invalid(
f"'{CONF_CONTINUOUS}: true' does not apply to a write PDU (function code "
f"0x{pdu[0]:02X}); continuous polling only applies to reads",
path=[CONF_CONTINUOUS],
)
return config
MODBUS_CLIENT_SEND_SCHEMA = cv.All(
_ACTION_BASE_SCHEMA.extend(
{
@@ -186,10 +167,12 @@ MODBUS_CLIENT_SEND_SCHEMA = cv.All(
)
),
**modbus.command_options_schema(direction="read", templatable=True),
**modbus.command_options_schema(direction="write", templatable=True),
cv.Optional(CONF_ON_RESPONSE): _handler_schema(),
}
),
_no_continuous_on_write,
modbus.reject_inapplicable_command_options(CONF_PDU),
modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS),
)
@@ -261,8 +244,7 @@ async def register_client_action(
var.get_not_sent_trigger(), [(_PDU_SPAN, "request")], not_sent_conf
)
# Wire any command options the action's schema opted into (e.g. continuous on reads). Pass the
# matching direction so a write action never generates a read option's setter; the write side
# has no options yet, so this is a no-op there.
# matching direction so a write action never generates a read option's setter.
await modbus.register_templatable_command_options(
var, config, args, command_direction
)
@@ -279,6 +261,8 @@ async def modbus_client_send_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
template_ = await cg.templatable(config[CONF_PDU], args, _PDU_BUFFER)
cg.add(var.set_pdu(template_))
# The read set is wired by register_client_action() below.
await modbus.register_templatable_command_options(var, config, args, "write")
return await register_client_action(
var,
config,
@@ -353,6 +337,7 @@ def _read_schema(max_count: int) -> cv.All:
}
),
_no_address_overflow(CONF_COUNT),
modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS),
)
@@ -364,21 +349,35 @@ def _write_multiple_schema(item: Callable[[Any], Any], max_values: int) -> cv.Al
cv.Required(CONF_VALUES): cv.templatable(
cv.All(cv.ensure_list(item), cv.Length(min=1, max=max_values))
),
**modbus.command_options_schema(direction="write", templatable=True),
}
),
_no_address_overflow(CONF_VALUES),
modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS),
)
_READ_REGISTERS_SCHEMA = _read_schema(modbus.MAX_NUM_OF_REGISTERS_TO_READ)
_WRITE_SINGLE_REGISTER_SCHEMA = _TYPED_ACTION_SCHEMA.extend(
{cv.Required(CONF_VALUE): cv.templatable(cv.hex_uint16_t)}
_WRITE_SINGLE_REGISTER_SCHEMA = cv.All(
_TYPED_ACTION_SCHEMA.extend(
{
cv.Required(CONF_VALUE): cv.templatable(cv.hex_uint16_t),
**modbus.command_options_schema(direction="write", templatable=True),
}
),
modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS),
)
# A coil is one bit, so the value is a boolean - the wire only carries 0x0000 or 0xFF00.
_WRITE_SINGLE_COIL_SCHEMA = _TYPED_ACTION_SCHEMA.extend(
{cv.Required(CONF_VALUE): cv.templatable(cv.boolean)}
_WRITE_SINGLE_COIL_SCHEMA = cv.All(
_TYPED_ACTION_SCHEMA.extend(
{
cv.Required(CONF_VALUE): cv.templatable(cv.boolean),
**modbus.command_options_schema(direction="write", templatable=True),
}
),
modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS),
)
@@ -542,10 +541,15 @@ _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA = cv.All(
cv.Length(min=1, max=modbus.MAX_NUM_OF_REGISTERS_TO_WRITE_RW),
)
),
# 0x17 counts as a read at address 0, so it takes allow_broadcast_read only.
**modbus.command_options_schema(
direction="read", templatable=True, function_code=0x17
),
}
),
_no_address_overflow(CONF_READ_COUNT, CONF_READ_ADDRESS),
_no_address_overflow(CONF_VALUES, CONF_WRITE_ADDRESS),
modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS),
)
@@ -85,18 +85,36 @@ template<typename... Ts> class ClientActionBase : public Action<Ts...>, public m
/// builds its static struct; declaring the values here instead of per action means a new read option
/// costs one TEMPLATABLE_VALUE plus one field below, and every read action picks it up.
/// The read/write split mirrors _COMMAND_OPTIONS in the modbus component's Python
/// (command_options_schema(direction="read") adds exactly these keys). When a write-side option
/// arrives it gets a WriteCommandOptions twin, so write actions never carry read-only members.
/// (command_options_schema(direction="read") adds exactly these keys); WriteCommandOptions is the twin.
template<typename... Ts> class ReadCommandOptions {
public:
// Poll: re-queue after each success until downgraded (replay with false) or failed. The hub strips
// it for mutating function codes at the door (see modbus::CommandOptions).
TEMPLATABLE_VALUE(bool, continuous)
TEMPLATABLE_VALUE(bool, allow_broadcast_read)
protected:
/// The options for this send, with every templatable value resolved against the action's arguments.
modbus::CommandOptions command_options_(const Ts &...x) const {
return {.continuous = this->continuous_.value(x...)};
return {.continuous = this->continuous_.value(x...),
.allow_broadcast_read = this->allow_broadcast_read_.value(x...)};
}
};
/// The write-side per-command options (command_options_schema(direction="write") adds exactly these keys).
template<typename... Ts> class WriteCommandOptions {
public:
TEMPLATABLE_VALUE(bool, expect_broadcast_write_response)
protected:
/// Resolves every write option into `options`, so send's merge of both sets stays exhaustive.
void apply_write_command_options_(modbus::CommandOptions &options, const Ts &...x) const {
options.expect_broadcast_write_response = this->expect_broadcast_write_response_.value(x...);
}
modbus::CommandOptions write_command_options_(const Ts &...x) const {
modbus::CommandOptions options{};
this->apply_write_command_options_(options, x...);
return options;
}
};
@@ -107,8 +125,11 @@ template<typename... Ts> class ReadCommandOptions {
/// modbus::helpers::create_*_pdu() builders and return it directly (smaller builder results convert).
/// A PduBuffer drops bytes past modbus::MAX_PDU_SIZE without reporting it (the hub's oversize check
/// cannot fire - that limit is the capacity), so an over-long lambda-built PDU is silently truncated.
/// A raw PDU may be a read or a write, so this action carries both option sets.
template<typename... Ts>
class ModbusClientSendAction : public ClientActionBase<Ts...>, public ReadCommandOptions<Ts...> {
class ModbusClientSendAction : public ClientActionBase<Ts...>,
public ReadCommandOptions<Ts...>,
public WriteCommandOptions<Ts...> {
public:
TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu)
@@ -116,7 +137,11 @@ class ModbusClientSendAction : public ClientActionBase<Ts...>, public ReadComman
return &this->response_trigger_;
}
void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(x...), this->command_options_(x...)); }
void play(const Ts &...x) override {
modbus::CommandOptions options = this->command_options_(x...);
this->apply_write_command_options_(options, x...);
this->send_or_resolve_(this->pdu_.value(x...), options);
}
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
this->response_trigger_.trigger(request_pdu, response_pdu);
@@ -218,7 +243,8 @@ template<typename... Ts> class ReadBitsAction : public TypedClientActionBase<Ts.
/// modbus_client.write_single_register: on_response is the acknowledgement (the ack only echoes the
/// request, so it carries no arguments).
template<typename... Ts> class WriteSingleRegisterAction : public TypedClientActionBase<Ts...> {
template<typename... Ts>
class WriteSingleRegisterAction : public TypedClientActionBase<Ts...>, public WriteCommandOptions<Ts...> {
public:
TEMPLATABLE_VALUE(uint16_t, start_address)
TEMPLATABLE_VALUE(uint16_t, value)
@@ -227,7 +253,8 @@ template<typename... Ts> class WriteSingleRegisterAction : public TypedClientAct
void play(const Ts &...x) override {
this->send_or_resolve_(
modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...)));
modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...)),
this->write_command_options_(x...));
}
void on_write_single_register(uint16_t address, uint16_t value, modbus::ResponseStatus status) override {
if (modbus::succeeded(status))
@@ -240,7 +267,8 @@ template<typename... Ts> class WriteSingleRegisterAction : public TypedClientAct
/// modbus_client.write_single_coil: on_response is the acknowledgement (no arguments). A coil holds one
/// bit, so the value is a bool - the wire only ever carries 0x0000 or 0xFF00.
template<typename... Ts> class WriteSingleCoilAction : public TypedClientActionBase<Ts...> {
template<typename... Ts>
class WriteSingleCoilAction : public TypedClientActionBase<Ts...>, public WriteCommandOptions<Ts...> {
public:
TEMPLATABLE_VALUE(uint16_t, start_address)
TEMPLATABLE_VALUE(bool, value)
@@ -249,7 +277,8 @@ template<typename... Ts> class WriteSingleCoilAction : public TypedClientActionB
void play(const Ts &...x) override {
this->send_or_resolve_(
modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...)));
modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...)),
this->write_command_options_(x...));
}
void on_write_single_coil(uint16_t address, bool value, modbus::ResponseStatus status) override {
if (modbus::succeeded(status))
@@ -264,7 +293,8 @@ template<typename... Ts> class WriteSingleCoilAction : public TypedClientActionB
/// A `values:` list is emitted as a flash array and sent straight from there; only a lambda builds a
/// vector, and only when it runs. Same split as canbus's send action, and for the same reason: a static
/// list must not allocate on every play().
template<typename... Ts> class WriteMultipleRegistersAction : public TypedClientActionBase<Ts...> {
template<typename... Ts>
class WriteMultipleRegistersAction : public TypedClientActionBase<Ts...>, public WriteCommandOptions<Ts...> {
public:
TEMPLATABLE_VALUE(uint16_t, start_address)
@@ -288,11 +318,13 @@ template<typename... Ts> class WriteMultipleRegistersAction : public TypedClient
// the empty PDU then resolves via on_not_sent like any refused send.
if (this->len_ >= 0) {
this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(
start, std::span<const uint16_t>(this->values_.data, static_cast<size_t>(this->len_))));
start, std::span<const uint16_t>(this->values_.data, static_cast<size_t>(this->len_))),
this->write_command_options_(x...));
return;
}
const std::vector<uint16_t> values = this->values_.func(x...);
this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span<const uint16_t>(values)));
this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span<const uint16_t>(values)),
this->write_command_options_(x...));
}
void on_write_multiple_registers(uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) override {
@@ -313,7 +345,8 @@ template<typename... Ts> class WriteMultipleRegistersAction : public TypedClient
/// A `values:` list is packed into wire layout at code-generation time and stored in flash, so play()
/// neither allocates nor packs. A lambda returns std::vector<bool> - already a bit per coil rather than
/// a byte - and is packed into a stack buffer on the way to the builder.
template<typename... Ts> class WriteMultipleCoilsAction : public TypedClientActionBase<Ts...> {
template<typename... Ts>
class WriteMultipleCoilsAction : public TypedClientActionBase<Ts...>, public WriteCommandOptions<Ts...> {
public:
TEMPLATABLE_VALUE(uint16_t, start_address)
@@ -334,13 +367,16 @@ template<typename... Ts> class WriteMultipleCoilsAction : public TypedClientActi
const uint16_t start = this->start_address_.value(x...);
if (this->count_ >= 0) {
const auto count = static_cast<uint16_t>(this->count_);
this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(
start,
modbus::PackedBits(std::span<const uint8_t>(this->values_.packed, modbus::packed_bit_bytes(count)), count)));
this->send_or_resolve_(
modbus::helpers::create_write_coils_pdu(
start, modbus::PackedBits(std::span<const uint8_t>(this->values_.packed, modbus::packed_bit_bytes(count)),
count)),
this->write_command_options_(x...));
return;
}
// The builder packs and bound-checks; an over-long set is rejected and logged there.
this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, this->values_.func(x...)));
this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, this->values_.func(x...)),
this->write_command_options_(x...));
}
void on_write_multiple_coils(uint16_t start_address, modbus::PackedBits bits,
modbus::ResponseStatus status) override {
@@ -359,7 +395,8 @@ template<typename... Ts> class WriteMultipleCoilsAction : public TypedClientActi
/// modbus_client.read_write_multiple_registers (FC 0x17): writes one register block and reads another back in
/// one transaction (write first, per Modbus 6.17). on_response delivers the read-back words as `values`.
template<typename... Ts> class ReadWriteMultipleRegistersAction : public TypedClientActionBase<Ts...> {
template<typename... Ts>
class ReadWriteMultipleRegistersAction : public TypedClientActionBase<Ts...>, public ReadCommandOptions<Ts...> {
public:
TEMPLATABLE_VALUE(uint16_t, read_address)
TEMPLATABLE_VALUE(uint16_t, read_count)
@@ -385,13 +422,15 @@ template<typename... Ts> class ReadWriteMultipleRegistersAction : public TypedCl
// An out-of-range read/write count builds an empty PDU (the builder logs why), resolving via on_not_sent.
if (this->len_ >= 0) {
this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu(
read_start, read_count, write_start,
std::span<const uint16_t>(this->values_.data, static_cast<size_t>(this->len_))));
read_start, read_count, write_start,
std::span<const uint16_t>(this->values_.data, static_cast<size_t>(this->len_))),
this->command_options_(x...));
return;
}
const std::vector<uint16_t> values = this->values_.func(x...);
this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu(
read_start, read_count, write_start, std::span<const uint16_t>(values)));
read_start, read_count, write_start, std::span<const uint16_t>(values)),
this->command_options_(x...));
}
// The 0x17 response carries only the read block, so the hub dispatch delivers it as a holding-register read.
void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span<const uint16_t> registers,
@@ -103,12 +103,20 @@ def _warn_removed_options(config: ConfigType) -> ConfigType:
def _reject_broadcast_address(config: ConfigType) -> ConfigType:
"""A modbus_controller polls one device, so its address cannot be the broadcast address (0):
a broadcast is never answered (Modbus 4.1), so no register could ever read back."""
"""Address 0 is rejected unless allow_broadcast_read, which in turn requires address 0."""
if config[modbus.CONF_ALLOW_BROADCAST_READ]:
if config.get(CONF_ADDRESS) != modbus.BROADCAST_ADDRESS:
raise cv.Invalid(
f"'{modbus.CONF_ALLOW_BROADCAST_READ}' only applies to the broadcast address; "
f"set 'address: 0' or remove the option.",
[modbus.CONF_ALLOW_BROADCAST_READ],
)
return config
modbus.reject_broadcast_address(
config.get(CONF_ADDRESS),
"a modbus_controller device address",
"Assign the unit address of the device you want to poll.",
"Assign the unit address of the device you want to poll, or set allow_broadcast_read if "
"it answers address 0.",
[CONF_ADDRESS],
)
return config
@@ -346,12 +354,52 @@ def _reject_continuous_write_custom_pdu(config: ConfigType) -> None:
)
def _reject_broadcastable_custom_pdu(config: ConfigType) -> None:
"""A broadcastable custom_pdu under an address-0 controller is a real broadcast, never answered."""
pdu = config.get(CONF_CUSTOM_PDU)
if pdu is None or not modbus.is_function_code_broadcastable(pdu[0]):
return
fconf = fv.full_config.get()
path = fconf.get_path_for_id(config[CONF_MODBUS_CONTROLLER_ID])[:-1]
controller = fconf.get_config_for_path(path)
if (
controller.get(CONF_ADDRESS) == modbus.BROADCAST_ADDRESS
and controller.get(modbus.CONF_ALLOW_BROADCAST_READ) is True
):
raise cv.Invalid(
f"a '{CONF_CUSTOM_PDU}' with function code 0x{pdu[0] & 0x7F:02X} is a real broadcast at "
f"address 0 and is never answered, so it can't be polled through the "
f"'{controller[CONF_ID]}' modbus_controller; use a read function code.",
[CONF_CUSTOM_PDU],
)
def validate_custom_pdu_item(config: ConfigType) -> None:
"""Final-validate for the read platforms that accept custom_pdu (sensor, binary_sensor,
text_sensor): migrate the deprecated custom_command, then reject a write-coded custom_pdu under a
continuously-polling controller."""
"""Final-validate for the platforms that accept custom_pdu."""
migrate_custom_command(config)
_reject_continuous_write_custom_pdu(config)
_reject_broadcastable_custom_pdu(config)
def _reject_write_option_off_broadcast(config: ConfigType) -> None:
if not any(config.get(key) is True for key in modbus.broadcast_only_option_keys()):
return
fconf = fv.full_config.get()
path = fconf.get_path_for_id(config[CONF_MODBUS_CONTROLLER_ID])[:-1]
controller = fconf.get_config_for_path(path)
if controller.get(CONF_ADDRESS) != modbus.BROADCAST_ADDRESS:
raise cv.Invalid(
f"'{modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE}' only applies when the "
f"'{controller[CONF_ID]}' modbus_controller is at address 0; remove the option.",
[modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE],
)
def validate_writer_item(config: ConfigType) -> None:
"""Final-validate for the writer platforms (number, output, select, switch)."""
if CONF_CUSTOM_PDU in config or CONF_CUSTOM_COMMAND in config:
validate_custom_pdu_item(config)
_reject_write_option_off_broadcast(config)
def _final_validate(config: ConfigType) -> None:
@@ -448,11 +496,7 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES]))
cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES]))
cg.add(
var.set_read_options(
modbus.command_options_expression(config, direction="read")
)
)
modbus.add_command_options(var, "set_read_options", config, direction="read")
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
@@ -24,7 +24,7 @@ void WriterDevice::warn_write_buffer_deprecated(const LogString *platform, uint1
bool WriterDevice::send_raw_frame_deprecated(std::span<const uint8_t> frame) {
if (frame.empty())
return false;
return this->parent_->queue_pdu(frame[0], frame.subspan(1), this);
return this->parent_->queue_pdu(frame[0], frame.subspan(1), this, this->write_options_);
}
void ControllerDevice::set_controller(ModbusController *controller) {
@@ -234,10 +234,13 @@ void ModbusCommandItem::on_sent(std::span<const uint8_t> request_pdu) {
// (frame[0]), which may differ from this controller's. (unqueue_command() is a no-op for a poll.)
// A custom polling command sends its PDU to this controller's own address, so only a factory custom
// command (a raw frame staged in payload) can carry a different address byte.
// An address-0 read with allow_broadcast_read is answered, so it keeps its terminal callback.
uint8_t wire_address = this->address_;
if (this->function_code_ == FunctionCode::CUSTOM && !this->payload.empty())
wire_address = this->payload.data()[0];
if (wire_address == modbus::BROADCAST_ADDRESS)
const bool answered = this->controller_->read_options().allow_broadcast_read &&
!modbus::helpers::is_function_code_broadcastable(request_pdu[0]);
if (wire_address == modbus::BROADCAST_ADDRESS && !answered)
this->controller_->unqueue_command(this);
}
@@ -285,8 +288,8 @@ void ModbusController::queue_command(ModbusCommandItem command) {
this->one_shot_command_items_.push_back(make_unique<ModbusCommandItem>(std::move(command)));
// A refused frame gets no terminal callback (see the hub contract), so reclaim the item here.
auto &item = this->one_shot_command_items_.back();
// We intentionally do not pass read_options_ here, because one-shot commands are usually writes, and are non-polling.
if (!item->send()) {
// One-shots never poll, so only the broadcast flag is passed (the hub strips it from writes).
if (!item->send({.allow_broadcast_read = this->read_options_.allow_broadcast_read})) {
// The caller (e.g. a write entity) has usually already published optimistically - surface the loss.
ESP_LOGW(TAG, "Command refused by hub: type=0x%X address=0x%X", static_cast<uint8_t>(item->register_type()),
item->register_address());
@@ -340,7 +343,7 @@ void ModbusController::update() {
if (this->can_send()) {
for (auto &poll : this->polling_devices_) {
ESP_LOGVV(TAG, "Updating range 0x%X", poll.register_address());
// read_options_ carries the controller's continuous flag (the offline probe above sends it too).
// read_options_ carries the controller's read-side flags (the offline probe above sends them too).
// A refusal is already logged by the hub; note the affected range for controller-level diagnostics.
if (!poll.queue(this->read_options_)) {
ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", poll.register_address());
@@ -280,10 +280,11 @@ class ControllerDevice : protected modbus::ModbusClientDevice {
void notify_online_(std::span<const uint8_t> request_pdu);
/// Write-path state owned by WriterEntity's forwarders, stored here so both bools land in the base's
/// tail padding instead of adding a word to every writer entity. The warn flag leaves in 2027.3.0.
bool dispatched_{false};
bool write_buffer_deprecated_warned_{false};
/// Write-path state for WriterEntity's forwarders, packed into the base's tail padding. The warn flag
/// leaves in 2027.3.0.
bool dispatched_ : 1 {false};
bool write_buffer_deprecated_warned_ : 1 {false};
modbus::CommandOptions write_options_{};
ModbusController *controller_{nullptr};
};
@@ -305,6 +306,8 @@ class WriterDevice final : public ControllerDevice {
bool dispatched() const { return this->dispatched_; }
void set_dispatched() { this->dispatched_ = true; }
void clear_dispatched() { this->dispatched_ = false; }
modbus::CommandOptions write_options() const { return this->write_options_; }
void set_write_options(modbus::CommandOptions options) { this->write_options_ = options; }
/// Warn once per entity that filling the write_lambda buffer parameter is deprecated (the entity is now the
/// command - call a write helper / queue_pdu() on `item` instead). The buffer parameter is removed in 2027.3.0.
void warn_write_buffer_deprecated(const LogString *platform, uint16_t address);
@@ -326,27 +329,29 @@ class WriterEntity {
/// Whether the lambda called a request helper since the last clear_dispatched_(). Deliberately records
/// the call, not the hub's accept/refuse: a refused lambda write must not fall through to the default write.
bool dispatched() const { return this->device_.dispatched(); }
void set_write_options(modbus::CommandOptions options) { this->device_.set_write_options(options); }
bool write_single_register(uint16_t address, uint16_t value) {
this->device_.set_dispatched();
return this->device_.write_single_register(address, value);
return this->device_.write_single_register(address, value, this->device_.write_options());
}
bool write_single_coil(uint16_t address, bool value) {
this->device_.set_dispatched();
return this->device_.write_single_coil(address, value);
return this->device_.write_single_coil(address, value, this->device_.write_options());
}
bool write_multiple_registers(uint16_t address, std::span<const uint16_t> values) {
this->device_.set_dispatched();
return this->device_.write_multiple_registers(address, values);
return this->device_.write_multiple_registers(address, values, this->device_.write_options());
}
bool write_multiple_coils(uint16_t address, std::span<const bool> values) {
this->device_.set_dispatched();
return this->device_.write_multiple_coils(address, values);
return this->device_.write_multiple_coils(address, values, this->device_.write_options());
}
bool write_multiple_coils(uint16_t address, modbus::PackedBits bits) {
this->device_.set_dispatched();
return this->device_.write_multiple_coils(address, bits);
return this->device_.write_multiple_coils(address, bits, this->device_.write_options());
}
bool queue_pdu(std::span<const uint8_t> pdu, modbus::CommandOptions options = {}) {
bool queue_pdu(std::span<const uint8_t> pdu) { return this->queue_pdu(pdu, this->device_.write_options()); }
bool queue_pdu(std::span<const uint8_t> pdu, modbus::CommandOptions options) {
this->device_.set_dispatched();
return this->device_.queue_pdu(pdu, options);
}
@@ -1,5 +1,5 @@
import esphome.codegen as cg
from esphome.components import number
from esphome.components import modbus, number
from esphome.components.modbus.helpers import (
MODBUS_WRITE_REGISTER_TYPE,
SENSOR_VALUE_TYPE,
@@ -23,8 +23,8 @@ from .. import (
add_modbus_base_properties,
modbus_calc_properties,
modbus_controller_ns,
validate_custom_pdu_item,
validate_range_reuse_migration,
validate_writer_item,
)
from ..const import (
CONF_BITMASK,
@@ -84,6 +84,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_STEP, default=1): cv.positive_float,
cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_,
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
**modbus.command_options_schema(direction="write"),
}
),
validate_min_max,
@@ -91,7 +92,7 @@ CONFIG_SCHEMA = cv.All(
validate_range_reuse_migration,
)
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
FINAL_VALIDATE_SCHEMA = validate_writer_item
async def to_code(config: ConfigType) -> None:
@@ -122,6 +123,7 @@ async def to_code(config: ConfigType) -> None:
cg.add(parent.add_sensor_item(var))
await add_modbus_base_properties(var, config, ModbusNumber)
cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE]))
modbus.add_command_options(var, "set_write_options", config, direction="write")
if CONF_WRITE_LAMBDA in config:
template_ = await cg.process_lambda(
config[CONF_WRITE_LAMBDA],
@@ -1,7 +1,7 @@
import logging
import esphome.codegen as cg
from esphome.components import output
from esphome.components import modbus, output
from esphome.components.modbus.helpers import (
SENSOR_VALUE_TYPE,
PduBuffer,
@@ -18,6 +18,7 @@ from .. import (
modbus_calc_properties,
modbus_controller_ns,
reject_odd_holding_write_offset,
validate_writer_item,
)
from ..const import (
CONF_CUSTOM_COMMAND,
@@ -79,6 +80,7 @@ CONFIG_SCHEMA = cv.All(
),
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
**modbus.command_options_schema(direction="write"),
}
),
"holding": cv.All(
@@ -98,6 +100,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_,
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
**modbus.command_options_schema(direction="write"),
}
),
reject_odd_holding_write_offset,
@@ -111,6 +114,9 @@ CONFIG_SCHEMA = cv.All(
)
FINAL_VALIDATE_SCHEMA = validate_writer_item
async def to_code(config: ConfigType) -> None:
byte_offset = modbus_calc_properties(config)
# Binary Output
@@ -153,6 +159,7 @@ async def to_code(config: ConfigType) -> None:
await output.register_output(var, config)
parent = await cg.get_variable(config[CONF_MODBUS_CONTROLLER_ID])
cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE]))
modbus.add_command_options(var, "set_write_options", config, direction="write")
cg.add(var.set_parent(parent))
if write_template:
cg.add(var.set_write_template(write_template))
@@ -2,7 +2,7 @@ from collections.abc import Callable
from typing import Any
import esphome.codegen as cg
from esphome.components import select
from esphome.components import modbus, select
from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE, RegisterValues
import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC
@@ -15,6 +15,7 @@ from .. import (
modbus_controller_ns,
validate_range_reuse_migration,
validate_skip_updates_deprecated,
validate_writer_item,
)
from ..const import (
CONF_FORCE_NEW_RANGE,
@@ -77,6 +78,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_REGISTER_COUNT): cv.positive_int,
cv.Required(CONF_OPTIONSMAP): ensure_option_map(),
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
**modbus.command_options_schema(direction="write"),
cv.Optional(CONF_OPTIMISTIC, default=False): cv.boolean,
cv.Optional(CONF_LAMBDA): cv.returning_lambda,
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
@@ -86,6 +88,9 @@ CONFIG_SCHEMA = cv.All(
)
FINAL_VALIDATE_SCHEMA = validate_writer_item
async def to_code(config: ConfigType) -> None:
options_map = config[CONF_OPTIONSMAP]
@@ -104,6 +109,7 @@ async def to_code(config: ConfigType) -> None:
cg.add(parent.add_sensor_item(var))
cg.add(var.set_parent(parent))
cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE]))
modbus.add_command_options(var, "set_write_options", config, direction="write")
cg.add(var.set_optimistic(config[CONF_OPTIMISTIC]))
if CONF_LAMBDA in config:
@@ -1,5 +1,5 @@
import esphome.codegen as cg
from esphome.components import switch
from esphome.components import modbus, switch
from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE, PduBuffer
import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS, CONF_ASSUMED_STATE, CONF_ID
@@ -13,9 +13,9 @@ from .. import (
modbus_calc_properties,
modbus_controller_ns,
reject_odd_holding_write_offset,
validate_custom_pdu_item,
validate_modbus_register,
validate_range_reuse_migration,
validate_writer_item,
)
from ..const import (
CONF_BITMASK,
@@ -51,6 +51,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_ASSUMED_STATE, default=False): cv.boolean,
cv.Optional(CONF_REGISTER_TYPE): cv.enum(MODBUS_REGISTER_TYPE),
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
**modbus.command_options_schema(direction="write"),
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
}
),
@@ -59,7 +60,7 @@ CONFIG_SCHEMA = cv.All(
validate_range_reuse_migration,
)
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
FINAL_VALIDATE_SCHEMA = validate_writer_item
async def to_code(config: ConfigType) -> None:
@@ -78,6 +79,7 @@ async def to_code(config: ConfigType) -> None:
paren = await cg.get_variable(config[CONF_MODBUS_CONTROLLER_ID])
cg.add(var.set_parent(paren))
cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE]))
modbus.add_command_options(var, "set_write_options", config, direction="write")
assumed_state = config[CONF_ASSUMED_STATE]
cg.add(var.set_assumed_state(assumed_state))
if not assumed_state:
+12 -6
View File
@@ -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:
+1
View File
@@ -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();
}
}
+9 -5
View File
@@ -174,6 +174,10 @@ NumberInRangeCondition = number_ns.class_(
NumberMode = number_ns.enum("NumberMode")
# Schema default that also matches the C++ initializer in number_traits.h; codegen
# skips the setter when the config equals it.
DEFAULT_MODE = "AUTO"
NUMBER_MODES = {
"AUTO": NumberMode.NUMBER_MODE_AUTO,
"BOX": NumberMode.NUMBER_MODE_BOX,
@@ -216,7 +220,7 @@ _NUMBER_SCHEMA = (
CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED
): validate_unit_of_measurement,
cv.Optional(
CONF_MODE, default="AUTO", visibility=cv.Visibility.ADVANCED
CONF_MODE, default=DEFAULT_MODE, visibility=cv.Visibility.ADVANCED
): cv.enum(NUMBER_MODES, upper=True),
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
@@ -286,10 +290,10 @@ async def setup_number_core_(
cg.add(var.traits.set_max_value(max_value))
cg.add(var.traits.set_step(step))
# Only set if non-default to avoid bloating setup() function
# (mode_ is initialized to NUMBER_MODE_AUTO in the header)
if config[CONF_MODE] != NumberMode.NUMBER_MODE_AUTO:
cg.add(var.traits.set_mode(config[CONF_MODE]))
# Skip the setter when the config matches the C++ initializer (DEFAULT_MODE).
# The validated value is the enum key string, not the C++ enum expression.
if (mode := config[CONF_MODE]) != DEFAULT_MODE:
cg.add(var.traits.set_mode(mode))
CORE.add_job(_build_number_automations, var, config)
+1 -1
View File
@@ -31,7 +31,7 @@ class NumberTraits {
float min_value_ = NAN;
float max_value_ = NAN;
float step_ = NAN;
NumberMode mode_{NUMBER_MODE_AUTO};
NumberMode mode_{NUMBER_MODE_AUTO}; // Keep in sync with DEFAULT_MODE in __init__.py
};
} // namespace esphome::number
+9 -4
View File
@@ -53,12 +53,17 @@ async def setup_output_platform_(obj, config):
if CONF_POWER_SUPPLY in config:
power_supply_ = await cg.get_variable(config[CONF_POWER_SUPPLY])
cg.add(obj.set_power_supply(power_supply_))
if CONF_MAX_POWER in config:
# The C++ initializers are max_power 1.0 and min_power 0.0; skip the setter when
# the config matches them. The define stays whenever the key is present because
# platforms such as ac_dimmer read the scaling fields directly.
if (max_power := config.get(CONF_MAX_POWER)) is not None:
cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING")
cg.add(obj.set_max_power(config[CONF_MAX_POWER]))
if CONF_MIN_POWER in config:
if max_power != 1.0:
cg.add(obj.set_max_power(max_power))
if (min_power := config.get(CONF_MIN_POWER)) is not None:
cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING")
cg.add(obj.set_min_power(config[CONF_MIN_POWER]))
if min_power != 0.0:
cg.add(obj.set_min_power(min_power))
# Only emit when zero_means_zero is actually enabled. The schema defaults to False
# so this key is always present; emitting unconditionally would force
# USE_OUTPUT_FLOAT_POWER_SCALING on for every output, defeating the gate.
+1
View File
@@ -123,6 +123,7 @@ class FloatOutput : public BinaryOutput {
virtual void write_state(float state) = 0;
#ifdef USE_OUTPUT_FLOAT_POWER_SCALING
// Codegen skips the setters for these values; keep in sync with output/__init__.py
float max_power_{1.0f};
float min_power_{0.0f};
bool zero_means_zero_{false};
+5 -1
View File
@@ -88,7 +88,11 @@ void PMSA003IComponent::update() {
bool PMSA003IComponent::read_data_(PM25AQIData *data) {
uint8_t buffer[COUNT_DATA_BYTES];
this->read_bytes_raw(buffer, COUNT_DATA_BYTES);
const i2c::ErrorCode error = this->read(buffer, COUNT_DATA_BYTES);
if (error != i2c::ERROR_OK) {
ESP_LOGW(TAG, "I2C error %d", error);
return false;
}
// https://github.com/adafruit/Adafruit_PM25AQI
+17 -5
View File
@@ -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 ***");
+2 -1
View File
@@ -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
+3 -1
View File
@@ -344,7 +344,9 @@ _SENSOR_SCHEMA = (
cv.requires_component("mqtt"),
cv.Any(None, cv.positive_time_period_milliseconds),
),
cv.Optional(CONF_FILTERS): validate_filters,
cv.Optional(
CONF_FILTERS, visibility=cv.Visibility.ADVANCED
): validate_filters,
cv.Optional(CONF_ON_VALUE): automation.validate_automation({}),
cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}),
cv.Optional(CONF_ON_VALUE_RANGE): automation.validate_automation(
@@ -1,6 +1,8 @@
#include "template_water_heater.h"
#include "esphome/core/log.h"
#include <cmath>
namespace esphome::template_ {
static const char *const TAG = "template.water_heater";
@@ -45,9 +47,12 @@ water_heater::WaterHeaterTraits TemplateWaterHeater::traits() {
void TemplateWaterHeater::loop() {
bool changed = false;
// NAN is passed through so a source that has no value yet shows as unknown, but NAN never
// equals NAN, so an already-NAN value must not count as a change or it would republish forever.
auto curr_temp = this->current_temperature_f_.call();
if (curr_temp.has_value()) {
if (*curr_temp != this->current_temperature_) {
if (*curr_temp != this->current_temperature_ &&
!(std::isnan(*curr_temp) && std::isnan(this->current_temperature_))) {
this->current_temperature_ = *curr_temp;
changed = true;
}
@@ -55,7 +60,8 @@ void TemplateWaterHeater::loop() {
auto target_temp = this->target_temperature_f_.call();
if (target_temp.has_value()) {
if (*target_temp != this->target_temperature_) {
if (*target_temp != this->target_temperature_ &&
!(std::isnan(*target_temp) && std::isnan(this->target_temperature_))) {
this->target_temperature_ = *target_temp;
changed = true;
}
+3 -1
View File
@@ -148,7 +148,9 @@ _TEXT_SENSOR_SCHEMA = (
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
cv.Optional(CONF_FILTERS): validate_filters,
cv.Optional(
CONF_FILTERS, visibility=cv.Visibility.ADVANCED
): validate_filters,
cv.Optional(CONF_ON_VALUE): automation.validate_automation({}),
cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}),
}
+15 -5
View File
@@ -56,6 +56,10 @@ CONF_SORTING_GROUPS = "sorting_groups"
CONF_SORTING_WEIGHT = "sorting_weight"
CONF_ALLOWED_ORIGINS = "allowed_origins"
# Schema default that also matches the C++ initializer in web_server_base.h; codegen
# skips the setter when the config equals it.
DEFAULT_PORT = 80
web_server_ns = cg.esphome_ns.namespace("web_server")
WebServer = web_server_ns.class_("WebServer", cg.Component, cg.Controller)
@@ -251,7 +255,7 @@ CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(WebServer),
cv.Optional(CONF_PORT, default=80): cv.port,
cv.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
cv.Optional(CONF_VERSION, default=2): cv.one_of(1, 2, 3, int=True),
cv.Optional(CONF_CSS_URL): cv.string,
cv.Optional(CONF_CSS_INCLUDE): cv.file_,
@@ -379,9 +383,11 @@ async def to_code(config: ConfigType) -> None:
version = config[CONF_VERSION]
cg.add(paren.set_port(config[CONF_PORT]))
# Skip the setter when the config matches the C++ initializer (DEFAULT_PORT).
if (port := config[CONF_PORT]) != DEFAULT_PORT:
cg.add(paren.set_port(port))
cg.add_define("USE_WEBSERVER")
cg.add_define("USE_WEBSERVER_PORT", config[CONF_PORT])
cg.add_define("USE_WEBSERVER_PORT", port)
cg.add_define("USE_WEBSERVER_VERSION", version)
if version >= 2:
# Don't compress the index HTML as the data sizes are almost the same.
@@ -395,9 +401,11 @@ async def to_code(config: ConfigType) -> None:
# Captive portal will still be able to perform OTA updates even when this is set
if config.get(CONF_OTA) is False:
cg.add_define("USE_WEBSERVER_OTA_DISABLED")
cg.add(var.set_expose_log(config[CONF_LOG]))
# expose_log_ is true in C++; only emit the setter to turn it off.
if config[CONF_LOG]:
request_log_listener() # Request a log listener slot for web server log streaming
else:
cg.add(var.set_expose_log(False))
if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]:
cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS")
if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None:
@@ -433,7 +441,9 @@ async def to_code(config: ConfigType) -> None:
path = CORE.relative_config_path(config[CONF_JS_INCLUDE])
with path.open(encoding="utf-8") as js_file:
add_resource_as_progmem("JS_INCLUDE", js_file.read())
cg.add(var.set_include_internal(config[CONF_INCLUDE_INTERNAL]))
# include_internal_ is false in C++; only emit the setter to turn it on.
if config[CONF_INCLUDE_INTERNAL]:
cg.add(var.set_include_internal(True))
if CONF_LOCAL in config and config[CONF_LOCAL]:
cg.add_define("USE_WEBSERVER_LOCAL")
if config[CONF_COMPRESSION] == "gzip":
+5 -2
View File
@@ -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();
@@ -170,7 +170,7 @@ class WebServerBase final {
protected:
uint8_t initialized_{0};
uint16_t port_{80};
uint16_t port_{80}; // Keep in sync with DEFAULT_PORT in web_server/__init__.py
AsyncWebServer *server_{nullptr};
std::vector<AsyncWebHandler *> handlers_;
#ifdef USE_WEBSERVER_AUTH
+57 -9
View File
@@ -167,6 +167,9 @@ MAX_WIFI_NETWORKS = 127
# get best-effort connection attempts. Longer timeout ensures we exhaust all options
# before falling back to AP mode. Aligned with improv wifi_timeout default.
DEFAULT_AP_TIMEOUT = "90s"
DEFAULT_REBOOT_TIMEOUT = "15min"
# Both defaults also match the C++ initializers in wifi_component.h; codegen skips
# the setter when the config equals them.
wifi_ns = cg.esphome_ns.namespace("wifi")
EAPAuth = wifi_ns.struct("EAPAuth")
@@ -285,7 +288,9 @@ WIFI_NETWORK_BASE = cv.Schema(
cv.Optional(CONF_SSID): cv.sensitive(cv.ssid),
cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password),
cv.Optional(CONF_CHANNEL): validate_channel,
cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA,
cv.Optional(
CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED
): STA_MANUAL_IP_SCHEMA,
}
)
@@ -484,12 +489,14 @@ CONFIG_SCHEMA = cv.All(
),
cv.Optional(CONF_SSID): cv.sensitive(cv.ssid),
cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password),
cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA,
cv.Optional(
CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED
): STA_MANUAL_IP_SCHEMA,
cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA,
cv.Optional(CONF_AP): wifi_network_ap,
cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name,
cv.Optional(
CONF_REBOOT_TIMEOUT, default="15min"
CONF_REBOOT_TIMEOUT, default=DEFAULT_REBOOT_TIMEOUT
): cv.positive_time_period_milliseconds,
cv.SplitDefault(
CONF_POWER_SAVE_MODE,
@@ -599,7 +606,8 @@ def wifi_network(config, ap, static_ip):
cg.add(ap.set_channel(config[CONF_CHANNEL]))
if static_ip is not None:
cg.add(ap.set_manual_ip(manual_ip(static_ip)))
if CONF_PRIORITY in config:
# priority_ is 0 in C++; skip the setter when the config matches it.
if config.get(CONF_PRIORITY, 0) != 0:
cg.add(ap.set_priority(config[CONF_PRIORITY]))
return ap
@@ -648,7 +656,9 @@ async def to_code(config):
WiFiAP(),
lambda ap: cg.add(var.set_ap(wifi_network(conf, ap, ip_config))),
)
cg.add(var.set_ap_timeout(conf[CONF_AP_TIMEOUT]))
# Skip the setter when the config matches the C++ initializer.
if (ap_timeout := conf[CONF_AP_TIMEOUT]) != cv.time_period(DEFAULT_AP_TIMEOUT):
cg.add(var.set_ap_timeout(ap_timeout))
cg.add_define("USE_WIFI_AP")
# ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which
@@ -664,10 +674,27 @@ async def to_code(config):
if has_manual_ip:
cg.add_define("USE_WIFI_MANUAL_IP")
cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT]))
cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE]))
if CONF_MIN_AUTH_MODE in config:
cg.add(var.set_min_auth_mode(config[CONF_MIN_AUTH_MODE]))
# The C++ initializers are DEFAULT_REBOOT_TIMEOUT, power save NONE and minimum
# auth WPA2; skip the setters when the config matches them.
if (reboot_timeout := config[CONF_REBOOT_TIMEOUT]) != cv.time_period(
DEFAULT_REBOOT_TIMEOUT
):
cg.add(var.set_reboot_timeout(reboot_timeout))
if (power_save_mode := config[CONF_POWER_SAVE_MODE]) != "NONE":
if reasons := CORE.data.get(POWER_SAVE_OFF_REASONS_KEY):
_LOGGER.warning(
"power_save_mode %s is not applied: %s",
power_save_mode,
"; ".join(reasons),
)
else:
cg.add(var.set_power_save_mode(power_save_mode))
# From here on force_power_save_off() can no longer take effect
CORE.data[POWER_SAVE_APPLIED_KEY] = True
if (
min_auth_mode := config.get(CONF_MIN_AUTH_MODE)
) is not None and min_auth_mode != "WPA2":
cg.add(var.set_min_auth_mode(min_auth_mode))
fast_connect = config[CONF_FAST_CONNECT]
if fast_connect[CONF_ENABLED]:
cg.add_define("USE_WIFI_FAST_CONNECT")
@@ -825,6 +852,8 @@ async def wifi_disable_to_code(config, action_id, template_arg, args):
KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results"
RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save"
POWER_SAVE_OFF_REASONS_KEY = "wifi_power_save_off_reasons"
POWER_SAVE_APPLIED_KEY = "wifi_power_save_applied"
RUNTIME_ROAMING_SUPPRESSION_KEY = "wifi_runtime_roaming_suppression"
# Keys for listener counts
IP_STATE_LISTENERS_KEY = "wifi_ip_state_listeners"
@@ -857,6 +886,25 @@ def request_wifi_scan_results_lock() -> None:
CORE.data[SCAN_RESULTS_LOCK_KEY] = True
def force_power_save_off(reason: str) -> None:
"""Keep the station out of WiFi power save regardless of power_save_mode.
Components whose platform cannot run power save safely call this from their
final validation (FINAL_VALIDATE_SCHEMA), which always runs before any code
generation. Every distinct reason is kept; when the configured mode is not
NONE, wifi's code generation logs them and skips the mode. Calling it once
wifi has generated its code is too late and raises.
"""
if POWER_SAVE_APPLIED_KEY in CORE.data:
raise EsphomeError(
"wifi.force_power_save_off() must be called from final validation, "
"before wifi generates its code"
)
reasons: list[str] = CORE.data.setdefault(POWER_SAVE_OFF_REASONS_KEY, [])
if reason not in reasons:
reasons.append(reason)
def enable_runtime_power_save_control():
"""Enable runtime WiFi power save control.
+2 -2
View File
@@ -913,11 +913,11 @@ class WiFiComponent final : public Component {
float output_power_{NAN};
uint32_t action_started_;
uint32_t last_connected_{0};
uint32_t reboot_timeout_{};
uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py
uint32_t roaming_last_check_{0};
uint32_t roaming_scan_end_{0}; // Timestamp when last roaming scan completed
#ifdef USE_WIFI_AP
uint32_t ap_timeout_{};
uint32_t ap_timeout_{90000}; // Keep in sync with DEFAULT_AP_TIMEOUT in __init__.py
#endif
// 1-byte enums and integers
+5 -1
View File
@@ -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
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.9.0b4"
__version__ = "2026.9.1"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
+2 -2
View File
@@ -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();
-1
View File
@@ -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
View File
@@ -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,12 @@
esphome:
name: bk-power-save
bk72xx:
board: cb2s
wifi:
ssid: test
password: testtest
power_save_mode: high
bk72xx_ble:
@@ -0,0 +1,20 @@
"""bk72xx_ble keeps WiFi power save off: the Beken SDK's MCU sleep does not
wake up once the station is stopped while the BLE controller runs."""
from collections.abc import Callable
from pathlib import Path
import pytest
def test_power_save_mode_is_not_applied_with_ble(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
caplog: pytest.LogCaptureFixture,
) -> None:
main_cpp = generate_main(component_config_path("test_power_save.yaml"))
assert "bk72xx_ble::BK72xxBLE" in main_cpp
assert "set_power_save_mode(" not in main_cpp
assert "power_save_mode HIGH is not applied" in caplog.text
assert "issues/18592" in caplog.text
@@ -0,0 +1,53 @@
"""Power-user fields are marked as advanced on the shared schemas.
``filters``, ``manual_ip`` and the GPIO switch interlock options are knobs
whose defaults suit nearly every user, so a schema-aware editor should keep
them behind its "advanced settings" disclosure rather than on the main form.
"""
from __future__ import annotations
import importlib
import pytest
from esphome.components import binary_sensor, ethernet, sensor, text_sensor, wifi
import esphome.config_validation as cv
def _markers(schema: cv.Schema) -> dict[str, object]:
s = schema
if hasattr(s, "validators"):
# cv.All -> the schema is the first validator.
s = s.validators[0]
return {str(k): k for k in s.schema}
def _gpio_switch_schema() -> cv.Schema:
return importlib.import_module("esphome.components.gpio.switch").CONFIG_SCHEMA
@pytest.mark.parametrize(
("label", "schema_factory", "fields"),
[
("sensor", sensor.sensor_schema, ["filters"]),
("binary_sensor", binary_sensor.binary_sensor_schema, ["filters"]),
("text_sensor", text_sensor.text_sensor_schema, ["filters"]),
("wifi_network", lambda: wifi.WIFI_NETWORK_BASE, ["manual_ip"]),
("wifi", lambda: wifi.CONFIG_SCHEMA, ["manual_ip"]),
("ethernet", lambda: ethernet.BASE_SCHEMA, ["manual_ip"]),
("gpio_switch", _gpio_switch_schema, ["interlock", "interlock_wait_time"]),
],
)
def test_power_user_fields_are_advanced(
label: str, schema_factory, fields: list[str]
) -> None:
markers = _markers(schema_factory())
for field in fields:
assert markers[field].visibility is cv.Visibility.ADVANCED, f"{label}.{field}"
def test_interlock_wait_time_keeps_its_default() -> None:
"""Marking the field advanced must not drop its default."""
markers = _markers(_gpio_switch_schema())
assert markers["interlock_wait_time"].default() == "0ms"
@@ -0,0 +1,19 @@
---
esphome:
name: test
esp8266:
board: d1_mini
output:
- platform: esp8266_pwm
id: default_frequency
pin: GPIO4
frequency: 1kHz
- platform: esp8266_pwm
id: custom_frequency
pin: GPIO5
frequency: 2kHz
- platform: esp8266_pwm
id: schema_default_frequency
pin: GPIO12
@@ -0,0 +1,16 @@
"""Tests for the esp8266_pwm output codegen."""
from collections.abc import Callable
from pathlib import Path
def test_default_frequency_is_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The 1 kHz default already lives in the C++ initializer."""
main_cpp = generate_main(component_config_path("frequency.yaml"))
assert "default_frequency->set_frequency(" not in main_cpp
assert "schema_default_frequency->set_frequency(" not in main_cpp
assert "custom_frequency->set_frequency(2000.0f);" in main_cpp
@@ -0,0 +1,29 @@
---
esphome:
name: test
esp32:
board: esp32dev
output:
- platform: ledc
id: out_a
pin: GPIO4
- platform: ledc
id: out_b
pin: GPIO5
light:
- platform: monochromatic
id: plain_light
output: out_a
flash_transition_length: 0s
- platform: monochromatic
id: fancy_light
output: out_b
flash_transition_length: 500ms
effects:
- pulse:
- platform: monochromatic
id: bare_light
output: out_a
@@ -0,0 +1,19 @@
"""Tests that light codegen skips setters for default values."""
from collections.abc import Callable
from pathlib import Path
def test_default_flash_length_and_empty_effects_are_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A 0 ms flash transition and an empty effect list match the C++ defaults."""
main_cpp = generate_main(component_config_path("transitions.yaml"))
assert "plain_light->set_flash_transition_length(" not in main_cpp
assert "plain_light->add_effects(" not in main_cpp
assert "bare_light->set_flash_transition_length(" not in main_cpp
assert "bare_light->add_effects(" not in main_cpp
assert "fancy_light->set_flash_transition_length(500);" in main_cpp
assert "fancy_light->add_effects({" in main_cpp
@@ -52,3 +52,35 @@ def test_logger_pre_setup_before_other_components(generate_main):
f"Component allocation '{alloc.group()}' at position {alloc.start()} "
f"appears before logger pre_setup() at position {logger_pre_setup.start()}"
)
def test_default_uart_selection_is_not_emitted(generate_main):
"""UART0 is the C++ initializer on ESP8266, so the setter is skipped."""
main_cpp = generate_main("tests/component_tests/logger/test_logger.yaml")
assert "set_uart_selection(" not in main_cpp
def test_custom_uart_selection_is_emitted(generate_main):
"""A non default UART still reaches the setter before pre_setup()."""
main_cpp = generate_main("tests/component_tests/logger/test_logger_uart1.yaml")
assert "set_uart_selection(logger::UART_SELECTION_UART1);" in main_cpp
def test_libretiny_default_uart_selection_is_not_emitted(generate_main):
"""DEFAULT is the C++ initializer on LibreTiny, so the setter is skipped."""
main_cpp = generate_main(
"tests/component_tests/logger/test_logger_libretiny_default.yaml"
)
assert "set_uart_selection(" not in main_cpp
def test_libretiny_uart0_is_emitted(generate_main):
"""UART0 is not the LibreTiny initializer, so it must still be set."""
main_cpp = generate_main(
"tests/component_tests/logger/test_logger_libretiny_uart0.yaml"
)
assert "set_uart_selection(logger::UART_SELECTION_UART0);" in main_cpp
@@ -0,0 +1,8 @@
---
esphome:
name: test
rtl87xx:
board: generic-rtl8710bn-2mb-788k
logger:
@@ -0,0 +1,9 @@
---
esphome:
name: test
rtl87xx:
board: generic-rtl8710bn-2mb-788k
logger:
hardware_uart: UART0
@@ -0,0 +1,9 @@
---
esphome:
name: test
esp8266:
board: d1_mini_lite
logger:
hardware_uart: UART1
@@ -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
+1 -2
View File
@@ -33,7 +33,6 @@ def test_server_schema_rejects_address_zero() -> None:
def test_client_schema_still_accepts_address_zero() -> None:
# Not rejected for clients today, but not supported either: a client broadcast gets no reply and
# stalls the hub for the full send-wait.
# A client may address 0: writes are broadcast, and reads are allowed with allow_broadcast_read.
schema = modbus.modbus_device_schema(0x01)
assert schema({CONF_MODBUS_ID: "hub", CONF_ADDRESS: 0})[CONF_ADDRESS] == 0
@@ -7,7 +7,7 @@ guard is a safety property: these tests pin it to every handler slot.
import pytest
from esphome import config_validation as cv
from esphome.components import modbus_client
from esphome.components import modbus, modbus_client
from esphome.components.modbus_client import (
CONF_ON_NO_RESPONSE,
CONF_ON_NOT_SENT,
@@ -126,7 +126,7 @@ def test_on_no_response_retry_lambda_accepted() -> None:
def test_continuous_on_write_pdu_rejected() -> None:
"""A literal write-code PDU with continuous: true is rejected at config time (reads only)."""
with pytest.raises(cv.Invalid, match="does not apply to a write PDU"):
with pytest.raises(cv.Invalid, match="does not apply to function code"):
MODBUS_CLIENT_SEND_SCHEMA(
{
CONF_ADDRESS: 0x01,
@@ -185,3 +185,145 @@ def test_multi_conf_no_default_is_set() -> None:
"""
assert modbus_client.MULTI_CONF is True
assert modbus_client.MULTI_CONF_NO_DEFAULT is True
@pytest.mark.parametrize("key", [CONF_CONTINUOUS, modbus.CONF_ALLOW_BROADCAST_READ])
def test_send_rejects_read_option_on_static_write_pdu(key: str) -> None:
# A read option set true on a static write PDU is refused at validation, naming the key.
config = {
CONF_ADDRESS: 1,
CONF_PDU: [0x06, 0x00, 0x10, 0x00, 0x01],
key: True,
}
with pytest.raises(
cv.Invalid, match=f"'{key}: true' does not apply to function code"
):
MODBUS_CLIENT_SEND_SCHEMA(config)
def test_send_accepts_allow_broadcast_read_on_read_pdu() -> None:
# allow_broadcast_read defaults to False and is accepted on a read PDU to address 0.
config = MODBUS_CLIENT_SEND_SCHEMA(
{CONF_ADDRESS: 0, CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02]}
)
assert config[modbus.CONF_ALLOW_BROADCAST_READ] is False
config = MODBUS_CLIENT_SEND_SCHEMA(
{
CONF_ADDRESS: 0,
CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02],
modbus.CONF_ALLOW_BROADCAST_READ: True,
}
)
assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True
def test_send_rejects_write_option_on_static_read_pdu() -> None:
# The write-side option is refused on a static read PDU, the mirror of the read-option check.
key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE
with pytest.raises(
cv.Invalid, match=f"'{key}: true' does not apply to function code"
):
MODBUS_CLIENT_SEND_SCHEMA(
{CONF_ADDRESS: 0, CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02], key: True}
)
def test_send_accepts_write_option_on_static_write_pdu() -> None:
config = MODBUS_CLIENT_SEND_SCHEMA(
{
CONF_ADDRESS: 0,
CONF_PDU: [0x06, 0x00, 0x10, 0x00, 0x01],
modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True,
}
)
assert config[modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE] is True
def test_write_actions_offer_write_option_only() -> None:
# Every write action takes expect_broadcast_write_response and none of the read options.
from esphome.components.modbus_client import (
_WRITE_MULTIPLE_COILS_SCHEMA,
_WRITE_MULTIPLE_REGISTERS_SCHEMA,
_WRITE_SINGLE_COIL_SCHEMA,
_WRITE_SINGLE_REGISTER_SCHEMA,
CONF_START_ADDRESS,
CONF_VALUE,
CONF_VALUES,
)
write_key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE
base = {CONF_ADDRESS: 0, CONF_START_ADDRESS: 0x10, write_key: True}
for schema, extra in (
(_WRITE_SINGLE_REGISTER_SCHEMA, {CONF_VALUE: 1}),
(_WRITE_SINGLE_COIL_SCHEMA, {CONF_VALUE: True}),
(_WRITE_MULTIPLE_REGISTERS_SCHEMA, {CONF_VALUES: [1, 2]}),
(_WRITE_MULTIPLE_COILS_SCHEMA, {CONF_VALUES: [True, False]}),
):
config = schema({**base, **extra})
assert config[write_key] is True
assert modbus.CONF_ALLOW_BROADCAST_READ not in config
with pytest.raises(cv.Invalid):
schema({**base, **extra, modbus.CONF_ALLOW_BROADCAST_READ: True})
def test_send_options_follow_the_hub_classification() -> None:
# A vendor code is broadcastable, so it takes the write-side flag and refuses the read-side one;
# 0x17 is a read for broadcast purposes, so the reverse holds.
write_key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE
read_key = modbus.CONF_ALLOW_BROADCAST_READ
assert MODBUS_CLIENT_SEND_SCHEMA(
{CONF_ADDRESS: 0, CONF_PDU: [0x41, 0x01], write_key: True}
)[write_key]
with pytest.raises(cv.Invalid, match=f"'{read_key}: true' does not apply"):
MODBUS_CLIENT_SEND_SCHEMA(
{CONF_ADDRESS: 0, CONF_PDU: [0x41, 0x01], read_key: True}
)
pdu_0x17 = [0x17, 0x00, 0x10, 0x00, 0x01, 0x00, 0x20, 0x00, 0x01, 0x02, 0x00, 0x01]
assert MODBUS_CLIENT_SEND_SCHEMA(
{CONF_ADDRESS: 0, CONF_PDU: pdu_0x17, read_key: True}
)[read_key]
with pytest.raises(cv.Invalid, match=f"'{write_key}: true' does not apply"):
MODBUS_CLIENT_SEND_SCHEMA(
{CONF_ADDRESS: 0, CONF_PDU: pdu_0x17, write_key: True}
)
def test_read_write_multiple_offers_allow_broadcast_read_only() -> None:
from esphome.components.modbus_client import (
_READ_WRITE_MULTIPLE_REGISTERS_SCHEMA,
CONF_READ_ADDRESS,
CONF_VALUES,
CONF_WRITE_ADDRESS,
)
config = _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA(
{
CONF_ADDRESS: 0,
CONF_READ_ADDRESS: 0x10,
CONF_WRITE_ADDRESS: 0x20,
CONF_VALUES: [1],
modbus.CONF_ALLOW_BROADCAST_READ: True,
}
)
assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True
assert CONF_CONTINUOUS not in config
assert modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE not in config
@pytest.mark.parametrize(
"key",
[modbus.CONF_ALLOW_BROADCAST_READ, modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE],
)
def test_broadcast_options_rejected_on_literal_unicast_address(key: str) -> None:
# A broadcast-only option on a literal non-zero address would be silently dropped by the hub.
if key == modbus.CONF_ALLOW_BROADCAST_READ:
pdu = [0x03, 0x00, 0x10, 0x00, 0x01]
else:
pdu = [0x06, 0x00, 0x10, 0x00, 0x01]
with pytest.raises(cv.Invalid, match="only applies to the broadcast address"):
MODBUS_CLIENT_SEND_SCHEMA({CONF_ADDRESS: 1, CONF_PDU: pdu, key: True})
# A templated address is not decidable at validation and passes through.
config = MODBUS_CLIENT_SEND_SCHEMA(
{CONF_ADDRESS: Lambda("return 1;"), CONF_PDU: pdu, key: True}
)
assert config[key] is True
@@ -0,0 +1,79 @@
"""A modbus_controller cannot poll the broadcast address (0) unless allow_broadcast_read says the
device answers it."""
import pytest
from esphome import config_validation as cv
from esphome.components import modbus
from esphome.components.modbus_controller import CONFIG_SCHEMA
from esphome.const import CONF_ADDRESS
from esphome.types import ConfigType
def _controller(address: int, **extra: object) -> ConfigType:
return CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", CONF_ADDRESS: address, **extra})
def test_address_zero_rejected_by_default() -> None:
with pytest.raises(cv.Invalid, match="broadcast address"):
_controller(0)
def test_address_zero_accepted_with_allow_broadcast_read() -> None:
config = _controller(0, **{modbus.CONF_ALLOW_BROADCAST_READ: True})
assert config[CONF_ADDRESS] == 0
assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True
def test_allow_broadcast_read_defaults_false() -> None:
assert _controller(1)[modbus.CONF_ALLOW_BROADCAST_READ] is False
def test_writer_entity_takes_expect_broadcast_write_response() -> None:
# The write-side option lives on the writing platforms, not the controller.
from esphome.components.modbus_controller.const import CONF_MODBUS_CONTROLLER_ID
from esphome.components.modbus_controller.switch import (
CONFIG_SCHEMA as SWITCH_SCHEMA,
)
from esphome.const import CONF_NAME
key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE
base = {
CONF_MODBUS_CONTROLLER_ID: "ctl",
CONF_NAME: "Switch",
"register_type": "coil",
CONF_ADDRESS: 0x20,
}
assert SWITCH_SCHEMA(base)[key] is False
assert SWITCH_SCHEMA({**base, CONF_NAME: "Switch 2", key: True})[key] is True
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", CONF_ADDRESS: 1, key: True})
def test_allow_broadcast_read_requires_address_zero() -> None:
# The option only means something at address 0; elsewhere it would be silently inert.
with pytest.raises(cv.Invalid, match="only applies to the broadcast address"):
_controller(5, **{modbus.CONF_ALLOW_BROADCAST_READ: True})
def test_add_command_options_skips_defaults() -> None:
# The setter is only emitted when an option differs from its C++ default.
import esphome.codegen as cg
from esphome.const import CONF_CONTINUOUS
var = cg.MockObj("ctl")
emitted: list = []
original = cg.add
cg.add = emitted.append
try:
modbus.add_command_options(
var, "set_read_options", {CONF_CONTINUOUS: False}, direction="read"
)
assert emitted == []
modbus.add_command_options(
var, "set_read_options", {CONF_CONTINUOUS: True}, direction="read"
)
assert len(emitted) == 1
assert "set_read_options" in str(emitted[0])
finally:
cg.add = original
@@ -9,6 +9,7 @@ test cannot: a write-coded custom_pdu polled continuously is rejected there.
import pytest
from voluptuous import Invalid, MultipleInvalid
from esphome.components import modbus
from esphome.components.modbus_controller import (
ModbusItemBaseSchema,
validate_custom_pdu_item,
@@ -55,14 +56,21 @@ def test_custom_pdu_rejects_non_byte_values() -> None:
ModbusItemBaseSchema({CONF_CUSTOM_PDU: [0x0103, 0x002A]})
def _controller_full_config(*, continuous: bool) -> Config:
def _controller_full_config(
*, continuous: bool, allow_broadcast_read: bool = False
) -> Config:
"""A minimal full-config graph with one modbus_controller declaring id 'ctl', enough for the
final-validate to resolve the controller (and its continuous flag) from an item's
final-validate to resolve the controller (and its option flags) from an item's
modbus_controller_id."""
ctl_id = ID("ctl", is_declaration=True)
config = Config()
config["modbus_controller"] = [
{CONF_ID: ctl_id, CONF_ADDRESS: 1, CONF_CONTINUOUS: continuous}
{
CONF_ID: ctl_id,
CONF_ADDRESS: 0 if allow_broadcast_read else 1,
CONF_CONTINUOUS: continuous,
modbus.CONF_ALLOW_BROADCAST_READ: allow_broadcast_read,
}
]
config.declare_ids.append((ctl_id, ["modbus_controller", 0, CONF_ID]))
return config
@@ -98,3 +106,64 @@ def test_continuous_read_custom_pdu_allowed(reset_full_config) -> None:
CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01],
}
)
def test_broadcastable_custom_pdu_rejected_under_broadcast_controller(
reset_full_config,
) -> None:
"""A vendor-coded custom_pdu under an allow_broadcast_read controller would be a real broadcast,
never answered, so it is rejected at final validate."""
fv.full_config.set(
_controller_full_config(continuous=False, allow_broadcast_read=True)
)
with pytest.raises(Invalid, match="is a real broadcast at address 0"):
validate_custom_pdu_item(
{
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
CONF_CUSTOM_PDU: [0x41, 0x00, 0x03],
}
)
def test_read_custom_pdu_allowed_under_broadcast_controller(reset_full_config) -> None:
"""A read-coded custom_pdu (0x03) is answered under allow_broadcast_read, so it is fine."""
fv.full_config.set(
_controller_full_config(continuous=False, allow_broadcast_read=True)
)
validate_custom_pdu_item(
{
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01],
}
)
def test_write_option_rejected_under_unicast_controller(reset_full_config) -> None:
"""expect_broadcast_write_response on a writer entity whose controller is not at address 0 is
rejected at final validate, where the controller's address is known."""
from esphome.components.modbus_controller import validate_writer_item
fv.full_config.set(_controller_full_config(continuous=False))
with pytest.raises(
Invalid, match="only applies when the 'ctl' modbus_controller is at address 0"
):
validate_writer_item(
{
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True,
}
)
def test_write_option_allowed_under_broadcast_controller(reset_full_config) -> None:
from esphome.components.modbus_controller import validate_writer_item
fv.full_config.set(
_controller_full_config(continuous=False, allow_broadcast_read=True)
)
validate_writer_item(
{
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True,
}
)
@@ -0,0 +1,28 @@
---
esphome:
name: test
esp32:
board: esp32dev
number:
- platform: template
id: auto_number
min_value: 0
max_value: 10
step: 1
optimistic: true
- platform: template
id: box_number
min_value: 0
max_value: 10
step: 1
mode: box
optimistic: true
- platform: template
id: explicit_auto_number
min_value: 0
max_value: 10
step: 1
mode: auto
optimistic: true
@@ -0,0 +1,16 @@
"""Tests for the number component codegen."""
from collections.abc import Callable
from pathlib import Path
def test_default_mode_is_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Mode auto is the C++ initializer, so only a non default mode is set."""
main_cpp = generate_main(component_config_path("mode.yaml"))
assert "auto_number->traits.set_mode(" not in main_cpp
assert "explicit_auto_number->traits.set_mode(" not in main_cpp
assert "box_number->traits.set_mode(number::NUMBER_MODE_BOX);" in main_cpp
+36 -16
View File
@@ -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,13 @@
---
esphome:
name: test
esp32:
board: esp32dev
output:
- platform: ac_dimmer
id: dimmer
gate_pin: GPIO4
zero_cross_pin: GPIO5
min_power: 0%
@@ -0,0 +1,18 @@
---
esphome:
name: test
esp32:
board: esp32dev
output:
- platform: ledc
id: default_power
pin: GPIO4
max_power: 100%
min_power: 0%
- platform: ledc
id: custom_power
pin: GPIO5
max_power: 90%
min_power: 1%
@@ -0,0 +1,31 @@
"""Tests for the output platform codegen."""
from collections.abc import Callable
from pathlib import Path
from esphome.core import CORE
def test_default_power_limits_are_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""max_power 100% and min_power 0% already live in the C++ initializers."""
main_cpp = generate_main(component_config_path("power_limits.yaml"))
assert "default_power->set_max_power(" not in main_cpp
assert "default_power->set_min_power(" not in main_cpp
assert "custom_power->set_max_power(0.9f);" in main_cpp
assert "custom_power->set_min_power(0.01f);" in main_cpp
assert "USE_OUTPUT_FLOAT_POWER_SCALING" in {d.name for d in CORE.defines}
def test_default_min_power_keeps_scaling_fields(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""ac_dimmer reads min_power_ directly, so the define must stay on for min_power 0%."""
main_cpp = generate_main(component_config_path("ac_dimmer_min_power_zero.yaml"))
assert "dimmer->set_min_power(" not in main_cpp
assert "USE_OUTPUT_FLOAT_POWER_SCALING" in {d.name for d in CORE.defines}
@@ -0,0 +1,12 @@
---
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: test
password: testtest
web_server:
@@ -0,0 +1,15 @@
---
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: test
password: testtest
web_server:
port: 8080
log: false
include_internal: true
@@ -0,0 +1,15 @@
---
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: test
password: testtest
web_server:
port: 80
log: true
include_internal: false
@@ -0,0 +1,35 @@
"""Tests that web_server only emits setters for non default values."""
from collections.abc import Callable
from pathlib import Path
import pytest
@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"])
def test_default_values_are_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
) -> None:
"""Port 80, log on and include_internal off already live in the C++ initializers.
Both the schema defaults and the same values written explicitly take the skip path.
"""
main_cpp = generate_main(component_config_path(config_file))
assert "set_port(" not in main_cpp
assert "set_expose_log(" not in main_cpp
assert "set_include_internal(" not in main_cpp
def test_custom_values_are_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Non default values still reach the C++ setters."""
main_cpp = generate_main(component_config_path("custom.yaml"))
assert "set_port(8080);" in main_cpp
assert "set_expose_log(false);" in main_cpp
assert "set_include_internal(true);" in main_cpp
@@ -0,0 +1,12 @@
---
esphome:
name: test
esp8266:
board: d1_mini
wifi:
ssid: test
password: testtest
ap:
ssid: fallback
@@ -0,0 +1,18 @@
---
esphome:
name: test
esp8266:
board: d1_mini
wifi:
networks:
- ssid: test
password: testtest
priority: 5
ap:
ssid: fallback
ap_timeout: 2min
reboot_timeout: 0s
power_save_mode: light
min_auth_mode: wpa
@@ -0,0 +1,18 @@
---
esphome:
name: test
esp8266:
board: d1_mini
wifi:
networks:
- ssid: test
password: testtest
priority: 0
ap:
ssid: fallback
ap_timeout: 90s
reboot_timeout: 15min
power_save_mode: none
min_auth_mode: wpa2
@@ -0,0 +1,39 @@
"""Tests that wifi codegen skips setters for default values."""
from collections.abc import Callable
from pathlib import Path
import pytest
@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"])
def test_default_values_are_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
) -> None:
"""Priority 0, 90 s AP timeout, 15 min reboot, power save none, WPA2 are C++ defaults.
Both the schema defaults and the same values written explicitly take the skip path.
"""
main_cpp = generate_main(component_config_path(config_file))
assert "set_priority(" not in main_cpp
assert "set_ap_timeout(" not in main_cpp
assert "set_reboot_timeout(" not in main_cpp
assert "set_power_save_mode(" not in main_cpp
assert "set_min_auth_mode(" not in main_cpp
def test_custom_values_are_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Non default values still reach the C++ setters."""
main_cpp = generate_main(component_config_path("custom.yaml"))
assert "set_priority(5);" in main_cpp
assert "set_ap_timeout(120000);" in main_cpp
assert "set_reboot_timeout(0);" in main_cpp
assert "set_power_save_mode(wifi::WIFI_POWER_SAVE_LIGHT);" in main_cpp
assert "set_min_auth_mode(wifi::WIFI_MIN_AUTH_MODE_WPA);" in main_cpp
@@ -0,0 +1,46 @@
"""Tests for wifi.force_power_save_off(), the hook platforms use to keep the
station out of power save."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components import wifi
from esphome.core import CORE, EsphomeError
def test_reasons_accumulate_without_duplicates() -> None:
"""Every caller's reason is kept once; a repeated reason is not duplicated."""
wifi.force_power_save_off("first")
wifi.force_power_save_off("first")
wifi.force_power_save_off("second")
assert CORE.data[wifi.POWER_SAVE_OFF_REASONS_KEY] == ["first", "second"]
def test_forced_off_skips_the_setter_and_warns(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
caplog: pytest.LogCaptureFixture,
) -> None:
"""With a reason recorded, power_save_mode is reported and not applied."""
wifi.force_power_save_off("the platform cannot sleep")
main_cpp = generate_main(component_config_path("custom.yaml"))
assert "set_power_save_mode(" not in main_cpp
assert (
"power_save_mode LIGHT is not applied: the platform cannot sleep" in caplog.text
)
def test_call_after_wifi_codegen_raises(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Once wifi has generated its code the hook cannot take effect any more."""
generate_main(component_config_path("custom.yaml"))
with pytest.raises(EsphomeError, match="before wifi generates its code"):
wifi.force_power_save_off("too late")
+1
View File
@@ -4,3 +4,4 @@ output:
gate_pin: ${gate_pin}
zero_cross_pin: ${zero_cross_pin}
zero_cross_interrupt_type: ANY
min_power: 0%
@@ -0,0 +1,9 @@
# A wifi power_save_mode other than NONE is forced off with a warning while
# bk72xx_ble is configured (esphome#18592); this config must still validate.
packages:
bk72xx_ble: !include common.yaml
wifi:
ssid: MySSID
password: password1
power_save_mode: high
@@ -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

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