Compare commits

..
47 changed files with 1693 additions and 1086 deletions
+2 -3
View File
@@ -6,7 +6,7 @@ import esphome.codegen as cg
from esphome.components.esp32 import (
add_idf_component,
add_idf_sdkconfig_option,
include_builtin_idf_component,
request_http_client,
require_certificate_bundle,
)
import esphome.config_validation as cv
@@ -334,8 +334,7 @@ def _emit_memory_pair(value: str | None, psram_key: str, internal_key: str) -> N
async def to_code(config: ConfigType) -> None:
# Re-enable ESP-IDF's HTTP client (excluded by default to save compile time)
include_builtin_idf_component("esp_http_client")
request_http_client()
# HTTPS streams verify the server against the root certificate bundle
require_certificate_bundle()
+147 -55
View File
@@ -73,6 +73,7 @@ from .const import (
KEY_FLASH_SIZE,
KEY_FULL_CERT_BUNDLE,
KEY_IDF_VERSION,
KEY_MBEDTLS_SDKCONFIG,
KEY_NETWORK_SDKCONFIG,
KEY_PATH,
KEY_REF,
@@ -225,7 +226,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = (
"cmock", # Unit testing mock framework - ESPHome doesn't use IDF's testing
"console", # Console REPL - unused by ESPHome; espressif/mdns pulls it back when configured
"driver", # Legacy driver shim - only needed by esp32_touch, esp32_can for legacy headers
"esp-tls", # TLS wrapper - re-included by http_request, mqtt, web_server_idf
"esp-tls", # TLS wrapper - re-included by request_tls()
"esp_adc", # ADC driver - only needed by adc component
"esp_coex", # WiFi/BT coexistence - re-included by esp32_ble_tracker, zigbee; esp_wifi/bt pull it back
"esp_driver_cam", # Camera driver - the esp32-camera managed component pulls it back
@@ -747,6 +748,48 @@ def request_software_coexistence() -> None:
include_builtin_idf_component("esp_coex")
@dataclass
class MbedtlsSdkconfigData:
"""Inputs for the mbedTLS sdkconfig flags, reconciled at FINAL.
Components call request_tls() / require_mbedtls_*() instead of writing the
CONFIG_MBEDTLS_* flags directly; _reconcile_mbedtls_sdkconfig() decides the
final values once every to_code has run.
"""
tls_required: bool = False # TLS/DTLS handshake user
ecp_required: bool = False # ECDH/ECDSA without TLS (openthread SRP host key)
peer_cert_required: bool = False # keep the peer certificate after the handshake
pkcs7_required: bool = False # PKCS#7 parsing
sha512_required: bool = False # SHA-384/SHA-512
# esp32 advanced disable_mbedtls_peer_cert / disable_mbedtls_pkcs7 options
disable_peer_cert: bool = True
disable_pkcs7: bool = True
def _mbedtls_sdkconfig() -> MbedtlsSdkconfigData:
data = CORE.data[KEY_ESP32]
if KEY_MBEDTLS_SDKCONFIG not in data:
data[KEY_MBEDTLS_SDKCONFIG] = MbedtlsSdkconfigData()
return data[KEY_MBEDTLS_SDKCONFIG]
def request_tls() -> None:
"""Request the mbedTLS TLS stack and the esp-tls wrapper.
Without a request TLS and its ECP/PEM-write/CRL/CSR crypto compile out;
hashes, AES and RSA stay available.
"""
_mbedtls_sdkconfig().tls_required = True
include_builtin_idf_component("esp-tls")
def request_http_client() -> None:
"""Request ESP-IDF's HTTP client; it links esp_tls even for plain http."""
include_builtin_idf_component("esp_http_client")
request_tls()
def add_idf_component(
*,
name: str,
@@ -1744,10 +1787,7 @@ KEY_VFS_TERMIOS_REQUIRED = "vfs_termios_required"
# Feature requirement tracking - components can call require_* functions to re-enable
# These are stored in CORE.data[KEY_ESP32] dict
KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED = "usb_serial_jtag_secondary_required"
KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required"
KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required"
KEY_FATFS_REQUIRED = "fatfs_required"
KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required"
KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required"
KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED = "libc_picolibc_newlib_compat_required"
@@ -1786,6 +1826,8 @@ def require_certificate_bundle() -> None:
certificates (http_request, audio streaming) call this so the bundle is
compiled and gen_crt_bundle runs only when something uses it.
"""
# esp_crt_bundle.c calls mbedtls_ssl_conf_*, so a bundle always needs TLS.
request_tls()
CORE.data[KEY_ESP32][KEY_CERT_BUNDLE] = True
@@ -1811,33 +1853,32 @@ def require_usb_serial_jtag_secondary() -> None:
CORE.data[KEY_ESP32][KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED] = True
def require_mbedtls_peer_cert() -> None:
"""Mark that mbedTLS peer certificate retention is required by a component.
def require_mbedtls_ecp() -> None:
"""Keep mbedTLS elliptic curve support (ECDH/ECDSA) without requesting TLS.
Call this from components that need access to the peer certificate after
the TLS handshake is complete. This prevents CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE
from being disabled.
Call this from components that sign or verify with ECDSA outside a TLS
handshake (openthread's SRP host key). WiFi, Bluetooth and secure boot
select it through Kconfig on their own.
"""
CORE.data[KEY_ESP32][KEY_MBEDTLS_PEER_CERT_REQUIRED] = True
_mbedtls_sdkconfig().ecp_required = True
def require_mbedtls_peer_cert() -> None:
"""Keep the peer certificate after the TLS handshake (CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE).
A user sdkconfig_options value takes precedence.
"""
_mbedtls_sdkconfig().peer_cert_required = True
def require_mbedtls_pkcs7() -> None:
"""Mark that mbedTLS PKCS#7 support is required by a component.
Call this from components that need PKCS#7 certificate validation.
This prevents CONFIG_MBEDTLS_PKCS7_C from being disabled.
"""
CORE.data[KEY_ESP32][KEY_MBEDTLS_PKCS7_REQUIRED] = True
"""Keep mbedTLS PKCS#7 support (CONFIG_MBEDTLS_PKCS7_C). A user sdkconfig_options value takes precedence."""
_mbedtls_sdkconfig().pkcs7_required = True
def require_mbedtls_sha512() -> None:
"""Mark that mbedTLS SHA-384/SHA-512 support is required by a component.
Call this from components that need to verify TLS certificates or signatures
using SHA-384 or SHA-512 algorithms. This prevents CONFIG_MBEDTLS_SHA384_C
and CONFIG_MBEDTLS_SHA512_C from being disabled.
"""
CORE.data[KEY_ESP32][KEY_MBEDTLS_SHA512_REQUIRED] = True
"""Keep mbedTLS SHA-384/SHA-512 (CONFIG_MBEDTLS_SHA384_C / CONFIG_MBEDTLS_SHA512_C)."""
_mbedtls_sdkconfig().sha512_required = True
def idf_version() -> cv.Version:
@@ -2302,6 +2343,81 @@ async def _reconcile_certificate_bundle_sdkconfig() -> None:
set_idf_sdkconfig_default("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN", True)
# User sdkconfig_options that mean "keep TLS on" when set to y.
_MBEDTLS_TLS_ON_OPTIONS = (
"CONFIG_MBEDTLS_TLS_ENABLED",
"CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT",
"CONFIG_MBEDTLS_TLS_SERVER_ONLY",
"CONFIG_MBEDTLS_TLS_CLIENT_ONLY",
)
# Any user option under these prefixes only makes sense with TLS compiled in.
_TLS_OPTION_PREFIXES = ("CONFIG_ESP_TLS_", "CONFIG_MBEDTLS_SSL_", "CONFIG_ESP_HTTPS_")
def _user_sdkconfig_wants_tls(options: dict[str, Any]) -> bool:
"""True when sdkconfig_options turn TLS on or tune something under it; an `n` is never a request."""
return any(
(name in _MBEDTLS_TLS_ON_OPTIONS and value == "y")
or (name == "CONFIG_MBEDTLS_TLS_DISABLED" and value == "n")
or (name.startswith(_TLS_OPTION_PREFIXES) and value != "n")
for name, value in options.items()
)
@coroutine_with_priority(CoroPriority.FINAL)
async def _reconcile_mbedtls_sdkconfig() -> None:
"""Reconcile the mbedTLS sdkconfig flags after every request_tls() / require_mbedtls_*() call.
mbedtls cannot be excluded from an IDF build (bootloader_support needs its
SHA-256), but with no TLS user the ssl_*.c sources and the TLS-only crypto
compile to empty objects. User sdkconfig_options win.
"""
data = _mbedtls_sdkconfig()
idf6 = idf_version() >= cv.Version(6, 0, 0)
# A component that re-includes esp-tls on its own (external components
# predating request_tls()) wants TLS just as much as a request_tls() call.
tls_required = (
data.tls_required
or "esp-tls" not in CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
)
if not CORE.using_arduino and not tls_required:
# IDF 6 made CONFIG_MBEDTLS_TLS_ENABLED a normal bool; on IDF 5 it has
# no prompt and is only reachable through the "None" TLS role choice.
if idf6:
set_idf_sdkconfig_default("CONFIG_MBEDTLS_TLS_ENABLED", False)
else:
set_idf_sdkconfig_default("CONFIG_MBEDTLS_TLS_DISABLED", True)
# Enterprise WiFi selects TLS back on; wifi writes this itself, but
# esp_wifi can also be in the build without a wifi: block (openthread).
set_idf_sdkconfig_default("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", False)
# WiFi (ESP_WIFI_MBEDTLS_CRYPTO), Bluetooth and signed apps
# (SECURE_SIGNED_APPS) select ECP back on through Kconfig.
if not data.ecp_required:
set_idf_sdkconfig_default("CONFIG_MBEDTLS_ECP_C", False)
set_idf_sdkconfig_default("CONFIG_MBEDTLS_PEM_WRITE_C", False)
set_idf_sdkconfig_default("CONFIG_MBEDTLS_X509_CRL_PARSE_C", False)
set_idf_sdkconfig_default("CONFIG_MBEDTLS_X509_CSR_PARSE_C", False)
# Keeping the peer certificate costs ~4KB heap per connection.
if data.peer_cert_required:
set_idf_sdkconfig_default("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", True)
elif data.disable_peer_cert:
set_idf_sdkconfig_default("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", False)
if data.pkcs7_required:
set_idf_sdkconfig_default("CONFIG_MBEDTLS_PKCS7_C", True)
elif data.disable_pkcs7:
set_idf_sdkconfig_default("CONFIG_MBEDTLS_PKCS7_C", False)
# SHA-384 shares the SHA-512 compression function, so both go together.
# Only IDF 6.0's PSA engine links a ~3KB software fallback for them; on
# IDF 5 they are a single hardware-only option with no code size cost.
if idf6 and not data.sha512_required:
set_idf_sdkconfig_default("CONFIG_MBEDTLS_SHA384_C", False)
set_idf_sdkconfig_default("CONFIG_MBEDTLS_SHA512_C", False)
@coroutine_with_priority(CoroPriority.FINAL)
async def _reconcile_network_sdkconfig() -> None:
"""Reconcile WiFi/Ethernet/Bluetooth/coexistence sdkconfig flags.
@@ -2950,38 +3066,6 @@ async def to_code(config):
if advanced[CONF_DISABLE_DEV_NULL_VFS]:
add_idf_sdkconfig_option("CONFIG_VFS_INITIALIZE_DEV_NULL", False)
# Disable keeping peer certificate after TLS handshake
# Saves ~4KB heap per connection, but prevents certificate inspection after handshake
# Components that need it can call require_mbedtls_peer_cert()
if CORE.data[KEY_ESP32].get(KEY_MBEDTLS_PEER_CERT_REQUIRED, False):
add_idf_sdkconfig_option("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", True)
elif advanced[CONF_DISABLE_MBEDTLS_PEER_CERT]:
add_idf_sdkconfig_option("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", False)
# Disable PKCS#7 support in mbedTLS
# Only needed for specific certificate validation scenarios
# Components that need it can call require_mbedtls_pkcs7()
if CORE.data[KEY_ESP32].get(KEY_MBEDTLS_PKCS7_REQUIRED, False):
# Component called require_mbedtls_pkcs7() - enable regardless of user setting
add_idf_sdkconfig_option("CONFIG_MBEDTLS_PKCS7_C", True)
elif advanced[CONF_DISABLE_MBEDTLS_PKCS7]:
add_idf_sdkconfig_option("CONFIG_MBEDTLS_PKCS7_C", False)
# Disable SHA-384 and SHA-512 in mbedTLS
# ESPHome doesn't use either algorithm. SHA-384 shares the same
# compression function as SHA-512 (mbedtls_internal_sha512_process),
# so both must be disabled to eliminate the ~3KB software fallback
# that IDF 6.0's PSA parallel engine always links in.
# On IDF < 6.0 these are a single config and hardware-only (no
# software fallback), so there was no code size cost to leaving
# them enabled.
# Components that need SHA-384/SHA-512 can call require_mbedtls_sha512()
if idf_version() >= cv.Version(6, 0, 0) and not CORE.data[KEY_ESP32].get(
KEY_MBEDTLS_SHA512_REQUIRED, False
):
add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA384_C", False)
add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA512_C", False)
# FINAL priority: runs after every require_libc_picolibc_newlib_compat() call
CORE.add_job(_set_libc_picolibc_newlib_compat)
@@ -2991,6 +3075,12 @@ async def to_code(config):
# FINAL priority: runs after every require_certificate_bundle() call
CORE.add_job(_reconcile_certificate_bundle_sdkconfig)
# FINAL priority: runs after every request_tls() / require_mbedtls_*() call
mbedtls = _mbedtls_sdkconfig()
mbedtls.disable_peer_cert = advanced[CONF_DISABLE_MBEDTLS_PEER_CERT]
mbedtls.disable_pkcs7 = advanced[CONF_DISABLE_MBEDTLS_PKCS7]
CORE.add_job(_reconcile_mbedtls_sdkconfig)
# FINAL: require_*() calls can come from to_code at or below this priority, so an
# inline read would be iteration-order-dependent; reconcile once after every job ran.
CORE.add_job(
@@ -3022,6 +3112,8 @@ async def to_code(config):
# so it still gets the CMN variant pinned.
if conf[CONF_SDKCONFIG_OPTIONS].get("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE") == "y":
require_certificate_bundle()
if _user_sdkconfig_wants_tls(conf[CONF_SDKCONFIG_OPTIONS]):
request_tls()
# Components from YAML are added in a separate coroutine with FINAL priority
# Schedule it to run after all other components
+1
View File
@@ -23,6 +23,7 @@ KEY_EXTRA_BUILD_FILES = "extra_build_files"
KEY_CERT_BUNDLE = "cert_bundle"
KEY_FULL_CERT_BUNDLE = "full_cert_bundle"
KEY_NETWORK_SDKCONFIG = "network_sdkconfig"
KEY_MBEDTLS_SDKCONFIG = "mbedtls_sdkconfig"
VARIANT_ESP32 = "ESP32"
VARIANT_ESP32C2 = "ESP32C2"
+1 -5
View File
@@ -202,11 +202,7 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_watchdog_timeout(timeout_ms))
if CORE.is_esp32:
# Re-enable ESP-IDF's HTTP client (excluded by default to save compile time).
# esp-tls is re-enabled too because http_request includes <esp_tls.h>
# directly and esp_http_client only pulls it in as a private dependency.
esp32.include_builtin_idf_component("esp_http_client")
esp32.include_builtin_idf_component("esp-tls")
esp32.request_http_client()
cg.add(var.set_buffer_size_rx(config[CONF_BUFFER_SIZE_RX]))
cg.add(var.set_buffer_size_tx(config[CONF_BUFFER_SIZE_TX]))
@@ -446,9 +446,9 @@ void MicroWakeWord::loop() {
xEventGroupClearBits(this->event_group_, EventGroupBits::TASK_STOPPING);
}
// Retries on a subsequent loop if the task is still running on the other core
if ((event_group_bits & EventGroupBits::TASK_STOPPED) && this->inference_task_.deallocate()) {
if ((event_group_bits & EventGroupBits::TASK_STOPPED)) {
ESP_LOGD(TAG, "Inference task is finished, freeing task resources");
this->inference_task_.deallocate();
xEventGroupClearBits(this->event_group_, ALL_BITS);
xQueueReset(this->detection_queue_);
this->set_state_(State::STOPPED);
@@ -382,8 +382,8 @@ void MixerSpeaker::loop() {
ESP_LOGV(TAG, "Stopping");
xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_STOPPING);
}
// 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()) {
if (event_group_bits & MIXER_TASK_STATE_STOPPED) {
this->task_.deallocate();
ESP_LOGD(TAG, "Stopped");
xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS);
this->all_stopped_since_ms_ = 0;
+3 -2
View File
@@ -7,6 +7,7 @@ from esphome.components.esp32 import (
add_idf_sdkconfig_option,
idf_version,
include_builtin_idf_component,
request_tls,
)
from esphome.config_helpers import (
filter_source_files_from_defines,
@@ -364,8 +365,8 @@ async def to_code(config):
add_idf_component(name="espressif/mqtt", ref="1.0.0")
else:
include_builtin_idf_component("mqtt")
# mqtt_client.h drags in esp_tls types; esp-tls is excluded by default
include_builtin_idf_component("esp-tls")
# esp-mqtt links transport_ssl.c (esp_tls) even for plain MQTT
request_tls()
cg.add_define("USE_MQTT")
cg.add_global(mqtt_ns.using)
+1 -4
View File
@@ -284,10 +284,7 @@ async def to_code(config):
)
if CORE.is_esp32:
# Re-enable ESP-IDF's HTTP client (excluded by default to save compile time)
# and esp-tls, whose sdkconfig options below need the component present
esp32.include_builtin_idf_component("esp_http_client")
esp32.include_builtin_idf_component("esp-tls")
esp32.request_http_client()
esp32.add_idf_sdkconfig_option("CONFIG_ESP_TLS_INSECURE", True)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", True
@@ -13,6 +13,7 @@ from esphome.components.esp32 import (
get_esp32_variant,
include_builtin_idf_component,
only_on_variant,
require_mbedtls_ecp,
require_vfs_select,
)
from esphome.components.mdns import MDNSComponent, enable_mdns_storage
@@ -282,6 +283,8 @@ async def to_code(config: ConfigType) -> None:
# Re-enable openthread IDF component (excluded by default)
if CORE.is_esp32:
include_builtin_idf_component("openthread")
# OPENTHREAD_CONFIG_ECDSA_ENABLE: the SRP client host key uses mbedtls_ecdsa_*
require_mbedtls_ecp()
cg.add_define("USE_OPENTHREAD")
if config.get(CONF_FORCE_DATASET):
@@ -153,8 +153,8 @@ void ResamplerSpeaker::loop() {
ESP_LOGV(TAG, "Stopping");
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::STATE_STOPPING);
}
// Retries on a subsequent loop if the task is still running on the other core
if ((event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) && this->task_.deallocate()) {
if (event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) {
this->task_.deallocate();
ESP_LOGD(TAG, "Stopped");
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ALL_BITS);
}
@@ -202,15 +202,8 @@ AudioPipelineState AudioPipeline::process_state() {
if (!this->is_playing_) {
// The tasks have been stopped for two ``process_state`` calls in a row, so delete the tasks
if (this->read_task_.is_created() || this->decode_task_.is_created()) {
// Both are attempted every time; a task that is still running on the other core is freed by a
// subsequent call, and freeing an already freed task succeeds without doing anything
bool read_task_freed = this->read_task_.deallocate();
bool decode_task_freed = this->decode_task_.deallocate();
if (!read_task_freed || !decode_task_freed) {
// A task is still running on the other core, so keep the pipeline in its current state and try
// again on the next call
return AudioPipelineState::PLAYING;
}
this->read_task_.deallocate();
this->decode_task_.deallocate();
if (this->hard_stop_) {
// Stop command was sent, so immediately end the playback
this->speaker_->stop();
@@ -17,9 +17,8 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config: ConfigType) -> None:
# Increase the maximum supported size of headers section in HTTP request packet to be processed by the server
add_idf_sdkconfig_option("CONFIG_HTTPD_MAX_REQ_HDR_LEN", 1024)
# Re-enable esp-tls (excluded by default to save compile time);
# web_server_idf.cpp includes <esp_tls_crypto.h> for digest auth
include_builtin_idf_component("esp-tls")
# Re-enable ESP-IDF's HTTP server (excluded by default to save compile time).
# Basic auth uses mbedtls_base64_encode directly, so no TLS stack is needed.
include_builtin_idf_component("esp_http_server")
@@ -9,7 +9,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esp_tls_crypto.h"
#include <mbedtls/base64.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
@@ -544,14 +544,14 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw
constexpr size_t max_digest_len = 350;
char digest[max_digest_len];
size_t out;
esp_crypto_base64_encode(reinterpret_cast<uint8_t *>(digest), max_digest_len, &out,
reinterpret_cast<const uint8_t *>(user_info), user_info_len);
mbedtls_base64_encode(reinterpret_cast<uint8_t *>(digest), max_digest_len, &out,
reinterpret_cast<const uint8_t *>(user_info), user_info_len);
// Constant-time comparison to avoid timing side channels.
// No early return on length mismatch — the length difference is folded
// into the accumulator so any mismatch is rejected.
const char *provided = auth_str + auth_prefix_len;
size_t digest_len = out; // length from esp_crypto_base64_encode
size_t digest_len = out;
// Derive provided_len from the already-sized std::string rather than
// rescanning with strlen (avoids attacker-controlled scan length).
size_t provided_len = auth.value().size() - auth_prefix_len;
+4
View File
@@ -11,6 +11,7 @@ from esphome.components.esp32 import (
const,
get_esp32_variant,
only_on_variant,
request_tls,
request_wifi,
)
from esphome.components.network import (
@@ -658,6 +659,9 @@ async def to_code(config):
# Disable Enterprise WiFi support if no EAP is configured
if CORE.is_esp32:
add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", has_eap)
# The supplicant's Kconfig select cannot override the IDF 5 TLS role choice
if has_eap:
request_tls()
# Only define USE_WIFI_MANUAL_IP if any AP uses manual IP
if has_manual_ip:
+7 -23
View File
@@ -40,31 +40,16 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size
return true;
}
bool StaticTask::destroy() {
if (this->handle_ == nullptr) {
return true;
void StaticTask::destroy() {
if (this->handle_ != nullptr) {
TaskHandle_t handle = this->handle_;
this->handle_ = nullptr;
vTaskDelete(handle);
}
// Suspending takes the task off the ready and event lists, so nothing can schedule it again. It only asks
// the other core to yield though, so the task may still be running on it for a moment.
vTaskSuspend(this->handle_);
if (eTaskGetState(this->handle_) != eSuspended) {
// The task is still running on the other core and using its stack. Deleting it now would only put it on
// the termination list and return, so the caller has to try again once it has been swapped out.
return false;
}
// The task cannot run again, so the delete completes right away instead of being left to the idle task.
TaskHandle_t handle = this->handle_;
this->handle_ = nullptr;
vTaskDelete(handle);
return true;
}
bool StaticTask::deallocate() {
if (!this->destroy()) {
return false;
}
void StaticTask::deallocate() {
this->destroy();
if (this->stack_buffer_ != nullptr) {
RAMAllocator<StackType_t> allocator(this->use_psram_ ? RAMAllocator<StackType_t>::ALLOC_EXTERNAL
: RAMAllocator<StackType_t>::ALLOC_INTERNAL);
@@ -72,7 +57,6 @@ bool StaticTask::deallocate() {
this->stack_buffer_ = nullptr;
this->stack_size_ = 0;
}
return true;
}
} // namespace esphome
+5 -12
View File
@@ -11,7 +11,6 @@ namespace esphome {
/** Helper for FreeRTOS static task management.
* Bundles TaskHandle_t, StaticTask_t, and the stack buffer into one object with create/destroy methods.
* Call destroy() and deallocate() from another task: a task cannot free the stack it is still running on.
*/
class StaticTask {
public:
@@ -24,7 +23,7 @@ class StaticTask {
/// @brief Allocate stack and create task.
/// @param fn Task function
/// @param name Task name (for debug)
/// @param stack_size Stack size in bytes (StackType_t is a byte on ESP-IDF)
/// @param stack_size Stack size in StackType_t words
/// @param param Parameter passed to task function
/// @param priority FreeRTOS task priority
/// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM
@@ -32,17 +31,11 @@ class StaticTask {
bool create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority,
bool use_psram);
/// @brief Delete the task, keeping the stack buffer allocated for reuse by a subsequent create() call.
/// The task must have finished its work and parked itself, either suspended or blocked indefinitely: it is
/// suspended here so that it cannot be scheduled again, and it is given no chance to clean up.
/// @return true if the task was deleted; false if it is still running on another core, in which case the
/// caller should try again later.
bool destroy();
/// @brief Delete the task but keep the stack buffer allocated for reuse by a subsequent create() call.
void destroy();
/// @brief Delete the task (if created) and free the stack buffer.
/// @return true if the stack buffer was freed; false if the task is still running on another core, in
/// which case the caller should try again later.
bool deallocate();
/// @brief Delete the task (if running) and free the stack buffer.
void deallocate();
protected:
TaskHandle_t handle_{nullptr};
+1 -1
View File
@@ -10,7 +10,7 @@ tzlocal==5.4.4 # from time
tzdata>=2026.3 # from time
pyserial==3.5
platformio==6.1.19
esptool==5.4.0
esptool==5.3.1
click==8.3.3
aioesphomeapi==46.3.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
-17
View File
@@ -1104,10 +1104,6 @@ def get_components_per_integration_fixture() -> dict[str, set[str]]:
_TEST_FUNC_RE = re.compile(r"async def (test_\w+)")
# Any usage form (decorator, pytestmark assignment or list element); only
# test_*.py files are scanned, so the marker docs elsewhere cannot false-hit
_SHARED_YAML_USE_RE = re.compile(r"\bmark\.shared_yaml")
_SHARED_YAML_ARG_RE = re.compile(r"\(\s*[\"'](\w+)[\"']\s*\)")
@cache
@@ -1127,19 +1123,6 @@ def get_fixture_to_test_files() -> dict[str, frozenset[str]]:
for func in _TEST_FUNC_RE.findall(content):
base_name = func.replace("test_", "").partition("[")[0]
result.setdefault(base_name, set()).add(rel_path)
# Shared fixtures are named by marker, not by a test function; each
# decorator must carry a string literal or its fixture would silently
# map to no tests
for use in _SHARED_YAML_USE_RE.finditer(content):
arg = _SHARED_YAML_ARG_RE.match(content, use.end())
if arg is None:
line = content.count("\n", 0, use.start()) + 1
raise ValueError(
f"{rel_path}:{line}: shared_yaml marker must take a "
"single-line string literal so CI test selection can map "
"its fixture"
)
result.setdefault(arg.group(1), set()).add(rel_path)
return {k: frozenset(v) for k, v in result.items()}
@@ -0,0 +1,19 @@
esphome:
name: test
esp32:
board: esp32-c6-devkitc-1
framework:
type: esp-idf
network:
enable_ipv6: true
openthread:
channel: 13
network_name: OpenThread-8f28
network_key: 0xdfd34f0f05cad978ec4e32b0413038ff
pan_id: 0x8f28
ext_pan_id: 0xd63e8e3e495ebbc3
pskc: 0xc23a76e98f1a6483639b1ac1271e2e27
mesh_local_prefix: fd53:145f:ed22:ad81::/64
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
sdkconfig_options:
CONFIG_ESP_TLS_INSECURE: y
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
sdkconfig_options:
CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE: n
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
sdkconfig_options:
CONFIG_MBEDTLS_TLS_DISABLED: n
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
sdkconfig_options:
CONFIG_MBEDTLS_TLS_ENABLED: n
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
sdkconfig_options:
CONFIG_MBEDTLS_TLS_CLIENT_ONLY: y
@@ -0,0 +1,15 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
networks:
- ssid: "test_ssid"
eap:
username: username
password: password
identity: identity
+223 -3
View File
@@ -16,15 +16,19 @@ from esphome.components.esp32 import (
KEY_VFS_TERMIOS_REQUIRED,
VARIANT_ESP32,
VARIANTS,
MbedtlsSdkconfigData,
NetworkSdkconfigData,
RawSdkconfigValue,
_ota_downgrade_protection_errors,
_reconcile_mbedtls_sdkconfig,
_reconcile_network_sdkconfig,
_reconcile_vfs_fatfs_sdkconfig,
)
from esphome.components.esp32.const import (
KEY_ESP32,
KEY_EXCLUDE_COMPONENTS,
KEY_IDF_VERSION,
KEY_MBEDTLS_SDKCONFIG,
KEY_NETWORK_SDKCONFIG,
KEY_SDKCONFIG_OPTIONS,
KEY_VARIANT,
@@ -272,8 +276,8 @@ def test_esp32_configuration_errors(
("esp_driver_i2c", "esp_driver_ledc", "esp_driver_gptimer"),
id="i2c_ledc_ac_dimmer",
),
# esp-tls has three owners; a per-owner config makes a dropped
# re-include from any single one fail the test.
# esp-tls comes back through request_tls(); a per-owner config makes
# a dropped request from any single one fail the test.
pytest.param(
"exclusion_reincludes_http_request.yaml",
("esp-tls", "esp_http_client"),
@@ -287,8 +291,9 @@ def test_esp32_configuration_errors(
id="mqtt",
),
pytest.param(
# Basic auth uses mbedtls_base64_encode directly, so no esp-tls.
"exclusion_reincludes_web_server.yaml",
("esp-tls", "esp_http_server"),
("esp_http_server",),
id="web_server_idf",
),
pytest.param(
@@ -430,6 +435,221 @@ def test_user_sdkconfig_certificate_bundle_wins(
assert sdkconfig.get("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL") is False
_TLS_OFF_CRYPTO = {
"CONFIG_MBEDTLS_ECP_C": False,
"CONFIG_MBEDTLS_PEM_WRITE_C": False,
"CONFIG_MBEDTLS_X509_CRL_PARSE_C": False,
"CONFIG_MBEDTLS_X509_CSR_PARSE_C": False,
}
_TLS_OFF_IDF5 = {
"CONFIG_MBEDTLS_TLS_DISABLED": True,
"CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT": False,
**_TLS_OFF_CRYPTO,
}
_TLS_OFF_IDF6 = {
"CONFIG_MBEDTLS_TLS_ENABLED": False,
"CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT": False,
**_TLS_OFF_CRYPTO,
}
_PEER_CERT_PKCS7_OFF = {
"CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE": False,
"CONFIG_MBEDTLS_PKCS7_C": False,
}
_IDF5 = cv.Version(5, 5, 5)
_IDF6 = cv.Version(6, 0, 0)
@pytest.mark.parametrize(
("framework", "idf", "data", "preset", "expected", "excluded"),
[
pytest.param(
PlatformFramework.ESP32_IDF,
_IDF5,
MbedtlsSdkconfigData(),
{},
{**_TLS_OFF_IDF5, **_PEER_CERT_PKCS7_OFF},
{"esp-tls"},
id="idf5_no_tls_user",
),
pytest.param(
# An external component that only re-included esp-tls keeps TLS.
PlatformFramework.ESP32_IDF,
_IDF5,
MbedtlsSdkconfigData(),
{},
_PEER_CERT_PKCS7_OFF,
set(),
id="idf_esp_tls_reincluded",
),
pytest.param(
PlatformFramework.ESP32_IDF,
_IDF6,
MbedtlsSdkconfigData(),
{},
{
**_TLS_OFF_IDF6,
**_PEER_CERT_PKCS7_OFF,
"CONFIG_MBEDTLS_SHA384_C": False,
"CONFIG_MBEDTLS_SHA512_C": False,
},
{"esp-tls"},
id="idf6_drops_sha512",
),
pytest.param(
PlatformFramework.ESP32_IDF,
_IDF6,
MbedtlsSdkconfigData(sha512_required=True),
{},
{**_TLS_OFF_IDF6, **_PEER_CERT_PKCS7_OFF},
{"esp-tls"},
id="idf6_sha512_required",
),
pytest.param(
PlatformFramework.ESP32_IDF,
_IDF5,
MbedtlsSdkconfigData(tls_required=True),
{},
_PEER_CERT_PKCS7_OFF,
{"esp-tls"},
id="idf_tls_requested",
),
pytest.param(
PlatformFramework.ESP32_IDF,
_IDF5,
MbedtlsSdkconfigData(ecp_required=True),
{},
{
"CONFIG_MBEDTLS_TLS_DISABLED": True,
"CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT": False,
"CONFIG_MBEDTLS_PEM_WRITE_C": False,
"CONFIG_MBEDTLS_X509_CRL_PARSE_C": False,
"CONFIG_MBEDTLS_X509_CSR_PARSE_C": False,
**_PEER_CERT_PKCS7_OFF,
},
{"esp-tls"},
id="idf_ecp_without_tls",
),
pytest.param(
PlatformFramework.ESP32_IDF,
_IDF5,
MbedtlsSdkconfigData(),
{"CONFIG_MBEDTLS_ECP_C": RawSdkconfigValue("y")},
{
**_TLS_OFF_IDF5,
"CONFIG_MBEDTLS_ECP_C": RawSdkconfigValue("y"),
**_PEER_CERT_PKCS7_OFF,
},
{"esp-tls"},
id="idf_user_ecp_wins",
),
pytest.param(
PlatformFramework.ESP32_IDF,
_IDF5,
MbedtlsSdkconfigData(peer_cert_required=True, pkcs7_required=True),
{},
{
**_TLS_OFF_IDF5,
"CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE": True,
"CONFIG_MBEDTLS_PKCS7_C": True,
},
{"esp-tls"},
id="idf_peer_cert_pkcs7_required",
),
pytest.param(
PlatformFramework.ESP32_IDF,
_IDF5,
MbedtlsSdkconfigData(disable_peer_cert=False, disable_pkcs7=False),
{},
_TLS_OFF_IDF5,
{"esp-tls"},
id="idf_advanced_disables_off",
),
pytest.param(
PlatformFramework.ESP32_ARDUINO,
_IDF5,
MbedtlsSdkconfigData(),
{},
_PEER_CERT_PKCS7_OFF,
{"esp-tls"},
id="arduino_keeps_tls",
),
],
)
def test_reconcile_mbedtls_sdkconfig(
set_core_config: SetCoreConfigCallable,
framework: PlatformFramework,
idf: cv.Version,
data: MbedtlsSdkconfigData,
preset: dict[str, Any],
expected: dict[str, Any],
excluded: set[str],
) -> None:
"""The FINAL-priority reconciler turns TLS off only when nothing requested it;
user sdkconfig_options always win."""
set_core_config(framework)
CORE.data[KEY_ESP32] = {
KEY_IDF_VERSION: idf,
KEY_SDKCONFIG_OPTIONS: dict(preset),
KEY_MBEDTLS_SDKCONFIG: data,
KEY_EXCLUDE_COMPONENTS: excluded,
}
asyncio.run(_reconcile_mbedtls_sdkconfig())
assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected
@pytest.mark.parametrize(
("config_file", "tls_off", "ecp_off"),
[
pytest.param("network_ethernet_only.yaml", True, True, id="ethernet_api"),
pytest.param(
"exclusion_reincludes_web_server.yaml", True, True, id="web_server_idf"
),
# openthread's SRP host key needs ECDSA, so ECP stays while TLS is off
pytest.param("tls_openthread_c6.yaml", True, False, id="openthread"),
pytest.param(
"exclusion_reincludes_http_request.yaml", False, False, id="http_request"
),
pytest.param("exclusion_reincludes_mqtt.yaml", False, False, id="mqtt"),
pytest.param("exclusion_reincludes_nextion.yaml", False, False, id="nextion"),
pytest.param("tls_wifi_eap.yaml", False, False, id="wifi_eap"),
pytest.param(
"certificate_bundle_sdkconfig.yaml", False, False, id="raw_bundle"
),
pytest.param("tls_sdkconfig_esp_tls.yaml", False, False, id="raw_esp_tls"),
pytest.param("tls_sdkconfig_tls_role.yaml", False, False, id="raw_tls_role"),
# A role option set to n is not a request.
pytest.param(
"tls_sdkconfig_tls_enabled_n.yaml", True, True, id="raw_tls_enabled_n"
),
# Disabling a TLS sub-option is not a request either.
pytest.param(
"tls_sdkconfig_peer_cert_n.yaml", True, True, id="raw_peer_cert_n"
),
# CONFIG_MBEDTLS_TLS_DISABLED=n is the IDF 5 way to keep TLS.
pytest.param(
"tls_sdkconfig_tls_disabled_n.yaml", False, False, id="raw_tls_disabled_n"
),
# SECURE_SIGNED_APPS selects ECP back on in Kconfig; ESPHome still writes the default.
pytest.param("signed_ota_ecdsa256_c6.yaml", True, True, id="signed_ota_ecdsa"),
],
)
def test_tls_disabled_sdkconfig(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
tls_off: bool,
ecp_off: bool,
) -> None:
"""TLS is compiled out unless a component or a raw sdkconfig option asks for it."""
generate_main(component_config_path(config_file))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert (sdkconfig.get("CONFIG_MBEDTLS_TLS_DISABLED") is True) is tls_off
assert ("CONFIG_MBEDTLS_ECP_C" in sdkconfig) is ecp_off
assert ("esp-tls" in CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]) is tls_off
def test_execute_from_psram_s3_sdkconfig(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
-7
View File
@@ -21,13 +21,6 @@ The `yaml_config` fixture automatically loads YAML configurations based on the t
- The fixture file must exist or the test will fail with a clear error message
- The fixture automatically injects a dynamic port number into the API configuration
Tests marked `@pytest.mark.shared_yaml("name")` load `fixtures/name.yaml` instead
of the test-named file and compile it in a shared, hash-keyed build directory, so
the whole group pays one full compile and each test only a relink. The marker
argument must be a single-line string literal (CI test selection maps fixtures to
test files by scanning for it), and marked tests must hand the `yaml_config`
content to `run_compiled` unmodified.
### Key Fixtures
- `run_compiled` - Combines write, compile, and run operations into a single context manager
+81 -335
View File
@@ -4,22 +4,17 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Callable, Generator
from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress
from contextlib import AbstractAsyncContextManager, asynccontextmanager
import fcntl
from functools import cache
import hashlib
import logging
import os
from pathlib import Path
import platform
import re
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import time
from typing import TextIO
from aioesphomeapi import APIClient, APIConnectionError, LogParser, ReconnectLogic
@@ -28,13 +23,7 @@ import pytest_asyncio
import esphome.config
from esphome.core import CORE
from esphome.helpers import (
get_usable_cpu_count,
read_file,
rmtree,
write_file,
write_file_if_changed,
)
from esphome.helpers import get_usable_cpu_count
from esphome.platformio.toolchain import get_idedata
from .const import (
@@ -67,21 +56,6 @@ import pty # not available on Windows
pytest.register_assert_rewrite("tests.integration.entity_utils")
def pytest_configure(config: pytest.Config) -> None:
config.addinivalue_line(
"markers",
"shared_yaml(name): load fixtures/<name>.yaml and compile it in a shared, "
"hash-keyed incremental build directory",
)
FIXTURES_DIR = Path(__file__).parent / "fixtures"
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
# CI caches parts of this path; keep in sync with ci.yml integration-tests.
INTEGRATION_TESTS_ROOT = Path.home() / ".esphome-integration-tests"
def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
"""Get environment variables for PlatformIO with shared cache."""
env = os.environ.copy()
@@ -104,7 +78,7 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
)
# Compile with THIS tree's esphome sources, not wherever the venv's editable
# install points (which may be a different git worktree or checkout).
repo_root = str(REPO_ROOT)
repo_root = str(Path(__file__).resolve().parent.parent.parent)
existing = env.get("PYTHONPATH")
env["PYTHONPATH"] = f"{repo_root}{os.pathsep}{existing}" if existing else repo_root
return env
@@ -114,7 +88,8 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
def shared_platformio_cache() -> Generator[Path]:
"""Initialize a shared PlatformIO cache for all integration tests."""
# Use a dedicated directory for integration tests to avoid conflicts.
test_cache_dir = INTEGRATION_TESTS_ROOT
# CI caches parts of this path; keep in sync with ci.yml integration-tests.
test_cache_dir = Path.home() / ".esphome-integration-tests"
cache_dir = test_cache_dir / "platformio"
# Use a lock file in the home directory to ensure only one process initializes the cache
@@ -137,9 +112,7 @@ def shared_platformio_cache() -> Generator[Path]:
init_dir = Path(tmpdir)
fixture_path = Path(__file__).parent / "fixtures" / "cache_init.yaml"
config_path = init_dir / "cache_init.yaml"
config_path.write_text(
fixture_path.read_text(encoding="utf-8"), encoding="utf-8"
)
config_path.write_text(fixture_path.read_text())
# Run compilation to populate the cache
# We must succeed here to avoid race conditions where multiple
@@ -189,6 +162,13 @@ def integration_test_dir() -> Generator[Path]:
yield Path(tmpdir)
@pytest.fixture
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Host preferences persist per device name; give the test its own so a
provisioned key never leaks into another run."""
monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs"))
@pytest.fixture
def reserved_tcp_port() -> Generator[tuple[int, socket.socket]]:
"""Reserve an unused TCP port by holding the socket open."""
@@ -208,29 +188,21 @@ def unused_tcp_port(reserved_tcp_port: tuple[int, socket.socket]) -> int:
return reserved_tcp_port[0]
@pytest.fixture(autouse=True)
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
"""Give every test its own host prefs dir; prefs are keyed only by device
name, which tests sharing a fixture also share."""
prefdir = tmp_path / "prefs"
monkeypatch.setenv("ESPHOME_PREFDIR", str(prefdir))
return prefdir
@pytest_asyncio.fixture
async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> str:
"""Load YAML configuration based on test name."""
shared_name = _shared_yaml_name(request)
# Base test name: test_ prefix and any parametrization stripped
base_name = shared_name or request.node.name.replace("test_", "").partition("[")[0]
# Get the test function name
test_name: str = request.node.name
# Extract the base test name (remove test_ prefix and any parametrization)
base_name = test_name.replace("test_", "").partition("[")[0]
# Load the fixture file
fixture_path = FIXTURES_DIR / f"{base_name}.yaml"
fixture_path = Path(__file__).parent / "fixtures" / f"{base_name}.yaml"
if not fixture_path.exists():
raise FileNotFoundError(f"Fixture file not found: {fixture_path}")
loop = asyncio.get_running_loop()
content = await loop.run_in_executor(None, read_file, fixture_path)
content = await loop.run_in_executor(None, fixture_path.read_text)
# Replace the port in the config if it contains api section
if "api:" in content:
@@ -254,13 +226,11 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s
# Replace external component path placeholder if present
if "EXTERNAL_COMPONENT_PATH" in content:
external_components_path = str(FIXTURES_DIR / "external_components")
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
content = content.replace("EXTERNAL_COMPONENT_PATH", external_components_path)
if shared_name is not None:
# _compile verifies the marked test compiles this content unmodified
request.node._shared_yaml_content = content
return content
@@ -270,218 +240,24 @@ async def write_yaml_config(
) -> AsyncGenerator[ConfigWriter]:
"""Write YAML configuration to a file."""
# Get the test name for default filename
base_name = request.node.name.replace("test_", "").partition("[")[0]
test_name = request.node.name
base_name = test_name.replace("test_", "").split("[")[0]
async def _write_config(content: str, filename: str | None = None) -> Path:
if filename is None:
filename = f"{base_name}.yaml"
config_path = integration_test_dir / filename
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, write_file, config_path, content)
await loop.run_in_executor(None, config_path.write_text, content)
return config_path
yield _write_config
# Deliberately not CI-cached (ci.yml caches only platformio/ subpaths); stale
# dirs for a fixture are pruned when its content hash changes.
SHARED_BUILDS_ROOT = INTEGRATION_TESTS_ROOT / "builds"
# In the dir name (not just the hash) so pruning stays inside this checkout
_REPO_KEY = hashlib.sha256(str(REPO_ROOT).encode()).hexdigest()[:8]
# Give a contended shared build lock time for a full cold compile ahead of us
_SHARED_LOCK_TIMEOUT_S = 900
_SHARED_LOCK_POLL_S = 0.1
_SHARED_LOCK_REPORT_S = 30
# Reclaims dirs orphaned by fixture renames or deleted checkouts
_STALE_BUILD_MAX_AGE_S = 30 * 24 * 3600
# ELF path per shared build dir; constant once compiled, so resolve it only once
_shared_elf_paths: dict[Path, Path] = {}
# Dirs this process already swept; pruning is session-scoped work
_pruned_dirs: set[Path] = set()
def _shared_yaml_name(request: pytest.FixtureRequest) -> str | None:
"""Name passed to the shared_yaml marker, or None when unmarked."""
marker = request.node.get_closest_marker("shared_yaml")
if marker is None:
return None
# Exactly one \w+ positional arg: the name doubles as a build dir
# component, and CI test selection (script/helpers.py) parses the same shape
if (
len(marker.args) != 1
or marker.kwargs
or not re.fullmatch(r"\w+", str(marker.args[0]))
):
raise ValueError(
"shared_yaml marker requires exactly one \\w+ fixture name literal"
)
return marker.args[0]
def _shared_build_prefix(name: str) -> str:
return f"{name}-{_REPO_KEY}-"
@cache
def _shared_build_dir(name: str) -> Path:
"""Dir keyed by checkout and fixture source, before per-test injections."""
key = hashlib.sha256((FIXTURES_DIR / f"{name}.yaml").read_bytes()).hexdigest()[:16]
return SHARED_BUILDS_ROOT / (_shared_build_prefix(name) + key)
def _read_stamp(stamp: Path, shared_dir: Path) -> Path | None:
"""ELF path recorded by the last completed compile, or None."""
try:
text = stamp.read_text(encoding="utf-8").strip()
except FileNotFoundError:
return None
except OSError as err:
print(f"Cannot read {stamp}: {err}")
return None
if not text:
print(f"Ignoring empty stamp {stamp}")
return None
built = Path(text)
# Never trust a stamp pointing outside its own build dir as an unlink target
if shared_dir.resolve() in built.resolve().parents:
return built
print(f"Ignoring stamp {stamp} pointing outside {shared_dir}")
return None
def _unused_since(stale: Path, cutoff: float) -> bool:
"""Whether a build dir looks untouched since cutoff; unknown counts as used."""
# Newest of the .built stamp (rewritten by every completed compile) and the
# dir itself (freshened by a worker claiming the dir before locking)
newest: float | None = None
for probe in (stale / ".built", stale):
try:
mtime = probe.stat().st_mtime
except FileNotFoundError:
continue
except NotADirectoryError:
return True # a stray file where a dir should be; reclaimable
except OSError as err:
print(f"Cannot age-probe {stale}: {err}")
return False # unknown never authorizes deletion
newest = mtime if newest is None else max(newest, mtime)
return newest is not None and newest < cutoff
def _prune_stale_builds(name: str, keep: Path) -> None:
"""Remove outdated build dirs (blocking, run in executor): this checkout's
other dirs for the fixture, plus anything untouched for 30 days. Tolerates
other workers pruning the same dirs concurrently."""
cutoff = time.time() - _STALE_BUILD_MAX_AGE_S
prefix = _shared_build_prefix(name)
for stale in SHARED_BUILDS_ROOT.iterdir():
if stale == keep:
continue
same_fixture = stale.name.startswith(prefix)
if not same_fixture and not _unused_since(stale, cutoff):
continue
# Creating .lock bumps the dir mtime, so remember whether the re-probe
# under the lock can trust it
lock_preexisting = (stale / ".lock").exists()
try:
lock_file = (stale / ".lock").open("w")
except FileNotFoundError:
continue # pruned by another worker meanwhile
except NotADirectoryError:
print(f"Removing stray file {stale}")
stale.unlink(missing_ok=True)
continue
except OSError as err:
print(f"Cannot prune {stale}: {err}")
continue
with lock_file:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
continue # still in use by another run
# Re-probe under the lock: a worker freshens its dir before
# locking, so a just-claimed dir no longer looks unused. A dir
# whose .lock we just created cannot be held by anyone, and our
# own open bumped its mtime, so its pre-open probe stands
if (
lock_preexisting
and not same_fixture
and not _unused_since(stale, cutoff)
):
continue
# rmtree tolerates races; a leftover partial tree only costs a
# rebuild, since the ELF is deleted before every compile
try:
rmtree(stale)
except OSError as err:
print(f"Failed to prune {stale}: {err}")
async def _run_esphome_compile(
config_path: Path, cwd: Path, env: dict[str, str]
) -> None:
"""Run `esphome compile`, retrying up to 3 times on a segfault."""
max_retries = 3
for attempt in range(max_retries):
# Compile using subprocess, inheriting stdout/stderr to show progress
proc = await asyncio.create_subprocess_exec(
sys.executable,
"-m",
"esphome",
"compile",
str(config_path),
cwd=cwd,
stdout=None, # Inherit stdout
stderr=None, # Inherit stderr
stdin=asyncio.subprocess.DEVNULL,
# Start in a new process group to isolate signal handling
start_new_session=True,
env=env,
close_fds=False,
)
await proc.wait()
if proc.returncode == 0:
break
if proc.returncode == -11 and attempt < max_retries - 1:
# Segfault (-11 = SIGSEGV), retry
print(
f"Compilation segfaulted (attempt {attempt + 1}/{max_retries}), retrying..."
)
await asyncio.sleep(1) # Brief pause before retry
continue
raise RuntimeError(
f"Failed to compile {config_path}, return code: {proc.returncode}. "
f"Run with 'pytest -s' to see compilation output."
)
def _resolve_compiled_binary(config_path: Path) -> Path:
"""Load the config to learn the compiled ELF path (blocking, run in executor)."""
CORE.reset() # Reset CORE state between test runs
CORE.config_path = config_path
config = esphome.config.read_config(
{"command": "compile", "config": str(config_path)}
)
if config is None:
raise RuntimeError(f"Failed to read config from {config_path}")
idedata = get_idedata(config)
binary_path = Path(idedata.firmware_elf_path)
if not binary_path.exists():
raise RuntimeError(f"Compiled binary not found at {binary_path}")
return binary_path
@pytest_asyncio.fixture
async def compile_esphome(
integration_test_dir: Path,
shared_platformio_cache: Path,
request: pytest.FixtureRequest,
) -> AsyncGenerator[CompileFunction]:
"""Compile an ESPHome configuration and return the binary path."""
@@ -489,96 +265,66 @@ async def compile_esphome(
# Use the shared PlatformIO cache for faster compilation
# This avoids re-downloading dependencies for each test
env = _get_platformio_env(shared_platformio_cache)
# Retry compilation up to 3 times if we get a segfault
max_retries = 3
for attempt in range(max_retries):
# Compile using subprocess, inheriting stdout/stderr to show progress
proc = await asyncio.create_subprocess_exec(
sys.executable,
"-m",
"esphome",
"compile",
str(config_path),
cwd=integration_test_dir,
stdout=None, # Inherit stdout
stderr=None, # Inherit stderr
stdin=asyncio.subprocess.DEVNULL,
# Start in a new process group to isolate signal handling
start_new_session=True,
env=env,
close_fds=False,
)
await proc.wait()
if proc.returncode == 0:
# Success!
break
if proc.returncode == -11 and attempt < max_retries - 1:
# Segfault (-11 = SIGSEGV), retry
print(
f"Compilation segfaulted (attempt {attempt + 1}/{max_retries}), retrying..."
)
await asyncio.sleep(1) # Brief pause before retry
continue
# Other error or final retry
raise RuntimeError(
f"Failed to compile {config_path}, return code: {proc.returncode}. "
f"Run with 'pytest -s' to see compilation output."
)
# Load the config to get idedata (blocking call, must use executor)
loop = asyncio.get_running_loop()
name = _shared_yaml_name(request)
if name is None:
await _run_esphome_compile(config_path, integration_test_dir, env)
return await loop.run_in_executor(
None, _resolve_compiled_binary, config_path
def _read_config_and_get_binary():
CORE.reset() # Reset CORE state between test runs
CORE.config_path = config_path
config = esphome.config.read_config(
{"command": "compile", "config": str(config_path)}
)
if config is None:
raise RuntimeError(f"Failed to read config from {config_path}")
# Shared fixture: build in a hash-keyed dir so tests sharing a config
# pay one full compile and later only a main.cpp (port) rebuild + relink
shared_dir = _shared_build_dir(name)
shared_dir.mkdir(parents=True, exist_ok=True)
# Freshen the dir before locking so a concurrent age sweep, which
# re-probes under the lock, never reaps a dir a worker just claimed;
# if a peer reaped it already, the guarded lock open recreates it
with suppress(FileNotFoundError):
os.utime(shared_dir)
if shared_dir not in _pruned_dirs:
_pruned_dirs.add(shared_dir)
await loop.run_in_executor(None, _prune_stale_builds, name, shared_dir)
shared_config = shared_dir / f"{name}.yaml"
private_binary = integration_test_dir / f"{name}.elf"
content = await loop.run_in_executor(None, read_file, config_path)
if content != getattr(request.node, "_shared_yaml_content", None):
# The dir is keyed by the fixture source; a mutated config would be
# cached under a hash that does not describe it
raise RuntimeError(
"shared_yaml tests must compile the yaml_config content unmodified"
)
# flock serializes concurrent xdist workers; closing the fd releases it.
# Hand-rolled rather than filelock.FileLock: non-blocking retries keep
# the wait cancellable, while a blocking acquire in an executor thread
# would survive test cancellation holding the fd
try:
lock_file = (shared_dir / ".lock").open("w")
except FileNotFoundError:
# A peer run pruning divergent hashes reaped the dir between our
# mkdir and this open; recreate it and pay a full rebuild
shared_dir.mkdir(parents=True, exist_ok=True)
lock_file = (shared_dir / ".lock").open("w")
with lock_file:
start = time.monotonic()
last_report = start
while True:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError:
now = time.monotonic()
if now - start > _SHARED_LOCK_TIMEOUT_S:
raise RuntimeError(
f"Timed out waiting for the {shared_dir} lock"
) from None
if now - last_report >= _SHARED_LOCK_REPORT_S:
last_report = now
print(
f"Waited {now - start:.0f}s for another worker's "
f"build of {shared_dir.name}"
)
await asyncio.sleep(_SHARED_LOCK_POLL_S)
# .built carries the ELF path of the last completed compile, so
# later workers skip the config re-read in _resolve_compiled_binary
stamp = shared_dir / ".built"
if (built := _shared_elf_paths.get(shared_dir)) is None:
built = await loop.run_in_executor(None, _read_stamp, stamp, shared_dir)
# Delete the ELF before compiling: whatever exists afterwards is
# this compile's output, so no staleness check is ever needed.
# With no usable stamp, sweep any leftover at the known layout
if built is not None:
built.unlink(missing_ok=True)
else:
# Layout-agnostic: ESPHOME_BUILD_PATH can move the build tree
for leftover in shared_dir.rglob("program"):
if leftover.is_file():
leftover.unlink()
await loop.run_in_executor(
None, write_file_if_changed, shared_config, content
)
await _run_esphome_compile(shared_config, shared_dir, env)
if built is None or not built.exists():
built = await loop.run_in_executor(
None, _resolve_compiled_binary, shared_config
)
_shared_elf_paths[shared_dir] = built
await loop.run_in_executor(None, write_file, stamp, str(built))
# Copy out before unlocking: another worker may relink firmware.elf
# while this test is still running its private copy
await loop.run_in_executor(None, shutil.copy2, built, private_binary)
return private_binary
# Get the compiled binary path
idedata = get_idedata(config)
return Path(idedata.firmware_elf_path)
binary_path = await loop.run_in_executor(None, _read_config_and_get_binary)
if not binary_path.exists():
raise RuntimeError(f"Compiled binary not found at {binary_path}")
return binary_path
yield _compile
@@ -0,0 +1,58 @@
esphome:
name: test-batch-window-filters
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: DEBUG
# Template sensor that we'll use to publish values
sensor:
- platform: template
name: "Source Sensor"
id: source_sensor
accuracy_decimals: 2
# Batch window filters (window_size == send_every) - use streaming filters
- platform: copy
source_id: source_sensor
name: "Min Sensor"
id: min_sensor
filters:
- min:
window_size: 5
send_every: 5
send_first_at: 1
- platform: copy
source_id: source_sensor
name: "Max Sensor"
id: max_sensor
filters:
- max:
window_size: 5
send_every: 5
send_first_at: 1
- platform: copy
source_id: source_sensor
name: "Moving Avg Sensor"
id: moving_avg_sensor
filters:
- sliding_window_moving_average:
window_size: 5
send_every: 5
send_first_at: 1
# Button to trigger publishing test values
button:
- platform: template
name: "Publish Values Button"
id: publish_button
on_press:
- lambda: |-
// Publish 10 values: 1.0, 2.0, ..., 10.0
for (int i = 1; i <= 10; i++) {
id(source_sensor).publish_state(float(i));
}
@@ -0,0 +1,111 @@
esphome:
name: uart-mock-modbus-cli-rw
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
# Two virtual buses looped back to each other: the client's transmissions reach the server and the
# server's replies reach the client. auto_start so forwarding is active before the button fires.
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_client
data: !lambda return data;
- id: virtual_uart_client
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: stored_1
type: uint16_t
initial_value: "0"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_client
id: virtual_modbus_client
role: client
turnaround_time: 10ms
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
registers:
# Writable + readable register: the read publishes what it returns, so the test can confirm the
# write half of the 0x17 ran before the read half (Modbus 6.17).
- address: 0x01
value_type: U_WORD
read_lambda: |-
id(srv_read_1).publish_state(id(stored_1));
return id(stored_1);
write_lambda: |-
id(stored_1) = x;
id(srv_write_1).publish_state(x);
return true;
# Read-only register, returned together with 0x01 by the 2-register read half.
- address: 0x02
value_type: U_WORD
read_lambda: return 0x00AA;
sensor:
# Server-side observations.
- platform: template
name: "srv_write_1"
id: srv_write_1
- platform: template
name: "srv_read_1"
id: srv_read_1
# Client-side read-back: the values the client's on_response received.
- platform: template
name: "client_read_0"
id: client_read_0
- platform: template
name: "client_read_1"
id: client_read_1
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
# FC 0x17: write reg 0x0001 = 0x1234, then read regs 0x0001..0x0002 back in the same transaction.
- modbus_client.read_write_multiple_registers:
address: 0x01
read_address: 0x0001
read_count: 2
write_address: 0x0001
values: [0x1234]
on_response:
then:
- lambda: |-
// values is the read-back block: reg 0x0001 (must be the just-written 0x1234) and reg 0x0002.
if (values.size() >= 2) {
id(client_read_0).publish_state(values[0]);
id(client_read_1).publish_state(values[1]);
}
@@ -0,0 +1,88 @@
esphome:
name: uart-mock-modbus-custom-pdu
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 259;
sensor:
# Plain read to confirm the controller <-> server link is up.
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "plain_read"
address: 0x01
register_type: holding
value_type: U_WORD
# Custom PDU: read holding register 0x0001, count 1. The PDU is
# {function code, address hi, address lo, count hi, count lo}; the device
# address and CRC are added by the hub. The lambda parses the response payload
# (the register value, big-endian).
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "custom_read"
custom_pdu: [0x03, 0x00, 0x01, 0x00, 0x01]
lambda: |-
if (data.size() < 2) return {};
return (float) ((data[0] << 8) | data[1]);
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
@@ -0,0 +1,106 @@
esphome:
name: uart-mock-modbus-dep-buffer
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: reg10
type: uint16_t
initial_value: "0"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x10
value_type: U_WORD
read_lambda: return id(reg10);
write_lambda: |-
id(reg10) = x;
return true;
# A number whose write_lambda uses the DEPRECATED buffer parameter (fills `payload` with a legacy raw
# frame as words: device address + function code + data) instead of the new item->write_* API. The write
# must still land with its legacy semantics, and the one-time deprecation warning must fire only once per
# entity no matter how many writes happen.
number:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "buf_number"
id: buf_number
address: 0x10
register_type: holding
value_type: U_WORD
min_value: 0
max_value: 1000
step: 1
write_lambda: |-
// Legacy raw frame as words: [addr 0x01 | fc 0x06], register 0x0010, value.
payload.push_back(0x0106);
payload.push_back(0x0010);
payload.push_back((uint16_t) x);
return {};
# Reports the server-side register so the test can observe that the deprecated buffer write landed.
sensor:
- platform: template
name: "written_value"
id: written_value
update_interval: 0.5s
lambda: "return id(reg10);"
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# The test drives the writes via number_command; the mock is autostart.
@@ -0,0 +1,95 @@
esphome:
name: uart-mock-modbus-lambda-invert
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: reg40
type: uint16_t
initial_value: "5"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x40
value_type: U_WORD
read_lambda: return id(reg40);
write_lambda: id(reg40) = x; return true;
# An active-low holding switch: the write_lambda inverts the wire value, but the entity must still
# report the REQUESTED state. assumed_state keeps the register unpolled, so the published state comes
# only from write_state() - turning ON writes 0x0000 yet the switch shows ON.
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "invert_switch"
register_type: holding
address: 0x40
assumed_state: true
write_lambda: |-
return !x;
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_40"
address: 0x40
register_type: holding
value_type: U_WORD
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
@@ -0,0 +1,97 @@
esphome:
name: uart-mock-modbus-lambda-write
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: reg30
type: uint16_t
initial_value: "0"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x30
value_type: U_WORD
read_lambda: return id(reg30);
write_lambda: id(reg30) = x; return true;
# A COIL-type switch (assumed_state, write-only) whose write_lambda ignores its own coil type and instead
# drives a HOLDING-REGISTER write on the mock server through the entity itself: `item` IS the command, so
# item->write_single_register() sends a register write from a coil entity (cross-type). Returning nothing
# (an empty optional) tells the write path the lambda already dispatched the frame - no default coil write.
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "cross_switch"
register_type: coil
address: 0x00
assumed_state: true
write_lambda: |-
item->write_single_register(0x30, x ? 1234 : 0);
return {};
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_30"
address: 0x30
register_type: holding
value_type: U_WORD
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
@@ -1,233 +0,0 @@
esphome:
name: uart-mock-modbus-loopback
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
# Shared loopback fixture (see the shared_yaml markers in the test file);
# register spaces are disjoint so each test only observes its own entities.
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: reg10
type: uint16_t
initial_value: "100"
- id: reg11
type: uint16_t
initial_value: "200"
- id: reg12
type: uint16_t
initial_value: "300"
- id: reg13
type: uint16_t
initial_value: "0xABCD"
- id: reg30
type: uint16_t
initial_value: "0"
- id: reg40
type: uint16_t
initial_value: "5"
- id: reg50
type: uint16_t
initial_value: "0"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 259;
- address: 0x10
value_type: U_WORD
read_lambda: return id(reg10);
write_lambda: id(reg10) = x; return true;
- address: 0x11
value_type: U_WORD
read_lambda: return id(reg11);
write_lambda: id(reg11) = x; return true;
- address: 0x12
value_type: U_WORD
read_lambda: return id(reg12);
write_lambda: id(reg12) = x; return true;
- address: 0x13
value_type: U_WORD
read_lambda: return id(reg13);
- address: 0x30
value_type: U_WORD
read_lambda: return id(reg30);
write_lambda: id(reg30) = x; return true;
- address: 0x40
value_type: U_WORD
read_lambda: return id(reg40);
write_lambda: id(reg40) = x; return true;
- address: 0x50
value_type: U_WORD
read_lambda: return id(reg50);
write_lambda: id(reg50) = x; return true;
# Byte-based offset: 2 bytes -> register 0x11 (the old code folded it in as a
# register count, hitting 0x12). assumed_state keeps the switch write-only.
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "offset_switch"
register_type: holding
address: 0x10
offset: 2
assumed_state: true
# Reading switch, byte offset 6 -> register 0x13; the pre-fix resolution (0x16)
# would draw ILLEGAL_DATA_ADDRESS and never publish.
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "read_offset_switch"
register_type: holding
address: 0x10
offset: 6
bitmask: 0x1
# Coil switch whose write_lambda dispatches a holding-register write via `item`;
# returning an empty optional suppresses the default coil write.
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "cross_switch"
register_type: coil
address: 0x00
assumed_state: true
write_lambda: |-
item->write_single_register(0x30, x ? 1234 : 0);
return {};
# Active-low: the write_lambda inverts the wire value but the entity must still
# report the requested state (assumed_state keeps the register unpolled).
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "invert_switch"
register_type: holding
address: 0x40
assumed_state: true
write_lambda: |-
return !x;
# Uses the deprecated buffer parameter (legacy raw frame as words); the write
# must land and the deprecation warning must fire only once per entity.
number:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "buf_number"
id: buf_number
address: 0x50
register_type: holding
value_type: U_WORD
min_value: 0
max_value: 1000
step: 1
write_lambda: |-
// Legacy raw frame as words: [addr 0x01 | fc 0x06], register 0x0050, value.
payload.push_back(0x0106);
payload.push_back(0x0050);
payload.push_back((uint16_t) x);
return {};
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "plain_read"
address: 0x01
register_type: holding
value_type: U_WORD
# Custom PDU: read holding register 0x0001; device address and CRC are added
# by the hub. The lambda parses the big-endian register value.
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "custom_read"
custom_pdu: [0x03, 0x00, 0x01, 0x00, 0x01]
lambda: |-
if (data.size() < 2) return {};
return (float) ((data[0] << 8) | data[1]);
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_10"
address: 0x10
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_11"
address: 0x11
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_12"
address: 0x12
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_30"
address: 0x30
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_40"
address: 0x40
register_type: holding
value_type: U_WORD
# Reports the server-side register so the test can observe that the deprecated buffer write landed.
- platform: template
name: "written_value"
id: written_value
update_interval: 0.5s
lambda: "return id(reg50);"
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# Nothing to start (mock is autostart); tests drive entities directly
@@ -0,0 +1,138 @@
esphome:
name: uart-mock-modbus-reg-offset
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: reg10
type: uint16_t
initial_value: "100"
- id: reg11
type: uint16_t
initial_value: "200"
- id: reg12
type: uint16_t
initial_value: "300"
- id: reg13
type: uint16_t
initial_value: "0xABCD"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x10
value_type: U_WORD
read_lambda: return id(reg10);
write_lambda: id(reg10) = x; return true;
- address: 0x11
value_type: U_WORD
read_lambda: return id(reg11);
write_lambda: id(reg11) = x; return true;
- address: 0x12
value_type: U_WORD
read_lambda: return id(reg12);
write_lambda: id(reg12) = x; return true;
- address: 0x13
value_type: U_WORD
read_lambda: return id(reg13);
write_lambda: id(reg13) = x; return true;
# A holding-register switch at 0x10 with a 2-BYTE offset. offset is byte-based, so the write must target
# register 0x10 + 2/2 = 0x11. The old (pre-fix) behavior folded offset into the address as a register
# count, hitting 0x12 instead. assumed_state keeps the switch write-only so it does not read any register.
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "offset_switch"
register_type: holding
address: 0x10
offset: 2
assumed_state: true
# A holding-register switch that READS its state. Byte offset 6 -> register 0x10 + 6/2 = 0x13. Post-fix
# the switch itself resolves to 0x13 (the even byte offset folds into the address as whole registers) and
# joins the 0x10..0x13 range, so no separate 0x13 sensor is needed. Pre-fix the whole byte offset folds
# into the address (0x16), where the server answers ILLEGAL_DATA_ADDRESS and the switch never publishes.
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "read_offset_switch"
register_type: holding
address: 0x10
offset: 6
bitmask: 0x1
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_10"
address: 0x10
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_11"
address: 0x11
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_12"
address: 0x12
register_type: holding
value_type: U_WORD
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
@@ -0,0 +1,124 @@
esphome:
name: uart-mock-modbus-server-test
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_dev
baud_rate: 9600
rx_full_threshold: 120
rx_timeout: 2
auto_start: false
debug:
injections:
- delay: 100ms
inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read)
- delay: 100ms
# Read holding register 7 on device 2
# Reply from device 2
# Read holding register 5 on device 1 (read_after_peer_response)
inject_rx:
[
0x02,
0x03,
0x00,
0x07,
0x00,
0x01,
0x35,
0xF8,
0x02,
0x03,
0x02,
0x00,
0xF0,
0xFC,
0x00,
0x01,
0x03,
0x00,
0x05,
0x00,
0x01,
0x94,
0x0B,
]
- delay: 100ms
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] # Read holding register 7 on device 2, with no response
- delay: 100ms
# Read holding register 7 on device 2, with no response
# Read holding register A on device 1 (read_after_peer_timeout)
inject_rx:
[
0x02,
0x03,
0x00,
0x07,
0x00,
0x01,
0x35,
0xF8,
0x01,
0x03,
0x00,
0x0A,
0x00,
0x01,
0xA4,
0x08,
]
modbus:
uart_id: virtual_uart_dev
role: server
modbus_server:
- address: 1
registers:
- address: 0x03
value_type: U_WORD
read_lambda: |-
id(basic_read).publish_state(1);
return 1;
- address: 0x05
value_type: U_WORD
read_lambda: |-
id(read_after_peer_response).publish_state(1);
return 1;
- address: 0x0A
value_type: U_WORD
read_lambda: |-
id(read_after_peer_timeout).publish_state(1);
return 1;
sensor:
- platform: template
name: "basic_read"
id: basic_read
- platform: template
name: "read_after_peer_response"
id: read_after_peer_response
- platform: template
name: "read_after_peer_timeout"
id: read_after_peer_timeout
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: "id(virtual_uart_dev).start_scenario();"
@@ -1,5 +1,5 @@
esphome:
name: uart-mock-modbus-mesh
name: uart-mock-modbus-server-contro
host:
api:
@@ -17,14 +17,13 @@ uart:
baud_rate: 115200
port: /dev/null
# Shared 3-bus mesh (see the shared_yaml markers): addr 1 = typed read-only
# registers, addr 5 = the read/write 0x17 target, addr 2/3 on the second
# server hub. auto_start everywhere: the controller polls at boot, so the
# forwarding must already be live or early requests generate warnings.
# Every test presses Start Scenario, so all merged actions fire in every test.
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
# auto_start must be true for loopback fixtures: the modbus controller
# polls on its update_interval immediately at boot, so the uart_mock
# forwarding must already be active or early requests are lost and
# generate modbus warnings.
auto_start: true
debug:
on_tx:
@@ -32,68 +31,35 @@ uart_mock:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- uart_mock.inject_rx:
id: virtual_uart_server_2
data: !lambda return data;
- id: virtual_uart_server_2
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
auto_start: true # See comment on virtual_uart_server above
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
- uart_mock.inject_rx:
id: virtual_uart_server_2
data: !lambda return data;
globals:
- id: stored_1
type: uint16_t
initial_value: "0"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_server_2
id: virtual_modbus_server_2
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_client
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_client
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
- address: 2
modbus_id: virtual_modbus_client
id: modbus_controller_2
update_interval: 1s
- address: 3
modbus_id: virtual_modbus_client
id: modbus_controller_3
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x01
value_type: U_WORD
@@ -137,34 +103,6 @@ modbus_server:
- address: 0x28
value_type: FP32_R
read_lambda: return 3.14;
- address: 5
modbus_id: virtual_modbus_server
registers:
# Writable + readable register: srv_write_1 plus the client's read-back
# confirm the write half of the 0x17 ran before the read half (Modbus 6.17).
- address: 0x01
value_type: U_WORD
read_lambda: return id(stored_1);
write_lambda: |-
id(stored_1) = x;
id(srv_write_1).publish_state(x);
return true;
# Read-only register, returned together with 0x01 by the 2-register read half.
- address: 0x02
value_type: U_WORD
read_lambda: return 0x00AA;
- address: 2
modbus_id: virtual_modbus_server_2
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 919;
- address: 3
modbus_id: virtual_modbus_server_2
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 929;
sensor:
- platform: modbus_controller
@@ -257,46 +195,9 @@ sensor:
address: 0x28
register_type: holding
value_type: FP32_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_2
name: "multi_reg_a"
address: 0x01
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_3
name: "multi_reg_b"
address: 0x01
register_type: holding
value_type: U_WORD
# client_read_write observations, server- and client-side.
- platform: template
name: "srv_write_1"
id: srv_write_1
- platform: template
name: "client_read_0"
id: client_read_0
- platform: template
name: "client_read_1"
id: client_read_1
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
# FC 0x17: write reg 0x0001 = 0x1234, then read regs 0x0001..0x0002 back in the same transaction.
- modbus_client.read_write_multiple_registers:
address: 5
read_address: 0x0001
read_count: 2
write_address: 0x0001
values: [0x1234]
on_response:
then:
- lambda: |-
// values is the read-back block: reg 0x0001 (must be the just-written 0x1234) and reg 0x0002.
if (values.size() >= 2) {
id(client_read_0).publish_state(values[0]);
id(client_read_1).publish_state(values[1]);
}
# This test does not have anything to start (mock is autostart)
@@ -0,0 +1,116 @@
esphome:
name: uart-mock-modbus-server-mult
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
# auto_start must be true for loopback fixtures: the modbus controller
# polls on its update_interval immediately at boot, so the uart_mock
# forwarding must already be active or early requests are lost and
# generate modbus warnings.
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- uart_mock.inject_rx:
id: virtual_uart_server_2
data: !lambda return data;
- id: virtual_uart_server_2
baud_rate: 9600
auto_start: true # See comment on virtual_uart_server above
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true # See comment on virtual_uart_server above
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
- uart_mock.inject_rx:
id: virtual_uart_server_2
data: !lambda return data;
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_server_2
id: virtual_modbus_server_2
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_client
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_client
update_interval: 1s
id: modbus_controller_1
- address: 2
modbus_id: virtual_modbus_client
update_interval: 1s
id: modbus_controller_2
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 919;
- address: 2
modbus_id: virtual_modbus_server_2
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 929;
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_word"
address: 0x01
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_2
name: "reg_u_word_2"
address: 0x01
register_type: holding
value_type: U_WORD
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
@@ -1,5 +1,5 @@
esphome:
name: uart-mock-modbus-srv-injected
name: uart-mock-modbus-srv-rw
host:
api:
@@ -17,8 +17,6 @@ uart:
baud_rate: 115200
port: /dev/null
# Shared server-role fixture (see the shared_yaml markers in the test file);
# the injections concatenate and each test waits only on its own sensors.
uart_mock:
- id: virtual_uart_dev
baud_rate: 9600
@@ -27,31 +25,18 @@ uart_mock:
auto_start: false
debug:
injections:
- delay: 100ms
inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read)
- delay: 100ms
# Read holding register 7 on device 2, its reply, then read holding
# register 5 on device 1 (read_after_peer_response)
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8,
0x02, 0x03, 0x02, 0x00, 0xF0, 0xFC,
0x00, 0x01, 0x03, 0x00, 0x05, 0x00, 0x01, 0x94, 0x0B]
- delay: 100ms
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] # Read holding register 7 on device 2, with no response
- delay: 100ms
# Read holding register 7 on device 2 with no response, then read
# holding register A on device 1 (read_after_peer_timeout)
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8,
0x01, 0x03, 0x00, 0x0A, 0x00, 0x01, 0xA4, 0x08]
# FC 0x17 on device 1: write reg 0x0001 = 0x1234 then read 0x0001..0x0002;
# per Modbus 6.17 the write runs first, so 0x0001 must read back 0x1234.
# FC 0x17 Read/Write Multiple Registers on device 1:
# write reg 0x0001 = 0x1234 (qty 1), then read regs 0x0001..0x0002 (qty 2).
# Per Modbus 6.17 the write is performed before the read, so reg 0x0001 must
# read back the just-written 0x1234 in the same request.
- delay: 100ms
inject_rx:
[0x01, 0x17, 0x00, 0x01, 0x00, 0x02, 0x00, 0x01, 0x00, 0x01, 0x02, 0x12, 0x34, 0x49, 0xD8]
# FC 0x17: write reg 0x0006 = 0x5678 (qty 1), then read reg 0x0006 (qty 1) -
# FC 0x17: write reg 0x0003 = 0x5678 (qty 1), then read reg 0x0003 (qty 1) -
# a write and read targeting a different register block.
- delay: 100ms
inject_rx:
[0x01, 0x17, 0x00, 0x06, 0x00, 0x01, 0x00, 0x06, 0x00, 0x01, 0x02, 0x56, 0x78, 0x8B, 0x55]
[0x01, 0x17, 0x00, 0x03, 0x00, 0x01, 0x00, 0x03, 0x00, 0x01, 0x02, 0x56, 0x78, 0x9B, 0x10]
globals:
- id: stored_1
@@ -85,18 +70,8 @@ modbus_server:
read_lambda: |-
id(rw_read_2).publish_state(0x00AA);
return 0x00AA;
# Second writable + readable register, targeted by the second request.
- address: 0x03
value_type: U_WORD
read_lambda: |-
id(basic_read).publish_state(1);
return 1;
- address: 0x05
value_type: U_WORD
read_lambda: |-
id(read_after_peer_response).publish_state(1);
return 1;
# Second writable + readable register, targeted by the second FC 0x17 request.
- address: 0x06
value_type: U_WORD
read_lambda: |-
id(rw_read_3).publish_state(id(stored_3));
@@ -105,22 +80,8 @@ modbus_server:
id(stored_3) = x;
id(rw_write_3).publish_state(x);
return true;
- address: 0x0A
value_type: U_WORD
read_lambda: |-
id(read_after_peer_timeout).publish_state(1);
return 1;
sensor:
- platform: template
name: "basic_read"
id: basic_read
- platform: template
name: "read_after_peer_response"
id: read_after_peer_response
- platform: template
name: "read_after_peer_timeout"
id: read_after_peer_timeout
- platform: template
name: "rw_write_1"
id: rw_write_1
+3 -11
View File
@@ -1,7 +1,7 @@
"""Helpers for manipulating the host platform's preferences file.
ESPHome's host platform stores preferences in
``$ESPHOME_PREFDIR/<app_name>.prefs`` using a simple binary layout that
``~/.esphome/prefs/<app_name>.prefs`` using a simple binary layout that
mirrors ``HostPreferences::sync()``:
``[uint32_t key][uint8_t len][uint8_t data[len]]`` per entry.
@@ -11,21 +11,13 @@ boot (e.g. forcing safe mode) or to clear stale state between runs.
from __future__ import annotations
import os
from pathlib import Path
import struct
def host_prefs_path(device_name: str) -> Path:
"""Return the on-disk prefs file path for a host-platform device.
Requires ESPHOME_PREFDIR, which the autouse isolated_preferences fixture
sets; refusing the ~/.esphome/prefs fallback keeps tests off real user
data if the fixture is ever bypassed."""
prefdir = os.environ.get("ESPHOME_PREFDIR")
if not prefdir:
raise RuntimeError("ESPHOME_PREFDIR is not set; refusing the real prefs dir")
return Path(prefdir) / f"{device_name}.prefs"
"""Return the on-disk prefs file path for a host-platform device."""
return Path.home() / ".esphome" / "prefs" / f"{device_name}.prefs"
def clear_host_prefs(device_name: str) -> None:
+141 -152
View File
@@ -1,154 +1,143 @@
{
"tests/integration/test_action_concurrent_reentry.py": 30.48,
"tests/integration/test_addressable_light_transition.py": 42.1,
"tests/integration/test_alarm_control_panel_state_transitions.py": 35.76,
"tests/integration/test_api_action_metadata.py": 22.35,
"tests/integration/test_api_action_responses.py": 30.31,
"tests/integration/test_api_action_timeout.py": 34.73,
"tests/integration/test_api_conditional_memory.py": 18.35,
"tests/integration/test_api_custom_services.py": 15.99,
"tests/integration/test_api_get_time_response_timezone.py": 24.21,
"tests/integration/test_api_homeassistant.py": 33.77,
"tests/integration/test_api_homeassistant_action_no_subscriber.py": 20.8,
"tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 23.55,
"tests/integration/test_api_list_entities_backpressure.py": 23.04,
"tests/integration/test_api_message_size_batching.py": 27.31,
"tests/integration/test_api_reboot_timeout.py": 29.32,
"tests/integration/test_api_string_lambda.py": 14.9,
"tests/integration/test_api_vv_logging.py": 26.25,
"tests/integration/test_api_zero_psk_provisioning.py": 38.19,
"tests/integration/test_areas_and_devices.py": 27.52,
"tests/integration/test_automation_wait_actions.py": 24.25,
"tests/integration/test_automations.py": 36.02,
"tests/integration/test_batch_delay_zero_rapid_transitions.py": 18.46,
"tests/integration/test_binary_sensor_autorepeat_filter.py": 17.47,
"tests/integration/test_binary_sensor_invalidate_state.py": 14.79,
"tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 21.52,
"tests/integration/test_build_info.py": 21.42,
"tests/integration/test_camera_mock.py": 17.02,
"tests/integration/test_climate_control_action.py": 26.56,
"tests/integration/test_climate_custom_modes.py": 18.82,
"tests/integration/test_continuation_actions.py": 20.39,
"tests/integration/test_cover_control_action.py": 19.91,
"tests/integration/test_crc8_helper.py": 16.73,
"tests/integration/test_device_id_in_state.py": 58.41,
"tests/integration/test_duplicate_entities.py": 30.76,
"tests/integration/test_entity_icon.py": 25.34,
"tests/integration/test_fan_turn_on_action.py": 23.64,
"tests/integration/test_fnv1_hash_object_id.py": 25.44,
"tests/integration/test_fnv1a_hash.py": 20.85,
"tests/integration/test_gpio_expander_cache.py": 14.42,
"tests/integration/test_host_logger_thread_safety.py": 21.31,
"tests/integration/test_host_mode_basic.py": 2.65,
"tests/integration/test_host_mode_batch_delay.py": 22.21,
"tests/integration/test_host_mode_climate_basic_state.py": 27.12,
"tests/integration/test_host_mode_climate_control.py": 21.57,
"tests/integration/test_host_mode_empty_string_options.py": 27.17,
"tests/integration/test_host_mode_entity_fields.py": 30.1,
"tests/integration/test_host_mode_fan_preset.py": 17.55,
"tests/integration/test_host_mode_many_entities.py": 38.98,
"tests/integration/test_host_mode_many_entities_multiple_connections.py": 23.82,
"tests/integration/test_host_mode_noise_encryption.py": 39.84,
"tests/integration/test_host_mode_reconnect.py": 13.1,
"tests/integration/test_host_mode_sensor.py": 22.17,
"tests/integration/test_host_ota.py": 92.05,
"tests/integration/test_host_preferences.py": 20.29,
"tests/integration/test_host_preferences_suspend_resume.py": 15.02,
"tests/integration/test_improv_serial_uart.py": 30.15,
"tests/integration/test_large_message_batching.py": 25.84,
"tests/integration/test_legacy_area.py": 21.24,
"tests/integration/test_legacy_climate_compat.py": 17.34,
"tests/integration/test_legacy_fan_compat.py": 22.6,
"tests/integration/test_light_automations.py": 29.13,
"tests/integration/test_light_binary_effect_off_phase.py": 33.99,
"tests/integration/test_light_calls.py": 26.81,
"tests/integration/test_light_constant_brightness.py": 25.0,
"tests/integration/test_light_control_action.py": 25.57,
"tests/integration/test_light_dim_relative_action.py": 21.4,
"tests/integration/test_light_effect_zero_brightness.py": 19.65,
"tests/integration/test_light_initial_state.py": 17.58,
"tests/integration/test_light_toggle_action.py": 28.28,
"tests/integration/test_lock_automations.py": 23.3,
"tests/integration/test_logger_buffered_recursion_guard.py": 22.96,
"tests/integration/test_loop_disable_enable.py": 16.18,
"tests/integration/test_loop_interval_decoupling.py": 25.19,
"tests/integration/test_loop_interval_default_not_pulled_forward.py": 20.59,
"tests/integration/test_lvgl_headless_render.py": 87.78,
"tests/integration/test_micros_to_millis.py": 18.73,
"tests/integration/test_multi_click_trigger.py": 24.2,
"tests/integration/test_multi_device_preferences.py": 20.52,
"tests/integration/test_noise_encryption_key_protection.py": 19.1,
"tests/integration/test_object_id_api_verification.py": 26.24,
"tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 14.88,
"tests/integration/test_object_id_no_friendly_name.py": 61.27,
"tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 82.32,
"tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 46.03,
"tests/integration/test_online_image_bmp.py": 34.21,
"tests/integration/test_oversized_payloads.py": 62.75,
"tests/integration/test_preference_key_stability.py": 26.8,
"tests/integration/test_runtime_stats.py": 28.26,
"tests/integration/test_safe_mode_loop_runs.py": 18.14,
"tests/integration/test_scheduler_blocking_warning.py": 28.7,
"tests/integration/test_scheduler_bulk_cleanup.py": 20.73,
"tests/integration/test_scheduler_defer_cancel.py": 22.99,
"tests/integration/test_scheduler_defer_cancel_regular.py": 21.97,
"tests/integration/test_scheduler_defer_fifo_simple.py": 24.15,
"tests/integration/test_scheduler_defer_stress.py": 23.11,
"tests/integration/test_scheduler_heap_stress.py": 20.2,
"tests/integration/test_scheduler_internal_id_no_collision.py": 23.75,
"tests/integration/test_scheduler_interval_reschedule.py": 15.32,
"tests/integration/test_scheduler_interval_zero_coerced.py": 20.1,
"tests/integration/test_scheduler_null_name.py": 17.43,
"tests/integration/test_scheduler_numeric_id_test.py": 25.51,
"tests/integration/test_scheduler_pool.py": 24.22,
"tests/integration/test_scheduler_rapid_cancellation.py": 24.01,
"tests/integration/test_scheduler_recursive_timeout.py": 22.94,
"tests/integration/test_scheduler_removed_item_race.py": 23.07,
"tests/integration/test_scheduler_self_keyed.py": 18.43,
"tests/integration/test_scheduler_simultaneous_callbacks.py": 21.99,
"tests/integration/test_scheduler_string_test.py": 17.27,
"tests/integration/test_script_array_params.py": 4.59,
"tests/integration/test_script_delay_params.py": 22.46,
"tests/integration/test_script_queued.py": 25.24,
"tests/integration/test_script_queued_idle_loop.py": 5.04,
"tests/integration/test_script_wait_on_boot.py": 21.77,
"tests/integration/test_sdl_headless_screenshot.py": 19.23,
"tests/integration/test_select_stringref_trigger.py": 19.31,
"tests/integration/test_sensor_filters_delta.py": 25.92,
"tests/integration/test_sensor_filters_ring_buffer.py": 22.39,
"tests/integration/test_sensor_filters_sliding_window.py": 57.93,
"tests/integration/test_sensor_filters_value_list.py": 20.32,
"tests/integration/test_sensor_timeout_filter.py": 25.35,
"tests/integration/test_snapshot_display.py": 19.7,
"tests/integration/test_socket_wake_gate_tcp.py": 14.5,
"tests/integration/test_status_flags.py": 33.83,
"tests/integration/test_strftime_to.py": 17.64,
"tests/integration/test_syslog.py": 24.49,
"tests/integration/test_template_alarm_control_panel_many_sensors.py": 24.81,
"tests/integration/test_template_climate_basic.py": 15.28,
"tests/integration/test_template_climate_custom_modes.py": 25.07,
"tests/integration/test_template_climate_nonoptimistic.py": 24.25,
"tests/integration/test_template_climate_on_control_ordering.py": 24.09,
"tests/integration/test_template_climate_publish_all_fields.py": 17.78,
"tests/integration/test_template_climate_sensor_push.py": 17.42,
"tests/integration/test_template_climate_set_actions.py": 23.63,
"tests/integration/test_template_climate_two_point_temperature.py": 25.19,
"tests/integration/test_template_text_save.py": 17.88,
"tests/integration/test_text_command.py": 22.71,
"tests/integration/test_text_sensor_raw_state.py": 25.17,
"tests/integration/test_uart_mock_ld2410.py": 58.15,
"tests/integration/test_uart_mock_ld2412.py": 61.14,
"tests/integration/test_uart_mock_ld2420.py": 33.87,
"tests/integration/test_uart_mock_ld2450.py": 26.06,
"tests/integration/test_uart_mock_modbus.py": 391.79,
"tests/integration/test_udp.py": 7.38,
"tests/integration/test_use_address_runtime.py": 24.09,
"tests/integration/test_valve_control_action.py": 23.22,
"tests/integration/test_varint_five_byte_device_id.py": 17.93,
"tests/integration/test_wait_until_mid_loop_timing.py": 22.26,
"tests/integration/test_wait_until_on_boot.py": 17.46,
"tests/integration/test_wait_until_ordering.py": 11.89,
"tests/integration/test_wait_until_reentrant_restart.py": 22.88,
"tests/integration/test_wake_loop_forces_phase_b.py": 16.6,
"tests/integration/test_water_heater_template.py": 19.66
"tests/integration/test_action_concurrent_reentry.py": 57.91,
"tests/integration/test_addressable_light_transition.py": 21.25,
"tests/integration/test_alarm_control_panel_state_transitions.py": 70.71,
"tests/integration/test_api_action_metadata.py": 66.6,
"tests/integration/test_api_action_responses.py": 36.1,
"tests/integration/test_api_action_timeout.py": 68.86,
"tests/integration/test_api_conditional_memory.py": 15.48,
"tests/integration/test_api_custom_services.py": 18.77,
"tests/integration/test_api_get_time_response_timezone.py": 21.08,
"tests/integration/test_api_homeassistant.py": 65.59,
"tests/integration/test_api_homeassistant_action_no_subscriber.py": 18.44,
"tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 15.05,
"tests/integration/test_api_list_entities_backpressure.py": 13.88,
"tests/integration/test_api_message_size_batching.py": 29.98,
"tests/integration/test_api_reboot_timeout.py": 16.05,
"tests/integration/test_api_string_lambda.py": 15.31,
"tests/integration/test_api_vv_logging.py": 19.28,
"tests/integration/test_api_zero_psk_provisioning.py": 31.5,
"tests/integration/test_areas_and_devices.py": 24.95,
"tests/integration/test_automation_wait_actions.py": 20.92,
"tests/integration/test_automations.py": 35.19,
"tests/integration/test_batch_delay_zero_rapid_transitions.py": 17.99,
"tests/integration/test_binary_sensor_autorepeat_filter.py": 20.39,
"tests/integration/test_binary_sensor_invalidate_state.py": 18.41,
"tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 24.69,
"tests/integration/test_build_info.py": 18.7,
"tests/integration/test_camera_mock.py": 16.23,
"tests/integration/test_climate_control_action.py": 21.14,
"tests/integration/test_climate_custom_modes.py": 20.74,
"tests/integration/test_continuation_actions.py": 16.81,
"tests/integration/test_cover_control_action.py": 20.34,
"tests/integration/test_crc8_helper.py": 9.36,
"tests/integration/test_device_id_in_state.py": 44.67,
"tests/integration/test_duplicate_entities.py": 23.58,
"tests/integration/test_entity_icon.py": 34.35,
"tests/integration/test_fan_turn_on_action.py": 24.23,
"tests/integration/test_fnv1_hash_object_id.py": 16.21,
"tests/integration/test_fnv1a_hash.py": 13.38,
"tests/integration/test_gpio_expander_cache.py": 13.06,
"tests/integration/test_host_logger_thread_safety.py": 23.66,
"tests/integration/test_host_mode_basic.py": 8.01,
"tests/integration/test_host_mode_batch_delay.py": 21.0,
"tests/integration/test_host_mode_climate_basic_state.py": 22.14,
"tests/integration/test_host_mode_climate_control.py": 19.39,
"tests/integration/test_host_mode_empty_string_options.py": 21.76,
"tests/integration/test_host_mode_entity_fields.py": 29.61,
"tests/integration/test_host_mode_fan_preset.py": 20.01,
"tests/integration/test_host_mode_many_entities.py": 39.08,
"tests/integration/test_host_mode_many_entities_multiple_connections.py": 23.92,
"tests/integration/test_host_mode_noise_encryption.py": 42.42,
"tests/integration/test_host_mode_reconnect.py": 3.41,
"tests/integration/test_host_mode_sensor.py": 22.96,
"tests/integration/test_host_ota.py": 29.5,
"tests/integration/test_host_preferences.py": 16.06,
"tests/integration/test_host_preferences_suspend_resume.py": 18.71,
"tests/integration/test_improv_serial_uart.py": 20.22,
"tests/integration/test_large_message_batching.py": 26.56,
"tests/integration/test_legacy_area.py": 22.72,
"tests/integration/test_legacy_climate_compat.py": 14.13,
"tests/integration/test_legacy_fan_compat.py": 14.33,
"tests/integration/test_light_automations.py": 18.81,
"tests/integration/test_light_binary_effect_off_phase.py": 8.38,
"tests/integration/test_light_calls.py": 21.88,
"tests/integration/test_light_constant_brightness.py": 59.45,
"tests/integration/test_light_control_action.py": 31.91,
"tests/integration/test_light_dim_relative_action.py": 14.43,
"tests/integration/test_light_effect_zero_brightness.py": 25.05,
"tests/integration/test_light_initial_state.py": 18.97,
"tests/integration/test_light_toggle_action.py": 17.44,
"tests/integration/test_lock_automations.py": 18.9,
"tests/integration/test_logger_buffered_recursion_guard.py": 18.2,
"tests/integration/test_loop_disable_enable.py": 63.35,
"tests/integration/test_loop_interval_decoupling.py": 17.7,
"tests/integration/test_loop_interval_default_not_pulled_forward.py": 21.56,
"tests/integration/test_micros_to_millis.py": 15.89,
"tests/integration/test_multi_click_trigger.py": 17.23,
"tests/integration/test_multi_device_preferences.py": 19.4,
"tests/integration/test_noise_encryption_key_protection.py": 72.59,
"tests/integration/test_object_id_api_verification.py": 19.22,
"tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 16.77,
"tests/integration/test_object_id_no_friendly_name.py": 45.8,
"tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 86.73,
"tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 40.4,
"tests/integration/test_online_image_bmp.py": 37.24,
"tests/integration/test_oversized_payloads.py": 55.75,
"tests/integration/test_preference_key_stability.py": 25.49,
"tests/integration/test_runtime_stats.py": 29.81,
"tests/integration/test_safe_mode_loop_runs.py": 6.26,
"tests/integration/test_scheduler_blocking_warning.py": 37.98,
"tests/integration/test_scheduler_bulk_cleanup.py": 18.67,
"tests/integration/test_scheduler_defer_cancel.py": 18.46,
"tests/integration/test_scheduler_defer_cancel_regular.py": 16.34,
"tests/integration/test_scheduler_defer_fifo_simple.py": 18.26,
"tests/integration/test_scheduler_defer_stress.py": 17.74,
"tests/integration/test_scheduler_heap_stress.py": 3.89,
"tests/integration/test_scheduler_internal_id_no_collision.py": 20.01,
"tests/integration/test_scheduler_interval_reschedule.py": 16.29,
"tests/integration/test_scheduler_interval_zero_coerced.py": 16.09,
"tests/integration/test_scheduler_null_name.py": 14.69,
"tests/integration/test_scheduler_numeric_id_test.py": 17.08,
"tests/integration/test_scheduler_pool.py": 19.88,
"tests/integration/test_scheduler_rapid_cancellation.py": 4.42,
"tests/integration/test_scheduler_recursive_timeout.py": 4.3,
"tests/integration/test_scheduler_removed_item_race.py": 15.49,
"tests/integration/test_scheduler_self_keyed.py": 25.77,
"tests/integration/test_scheduler_simultaneous_callbacks.py": 14.84,
"tests/integration/test_scheduler_string_test.py": 15.42,
"tests/integration/test_script_array_params.py": 12.73,
"tests/integration/test_script_delay_params.py": 12.69,
"tests/integration/test_script_queued.py": 20.38,
"tests/integration/test_script_queued_idle_loop.py": 25.06,
"tests/integration/test_script_wait_on_boot.py": 15.67,
"tests/integration/test_select_stringref_trigger.py": 19.48,
"tests/integration/test_sensor_filters_delta.py": 27.62,
"tests/integration/test_sensor_filters_ring_buffer.py": 20.27,
"tests/integration/test_sensor_filters_sliding_window.py": 56.28,
"tests/integration/test_sensor_filters_value_list.py": 20.6,
"tests/integration/test_sensor_timeout_filter.py": 22.21,
"tests/integration/test_socket_wake_gate_tcp.py": 16.37,
"tests/integration/test_status_flags.py": 29.68,
"tests/integration/test_strftime_to.py": 17.42,
"tests/integration/test_syslog.py": 18.39,
"tests/integration/test_template_alarm_control_panel_many_sensors.py": 25.61,
"tests/integration/test_template_text_save.py": 19.16,
"tests/integration/test_text_command.py": 16.43,
"tests/integration/test_text_sensor_raw_state.py": 17.19,
"tests/integration/test_uart_mock_ld2410.py": 37.0,
"tests/integration/test_uart_mock_ld2412.py": 40.82,
"tests/integration/test_uart_mock_ld2420.py": 32.7,
"tests/integration/test_uart_mock_ld2450.py": 32.84,
"tests/integration/test_uart_mock_modbus.py": 548.87,
"tests/integration/test_udp.py": 16.67,
"tests/integration/test_use_address_runtime.py": 27.26,
"tests/integration/test_valve_control_action.py": 24.58,
"tests/integration/test_varint_five_byte_device_id.py": 22.5,
"tests/integration/test_wait_until_mid_loop_timing.py": 22.05,
"tests/integration/test_wait_until_on_boot.py": 10.37,
"tests/integration/test_wait_until_ordering.py": 18.23,
"tests/integration/test_wait_until_reentrant_restart.py": 19.35,
"tests/integration/test_wake_loop_forces_phase_b.py": 17.83,
"tests/integration/test_water_heater_template.py": 25.7
}
@@ -24,6 +24,7 @@ from .types import (
RunCompiledFunction,
)
pytestmark = pytest.mark.usefixtures("isolated_preferences")
NEW_KEY = PROVISIONING_PSK
@@ -41,6 +41,15 @@ async def _poll_until_exists(path: Path) -> None:
await asyncio.sleep(0.05)
@pytest.fixture(autouse=True)
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> Path:
"""Keep host preferences per-test so this test never touches the real
~/.esphome/prefs and never races other tests over ESPHOME_PREFDIR."""
prefdir = tmp_path / "prefs"
monkeypatch.setenv("ESPHOME_PREFDIR", str(prefdir))
return prefdir / f"{DEVICE_NAME}.prefs"
@pytest.mark.asyncio
async def test_host_preferences_suspend_resume(
yaml_config: str,
@@ -49,7 +58,7 @@ async def test_host_preferences_suspend_resume(
isolated_preferences: Path,
) -> None:
"""Test that a running syncer flushes, a suspended one doesn't, and resume restores flushing."""
pref_file = isolated_preferences / f"{DEVICE_NAME}.prefs"
pref_file = isolated_preferences
loop = asyncio.get_running_loop()
saved_in_memory = loop.create_future()
@@ -11,6 +11,14 @@ from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.fixture(autouse=True)
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
"""Keep host preferences per-test so RESTORE_AND_ON never loads a stale value left
behind by a previous run (host preferences otherwise persist to ~/.esphome/prefs,
keyed only by device name)."""
monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs"))
@pytest.mark.asyncio
async def test_light_initial_state(
yaml_config: str,
+7 -16
View File
@@ -173,7 +173,6 @@ async def test_uart_mock_modbus_no_threshold(
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.shared_yaml("uart_mock_modbus_server_injected")
@pytest.mark.asyncio
async def test_uart_mock_modbus_server(
yaml_config: str,
@@ -204,7 +203,6 @@ async def test_uart_mock_modbus_server(
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.shared_yaml("uart_mock_modbus_server_injected")
@pytest.mark.asyncio
async def test_uart_mock_modbus_server_read_write(
yaml_config: str,
@@ -233,8 +231,8 @@ async def test_uart_mock_modbus_server_read_write(
"rw_write_1": 4660, # 0x1234 written to reg 0x0001
"rw_read_1": 4660, # reg 0x0001 reads back the just-written value
"rw_read_2": 170, # 0x00AA read from reg 0x0002 in the same request
"rw_write_3": 22136, # 0x5678 written to reg 0x0006
"rw_read_3": 22136, # reg 0x0006 reads back the just-written value
"rw_write_3": 22136, # 0x5678 written to reg 0x0003
"rw_read_3": 22136, # reg 0x0003 reads back the just-written value
}
)
@@ -243,8 +241,7 @@ async def test_uart_mock_modbus_server_read_write(
api_client_connected() as client,
):
await tracker.setup_and_start_scenario(client)
# The FC 0x17 injections fire last, behind four earlier 100ms delays
await tracker.await_all(futures, timeout=4.0)
await tracker.await_all(futures)
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@@ -299,7 +296,6 @@ async def test_uart_mock_modbus_server_read_write_invalid(
)
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
@pytest.mark.asyncio
async def test_uart_mock_modbus_server_controller(
yaml_config: str,
@@ -489,7 +485,6 @@ async def test_uart_mock_modbus_server_controller_bits(
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
@pytest.mark.asyncio
async def test_uart_mock_modbus_server_controller_multiple(
yaml_config: str,
@@ -500,7 +495,7 @@ async def test_uart_mock_modbus_server_controller_multiple(
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
expected_values = {"multi_reg_a": 919, "multi_reg_b": 929}
expected_values = {"reg_u_word": 919, "reg_u_word_2": 929}
tracker = SensorTracker(list(expected_values.keys()))
futures = tracker.expect_all(expected_values)
@@ -711,7 +706,6 @@ async def test_uart_mock_modbus_shared_address(
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
@pytest.mark.asyncio
async def test_uart_mock_modbus_custom_pdu(
yaml_config: str,
@@ -938,7 +932,6 @@ async def test_uart_mock_modbus_broadcast_write(
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
@pytest.mark.asyncio
async def test_uart_mock_modbus_client_read_write(
yaml_config: str,
@@ -954,7 +947,9 @@ async def test_uart_mock_modbus_client_read_write(
"""
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
tracker = SensorTracker(["srv_write_1", "client_read_0", "client_read_1"])
tracker = SensorTracker(
["srv_write_1", "srv_read_1", "client_read_0", "client_read_1"]
)
futures = tracker.expect_all(
{
"srv_write_1": 4660, # server wrote 0x1234 to reg 0x0001
@@ -972,7 +967,6 @@ async def test_uart_mock_modbus_client_read_write(
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
@pytest.mark.asyncio
async def test_uart_mock_modbus_register_offset(
yaml_config: str,
@@ -1028,7 +1022,6 @@ async def test_uart_mock_modbus_register_offset(
)
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
@pytest.mark.asyncio
async def test_uart_mock_modbus_lambda_write(
yaml_config: str,
@@ -1065,7 +1058,6 @@ async def test_uart_mock_modbus_lambda_write(
await tracker.await_change(wrote_30, "reg_30", timeout=4.0)
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
@pytest.mark.asyncio
async def test_uart_mock_modbus_lambda_invert(
yaml_config: str,
@@ -1121,7 +1113,6 @@ async def test_uart_mock_modbus_lambda_invert(
)
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
@pytest.mark.asyncio
async def test_uart_mock_modbus_deprecated_write_buffer(
yaml_config: str,
-28
View File
@@ -2122,34 +2122,6 @@ def test_get_cpp_changed_components_independent_of_cwd(
) == ["time"]
def test_fixture_map_includes_shared_yaml_markers() -> None:
"""Fixtures named only by shared_yaml markers must map to their test file."""
helpers.get_fixture_to_test_files.cache_clear()
mapping = helpers.get_fixture_to_test_files()
for fixture in (
"uart_mock_modbus_loopback",
"uart_mock_modbus_mesh",
"uart_mock_modbus_server_injected",
):
assert mapping[fixture] == frozenset(
{"tests/integration/test_uart_mock_modbus.py"}
)
def test_no_orphan_integration_fixtures() -> None:
"""Every fixture must reach CI test selection; an orphan selects nothing."""
helpers.get_fixture_to_test_files.cache_clear()
mapping = helpers.get_fixture_to_test_files()
fixtures_dir = (Path(__file__).parent.parent / "integration" / "fixtures").resolve()
fixtures = list(fixtures_dir.glob("*.yaml"))
assert fixtures, f"no fixtures found under {fixtures_dir}"
# cache_init is covered via INTEGRATION_TESTS_TRIGGER_FILES instead
orphans = [
f.stem for f in fixtures if f.stem != "cache_init" and f.stem not in mapping
]
assert not orphans, f"fixtures invisible to CI test selection: {orphans}"
def test_lpt_partition_balances_skewed_weights() -> None:
"""Heavy items spread across groups instead of clustering."""
items = [f"i{n}" for n in range(6)]