Compare commits

...
Author SHA1 Message Date
J. Nick Koston 431c872976 Keep one deadline per group of lines and exclude disconnects from the connect wait 2026-10-21 10:46:13 -10:00
J. Nick Koston 3f3e2a22d3 [tests] Use LineWaiter for the integration tests that parse or count log lines 2026-10-21 10:38:00 -10:00
J. Nick Koston 63b610bf17 [tests] Wait for the ld2410 query ACK log line instead of asserting it arrived (#21191) 2026-10-21 10:13:22 -10:00
J. Nick Koston 62c9e05541 [tests] Wait for the template sensor's first reading before the Home Assistant service tests (#21302) 2026-10-21 09:56:20 -10:00
esphome[bot] 671ac3e0bc Bump bundled esphome-device-builder to 1.26.0 (#21299) 2026-10-21 09:45:18 -10:00
dependabot[bot] 6671ded245 Bump platformdirs from 4.13.3 to 4.13.4 (#21297)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-21 19:32:36 +00:00
dependabot[bot] 9010c4bb2e Bump filelock from 4.2.1 to 4.2.2 (#21295)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-21 19:32:16 +00:00
J. Nick Koston e0a48ff683 [waveshare_epaper] Set removal of the deprecated component for 2027.5.0 (#20692) 2026-10-21 19:06:38 +00:00
e3d7b957a7 [mqtt] increase TLS options and compatibility (#14318)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-21 19:02:31 +00:00
J. Nick Koston 9ab230f98d [speaker] Build the pipeline task names on the stack (#21203) 2026-10-21 08:41:59 -10:00
65 changed files with 1130 additions and 1815 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.25.0
RUN uv pip install --no-cache-dir esphome-device-builder==1.26.0
RUN \
platformio settings set enable_telemetry No \
+62 -26
View File
@@ -1,8 +1,10 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import logger, network, socket
from esphome.components.const import CONF_VERIFY_SSL
from esphome.components.esp32 import (
add_idf_component,
allow_insecure_tls,
idf_version,
include_builtin_idf_component,
request_tls,
@@ -79,6 +81,7 @@ def AUTO_LOAD():
return ["json"]
CONF_TLS = "tls"
CONF_IDF_SEND_ASYNC = "idf_send_async"
CONF_WAIT_FOR_CONNECTION = "wait_for_connection"
CONF_TRANSPORT = "transport"
@@ -187,26 +190,8 @@ def _validate_transport(value: str) -> str:
def validate_config(value):
transport = value.get(CONF_TRANSPORT)
# A certificate with plain ws is almost certainly a typo for wss
if transport == MQTT_TRANSPORT_WS and (
CONF_CERTIFICATE_AUTHORITY in value or CONF_CLIENT_CERTIFICATE in value
):
raise cv.Invalid(
"transport 'ws' is unencrypted; remove the certificate options "
"or switch to transport 'wss'"
)
# TLS runs only with a CA or over wss, so a client certificate needs one of them
if (
CONF_CLIENT_CERTIFICATE in value
and CONF_CERTIFICATE_AUTHORITY not in value
and transport != MQTT_TRANSPORT_WSS
):
raise cv.Invalid(
"'client_certificate' requires 'certificate_authority' or transport 'wss'"
)
if (
transport not in (MQTT_TRANSPORT_WS, MQTT_TRANSPORT_WSS)
value.get(CONF_TRANSPORT) not in (MQTT_TRANSPORT_WS, MQTT_TRANSPORT_WSS)
and CONF_WS_PATH in value
):
raise cv.Invalid(
@@ -260,6 +245,47 @@ def validate_config(value):
return out
# Keys that only make sense over TLS; any of them switches it on
TLS_IMPLYING_KEYS = frozenset(
{CONF_CERTIFICATE_AUTHORITY, CONF_VERIFY_SSL, CONF_CLIENT_CERTIFICATE}
)
def _derive_tls(config: ConfigType) -> ConfigType:
"""Derive ``tls`` from the certificate options and the transport, reject contradictions, pick the port."""
# skip_cert_cn_check has a default on ESP32, so its value decides, not its presence
if config.get(CONF_VERIFY_SSL) is False and config.get(CONF_SKIP_CERT_CN_CHECK):
raise cv.Invalid(
"'skip_cert_cn_check' has no effect when 'verify_ssl' is false."
)
transport = config.get(CONF_TRANSPORT)
if (
config.keys() & TLS_IMPLYING_KEYS
or config.get(CONF_SKIP_CERT_CN_CHECK)
or transport == MQTT_TRANSPORT_WSS
):
if config.get(CONF_TLS) is False:
raise cv.Invalid(
"'tls' cannot be false when a certificate, 'verify_ssl', "
"'skip_cert_cn_check' or transport 'wss' is set."
)
config[CONF_TLS] = True
# TLS with plain ws is almost certainly a typo for wss
if transport == MQTT_TRANSPORT_WS and config.get(CONF_TLS):
raise cv.Invalid(
"transport 'ws' is unencrypted; remove the TLS options "
"or switch to transport 'wss'"
)
config.setdefault(CONF_PORT, 8883 if config.get(CONF_TLS) else 1883)
return config
_validate_tls = cv.All(
cv.only_when(CONF_CERTIFICATE_AUTHORITY, CONF_VERIFY_SSL, False, negate=True),
_derive_tls,
)
def _consume_mqtt_sockets(config: ConfigType) -> ConfigType:
"""Register socket needs for MQTT component."""
# MQTT needs 1 socket for the broker connection
@@ -283,7 +309,7 @@ CONFIG_SCHEMA = cv.All(
cv.GenerateID(): cv.declare_id(MQTTClientComponent),
cv.Required(CONF_BROKER): cv.string_strict,
cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean,
cv.Optional(CONF_PORT, default=1883): cv.port,
cv.Optional(CONF_PORT): cv.port,
cv.Optional(CONF_USERNAME, default=""): cv.string,
cv.Optional(CONF_PASSWORD, default=""): cv.sensitive(),
cv.Optional(CONF_CLEAN_SESSION, default=False): cv.boolean,
@@ -295,6 +321,8 @@ CONFIG_SCHEMA = cv.All(
cv.SplitDefault(CONF_IDF_SEND_ASYNC, esp32=False): cv.All(
cv.boolean, cv.only_on_esp32
),
cv.Optional(CONF_TLS): cv.All(cv.boolean, cv.only_on_esp32),
cv.Optional(CONF_VERIFY_SSL): cv.All(cv.boolean, cv.only_on_esp32),
cv.Optional(CONF_CERTIFICATE_AUTHORITY): cv.All(
cv.string, cv.only_on_esp32
),
@@ -356,6 +384,7 @@ CONFIG_SCHEMA = cv.All(
}
),
validate_config,
_validate_tls,
cv.only_on(
[
PLATFORM_BK72XX,
@@ -497,21 +526,28 @@ async def to_code(config):
# esp-idf only
transport = config.get(CONF_TRANSPORT)
ca = cg.add_optional(var.set_ca_certificate, config.get(CONF_CERTIFICATE_AUTHORITY))
if ca is not None or transport == MQTT_TRANSPORT_WSS:
cg.add(var.set_skip_cert_cn_check(config[CONF_SKIP_CERT_CN_CHECK]))
if config.get(CONF_TLS):
cg.add_define("USE_MQTT_TLS")
if (client_certificate := config.get(CONF_CLIENT_CERTIFICATE)) is not None:
cg.add(var.set_cl_certificate(client_certificate))
cg.add(var.set_cl_key(config[CONF_CLIENT_CERTIFICATE_KEY]))
if config.get(CONF_SKIP_CERT_CN_CHECK):
cg.add(var.set_skip_cert_cn_check(True))
# Trust source: none, the given CA, or the certificate bundle
if config.get(CONF_VERIFY_SSL) is False:
cg.add_define("USE_MQTT_TLS_INSECURE")
allow_insecure_tls()
elif (ca := config.get(CONF_CERTIFICATE_AUTHORITY)) is not None:
cg.add(var.set_ca_certificate(ca))
else:
cg.add_define("USE_MQTT_CERT_BUNDLE")
require_certificate_bundle()
if CORE.is_esp32:
if transport != MQTT_TRANSPORT_TCP:
cg.add(var.set_transport(transport))
if transport in (MQTT_TRANSPORT_WS, MQTT_TRANSPORT_WSS):
cg.add(var.set_ws_path(config.get(CONF_WS_PATH, DEFAULT_WS_PATH)))
# wss without an explicit CA verifies the broker against the bundled roots
if transport == MQTT_TRANSPORT_WSS and ca is None:
require_certificate_bundle()
if CONF_IDF_SEND_ASYNC in config and config[CONF_IDF_SEND_ASYNC]:
cg.add_define("USE_MQTT_IDF_ENQUEUE")
+22 -28
View File
@@ -8,7 +8,7 @@
#include "esphome/core/log.h"
#include "esphome/core/application.h"
#if CONFIG_MBEDTLS_CERTIFICATE_BUNDLE
#ifdef USE_MQTT_CERT_BUNDLE
#include "esp_crt_bundle.h"
#endif
@@ -43,46 +43,36 @@ bool MQTTBackendESP32::initialize_() {
if (!this->client_id_.empty()) {
mqtt_cfg_.credentials.client_id = this->client_id_.c_str();
}
// Plain TCP keeps upgrading to SSL when a CA certificate is configured
bool uses_tls = false;
#ifdef USE_MQTT_TLS
mqtt_cfg_.broker.verification.skip_cert_common_name_check = this->skip_cert_cn_check_;
mqtt_cfg_.broker.verification.certificate = this->ca_certificate_;
#ifdef USE_MQTT_CERT_BUNDLE
mqtt_cfg_.broker.verification.crt_bundle_attach = esp_crt_bundle_attach;
#endif
// Validation only accepts the client certificate together with its key
mqtt_cfg_.credentials.authentication.certificate = this->cl_certificate_;
mqtt_cfg_.credentials.authentication.key = this->cl_key_;
#endif
// Validation keeps TLS off plain ws, so only the TCP transport has two forms
switch (this->transport_) {
case MQTTTransport::MQTT_TRANSPORT_WS:
mqtt_cfg_.broker.address.transport = MQTT_TRANSPORT_OVER_WS;
break;
case MQTTTransport::MQTT_TRANSPORT_WSS:
mqtt_cfg_.broker.address.transport = MQTT_TRANSPORT_OVER_WSS;
uses_tls = true;
break;
case MQTTTransport::MQTT_TRANSPORT_TCP:
default:
if (this->ca_certificate_.has_value()) {
mqtt_cfg_.broker.address.transport = MQTT_TRANSPORT_OVER_SSL;
uses_tls = true;
} else {
mqtt_cfg_.broker.address.transport = MQTT_TRANSPORT_OVER_TCP;
}
#ifdef USE_MQTT_TLS
mqtt_cfg_.broker.address.transport = MQTT_TRANSPORT_OVER_SSL;
#else
mqtt_cfg_.broker.address.transport = MQTT_TRANSPORT_OVER_TCP;
#endif
break;
}
// Only set for the WebSocket transports
mqtt_cfg_.broker.address.path = this->ws_path_;
if (uses_tls) {
if (this->ca_certificate_.has_value()) {
mqtt_cfg_.broker.verification.certificate = this->ca_certificate_.value().c_str();
#if CONFIG_MBEDTLS_CERTIFICATE_BUNDLE
} else {
// wss without a CA verifies the broker against the bundled roots, as http_request does
mqtt_cfg_.broker.verification.crt_bundle_attach = esp_crt_bundle_attach;
#endif
}
mqtt_cfg_.broker.verification.skip_cert_common_name_check = this->skip_cert_cn_check_;
if (this->cl_certificate_.has_value() && this->cl_key_.has_value()) {
mqtt_cfg_.credentials.authentication.certificate = this->cl_certificate_.value().c_str();
mqtt_cfg_.credentials.authentication.key = this->cl_key_.value().c_str();
}
}
auto *mqtt_client = esp_mqtt_client_init(&mqtt_cfg_);
if (mqtt_client) {
handler_.reset(mqtt_client);
@@ -91,7 +81,11 @@ bool MQTTBackendESP32::initialize_() {
#if defined(USE_MQTT_IDF_ENQUEUE)
// Create the task only after MQTT client is initialized successfully
// Use larger stack size when TLS is enabled
size_t stack_size = uses_tls ? TASK_STACK_SIZE_TLS : TASK_STACK_SIZE;
#ifdef USE_MQTT_TLS
constexpr size_t stack_size = TASK_STACK_SIZE_TLS;
#else
constexpr size_t stack_size = TASK_STACK_SIZE;
#endif
xTaskCreate(esphome_mqtt_task, "esphome_mqtt", stack_size, (void *) this, TASK_PRIORITY, &this->task_handle_);
if (this->task_handle_ == nullptr) {
ESP_LOGE(TAG, "Failed to create MQTT task");
+12 -7
View File
@@ -214,10 +214,13 @@ class MQTTBackendESP32 final : public MQTTBackend {
void loop() final;
void set_ca_certificate(const std::string &cert) { ca_certificate_ = cert; }
void set_cl_certificate(const std::string &cert) { cl_certificate_ = cert; }
void set_cl_key(const std::string &key) { cl_key_ = key; }
void set_skip_cert_cn_check(bool skip_check) { skip_cert_cn_check_ = skip_check; }
#ifdef USE_MQTT_TLS
// The certificates are string literals from code generation, so only the pointers are kept
void set_ca_certificate(const char *cert) { this->ca_certificate_ = cert; }
void set_cl_certificate(const char *cert) { this->cl_certificate_ = cert; }
void set_cl_key(const char *key) { this->cl_key_ = key; }
void set_skip_cert_cn_check(bool skip_check) { this->skip_cert_cn_check_ = skip_check; }
#endif
void set_transport(MQTTTransport transport) { this->transport_ = transport; }
void set_ws_path(const char *path) { this->ws_path_ = path; }
MQTTTransport get_transport() const { return this->transport_; }
@@ -256,10 +259,12 @@ class MQTTBackendESP32 final : public MQTTBackend {
std::string client_id_;
uint16_t keep_alive_;
bool clean_session_;
optional<std::string> ca_certificate_;
optional<std::string> cl_certificate_;
optional<std::string> cl_key_;
#ifdef USE_MQTT_TLS
const char *ca_certificate_{nullptr};
const char *cl_certificate_{nullptr};
const char *cl_key_{nullptr};
bool skip_cert_cn_check_{false};
#endif
MQTTTransport transport_{MQTTTransport::MQTT_TRANSPORT_TCP};
const char *ws_path_{nullptr};
#if defined(USE_MQTT_IDF_ENQUEUE)
+7
View File
@@ -222,6 +222,13 @@ void MQTTClientComponent::dump_config() {
transport == MQTTTransport::MQTT_TRANSPORT_WSS ? LOG_STR_LITERAL("wss") : LOG_STR_LITERAL("ws"),
this->mqtt_backend_.get_ws_path());
}
#endif
#if defined(USE_MQTT_CERT_BUNDLE)
ESP_LOGCONFIG(TAG, " TLS: certificate bundle");
#elif defined(USE_MQTT_TLS_INSECURE)
ESP_LOGCONFIG(TAG, " TLS: no verification");
#elif defined(USE_MQTT_TLS)
ESP_LOGCONFIG(TAG, " TLS: CA certificate");
#endif
if (this->is_discovery_ip_enabled()) {
ESP_LOGCONFIG(TAG, " Discovery IP enabled");
+3 -1
View File
@@ -147,11 +147,13 @@ class MQTTClientComponent final : public Component {
bool is_discovery_enabled() const;
bool is_discovery_ip_enabled() const;
#ifdef USE_ESP32
#if defined(USE_ESP32) && defined(USE_MQTT_TLS)
void set_ca_certificate(const char *cert) { this->mqtt_backend_.set_ca_certificate(cert); }
void set_cl_certificate(const char *cert) { this->mqtt_backend_.set_cl_certificate(cert); }
void set_cl_key(const char *key) { this->mqtt_backend_.set_cl_key(key); }
void set_skip_cert_cn_check(bool skip_check) { this->mqtt_backend_.set_skip_cert_cn_check(skip_check); }
#endif
#ifdef USE_ESP32
void set_transport(MQTTTransport transport) { this->mqtt_backend_.set_transport(transport); }
void set_ws_path(const char *path) { this->mqtt_backend_.set_ws_path(path); }
#endif
@@ -48,8 +48,8 @@ enum EventGroupBits : uint32_t {
};
AudioPipeline::AudioPipeline(speaker::Speaker *speaker, size_t buffer_size, bool task_stack_in_psram,
std::string base_name, UBaseType_t priority)
: base_name_(std::move(base_name)),
const char *base_name, UBaseType_t priority)
: base_name_(base_name),
priority_(priority),
task_stack_in_psram_(task_stack_in_psram),
speaker_(speaker),
@@ -269,18 +269,20 @@ esp_err_t AudioPipeline::allocate_communications_() {
}
esp_err_t AudioPipeline::start_tasks_() {
char task_name[configMAX_TASK_NAME_LEN];
if (!this->read_task_.is_created()) {
// Reader task uses the AudioReader class which uses esp_http_client. This crashes on IDF 5.4 if the task stack is
// in PSRAM. As a workaround, always allocate the read task in internal memory.
if (!this->read_task_.create(read_task, (this->base_name_ + "_read").c_str(), READ_TASK_STACK_SIZE, (void *) this,
this->priority_, false)) {
snprintf(task_name, sizeof(task_name), "%s_read", this->base_name_);
if (!this->read_task_.create(read_task, task_name, READ_TASK_STACK_SIZE, (void *) this, this->priority_, false)) {
return ESP_ERR_NO_MEM;
}
}
if (!this->decode_task_.is_created()) {
if (!this->decode_task_.create(decode_task, (this->base_name_ + "_decode").c_str(), DECODE_TASK_STACK_SIZE,
(void *) this, this->priority_, this->task_stack_in_psram_)) {
snprintf(task_name, sizeof(task_name), "%s_decode", this->base_name_);
if (!this->decode_task_.create(decode_task, task_name, DECODE_TASK_STACK_SIZE, (void *) this, this->priority_,
this->task_stack_in_psram_)) {
return ESP_ERR_NO_MEM;
}
}
@@ -63,7 +63,7 @@ class AudioPipeline final {
/// @param task_stack_in_psram True if the task stack should be allocated in PSRAM, false otherwise
/// @param task_name FreeRTOS task base name
/// @param priority FreeRTOS task priority
AudioPipeline(speaker::Speaker *speaker, size_t buffer_size, bool task_stack_in_psram, std::string base_name,
AudioPipeline(speaker::Speaker *speaker, size_t buffer_size, bool task_stack_in_psram, const char *base_name,
UBaseType_t priority);
/// @brief Starts an audio pipeline given a media url
@@ -103,7 +103,7 @@ class AudioPipeline final {
/// @return ESP_OK if successful or an appropriate error if not
esp_err_t start_tasks_();
std::string base_name_;
const char *base_name_;
UBaseType_t priority_;
uint32_t playback_ms_{0};
@@ -4,4 +4,5 @@ DOMAIN = "waveshare_epaper"
DEPRECATED_COMPONENT = """
The 'waveshare_epaper' component is deprecated and no new models will be added to it.
New model PRs should target the newer and more performant 'epaper_spi' component.
It will be removed in ESPHome 2027.5.0.
"""
@@ -1,3 +1,5 @@
import logging
from esphome import core, pins
import esphome.codegen as cg
from esphome.components import display, spi
@@ -17,6 +19,8 @@ from esphome.types import ConfigType
DEPENDENCIES = ["spi"]
LOGGER = logging.getLogger(__name__)
waveshare_epaper_ns = cg.esphome_ns.namespace("waveshare_epaper")
WaveshareEPaperBase = waveshare_epaper_ns.class_(
"WaveshareEPaperBase", cg.PollingComponent, spi.SPIDevice, display.DisplayBuffer
@@ -229,6 +233,11 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema(
async def to_code(config: ConfigType) -> None:
# Remove before 2027.5.0
LOGGER.warning(
"The 'waveshare_epaper' component is deprecated and will be removed in ESPHome 2027.5.0, "
"migrate to 'epaper_spi' instead."
)
model_type, model = MODELS[config[CONF_MODEL]]
if model_type == "a":
rhs = WaveshareEPaperTypeA.new(model)
+3
View File
@@ -434,6 +434,9 @@
#define USE_ESP32_CRASH_HANDLER
#define USE_ESP32_VASPRINTF_STUB
#define USE_MQTT_IDF_ENQUEUE
#define USE_MQTT_TLS
#define USE_MQTT_CERT_BUNDLE
#define USE_MQTT_TLS_INSECURE
#define USE_ESPHOME_TASK_LOG_BUFFER
#define ESPHOME_TASK_LOG_BUFFER_SIZE 768
#define USE_OTA_ROLLBACK
+18 -11
View File
@@ -97,11 +97,12 @@ def prepare(
# Match the firmware's transport; imported here to keep component code off the CLI start-up path
from esphome.components.mqtt import (
CONF_TLS,
CONF_TRANSPORT,
CONF_VERIFY_SSL,
CONF_WS_PATH,
DEFAULT_WS_PATH,
MQTT_TRANSPORT_TCP,
MQTT_TRANSPORT_WSS,
)
transport = config[CONF_MQTT].get(CONF_TRANSPORT, MQTT_TRANSPORT_TCP)
@@ -122,15 +123,21 @@ def prepare(
elif username:
client.username_pw_set(username, password)
# TLS with the CA, or the system CAs over wss
certificate_authority = config[CONF_MQTT].get(CONF_CERTIFICATE_AUTHORITY)
if certificate_authority or transport == MQTT_TRANSPORT_WSS:
context = ssl.create_default_context(cadata=certificate_authority)
if config[CONF_MQTT].get(CONF_SKIP_CERT_CN_CHECK):
mqtt_conf = config[CONF_MQTT]
# Validation sets tls whenever a certificate option or the wss transport implies it
if mqtt_conf.get(CONF_TLS):
if mqtt_conf.get(CONF_VERIFY_SSL) is False:
context = ssl.create_default_context()
context.check_hostname = False
if config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE) and config[CONF_MQTT].get(
CONF_CLIENT_CERTIFICATE_KEY
):
context.verify_mode = ssl.CERT_NONE
else:
# Without a CA the system trust store stands in for the firmware's bundle
context = ssl.create_default_context(
cadata=mqtt_conf.get(CONF_CERTIFICATE_AUTHORITY)
)
if mqtt_conf.get(CONF_SKIP_CERT_CN_CHECK):
context.check_hostname = False
if mqtt_conf.get(CONF_CLIENT_CERTIFICATE):
with (
tempfile.NamedTemporaryFile(
encoding="utf-8", mode="w+", delete=False
@@ -140,8 +147,8 @@ def prepare(
) as key_file,
):
try:
cert_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE))
key_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE_KEY))
cert_file.write(mqtt_conf[CONF_CLIENT_CERTIFICATE])
key_file.write(mqtt_conf[CONF_CLIENT_CERTIFICATE_KEY])
cert_file.close()
key_file.close()
context.load_cert_chain(cert_file.name, key_file.name)
+2 -2
View File
@@ -31,9 +31,9 @@ smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64"
smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64"
requests==2.34.2
py7zr==1.1.4
platformdirs==4.13.3 # native esp-idf toolchain global cache dir
platformdirs==4.13.4 # native esp-idf toolchain global cache dir
ninja==1.13.2 # native host and esp8266 arduino toolchain build driver
filelock==4.2.1 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
filelock==4.2.2 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
# esp-idf >= 5.0 requires this
pyparsing >= 3.3.3
@@ -10,6 +10,7 @@ from esphome.components.mqtt import (
MQTT_TRANSPORT_TCP,
MQTT_TRANSPORT_WS,
MQTT_TRANSPORT_WSS,
_validate_tls,
_validate_transport,
validate_config,
)
@@ -86,22 +87,11 @@ def test_plaintext_ws_with_certificate_rejected(
) -> None:
"""Test that pairing a CA or client certificate with unencrypted ws is rejected."""
with pytest.raises(cv.Invalid, match="unencrypted"):
validate_config(
{
CONF_TRANSPORT: MQTT_TRANSPORT_WS,
CONF_TOPIC_PREFIX: "test",
**certificate_options,
}
)
_validate_tls({CONF_TRANSPORT: MQTT_TRANSPORT_WS, **certificate_options})
def test_client_certificate_requires_tls() -> None:
"""Test that a client certificate without a CA is only accepted over wss."""
certificate = {
CONF_CLIENT_CERTIFICATE: "-----BEGIN CERTIFICATE-----",
CONF_CLIENT_CERTIFICATE_KEY: "-----BEGIN PRIVATE KEY-----",
CONF_TOPIC_PREFIX: "test",
}
with pytest.raises(cv.Invalid, match="requires 'certificate_authority'"):
validate_config({CONF_TRANSPORT: MQTT_TRANSPORT_TCP, **certificate})
validate_config({CONF_TRANSPORT: MQTT_TRANSPORT_WSS, **certificate})
def test_wss_switches_tls_on() -> None:
"""Test that wss implies tls and cannot be combined with tls false."""
assert _validate_tls({CONF_TRANSPORT: MQTT_TRANSPORT_WSS})["tls"] is True
with pytest.raises(cv.Invalid, match="'tls' cannot be false"):
_validate_tls({CONF_TRANSPORT: MQTT_TRANSPORT_WSS, "tls": False})
@@ -0,0 +1,72 @@
"""Tests for the mqtt TLS validation rules and the port default."""
import pytest
from esphome import config_validation as cv
from esphome.components.const import CONF_VERIFY_SSL
from esphome.components.mqtt import CONF_TLS, _validate_tls
from esphome.const import (
CONF_BROKER,
CONF_CERTIFICATE_AUTHORITY,
CONF_CLIENT_CERTIFICATE,
CONF_PORT,
CONF_SKIP_CERT_CN_CHECK,
CONF_TOPIC_PREFIX,
)
from esphome.types import ConfigType
BASE: ConfigType = {CONF_BROKER: "broker.local", CONF_TOPIC_PREFIX: "node"}
def _validate(**extra: object) -> ConfigType:
return _validate_tls({**BASE, **extra})
def test_plain_defaults_to_port_1883() -> None:
out = _validate()
assert out[CONF_PORT] == 1883
assert CONF_TLS not in out
@pytest.mark.parametrize(
"extra",
[
{CONF_TLS: True},
{CONF_CERTIFICATE_AUTHORITY: "cert"},
{CONF_VERIFY_SSL: False},
{CONF_CLIENT_CERTIFICATE: "cert"},
{CONF_SKIP_CERT_CN_CHECK: True},
],
)
def test_tls_defaults_to_port_8883(extra: ConfigType) -> None:
out = _validate(**extra)
assert out[CONF_TLS] is True
assert out[CONF_PORT] == 8883
def test_explicit_port_is_kept() -> None:
assert _validate(**{CONF_TLS: True, CONF_PORT: 1234})[CONF_PORT] == 1234
def test_certificate_authority_with_verify_ssl_false_rejected() -> None:
with pytest.raises(cv.Invalid, match="only valid when 'verify_ssl' is not false"):
_validate(**{CONF_CERTIFICATE_AUTHORITY: "cert", CONF_VERIFY_SSL: False})
def test_skip_cert_cn_check_with_verify_ssl_false_rejected() -> None:
with pytest.raises(cv.Invalid, match="'skip_cert_cn_check' has no effect"):
_validate(**{CONF_SKIP_CERT_CN_CHECK: True, CONF_VERIFY_SSL: False})
@pytest.mark.parametrize(
"extra",
[
{CONF_CERTIFICATE_AUTHORITY: "cert"},
{CONF_VERIFY_SSL: True},
{CONF_CLIENT_CERTIFICATE: "cert"},
{CONF_SKIP_CERT_CN_CHECK: True},
],
)
def test_tls_false_with_certificate_options_rejected(extra: ConfigType) -> None:
with pytest.raises(cv.Invalid, match="'tls' cannot be false"):
_validate(**{CONF_TLS: False, **extra})
@@ -1,3 +1,6 @@
packages:
common: !include common.yaml
update: !include common-update.yaml
mqtt:
tls: true
@@ -0,0 +1,10 @@
packages:
common: !include common.yaml
mqtt:
broker: test.mosquitto.org
skip_cert_cn_check: true
certificate_authority: |
-----BEGIN CERTIFICATE-----
MIIBszCCAVoCCQDAAAAAAAAAADAKBggqhkjOPQQDAjAfMR0wGwYDVQQDDBRtcXR0
-----END CERTIFICATE-----
@@ -0,0 +1,6 @@
packages:
common: !include common.yaml
mqtt:
broker: test.mosquitto.org
verify_ssl: false
@@ -2,12 +2,12 @@
from __future__ import annotations
import asyncio
import collections
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@@ -23,54 +23,41 @@ async def test_action_concurrent_reentry(
This test runs a script in parallel with varying arguments and verifies if
each script keeps its original argument throughout its execution
"""
test_complete = asyncio.Event()
waiter = LineWaiter()
expected = {0, 1, 2, 3, 4}
# Patterns to match in logs
after_wait_until_pattern = re.compile(r"AFTER wait_until ARG (\d+)")
after_script_wait_pattern = re.compile(r"AFTER script\.wait ARG (\d+)")
after_repeat_pattern = re.compile(r"AFTER repeat ARG (\d+)")
in_repeat_pattern = re.compile(r"IN repeat (\d+) ARG (\d+)")
after_while_pattern = re.compile(r"AFTER while ARG (\d+)")
in_while_pattern = re.compile(r"IN while ARG (\d+)")
def captures(pattern: str) -> list[tuple[int, ...]]:
return [
tuple(map(int, m.groups()))
for line in waiter.lines
if (m := re.search(pattern, line))
]
after_wait_until_args = []
after_script_wait_args = []
after_while_args = []
in_while_args = []
after_repeat_args = []
in_repeat_args = collections.defaultdict(list)
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if test_complete.is_set():
return
if mo := after_wait_until_pattern.search(line):
after_wait_until_args.append(int(mo.group(1)))
elif mo := after_script_wait_pattern.search(line):
after_script_wait_args.append(int(mo.group(1)))
elif mo := in_while_pattern.search(line):
in_while_args.append(int(mo.group(1)))
elif mo := after_while_pattern.search(line):
after_while_args.append(int(mo.group(1)))
elif mo := in_repeat_pattern.search(line):
in_repeat_args[int(mo.group(1))].append(int(mo.group(2)))
elif mo := after_repeat_pattern.search(line):
after_repeat_args.append(int(mo.group(1)))
if len(after_repeat_args) == len(expected):
test_complete.set()
def arg_list(pattern: str) -> list[int]:
return [arg for (arg,) in captures(pattern)]
# Run with log monitoring
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify device info
await assert_device_name(client, "action-concurrent-reentry")
# Wait for tests to complete with timeout
await wait_or_fail(test_complete, 8.0, "test timed out")
await wait_or_fail(
waiter.wait_for_each(*(f"AFTER repeat ARG {arg}" for arg in expected)),
8.0,
"test timed out",
)
after_wait_until_args = arg_list(r"AFTER wait_until ARG (\d+)")
after_script_wait_args = arg_list(r"AFTER script\.wait ARG (\d+)")
after_repeat_args = arg_list(r"AFTER repeat ARG (\d+)")
after_while_args = arg_list(r"AFTER while ARG (\d+)")
in_while_args = arg_list(r"IN while ARG (\d+)")
in_repeat_args = collections.defaultdict(list)
for iteration, arg in captures(r"IN repeat (\d+) ARG (\d+)"):
in_repeat_args[iteration].append(arg)
# order may change, but all args must be present
for args in in_repeat_args.values():
+5 -17
View File
@@ -2,13 +2,11 @@
from __future__ import annotations
import asyncio
import re
import pytest
from esphome.helpers import fnv1_hash
from .log_utils import LineWaiter
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -19,20 +17,10 @@ async def test_api_action_metadata(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Action and argument metadata reach the client and the actions still run."""
loop = asyncio.get_running_loop()
buzzer_called = loop.create_future()
plain_called = loop.create_future()
buzzer_pattern = re.compile(r"Buzzer: two_short")
plain_pattern = re.compile(r"Plain action called")
def check_output(line: str) -> None:
if not buzzer_called.done() and buzzer_pattern.search(line):
buzzer_called.set_result(True)
elif not plain_called.done() and plain_pattern.search(line):
plain_called.set_result(True)
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
_, services = await client.list_entities_services()
@@ -61,5 +49,5 @@ async def test_api_action_metadata(
buzzer, {"song_str": "two_short:d=4,o=5,b=100:16e6,16e6", "volume": 3}
)
await client.execute_service(plain, {"value": 1})
await asyncio.wait_for(buzzer_called, timeout=5.0)
await asyncio.wait_for(plain_called, timeout=5.0)
await waiter.wait_for("Buzzer: two_short", timeout=5.0)
await waiter.wait_for("Plain action called", timeout=5.0)
+15 -49
View File
@@ -5,13 +5,12 @@ Tests the supports_response modes: none, status, optional, only.
from __future__ import annotations
import asyncio
import json
import re
from aioesphomeapi import SupportsResponseType, UserServiceArgType
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -23,50 +22,11 @@ async def test_api_action_responses(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test API action response modes work correctly."""
loop = asyncio.get_running_loop()
# Track log messages for each action type
no_response_future = loop.create_future()
status_success_future = loop.create_future()
status_error_future = loop.create_future()
optional_response_future = loop.create_future()
only_response_future = loop.create_future()
nested_json_future = loop.create_future()
# Patterns to match in logs
no_response_pattern = re.compile(r"ACTION_NO_RESPONSE called with: test_message")
status_success_pattern = re.compile(
r"ACTION_STATUS_RESPONSE success \(call_id=\d+\)"
)
status_error_pattern = re.compile(r"ACTION_STATUS_RESPONSE error \(call_id=\d+\)")
optional_response_pattern = re.compile(
r"ACTION_OPTIONAL_RESPONSE \(call_id=\d+, return_response=\d+, value=42\)"
)
only_response_pattern = re.compile(
r"ACTION_ONLY_RESPONSE \(call_id=\d+, name=World\)"
)
nested_json_pattern = re.compile(r"ACTION_NESTED_JSON \(call_id=\d+\)")
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if not no_response_future.done() and no_response_pattern.search(line):
no_response_future.set_result(True)
elif not status_success_future.done() and status_success_pattern.search(line):
status_success_future.set_result(True)
elif not status_error_future.done() and status_error_pattern.search(line):
status_error_future.set_result(True)
elif not optional_response_future.done() and optional_response_pattern.search(
line
):
optional_response_future.set_result(True)
elif not only_response_future.done() and only_response_pattern.search(line):
only_response_future.set_result(True)
elif not nested_json_future.done() and nested_json_pattern.search(line):
nested_json_future.set_result(True)
waiter = LineWaiter()
# Run with log monitoring
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify device info
@@ -146,7 +106,9 @@ async def test_api_action_responses(
action_no_response, {"message": "test_message"}
)
assert response is None, "action_no_response should not return a response"
await asyncio.wait_for(no_response_future, timeout=5.0)
await waiter.wait_for(
"ACTION_NO_RESPONSE called with: test_message", timeout=5.0
)
# Test action_status_response with success (supports_response: status)
response = await client.execute_service(
@@ -154,7 +116,7 @@ async def test_api_action_responses(
{"should_succeed": True},
return_response=True,
)
await asyncio.wait_for(status_success_future, timeout=5.0)
await waiter.wait_for("ACTION_STATUS_RESPONSE success (call_id=", timeout=5.0)
assert response is not None, "Expected response for status action"
assert response.success is True, (
f"Expected success=True, got {response.success}"
@@ -169,7 +131,7 @@ async def test_api_action_responses(
{"should_succeed": False},
return_response=True,
)
await asyncio.wait_for(status_error_future, timeout=5.0)
await waiter.wait_for("ACTION_STATUS_RESPONSE error (call_id=", timeout=5.0)
assert response is not None, "Expected response for status action"
assert response.success is False, (
f"Expected success=False, got {response.success}"
@@ -185,7 +147,9 @@ async def test_api_action_responses(
{"value": 42},
return_response=True,
)
await asyncio.wait_for(optional_response_future, timeout=5.0)
await waiter.wait_for(
"ACTION_OPTIONAL_RESPONSE (call_id=", "value=42)", timeout=5.0
)
assert response is not None, "Expected response for optional action"
assert response.success is True, (
f"Expected success=True, got {response.success}"
@@ -205,7 +169,9 @@ async def test_api_action_responses(
{"name": "World"},
return_response=True,
)
await asyncio.wait_for(only_response_future, timeout=5.0)
await waiter.wait_for(
"ACTION_ONLY_RESPONSE (call_id=", "name=World)", timeout=5.0
)
assert response is not None, "Expected response for only action"
assert response.success is True, (
f"Expected success=True, got {response.success}"
@@ -224,7 +190,7 @@ async def test_api_action_responses(
{},
return_response=True,
)
await asyncio.wait_for(nested_json_future, timeout=5.0)
await waiter.wait_for("ACTION_NESTED_JSON (call_id=", timeout=5.0)
assert response is not None, "Expected response for nested json action"
assert response.success is True, (
f"Expected success=True, got {response.success}"
+13 -55
View File
@@ -8,10 +8,10 @@ from __future__ import annotations
import asyncio
import contextlib
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -30,52 +30,11 @@ async def test_api_action_timeout(
2. Actions that exceed the timeout have their calls cleaned up
3. Late responses log a warning but don't crash
"""
loop = asyncio.get_running_loop()
# Track log messages
immediate_future = loop.create_future()
short_delay_responding_future = loop.create_future()
long_delay_starting_future = loop.create_future()
long_delay_responding_future = loop.create_future()
timeout_warning_future = loop.create_future()
# Patterns to match in logs
immediate_pattern = re.compile(r"ACTION_IMMEDIATE responding")
short_delay_responding_pattern = re.compile(r"ACTION_SHORT_DELAY responding")
long_delay_starting_pattern = re.compile(r"ACTION_LONG_DELAY starting")
long_delay_responding_pattern = re.compile(
r"ACTION_LONG_DELAY responding \(after timeout\)"
)
# This warning is logged when api.respond is called after the action call timed out
timeout_warning_pattern = re.compile(
r"Cannot send response: no active call found for action_call_id"
)
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if not immediate_future.done() and immediate_pattern.search(line):
immediate_future.set_result(True)
elif (
not short_delay_responding_future.done()
and short_delay_responding_pattern.search(line)
):
short_delay_responding_future.set_result(True)
elif (
not long_delay_starting_future.done()
and long_delay_starting_pattern.search(line)
):
long_delay_starting_future.set_result(True)
elif (
not long_delay_responding_future.done()
and long_delay_responding_pattern.search(line)
):
long_delay_responding_future.set_result(True)
elif not timeout_warning_future.done() and timeout_warning_pattern.search(line):
timeout_warning_future.set_result(True)
waiter = LineWaiter()
# Run with log monitoring
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify device info
@@ -98,7 +57,7 @@ async def test_api_action_timeout(
{},
return_response=True,
)
await asyncio.wait_for(immediate_future, timeout=1.0)
await waiter.wait_for("ACTION_IMMEDIATE responding", timeout=1.0)
assert response is not None, "Expected response for immediate action"
assert response.success is True
@@ -108,7 +67,7 @@ async def test_api_action_timeout(
{},
return_response=True,
)
await asyncio.wait_for(short_delay_responding_future, timeout=1.0)
await waiter.wait_for("ACTION_SHORT_DELAY responding", timeout=1.0)
assert response is not None, "Expected response for short delay action"
assert response.success is True
@@ -128,13 +87,18 @@ async def test_api_action_timeout(
)
# Wait for the action to start
await asyncio.wait_for(long_delay_starting_future, timeout=1.0)
await waiter.wait_for("ACTION_LONG_DELAY starting", timeout=1.0)
# Wait for the action to try to respond (after 1s delay)
await asyncio.wait_for(long_delay_responding_future, timeout=2.0)
await waiter.wait_for(
"ACTION_LONG_DELAY responding (after timeout)", timeout=2.0
)
# Wait for the warning log about no active call
await asyncio.wait_for(timeout_warning_future, timeout=1.0)
await waiter.wait_for(
"Cannot send response: no active call found for action_call_id",
timeout=1.0,
)
# The client task should complete (either with None response or timeout)
# Client timing out is acceptable - the server-side timeout already cleaned up the call
@@ -143,12 +107,6 @@ async def test_api_action_timeout(
# Verify the system is still functional after the timeout
# Call the immediate action again to prove cleanup worked
immediate_future_2 = loop.create_future()
def check_output_2(line: str) -> None:
if not immediate_future_2.done() and immediate_pattern.search(line):
immediate_future_2.set_result(True)
response = await client.execute_service(
action_immediate,
{},
@@ -2,12 +2,10 @@
from __future__ import annotations
import asyncio
import re
from aioesphomeapi import UserServiceArgType
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -19,45 +17,17 @@ async def test_api_conditional_memory(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test API triggers and services work correctly with conditional compilation."""
loop = asyncio.get_running_loop()
# Track log messages
connected_future = loop.create_future()
disconnected_future = loop.create_future()
no_clients_future = loop.create_future()
service_simple_future = loop.create_future()
service_args_future = loop.create_future()
# Patterns to match in logs
connected_pattern = re.compile(r"Client .* connected from")
disconnected_pattern = re.compile(r"Client .* disconnected from")
no_clients_pattern = re.compile(r"No clients remaining")
service_simple_pattern = re.compile(r"Simple service called")
service_args_pattern = re.compile(
r"Service called with: test_string, 123, 1, 42\.50"
)
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if not connected_future.done() and connected_pattern.search(line):
connected_future.set_result(True)
elif not disconnected_future.done() and disconnected_pattern.search(line):
disconnected_future.set_result(True)
elif not no_clients_future.done() and no_clients_pattern.search(line):
no_clients_future.set_result(True)
elif not service_simple_future.done() and service_simple_pattern.search(line):
service_simple_future.set_result(True)
elif not service_args_future.done() and service_args_pattern.search(line):
service_args_future.set_result(True)
waiter = LineWaiter()
# Run with log monitoring
async with run_compiled(yaml_config, line_callback=check_output):
async with run_compiled(yaml_config, line_callback=waiter.callback):
async with api_client_connected() as client:
# Verify device info
await assert_device_name(client, "api-conditional-memory-test")
# Wait for connection log
await asyncio.wait_for(connected_future, timeout=5.0)
# The leading space keeps "disconnected from" from matching
await waiter.wait_for("Client", " connected from", timeout=5.0)
# List services
_, services = await client.list_entities_services()
@@ -85,7 +55,7 @@ async def test_api_conditional_memory(
await client.execute_service(simple_service, {})
# Wait for service log
await asyncio.wait_for(service_simple_future, timeout=5.0)
await waiter.wait_for("Simple service called", timeout=5.0)
# Call service with arguments
await client.execute_service(
@@ -99,11 +69,13 @@ async def test_api_conditional_memory(
)
# Wait for service with args log
await asyncio.wait_for(service_args_future, timeout=5.0)
await waiter.wait_for(
"Service called with: test_string, 123, 1, 42.50", timeout=5.0
)
# Client disconnected here, wait for disconnect log
await asyncio.wait_for(disconnected_future, timeout=5.0)
await waiter.wait_for("Client", "disconnected from", timeout=5.0)
# Verify fix for issue #11131: api.connected should be false in trigger
# when the last client disconnects
await asyncio.wait_for(no_clients_future, timeout=5.0)
await waiter.wait_for("No clients remaining", timeout=5.0)
+18 -53
View File
@@ -2,12 +2,10 @@
from __future__ import annotations
import asyncio
import re
from aioesphomeapi import UserServiceArgType
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -19,52 +17,11 @@ async def test_api_custom_services(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test CustomAPIDevice services work correctly with custom_services: true."""
loop = asyncio.get_running_loop()
# Track log messages
yaml_service_future = loop.create_future()
yaml_args_future = loop.create_future()
yaml_many_args_future = loop.create_future()
custom_service_future = loop.create_future()
custom_args_future = loop.create_future()
custom_arrays_future = loop.create_future()
ha_state_future = loop.create_future()
# Patterns to match in logs
yaml_service_pattern = re.compile(r"YAML service called")
yaml_args_pattern = re.compile(r"YAML service with args: 123, test_value")
yaml_many_args_pattern = re.compile(r"YAML service many args: 42, 3\.14, 1, hello")
custom_service_pattern = re.compile(r"Custom test service called!")
custom_args_pattern = re.compile(
r"Custom service called with: test_string, 456, 1, 78\.90"
)
custom_arrays_pattern = re.compile(
r"Array service called with 2 bools, 3 ints, 2 floats, 2 strings"
)
ha_state_pattern = re.compile(
r"This subscription uses std::string API for backward compatibility"
)
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if not yaml_service_future.done() and yaml_service_pattern.search(line):
yaml_service_future.set_result(True)
elif not yaml_args_future.done() and yaml_args_pattern.search(line):
yaml_args_future.set_result(True)
elif not yaml_many_args_future.done() and yaml_many_args_pattern.search(line):
yaml_many_args_future.set_result(True)
elif not custom_service_future.done() and custom_service_pattern.search(line):
custom_service_future.set_result(True)
elif not custom_args_future.done() and custom_args_pattern.search(line):
custom_args_future.set_result(True)
elif not custom_arrays_future.done() and custom_arrays_pattern.search(line):
custom_arrays_future.set_result(True)
elif not ha_state_future.done() and ha_state_pattern.search(line):
ha_state_future.set_result(True)
waiter = LineWaiter()
# Run with log monitoring
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify device info
@@ -88,7 +45,7 @@ async def test_api_custom_services(
# Test YAML service
await client.execute_service(yaml_service, {})
await asyncio.wait_for(yaml_service_future, timeout=5.0)
await waiter.wait_for("YAML service called", timeout=5.0)
# Verify YAML service with args arguments
assert len(yaml_args_service.args) == 2
@@ -104,7 +61,7 @@ async def test_api_custom_services(
"my_string": "test_value",
},
)
await asyncio.wait_for(yaml_args_future, timeout=5.0)
await waiter.wait_for("YAML service with args: 123, test_value", timeout=5.0)
# Verify YAML service with many args arguments
assert len(yaml_many_args_service.args) == 4
@@ -126,11 +83,11 @@ async def test_api_custom_services(
"arg4": "hello",
},
)
await asyncio.wait_for(yaml_many_args_future, timeout=5.0)
await waiter.wait_for("YAML service many args: 42, 3.14, 1, hello", timeout=5.0)
# Test simple CustomAPIDevice service
await client.execute_service(custom_service, {})
await asyncio.wait_for(custom_service_future, timeout=5.0)
await waiter.wait_for("Custom test service called!", timeout=5.0)
# Verify custom_args_service arguments
assert len(custom_args_service.args) == 4
@@ -150,7 +107,9 @@ async def test_api_custom_services(
"arg_float": 78.9,
},
)
await asyncio.wait_for(custom_args_future, timeout=5.0)
await waiter.wait_for(
"Custom service called with: test_string, 456, 1, 78.90", timeout=5.0
)
# Verify array service arguments
assert len(custom_arrays_service.args) == 4
@@ -170,9 +129,15 @@ async def test_api_custom_services(
"string_array": ["hello", "world"],
},
)
await asyncio.wait_for(custom_arrays_future, timeout=5.0)
await waiter.wait_for(
"Array service called with 2 bools, 3 ints, 2 floats, 2 strings",
timeout=5.0,
)
# Test Home Assistant state subscription (std::string API backward compatibility)
# This verifies that custom_api_device.h can still use std::string overloads
client.send_home_assistant_state("sensor.custom_test", "", "42.5")
await asyncio.wait_for(ha_state_future, timeout=5.0)
await waiter.wait_for(
"This subscription uses std::string API for backward compatibility",
timeout=5.0,
)
+21 -3
View File
@@ -13,10 +13,15 @@ from __future__ import annotations
import asyncio
import re
from aioesphomeapi import HomeassistantServiceCall
from aioesphomeapi import HomeassistantServiceCall, SensorInfo, SensorState
import pytest
from .state_utils import assert_device_name, require_service
from .state_utils import (
assert_device_name,
require_entity,
require_service,
wait_for_state,
)
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -224,11 +229,24 @@ async def test_api_homeassistant(
client.send_home_assistant_state(entity, "step", "0.5")
# List entities and services
_, services = await client.list_entities_services()
entities, services = await client.list_entities_services()
# Find the trigger service
trigger_service = require_service(services, "trigger_all_tests")
# The complex lambda service reads the template sensor, whose first update lands at
# a random point in its interval; until then its state is NAN, which the int cast
# turns into INT_MIN. Wait for the first reading before firing the tests.
test_sensor = require_entity(entities, "test_sensor", SensorInfo, exact=True)
await wait_for_state(
client,
lambda s: (
isinstance(s, SensorState)
and s.key == test_sensor.key
and s.state == 42.0
),
)
# Execute all tests
await client.execute_service(trigger_service, {})
+7 -15
View File
@@ -1,10 +1,8 @@
"""Test API server reboot timeout functionality."""
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .types import RunCompiledFunction
from .wait_utils import wait_or_fail
@@ -15,21 +13,15 @@ async def test_api_reboot_timeout(
run_compiled: RunCompiledFunction,
) -> None:
"""Test that the device reboots when no API clients connect within the timeout."""
loop = asyncio.get_running_loop()
reboot_future = loop.create_future()
reboot_pattern = re.compile(r"No clients; rebooting")
def check_output(line: str) -> None:
"""Check output for reboot message."""
if not reboot_future.done() and reboot_pattern.search(line):
reboot_future.set_result(True)
waiter = LineWaiter()
# Run the device without connecting any API client
async with run_compiled(yaml_config, line_callback=check_output):
# Wait for reboot with timeout
# (0.5s reboot timeout + some margin for processing)
async with run_compiled(yaml_config, line_callback=waiter.callback):
# 0.5s reboot timeout + some margin for processing
await wait_or_fail(
reboot_future, 2.0, "Device did not reboot within expected timeout"
waiter.wait_for("No clients; rebooting"),
2.0,
"Device did not reboot within expected timeout",
)
# Test passes if we get here - reboot was detected
+12 -36
View File
@@ -2,11 +2,9 @@
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@@ -19,34 +17,11 @@ async def test_api_string_lambda(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test Home Assistant action lambdas that return different types are sent as strings."""
loop = asyncio.get_running_loop()
# Track log messages for all four service calls
string_called_future = loop.create_future()
int_called_future = loop.create_future()
float_called_future = loop.create_future()
char_ptr_called_future = loop.create_future()
# Patterns to match in logs - confirms the lambdas compiled and executed
string_pattern = re.compile(r"Service called with string: STRING_FROM_LAMBDA")
int_pattern = re.compile(r"Service called with int: 42")
float_pattern = re.compile(r"Service called with float: 3\.14")
char_ptr_pattern = re.compile(r"Service called with number for char\* test: 123")
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if not string_called_future.done() and string_pattern.search(line):
string_called_future.set_result(True)
if not int_called_future.done() and int_pattern.search(line):
int_called_future.set_result(True)
if not float_called_future.done() and float_pattern.search(line):
float_called_future.set_result(True)
if not char_ptr_called_future.done() and char_ptr_pattern.search(line):
char_ptr_called_future.set_result(True)
waiter = LineWaiter()
# Run with log monitoring
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify device info
@@ -74,15 +49,16 @@ async def test_api_string_lambda(
char_ptr_service, {"input_number": 123, "input_string": "test_string"}
)
# Wait for all service log messages
# This confirms the lambdas compiled successfully and executed
# Confirms the lambdas compiled and executed
await wait_or_fail(
asyncio.gather(
string_called_future,
int_called_future,
float_called_future,
char_ptr_called_future,
waiter.wait_for_each(
"Service called with string: STRING_FROM_LAMBDA",
"Service called with int: 42",
"Service called with float: 3.14",
"Service called with number for char* test: 123",
timeout=10.0,
),
5.0,
"One or more service log messages not received - lambda may have failed to compile or execute",
"Not every lambda service logged its call",
logged=waiter.lines,
)
@@ -2,11 +2,11 @@
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .types import APIClientConnectedFactory, RunCompiledFunction
WARN_PATTERN = re.compile(
@@ -23,25 +23,24 @@ async def test_blocking_warning_log_time_not_charged_to_next_operation(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Synchronous warning-log delays must not be charged to the next operation."""
loop = asyncio.get_running_loop()
complete = asyncio.Event()
warnings: list[tuple[str, int, int]] = []
injected_delay_total = 0
def check_output(line: str) -> None:
nonlocal injected_delay_total
if match := WARN_PATTERN.search(line):
warnings.append((match.group(1), int(match.group(2)), int(match.group(3))))
if match := COMPLETE_PATTERN.search(line):
injected_delay_total = int(match.group(1))
loop.call_soon_threadsafe(complete.set)
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
assert await client.device_info() is not None
await asyncio.wait_for(complete.wait(), timeout=10.0)
complete_line = await waiter.wait_for(
"BLOCKING_WARNING_CASCADE_TEST_COMPLETE total=", timeout=10.0
)
assert (match := COMPLETE_PATTERN.search(complete_line)), complete_line
injected_delay_total = int(match.group(1))
warnings = [
(m.group(1), int(m.group(2)), int(m.group(3)))
for line in waiter.lines
if (m := WARN_PATTERN.search(line))
]
assert injected_delay_total == 270, (
f"Expected 270 ms of injected warning-log delay, got {injected_delay_total} ms"
+18 -42
View File
@@ -7,6 +7,7 @@ import re
import pytest
from .log_utils import LineWaiter
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -17,97 +18,76 @@ async def test_branch_resume_async(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A branch that ends after a delay must resume its owner exactly once."""
lines: list[str] = []
waiters: list[tuple[str, asyncio.Future[None]]] = []
waiter = LineWaiter()
def has_marker(text: str, line: str) -> bool:
# Whole markers only: "after-if" must not match "queued-after-if-1"
return re.search(rf"(?<![\w-]){re.escape(text)}(?![\w-])", line) is not None
def check_output(line: str) -> None:
lines.append(line)
for text, future in waiters:
if has_marker(text, line) and not future.done():
future.set_result(None)
def wait_for(text: str) -> asyncio.Future[None]:
future: asyncio.Future[None] = asyncio.get_running_loop().create_future()
waiters.append((text, future))
return future
def count(text: str) -> int:
return sum(has_marker(text, line) for line in lines)
return sum(has_marker(text, line) for line in waiter.lines)
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
_, services = await client.list_entities_services()
service = {s.name: s for s in services}
done = wait_for("after-if")
await client.execute_service(service["run_delay_in_if"], {})
await asyncio.wait_for(done, timeout=2.0)
await waiter.wait_for("after-if", timeout=2.0)
assert count("if-branch-after-delay") == 1
assert count("if-else-should-not-run") == 0
assert count("after-if") == 1
done = wait_for("after-else")
await client.execute_service(service["run_delay_in_else"], {})
await asyncio.wait_for(done, timeout=2.0)
await waiter.wait_for("after-else", timeout=2.0)
assert count("else-branch-after-delay") == 1
assert count("if-then-should-not-run") == 0
assert count("after-else") == 1
done = wait_for("after-while")
await client.execute_service(service["run_delay_in_while"], {})
await asyncio.wait_for(done, timeout=2.0)
await waiter.wait_for("after-while", timeout=2.0)
assert [count(f"while-body-{i}") for i in (1, 2, 3)] == [1, 1, 1]
assert count("while-body-4") == 0
assert count("after-while") == 1
done = wait_for("stop-check-done")
await client.execute_service(service["run_stop_in_branch"], {})
await asyncio.wait_for(done, timeout=3.0)
await waiter.wait_for("stop-check-done", timeout=3.0)
assert count("stop-running=0") == 1
assert count("stopped-branch-should-not-run") == 0
assert count("stopped-after-if-should-not-run") == 0
# A while condition that stops the script on a later pass must not start the body again
done = wait_for("later-check-done")
await client.execute_service(service["run_stop_in_later_while_check"], {})
await asyncio.wait_for(done, timeout=2.0)
await waiter.wait_for("later-check-done", timeout=2.0)
assert count("later-check-body-1") == 1
assert count("later-check-body-2") == 0
assert count("after-later-check-should-not-run") == 0
# An empty loop body cannot resume the loop, so the while finishes at once
done = wait_for("after-empty-while")
await client.execute_service(service["run_empty_while"], {})
await asyncio.wait_for(done, timeout=2.0)
await waiter.wait_for("after-empty-while", timeout=2.0)
assert count("after-empty-while") == 1
done = wait_for("after-nested-while")
await client.execute_service(service["run_nested_if_last_in_while"], {})
await asyncio.wait_for(done, timeout=2.0)
await waiter.wait_for("after-nested-while", timeout=2.0)
assert [count(f"nested-if-in-while-{i}") for i in (1, 2)] == [1, 1]
assert count("nested-if-in-while-3") == 0
assert count("after-nested-while") == 1
# Stop during the second iteration's delay: that iteration and the line after the loop never run;
# a fresh run then completes
done = wait_for("stop-while-done")
await client.execute_service(service["run_stop_mid_while"], {})
await asyncio.wait_for(done, timeout=3.0)
await waiter.wait_for("stop-while-done", timeout=3.0)
assert count("stop-while-running=0") == 1
assert count("stop-while-body-1") == 2
assert [count(f"stop-while-body-{i}") for i in (2, 3, 4, 5)] == [1, 1, 1, 1]
assert count("stop-while-done") == 1
done = wait_for("queued-after-if-3")
await client.execute_service(service["run_queued_branch"], {})
await asyncio.wait_for(done, timeout=2.0)
queued = [line for line in lines if "queued-" in line]
await waiter.wait_for("queued-after-if-3", timeout=2.0)
queued = [line for line in waiter.lines if "queued-" in line]
order = [
"queued-branch-1",
"queued-after-if-1",
@@ -118,33 +98,29 @@ async def test_branch_resume_async(
]
assert [next(o for o in order if o in line) for line in queued] == order
done = wait_for("restart-after-if-2")
await client.execute_service(service["run_restart_branch"], {})
await asyncio.wait_for(done, timeout=2.0)
await waiter.wait_for("restart-after-if-2", timeout=2.0)
await asyncio.sleep(0.2)
assert count("restart-branch-1") == 0
assert count("restart-after-if-1") == 0
assert count("restart-branch-2") == 1
assert count("restart-after-if-2") == 1
done = wait_for("after-delay-last")
await client.execute_service(service["run_delay_last_in_branch"], {})
await asyncio.wait_for(done, timeout=2.0)
await waiter.wait_for("after-delay-last", timeout=2.0)
assert count("delay-last-start") == 1
assert count("after-delay-last") == 1
done = wait_for("parallel-after-if-3")
await client.execute_service(service["run_parallel_branch"], {})
await asyncio.wait_for(done, timeout=2.0)
await waiter.wait_for("parallel-after-if-3", timeout=2.0)
await asyncio.sleep(0.2)
for run in (1, 2, 3):
assert count(f"parallel-branch-done-{run}") == 1
assert count(f"parallel-after-if-{run}") == 1
# A condition that stops its own script must not start a branch or continue the chain
done = wait_for("stop-in-conditions-done")
await client.execute_service(service["run_stop_in_conditions"], {})
await asyncio.wait_for(done, timeout=2.0)
await waiter.wait_for("stop-in-conditions-done", timeout=2.0)
assert count("if-stopped-by-condition-should-not-run") == 0
assert count("if-else-stopped-by-condition-should-not-run") == 0
assert count("after-if-stopped-by-condition-should-not-run") == 0
+21 -20
View File
@@ -10,15 +10,19 @@ to be updated to match the other.
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
# Format: "[timestamp][level][FNV1_OID:line]: test_name PASSED"
RESULT_PATTERN = re.compile(r"\[FNV1_OID:\d+\]:\s+(\w+)\s+(PASSED|FAILED)")
@pytest.mark.asyncio
async def test_fnv1_hash_object_id(
@@ -28,8 +32,7 @@ async def test_fnv1_hash_object_id(
) -> None:
"""Test that C++ fnv1_hash_object_id matches Python implementation."""
test_results: dict[str, str] = {}
all_tests_complete = asyncio.Event()
waiter = LineWaiter()
expected_tests = {
"foo",
"upper",
@@ -41,34 +44,32 @@ async def test_fnv1_hash_object_id(
"empty",
}
def on_log_line(line: str) -> None:
"""Capture log lines with test results."""
# Strip ANSI escape codes
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
# Look for our test result messages
# Format: "[timestamp][level][FNV1_OID:line]: test_name PASSED"
match = re.search(r"\[FNV1_OID:\d+\]:\s+(\w+)\s+(PASSED|FAILED)", clean_line)
if match:
test_name = match.group(1)
result = match.group(2)
test_results[test_name] = result
if set(test_results.keys()) >= expected_tests:
all_tests_complete.set()
def test_results() -> dict[str, str]:
return {
m.group(1): m.group(2)
for line in waiter.lines
if (m := RESULT_PATTERN.search(ANSI_PATTERN.sub("", line)))
}
async def wait_all_results() -> None:
for name in expected_tests:
await waiter.wait_for("[FNV1_OID:", f"]: {name} ")
async with (
run_compiled(yaml_config, line_callback=on_log_line),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
await assert_device_name(client, "fnv1-hash-object-id-test")
# Wait for all tests to complete or timeout
await wait_or_fail(
all_tests_complete, 2.0, "Tests timed out", test_results=test_results
wait_all_results(), 2.0, "Tests timed out", test_results=test_results
)
results = test_results()
# Verify all tests passed
for test_name in expected_tests:
assert test_name in test_results, f"{test_name} test not found"
assert test_results[test_name] == "PASSED", (
assert test_name in results, f"{test_name} test not found"
assert results[test_name] == "PASSED", (
f"{test_name} test failed - C++ and Python hash mismatch"
)
+29 -30
View File
@@ -2,15 +2,19 @@
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
# Format: "[timestamp][level][FNV1A:line]: test_name PASSED"
RESULT_PATTERN = re.compile(r"\[FNV1A:\d+\]:\s+(\w+)\s+(PASSED|FAILED)")
@pytest.mark.asyncio
async def test_fnv1a_hash(
@@ -20,49 +24,44 @@ async def test_fnv1a_hash(
) -> None:
"""Test that FNV-1a hash functions work correctly."""
test_results = {}
all_tests_complete = asyncio.Event()
waiter = LineWaiter()
expected_tests = {"empty", "known_hello", "known_helloworld", "extend", "string"}
def on_log_line(line: str) -> None:
"""Capture log lines with test results."""
# Strip ANSI escape codes
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
# Look for our test result messages
# Format: "[timestamp][level][FNV1A:line]: test_name PASSED"
match = re.search(r"\[FNV1A:\d+\]:\s+(\w+)\s+(PASSED|FAILED)", clean_line)
if match:
test_name = match.group(1)
result = match.group(2)
test_results[test_name] = result
if set(test_results.keys()) >= expected_tests:
all_tests_complete.set()
def test_results() -> dict[str, str]:
return {
m.group(1): m.group(2)
for line in waiter.lines
if (m := RESULT_PATTERN.search(ANSI_PATTERN.sub("", line)))
}
async def wait_all_results() -> None:
for name in expected_tests:
await waiter.wait_for("[FNV1A:", f"]: {name} ")
async with (
run_compiled(yaml_config, line_callback=on_log_line),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
await assert_device_name(client, "fnv1a-hash-test")
# Wait for all tests to complete or timeout
await wait_or_fail(
all_tests_complete, 2.0, "Tests timed out", test_results=test_results
wait_all_results(), 2.0, "Tests timed out", test_results=test_results
)
results = test_results()
# Verify all tests passed
assert "empty" in test_results, "empty string test not found"
assert test_results["empty"] == "PASSED", "empty string test failed"
assert "empty" in results, "empty string test not found"
assert results["empty"] == "PASSED", "empty string test failed"
assert "known_hello" in test_results, "known_hello test not found"
assert test_results["known_hello"] == "PASSED", "known_hello test failed"
assert "known_hello" in results, "known_hello test not found"
assert results["known_hello"] == "PASSED", "known_hello test failed"
assert "known_helloworld" in test_results, "known_helloworld test not found"
assert test_results["known_helloworld"] == "PASSED", (
"known_helloworld test failed"
)
assert "known_helloworld" in results, "known_helloworld test not found"
assert results["known_helloworld"] == "PASSED", "known_helloworld test failed"
assert "extend" in test_results, "fnv1a_hash_extend test not found"
assert test_results["extend"] == "PASSED", "fnv1a_hash_extend test failed"
assert "extend" in results, "fnv1a_hash_extend test not found"
assert results["extend"] == "PASSED", "fnv1a_hash_extend test failed"
assert "string" in test_results, "std::string test not found"
assert test_results["string"] == "PASSED", "std::string test failed"
assert "string" in results, "std::string test not found"
assert results["string"] == "PASSED", "std::string test failed"
+69 -104
View File
@@ -2,15 +2,72 @@
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
# Any variation of digital_read, anywhere in the message
READ_PATTERN = re.compile(r"(?:uint16_)?digital_read_(?:hw|cache) pin=\d+")
# The whole read message, so a prefixed read is reported as a mismatch
READ_MESSAGE_PATTERN = re.compile(r"^(\w+) pin=(\d+)")
HW = "digital_read_hw"
CACHE = "digital_read_cache"
UINT16_HW = "uint16_digital_read_hw"
UINT16_CACHE = "uint16_digital_read_cache"
EXPECTED_READS: list[tuple[str, int]] = [
(HW, 0),
*[(CACHE, i) for i in range(8)],
(HW, 8),
*[(CACHE, i) for i in range(8, 16)],
(HW, 16),
*[(CACHE, i) for i in range(16, 24)],
(HW, 24),
*[(CACHE, i) for i in range(24, 32)],
(HW, 3),
(CACHE, 3),
(HW, 3),
(CACHE, 3),
(CACHE, 4),
(HW, 3),
(CACHE, 3),
(HW, 10),
(CACHE, 10),
# full cache reset here for testing
(HW, 15),
(CACHE, 15),
(CACHE, 14),
(HW, 14),
(CACHE, 14),
# uint16_t component tests (single bank of 16 pins)
(UINT16_HW, 0), # First pin triggers hw read
*[(UINT16_CACHE, i) for i in range(16)], # All 16 pins return via cache
# After cache reset
(UINT16_HW, 5), # First read after reset triggers hw
(UINT16_CACHE, 5),
(UINT16_CACHE, 10), # These use cache (same bank)
(UINT16_CACHE, 15),
(UINT16_CACHE, 0),
]
def _read(line: str) -> tuple[str, int] | str | None:
"""Return (name, pin) for a read log line, the message if it is malformed, else None."""
clean_line = ANSI_PATTERN.sub("", line)
msg = clean_line.split(": ", 1)[-1]
if not READ_PATTERN.search(msg):
return None
if match := READ_MESSAGE_PATTERN.search(msg):
return match.group(1), int(match.group(2))
return msg
@pytest.mark.asyncio
async def test_gpio_expander_cache(
@@ -19,114 +76,22 @@ async def test_gpio_expander_cache(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test gpio_expander::CachedGpioExpander correctly calls hardware functions."""
logs_done = asyncio.Event()
waiter = LineWaiter()
# Patterns to match in logs - match any variation of digital_read
read_hw_pattern = re.compile(r"(?:uint16_)?digital_read_hw pin=(\d+)")
read_cache_pattern = re.compile(r"(?:uint16_)?digital_read_cache pin=(\d+)")
# Keep specific patterns for building the expected order
digital_read_hw_pattern = re.compile(r"^digital_read_hw pin=(\d+)")
digital_read_cache_pattern = re.compile(r"^digital_read_cache pin=(\d+)")
uint16_read_hw_pattern = re.compile(r"^uint16_digital_read_hw pin=(\d+)")
uint16_read_cache_pattern = re.compile(r"^uint16_digital_read_cache pin=(\d+)")
# ensure logs are in the expected order
log_order = [
(digital_read_hw_pattern, 0),
[(digital_read_cache_pattern, i) for i in range(8)],
(digital_read_hw_pattern, 8),
[(digital_read_cache_pattern, i) for i in range(8, 16)],
(digital_read_hw_pattern, 16),
[(digital_read_cache_pattern, i) for i in range(16, 24)],
(digital_read_hw_pattern, 24),
[(digital_read_cache_pattern, i) for i in range(24, 32)],
(digital_read_hw_pattern, 3),
(digital_read_cache_pattern, 3),
(digital_read_hw_pattern, 3),
(digital_read_cache_pattern, 3),
(digital_read_cache_pattern, 4),
(digital_read_hw_pattern, 3),
(digital_read_cache_pattern, 3),
(digital_read_hw_pattern, 10),
(digital_read_cache_pattern, 10),
# full cache reset here for testing
(digital_read_hw_pattern, 15),
(digital_read_cache_pattern, 15),
(digital_read_cache_pattern, 14),
(digital_read_hw_pattern, 14),
(digital_read_cache_pattern, 14),
# uint16_t component tests (single bank of 16 pins)
(uint16_read_hw_pattern, 0), # First pin triggers hw read
[
(uint16_read_cache_pattern, i) for i in range(16)
], # All 16 pins return via cache
# After cache reset
(uint16_read_hw_pattern, 5), # First read after reset triggers hw
(uint16_read_cache_pattern, 5),
(uint16_read_cache_pattern, 10), # These use cache (same bank)
(uint16_read_cache_pattern, 15),
(uint16_read_cache_pattern, 0),
]
# Flatten the log order for easier processing
log_order: list[tuple[re.Pattern, int]] = [
item
for sublist in log_order
for item in (sublist if isinstance(sublist, list) else [sublist])
]
index = 0
def check_output(line: str) -> None:
"""Check log output for expected messages."""
nonlocal index
if logs_done.is_set():
return
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
# Extract just the log message part (after the log level)
msg = clean_line.split(": ", 1)[-1] if ": " in clean_line else clean_line
# Check if this line contains a read operation we're tracking
if read_hw_pattern.search(msg) or read_cache_pattern.search(msg):
if index >= len(log_order):
print(f"Received unexpected log line: {msg}")
logs_done.set()
return
pattern, expected_pin = log_order[index]
match = pattern.search(msg)
if not match:
print(f"Log line did not match next expected pattern: {msg}")
print(f"Expected pattern: {pattern.pattern}")
logs_done.set()
return
pin = int(match.group(1))
if pin != expected_pin:
print(f"Unexpected pin number. Expected {expected_pin}, got {pin}")
logs_done.set()
return
index += 1
elif "DONE_UINT16" in clean_line:
# uint16 component is done, check if we've seen all expected logs
if index == len(log_order):
logs_done.set()
# Run with log monitoring
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify device info
await assert_device_name(client, "gpio-expander-cache")
await wait_or_fail(logs_done, 5.0, "Timeout waiting for logs to complete")
assert index == len(log_order), (
f"Expected {len(log_order)} log entries, but got {index}"
done_line = await wait_or_fail(
waiter.wait_for("DONE_UINT16"),
5.0,
"Timeout waiting for logs to complete",
)
# Only the reads logged before the uint16 component finished count
before_done = waiter.lines[: waiter.lines.index(done_line)]
reads = [read for line in before_done if (read := _read(line)) is not None]
assert reads == EXPECTED_READS
@@ -11,6 +11,7 @@ import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@@ -45,18 +46,11 @@ async def test_host_logger_thread_safety(
3. Verifies no lines are corrupted (partially written or interleaved)
4. Verifies all expected messages were received
"""
collected_lines: list[str] = []
test_complete_event = asyncio.Event()
def line_callback(line: str) -> None:
"""Collect log lines and detect test completion."""
collected_lines.append(line)
if "RACE_TEST_COMPLETE" in line:
test_complete_event.set()
waiter = LineWaiter()
# Run the test binary and collect output
async with (
run_compiled(yaml_config, line_callback=line_callback),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify connection works
@@ -70,10 +64,10 @@ async def test_host_logger_thread_safety(
# Wait for test to complete (with timeout)
await wait_or_fail(
test_complete_event,
waiter.wait_for("RACE_TEST_COMPLETE", timeout=40.0),
30.0,
"Test did not complete within timeout",
collected_lines=collected_lines,
collected_lines=waiter.lines,
)
# Give a bit more time for any remaining buffered messages
@@ -86,7 +80,7 @@ async def test_host_logger_thread_safety(
test_completed = False
reported_total = 0
for line in collected_lines:
for line in waiter.lines:
# Check for test start
start_match = TEST_START_PATTERN.search(line)
if start_match:
+23 -84
View File
@@ -2,15 +2,16 @@
from __future__ import annotations
import asyncio
import re
from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo
import pytest
from .log_utils import LineWaiter
from .state_utils import require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
VALUE_PATTERN = re.compile(r"value=([0-9.]+)")
@pytest.mark.asyncio
@@ -20,56 +21,16 @@ async def test_host_preferences_save_load(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that preferences are correctly saved and loaded after our optimization fix."""
loop = asyncio.get_running_loop()
log_lines: list[str] = []
preferences_saved = loop.create_future()
preferences_loaded = loop.create_future()
values_match = loop.create_future()
final_load_complete = loop.create_future()
waiter = LineWaiter()
# Patterns to match preference logs
save_pattern = re.compile(r"Preference saved: key=(\w+), value=([0-9.]+)")
load_pattern = re.compile(r"Preference loaded: key=(\w+), value=([0-9.]+)")
verify_pattern = re.compile(r"Preferences verified: values match!")
final_load_success_pattern = re.compile(
r"Final load test: loaded \d+ preferences successfully"
)
saved_values: dict[str, float] = {}
loaded_values: dict[str, float] = {}
def check_output(line: str) -> None:
"""Check log output for preference operations."""
log_lines.append(line)
# Look for save operations
match = save_pattern.search(line)
if match:
key = match.group(1)
value = float(match.group(2))
saved_values[key] = value
if len(saved_values) >= 2 and not preferences_saved.done():
preferences_saved.set_result(True)
# Look for load operations
match = load_pattern.search(line)
if match:
key = match.group(1)
value = float(match.group(2))
loaded_values[key] = value
if len(loaded_values) >= 2 and not preferences_loaded.done():
preferences_loaded.set_result(True)
# Look for verification
if verify_pattern.search(line) and not values_match.done():
values_match.set_result(True)
# Look for final load test completion
if final_load_success_pattern.search(line) and not final_load_complete.done():
final_load_complete.set_result(True)
async def logged_value(action: str, key: str) -> float:
line = await waiter.wait_for(f"Preference {action}: key={key},", timeout=5.0)
match = VALUE_PATTERN.search(line)
assert match is not None, f"Unexpected preference line: {line}"
return float(match.group(1))
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Get entity list
@@ -94,20 +55,10 @@ async def test_host_preferences_save_load(
# Save preferences
client.button_command(save_button.key)
# Wait for save to complete
await wait_or_fail(
preferences_saved, 5.0, "Preferences not saved within timeout"
)
# Verify we saved the expected values
assert "switch" in saved_values, f"Switch preference not saved: {saved_values}"
assert "number" in saved_values, f"Number preference not saved: {saved_values}"
assert saved_values["switch"] == 1.0, (
f"Switch value incorrect: {saved_values['switch']}"
)
assert saved_values["number"] == 42.5, (
f"Number value incorrect: {saved_values['number']}"
)
saved_switch = await logged_value("saved", "switch")
saved_number = await logged_value("saved", "number")
assert saved_switch == 1.0, f"Switch value incorrect: {saved_switch}"
assert saved_number == 42.5, f"Number value incorrect: {saved_number}"
# Change the values to something else
client.switch_command(test_switch.key, False)
@@ -116,32 +67,20 @@ async def test_host_preferences_save_load(
# Load preferences (should restore the saved values)
client.button_command(load_button.key)
# Wait for load to complete
await wait_or_fail(
preferences_loaded, 5.0, "Preferences not loaded within timeout"
loaded_switch = await logged_value("loaded", "switch")
loaded_number = await logged_value("loaded", "number")
assert loaded_switch == saved_switch, (
f"Loaded switch value {loaded_switch} doesn't match saved {saved_switch}"
)
# Verify loaded values match saved values
assert "switch" in loaded_values, (
f"Switch preference not loaded: {loaded_values}"
)
assert "number" in loaded_values, (
f"Number preference not loaded: {loaded_values}"
)
assert loaded_values["switch"] == saved_values["switch"], (
f"Loaded switch value {loaded_values['switch']} doesn't match saved {saved_values['switch']}"
)
assert loaded_values["number"] == saved_values["number"], (
f"Loaded number value {loaded_values['number']} doesn't match saved {saved_values['number']}"
assert loaded_number == saved_number, (
f"Loaded number value {loaded_number} doesn't match saved {saved_number}"
)
# Verify the values were actually restored
client.button_command(verify_button.key)
# Wait for verification
await wait_or_fail(
values_match, 5.0, "Preference verification failed within timeout"
)
await waiter.wait_for("Preferences verified: values match!", timeout=5.0)
# Test that non-existent preferences don't crash (tests our fix)
# This will trigger load attempts for keys that don't exist
@@ -149,6 +88,6 @@ async def test_host_preferences_save_load(
client.button_command(load_button.key)
# Wait for the final load test to complete
await wait_or_fail(
final_load_complete, 5.0, "Final load test did not complete within timeout"
await waiter.wait_for(
"Final load test: loaded", "preferences successfully", timeout=5.0
)
+10 -13
View File
@@ -2,11 +2,11 @@
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -33,26 +33,23 @@ async def test_in_range_conditions(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Each in_range condition gives the same result for sensors and numbers."""
loop = asyncio.get_running_loop()
results: dict[tuple[str, str, str], int] = {}
done = loop.create_future()
def check_output(line: str) -> None:
if match := RESULT_RE.search(line):
label, entity, condition, value = match.groups()
results[(label, entity, condition)] = int(value)
elif "INRANGE done" in line and not done.done():
done.set_result(True)
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
_, services = await client.list_entities_services()
service = require_service(services, "run_cases")
await client.execute_service(service, {})
await asyncio.wait_for(done, timeout=10.0)
await waiter.wait_for("INRANGE done", timeout=10.0)
# Key: (label, entity, condition); a repeated result keeps the last value
results = {
match.groups()[:3]: int(match[4])
for line in waiter.lines
if (match := RESULT_RE.search(line))
}
expected = {
(label, entity, condition): row[index]
for label, row in EXPECTED.items()
@@ -19,10 +19,16 @@ from typing import Any
from aioesphomeapi import EntityState, LightInfo, LightState
import pytest
from .log_utils import LineWaiter
from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
CB_CW_RE = re.compile(r"(?<!N)CB_CW_OUTPUT:([\d.]+)")
CB_WW_RE = re.compile(r"(?<!N)CB_WW_OUTPUT:([\d.]+)")
NCB_CW_RE = re.compile(r"NCB_CW_OUTPUT:([\d.]+)")
NCB_WW_RE = re.compile(r"NCB_WW_OUTPUT:([\d.]+)")
@pytest.mark.asyncio
async def test_light_constant_brightness(
@@ -31,34 +37,19 @@ async def test_light_constant_brightness(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test constant_brightness true and false behavior with gamma correction."""
# Track output values for both lights from log lines
cb_cw_pattern = re.compile(r"(?<!N)CB_CW_OUTPUT:([\d.]+)")
cb_ww_pattern = re.compile(r"(?<!N)CB_WW_OUTPUT:([\d.]+)")
ncb_cw_pattern = re.compile(r"NCB_CW_OUTPUT:([\d.]+)")
ncb_ww_pattern = re.compile(r"NCB_WW_OUTPUT:([\d.]+)")
waiter = LineWaiter()
latest: dict[str, float] = {
"cb_cw": 0.0,
"cb_ww": 0.0,
"ncb_cw": 0.0,
"ncb_ww": 0.0,
}
def on_log_line(line: str) -> None:
for pattern, key in [
(cb_cw_pattern, "cb_cw"),
(cb_ww_pattern, "cb_ww"),
(ncb_cw_pattern, "ncb_cw"),
(ncb_ww_pattern, "ncb_ww"),
]:
match = pattern.search(line)
if match:
latest[key] = float(match.group(1))
def latest(pattern: re.Pattern[str]) -> float:
"""Return the last output value logged so far, or 0.0 if none."""
for line in reversed(waiter.lines):
if match := pattern.search(line):
return float(match[1])
return 0.0
loop = asyncio.get_running_loop()
async with (
run_compiled(yaml_config, line_callback=on_log_line),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
@@ -118,7 +109,7 @@ async def test_light_constant_brightness(
await send_and_wait(
cb_light.key, color_temperature=mireds, transition_length=0
)
cb_totals.append((mireds, latest["cb_cw"], latest["cb_ww"]))
cb_totals.append((mireds, latest(CB_CW_RE), latest(CB_WW_RE)))
# All totals should be approximately equal (constant brightness)
reference_total = next((cw + ww for _, cw, ww in cb_totals if cw + ww > 0), 0)
@@ -152,7 +143,7 @@ async def test_light_constant_brightness(
await send_and_wait(
ncb_light.key, color_temperature=mireds, transition_length=0
)
ncb_totals.append((mireds, latest["ncb_cw"], latest["ncb_ww"]))
ncb_totals.append((mireds, latest(NCB_CW_RE), latest(NCB_WW_RE)))
extreme_cw = ncb_totals[0] # 153 mireds - pure cold
extreme_ww = ncb_totals[-1] # 500 mireds - pure warm
@@ -16,15 +16,17 @@ requested cadence while the component phase stays gated at loop_interval_.
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
MEASUREMENT_RE = re.compile(r"MEASUREMENT_DONE loop_delta=(\d+) sched_delta=(\d+)")
@pytest.mark.asyncio
async def test_loop_interval_decoupling(
@@ -33,23 +35,22 @@ async def test_loop_interval_decoupling(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Raised loop_interval_ must not clamp scheduler item cadence."""
loop = asyncio.get_running_loop()
measurement_done: asyncio.Future[tuple[int, int]] = loop.create_future()
def on_log_line(line: str) -> None:
match = re.search(r"MEASUREMENT_DONE loop_delta=(\d+) sched_delta=(\d+)", line)
if match and not measurement_done.done():
measurement_done.set_result((int(match.group(1)), int(match.group(2))))
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=on_log_line),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
await assert_device_name(client, "loop-interval-decouple")
loop_delta, sched_delta = await wait_or_fail(
measurement_done, 10.0, "MEASUREMENT_DONE marker never appeared"
line = await wait_or_fail(
waiter.wait_for("MEASUREMENT_DONE loop_delta=", timeout=20.0),
10.0,
"MEASUREMENT_DONE marker never appeared",
)
match = MEASUREMENT_RE.search(line)
assert match, line
loop_delta, sched_delta = int(match[1]), int(match[2])
# Observation window = 2s, loop_interval_ = 500ms.
# Component phase should fire ~4 times in 2s. The upper bound must be
@@ -16,15 +16,17 @@ phase stays at ~62 Hz regardless of scheduler activity.
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
MEASUREMENT_RE = re.compile(r"MEASUREMENT_DONE loop_delta=(\d+)")
@pytest.mark.asyncio
async def test_loop_interval_default_not_pulled_forward(
@@ -33,23 +35,22 @@ async def test_loop_interval_default_not_pulled_forward(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Fast scheduler item must not pull component phase past default ~62 Hz."""
loop = asyncio.get_running_loop()
measurement_done: asyncio.Future[int] = loop.create_future()
def on_log_line(line: str) -> None:
match = re.search(r"MEASUREMENT_DONE loop_delta=(\d+)", line)
if match and not measurement_done.done():
measurement_done.set_result(int(match.group(1)))
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=on_log_line),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
await assert_device_name(client, "loop-default-not-pulled")
loop_delta = await wait_or_fail(
measurement_done, 10.0, "MEASUREMENT_DONE marker never appeared"
line = await wait_or_fail(
waiter.wait_for("MEASUREMENT_DONE loop_delta=", timeout=20.0),
10.0,
"MEASUREMENT_DONE marker never appeared",
)
match = MEASUREMENT_RE.search(line)
assert match, line
loop_delta = int(match[1])
# Observation window = 2s, loop_interval_ default = 16ms → ~62 Hz →
# ~125 component-phase iterations expected.
+11 -44
View File
@@ -8,14 +8,15 @@ orchestrated entirely in YAML.
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
COUNT_PATTERN = re.compile(r"count=(\d+)")
@pytest.mark.asyncio
@@ -25,29 +26,10 @@ async def test_multi_click_trigger(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that on_multi_click triggers for single, double, and long press patterns."""
loop = asyncio.get_running_loop()
single_click_pattern = re.compile(r"SINGLE_CLICK count=(\d+)")
double_click_pattern = re.compile(r"DOUBLE_CLICK count=(\d+)")
long_press_pattern = re.compile(r"LONG_PRESS count=(\d+)")
single_click_future: asyncio.Future[int] = loop.create_future()
double_click_future: asyncio.Future[int] = loop.create_future()
long_press_future: asyncio.Future[int] = loop.create_future()
def check_output(line: str) -> None:
"""Check log output for multi-click trigger messages."""
if m := single_click_pattern.search(line):
if not single_click_future.done():
single_click_future.set_result(int(m.group(1)))
elif m := double_click_pattern.search(line):
if not double_click_future.done():
double_click_future.set_result(int(m.group(1)))
elif (m := long_press_pattern.search(line)) and not long_press_future.done():
long_press_future.set_result(int(m.group(1)))
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
_entities, services = await client.list_entities_services()
@@ -57,24 +39,9 @@ async def test_multi_click_trigger(
# Kick off the entire test sequence (runs in YAML with delays)
await client.execute_service(test_service, {})
# Wait for all three triggers
count = await wait_or_fail(
single_click_future,
5.0,
"Timeout waiting for SINGLE_CLICK - on_multi_click did not trigger.",
)
assert count == 1, f"Expected single click count=1, got {count}"
count = await wait_or_fail(
double_click_future,
5.0,
"Timeout waiting for DOUBLE_CLICK - on_multi_click did not trigger.",
)
assert count == 1, f"Expected double click count=1, got {count}"
count = await wait_or_fail(
long_press_future,
5.0,
"Timeout waiting for LONG_PRESS - on_multi_click did not trigger.",
)
assert count == 1, f"Expected long press count=1, got {count}"
for label in ("SINGLE_CLICK", "DOUBLE_CLICK", "LONG_PRESS"):
line = await waiter.wait_for(f"{label} count=", timeout=5.0)
match = COUNT_PATTERN.search(line)
assert match is not None and match.group(1) == "1", (
f"Expected {label} count=1, got: {line}"
)
@@ -2,16 +2,20 @@
from __future__ import annotations
import asyncio
import re
from aioesphomeapi import ButtonInfo, NumberInfo, SelectInfo, SwitchInfo
import pytest
from .log_utils import LineWaiter
from .state_utils import require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
# Keys are "A", "B" and "Main"
SWITCH_HASH_RE = re.compile(r"(?:Device )?\b([AB]|Main) Switch Pref Hash: (\d+)")
NUMBER_HASH_RE = re.compile(r"(?:Device )?\b([AB]|Main) Number Pref Hash: (\d+)")
@pytest.mark.asyncio
async def test_multi_device_preferences(
@@ -20,58 +24,17 @@ async def test_multi_device_preferences(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that entities with same names on different devices have unique preference storage."""
loop = asyncio.get_running_loop()
log_lines: list[str] = []
preferences_logged = loop.create_future()
waiter = LineWaiter()
# Patterns to match preference hash logs
switch_hash_pattern_device = re.compile(r"Device ([AB]) Switch Pref Hash: (\d+)")
switch_hash_pattern_main = re.compile(r"Main Switch Pref Hash: (\d+)")
number_hash_pattern_device = re.compile(r"Device ([AB]) Number Pref Hash: (\d+)")
number_hash_pattern_main = re.compile(r"Main Number Pref Hash: (\d+)")
switch_hashes: dict[str, int] = {}
number_hashes: dict[str, int] = {}
def check_output(line: str) -> None:
"""Check log output for preference hash information."""
log_lines.append(line)
# Look for device switch preference hash logs
match = switch_hash_pattern_device.search(line)
if match:
device = match.group(1)
hash_value = int(match.group(2))
switch_hashes[device] = hash_value
# Look for main switch preference hash
match = switch_hash_pattern_main.search(line)
if match:
hash_value = int(match.group(1))
switch_hashes["Main"] = hash_value
# Look for device number preference hash logs
match = number_hash_pattern_device.search(line)
if match:
device = match.group(1)
hash_value = int(match.group(2))
number_hashes[device] = hash_value
# Look for main number preference hash
match = number_hash_pattern_main.search(line)
if match:
hash_value = int(match.group(1))
number_hashes["Main"] = hash_value
# If we have all hashes, complete the future
if (
len(switch_hashes) == 3
and len(number_hashes) == 3
and not preferences_logged.done()
):
preferences_logged.set_result(True)
def hashes(pattern: re.Pattern[str]) -> dict[str, int]:
return {
match[1]: int(match[2])
for line in waiter.lines
if (match := pattern.search(line))
}
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Get entity list
@@ -105,10 +68,14 @@ async def test_multi_device_preferences(
# Press the button to trigger logging
client.button_command(test_button.key)
# Wait for preference hashes to be logged
# The last of the six hash lines
await wait_or_fail(
preferences_logged, 5.0, "Preference hashes not logged within timeout"
waiter.wait_for("Main Number Pref Hash:", timeout=20.0),
5.0,
"Preference hashes not logged within timeout",
)
switch_hashes = hashes(SWITCH_HASH_RE)
number_hashes = hashes(NUMBER_HASH_RE)
# Verify all switch preference hashes are unique
assert len(switch_hashes) == 3, (
+22 -64
View File
@@ -6,11 +6,18 @@ import asyncio
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedWithDisconnectFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
def _crashed(waiter: LineWaiter) -> bool:
return any(
"Segmentation fault" in line or "core dumped" in line for line in waiter.lines
)
@pytest.mark.asyncio
async def test_oversized_payload_plaintext(
yaml_config: str,
@@ -18,23 +25,9 @@ async def test_oversized_payload_plaintext(
api_client_connected_with_disconnect: APIClientConnectedWithDisconnectFactory,
) -> None:
"""Test that oversized payloads (>32768 bytes) from client cause disconnection without crashing."""
process_exited = False
helper_log_event = asyncio.Event()
waiter = LineWaiter()
def check_logs(line: str) -> None:
nonlocal process_exited
# Check for signs that the process exited/crashed
if "Segmentation fault" in line or "core dumped" in line:
process_exited = True
# Check for HELPER_LOG message about message size exceeding maximum
if (
"[VV]" in line
and "Bad packet: message size" in line
and "exceeds maximum" in line
):
helper_log_event.set()
async with run_compiled(yaml_config, line_callback=check_logs):
async with run_compiled(yaml_config, line_callback=waiter.callback):
async with api_client_connected_with_disconnect() as (client, disconnect_event):
# Verify basic connection works first
await assert_device_name(client, "oversized-plaintext")
@@ -53,10 +46,10 @@ async def test_oversized_payload_plaintext(
await asyncio.wait_for(disconnect_event.wait(), timeout=5.0)
# After disconnection, verify process didn't crash
assert not process_exited, "ESPHome process should not crash"
assert not _crashed(waiter), "ESPHome process should not crash"
# Wait for the expected log message (may arrive after disconnect event)
await wait_or_fail(
helper_log_event,
waiter.wait_for("[VV]", "Bad packet: message size", "exceeds maximum"),
2.0,
"Expected to see HELPER_LOG about message size exceeding maximum",
)
@@ -76,23 +69,9 @@ async def test_oversized_protobuf_message_id_plaintext(
This tests the message type limit - message IDs must fit in a uint16_t (0-65535).
"""
process_exited = False
helper_log_event = asyncio.Event()
waiter = LineWaiter()
def check_logs(line: str) -> None:
nonlocal process_exited
# Check for signs that the process exited/crashed
if "Segmentation fault" in line or "core dumped" in line:
process_exited = True
# Check for HELPER_LOG message about message type exceeding maximum
if (
"[VV]" in line
and "Bad packet: message type" in line
and "exceeds maximum" in line
):
helper_log_event.set()
async with run_compiled(yaml_config, line_callback=check_logs):
async with run_compiled(yaml_config, line_callback=waiter.callback):
async with api_client_connected_with_disconnect() as (client, disconnect_event):
# Verify basic connection works first
await assert_device_name(client, "oversized-protobuf-plaintext")
@@ -111,10 +90,10 @@ async def test_oversized_protobuf_message_id_plaintext(
await asyncio.wait_for(disconnect_event.wait(), timeout=5.0)
# After disconnection, verify process didn't crash
assert not process_exited, "ESPHome process should not crash"
assert not _crashed(waiter), "ESPHome process should not crash"
# Wait for the expected log message (may arrive after disconnect event)
await wait_or_fail(
helper_log_event,
waiter.wait_for("[VV]", "Bad packet: message type", "exceeds maximum"),
2.0,
"Expected to see HELPER_LOG about message type exceeding maximum",
)
@@ -132,24 +111,9 @@ async def test_oversized_payload_noise(
) -> None:
"""Test that oversized payloads from client cause disconnection without crashing with noise encryption."""
noise_key = "N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU="
process_exited = False
helper_log_event = asyncio.Event()
waiter = LineWaiter()
def check_logs(line: str) -> None:
nonlocal process_exited
# Check for signs that the process exited/crashed
if "Segmentation fault" in line or "core dumped" in line:
process_exited = True
# Check for HELPER_LOG message about message size exceeding maximum
# With our new protection, oversized messages are rejected at frame level
if (
"[VV]" in line
and "Bad packet: message size" in line
and "exceeds maximum" in line
):
helper_log_event.set()
async with run_compiled(yaml_config, line_callback=check_logs):
async with run_compiled(yaml_config, line_callback=waiter.callback):
async with api_client_connected_with_disconnect(noise_psk=noise_key) as (
client,
disconnect_event,
@@ -172,10 +136,10 @@ async def test_oversized_payload_noise(
await asyncio.wait_for(disconnect_event.wait(), timeout=5.0)
# After disconnection, verify process didn't crash
assert not process_exited, "ESPHome process should not crash"
assert not _crashed(waiter), "ESPHome process should not crash"
# Wait for the expected log message (may arrive after disconnect event)
await wait_or_fail(
helper_log_event,
waiter.wait_for("[VV]", "Bad packet: message size", "exceeds maximum"),
2.0,
"Expected to see HELPER_LOG about message size exceeding maximum",
)
@@ -201,15 +165,9 @@ async def test_oversized_protobuf_message_id_noise(
read the full message to maintain encryption stream continuity.
"""
noise_key = "N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU="
process_exited = False
waiter = LineWaiter()
def check_logs(line: str) -> None:
nonlocal process_exited
# Check for signs that the process exited/crashed
if "Segmentation fault" in line or "core dumped" in line:
process_exited = True
async with run_compiled(yaml_config, line_callback=check_logs):
async with run_compiled(yaml_config, line_callback=waiter.callback):
async with api_client_connected_with_disconnect(noise_psk=noise_key) as (
client,
disconnect_event,
@@ -241,7 +199,7 @@ async def test_oversized_protobuf_message_id_noise(
await assert_device_name(client, "oversized-noise")
# After test, verify process didn't crash
assert not process_exited, "ESPHome process should not crash"
assert not _crashed(waiter), "ESPHome process should not crash"
# Verify we can still reconnect
async with api_client_connected_with_disconnect(noise_psk=noise_key) as (
+5 -18
View File
@@ -23,13 +23,11 @@ This test uses ``run_binary`` directly to skip the port wait.
from __future__ import annotations
import asyncio
import re
import pytest
from .conftest import run_binary
from .host_prefs import clear_host_prefs, force_safe_mode
from .log_utils import LineWaiter
from .types import CompileFunction, ConfigWriter
from .wait_utils import wait_or_fail
@@ -54,27 +52,16 @@ async def test_safe_mode_loop_runs(
force_safe_mode(DEVICE_NAME)
try:
loop = asyncio.get_running_loop()
safe_mode_active = loop.create_future()
thread_log_seen = loop.create_future()
safe_mode_pattern = re.compile(r"SAFE MODE IS ACTIVE")
thread_log_pattern = re.compile(re.escape(THREAD_LOG_MARKER))
def on_log(line: str) -> None:
if not safe_mode_active.done() and safe_mode_pattern.search(line):
safe_mode_active.set_result(True)
if not thread_log_seen.done() and thread_log_pattern.search(line):
thread_log_seen.set_result(True)
async with run_binary(binary_path, line_callback=on_log):
waiter = LineWaiter()
async with run_binary(binary_path, line_callback=waiter.callback):
await wait_or_fail(
safe_mode_active,
waiter.wait_for("SAFE MODE IS ACTIVE", timeout=15.0),
15.0,
"Did not observe 'SAFE MODE IS ACTIVE' -- safe mode "
"didn't trigger, so this test isn't exercising the bug.",
)
await wait_or_fail(
thread_log_seen,
waiter.wait_for(THREAD_LOG_MARKER),
10.0,
f"Did not observe thread-logged marker {THREAD_LOG_MARKER!r} "
"within timeout. Logger::loop() never drained the task "
@@ -6,16 +6,20 @@ takes the separate defer queue on multi-threaded builds.
from __future__ import annotations
import asyncio
import re
from aioesphomeapi import ButtonInfo
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
RESULT_RE = re.compile(
r"SCHEDULER_WAKE_RESULT delay=\d+ elapsed=(\d+) loop_delta=(-?\d+)"
)
@pytest.mark.asyncio
async def test_scheduler_background_wake(
@@ -23,25 +27,10 @@ async def test_scheduler_background_wake(
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
loop = asyncio.get_running_loop()
results: dict[int, asyncio.Future[tuple[int, int]]] = {
100: loop.create_future(),
0: loop.create_future(),
}
def on_log_line(line: str) -> None:
match = re.search(
r"SCHEDULER_WAKE_RESULT delay=(\d+) elapsed=(\d+) loop_delta=(-?\d+)",
line,
)
if match is None:
return
result = results[int(match.group(1))]
if not result.done():
result.set_result((int(match.group(2)), int(match.group(3))))
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=on_log_line),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
await assert_device_name(client, "scheduler-background-wake")
@@ -58,11 +47,16 @@ async def test_scheduler_background_wake(
)
client.button_command(start_button.key)
elapsed, loop_delta = await wait_or_fail(
results[delay],
line = await wait_or_fail(
waiter.wait_for(
f"SCHEDULER_WAKE_RESULT delay={delay} elapsed=", timeout=20.0
),
10.0,
f"background insert with delay={delay} did not fire",
)
match = RESULT_RE.search(line)
assert match, line
elapsed, loop_delta = int(match[1]), int(match[2])
assert elapsed < 1000, (
f"background insert with delay={delay} should interrupt the "
@@ -8,17 +8,18 @@ the owning script and report the real threshold (50 ms).
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .types import APIClientConnectedFactory, RunCompiledFunction
# Matches: "<source> took a long time for an operation (NN ms), max is NN ms"
WARN_PATTERN = re.compile(
r"(\S+) took a long time for an operation \((\d+) ms\), max is (\d+) ms"
)
WARN_NEEDLE = "took a long time for an operation"
@pytest.mark.asyncio
@@ -28,15 +29,10 @@ async def test_scheduler_blocking_warning(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Deferred blocking work inside a script is attributed to the script, not "<null>"."""
loop = asyncio.get_running_loop()
warning_future: asyncio.Future[str] = loop.create_future()
def check_output(line: str) -> None:
if WARN_PATTERN.search(line) and not warning_future.done():
warning_future.set_result(line)
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
device_info = await client.device_info()
@@ -44,7 +40,7 @@ async def test_scheduler_blocking_warning(
# on_boot runs the script, which defers via delay then busy-blocks > 50 ms in the
# continuation, tripping the blocking warning.
warning_line = await asyncio.wait_for(warning_future, timeout=10.0)
warning_line = await waiter.wait_for(WARN_NEEDLE, timeout=10.0)
# Must name the owning script, not "<null>" and not the generic fallback.
assert "<null>" not in warning_line, (
@@ -69,19 +65,14 @@ async def test_scheduler_blocking_warning_generic_source(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A delay in a plain (non-script) automation logs the generic label, not a script name."""
loop = asyncio.get_running_loop()
warning_future: asyncio.Future[str] = loop.create_future()
def check_output(line: str) -> None:
if WARN_PATTERN.search(line) and not warning_future.done():
warning_future.set_result(line)
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
assert await client.device_info() is not None
warning_line = await asyncio.wait_for(warning_future, timeout=10.0)
warning_line = await waiter.wait_for(WARN_NEEDLE, timeout=10.0)
assert "a scheduled task took a long time" in warning_line, (
f"Non-script deferred work should log the generic label, got: {warning_line}"
@@ -104,17 +95,12 @@ async def test_scheduler_delay_runs_on_failed_component(
Deferred (SELF_POINTER) scheduler items have no owning component, so the scheduler's
failed-component skip must not drop them.
"""
loop = asyncio.get_running_loop()
fired: asyncio.Future[bool] = loop.create_future()
def check_output(line: str) -> None:
if "DELAY_FIRED_AFTER_FAIL" in line and not fired.done():
fired.set_result(True)
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
assert await client.device_info() is not None
# If the failed host component wrongly dropped the delay, this times out.
await asyncio.wait_for(fired, timeout=10.0)
await waiter.wait_for("DELAY_FIRED_AFTER_FAIL", timeout=10.0)
@@ -5,10 +5,15 @@ import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
CANCELLED_RE = re.compile(r"Successfully cancelled (\d+) timeouts")
REMOVED_RE = re.compile(r"Bulk cleanup triggered: removed (\d+) items")
POST_CLEANUP_RE = re.compile(r"Post-cleanup timeout \d+ executed correctly")
@pytest.mark.asyncio
async def test_scheduler_bulk_cleanup(
@@ -18,52 +23,10 @@ async def test_scheduler_bulk_cleanup(
) -> None:
"""Test that bulk cleanup path is triggered when many items are cancelled."""
# Create a future to signal test completion
loop = asyncio.get_event_loop()
test_complete_future: asyncio.Future[None] = loop.create_future()
bulk_cleanup_triggered = False
cleanup_stats: dict[str, int] = {
"removed": 0,
"before": 0,
"after": 0,
}
post_cleanup_executed = 0
def on_log_line(line: str) -> None:
nonlocal bulk_cleanup_triggered, post_cleanup_executed
# Look for logs indicating bulk cleanup was triggered
# The actual cleanup happens silently, so we track the cancel operations
if "Successfully cancelled" in line and "timeouts" in line:
match = re.search(r"Successfully cancelled (\d+) timeouts", line)
if match and int(match.group(1)) > 10:
bulk_cleanup_triggered = True
# Track cleanup statistics
match = re.search(r"Bulk cleanup triggered: removed (\d+) items", line)
if match:
cleanup_stats["removed"] = int(match.group(1))
match = re.search(r"Items before cleanup: (\d+), after: (\d+)", line)
if match:
cleanup_stats["before"] = int(match.group(1))
cleanup_stats["after"] = int(match.group(2))
# Track post-cleanup timeout executions
if "Post-cleanup timeout" in line and "executed correctly" in line:
match = re.search(r"Post-cleanup timeout (\d+) executed correctly", line)
if match:
post_cleanup_executed += 1
# Check for final test completion
if (
"All post-cleanup timeouts completed - test finished" in line
and not test_complete_future.done()
):
test_complete_future.set_result(None)
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=on_log_line),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify we can connect
@@ -80,17 +43,34 @@ async def test_scheduler_bulk_cleanup(
# Execute the test
await client.execute_service(trigger_bulk_cleanup_service, {})
# Wait for test completion
await wait_or_fail(test_complete_future, 10.0, "Bulk cleanup test timed out")
await wait_or_fail(
waiter.wait_for(
"All post-cleanup timeouts completed - test finished", timeout=20.0
),
10.0,
"Bulk cleanup test timed out",
)
cancelled = [
int(match[1])
for line in waiter.lines
if (match := CANCELLED_RE.search(line))
]
removed = [
int(match[1]) for line in waiter.lines if (match := REMOVED_RE.search(line))
]
post_cleanup_executed = sum(
POST_CLEANUP_RE.search(line) is not None for line in waiter.lines
)
# Verify bulk cleanup was triggered
assert bulk_cleanup_triggered, (
assert any(count > 10 for count in cancelled), (
"Bulk cleanup path was not triggered - MAX_LOGICALLY_DELETED_ITEMS threshold not reached"
)
# Verify cleanup statistics
assert cleanup_stats["removed"] > 10, (
f"Expected more than 10 items removed, got {cleanup_stats['removed']}"
assert removed and removed[-1] > 10, (
f"Expected more than 10 items removed, got {removed}"
)
# Verify scheduler still works after bulk cleanup
@@ -4,6 +4,7 @@ import asyncio
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@@ -17,27 +18,13 @@ async def test_scheduler_defer_cancels_regular(
) -> None:
"""Test that set_timeout(name, 0) cancels a previously scheduled set_timeout(name, delay)."""
# Create a future to signal test completion
loop = asyncio.get_running_loop()
test_complete_future: asyncio.Future[None] = loop.create_future()
waiter = LineWaiter()
# Track log messages
log_messages: list[str] = []
error_detected = False
def on_log_line(line: str) -> None:
nonlocal error_detected
if "TEST" in line:
log_messages.append(line)
if "ERROR: Regular timeout executed" in line:
error_detected = True
if "Test complete" in line and not test_complete_future.done():
test_complete_future.set_result(None)
def log_messages() -> list[str]:
return [line for line in waiter.lines if "TEST" in line]
async with (
run_compiled(yaml_config, line_callback=on_log_line),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify we can connect
@@ -54,29 +41,33 @@ async def test_scheduler_defer_cancels_regular(
# Execute the test
await client.execute_service(test_service, {})
# Wait for test completion
await wait_or_fail(
test_complete_future, 5.0, "Test timed out", log_messages=log_messages
waiter.wait_for("Test complete", timeout=20.0),
5.0,
"Test timed out",
log_messages=log_messages,
)
# Verify results
assert not error_detected, (
f"Regular timeout should have been cancelled but it executed! Logs: {log_messages}"
messages = log_messages()
assert not any(
"ERROR: Regular timeout executed" in line for line in waiter.lines
), (
f"Regular timeout should have been cancelled but it executed! Logs: {messages}"
)
# Verify the deferred timeout executed
assert any(
"SUCCESS: Deferred timeout executed" in msg for msg in log_messages
), f"Deferred timeout should have executed. Logs: {log_messages}"
assert any("SUCCESS: Deferred timeout executed" in msg for msg in messages), (
f"Deferred timeout should have executed. Logs: {messages}"
)
# Verify the expected sequence of events
assert any(
"Starting defer cancels regular timeout test" in msg for msg in log_messages
"Starting defer cancels regular timeout test" in msg for msg in messages
)
assert any(
"Scheduled regular timeout with 100ms delay" in msg for msg in log_messages
"Scheduled regular timeout with 100ms delay" in msg for msg in messages
)
assert any(
"Scheduled deferred timeout - should cancel regular timeout" in msg
for msg in log_messages
for msg in messages
)
@@ -2,15 +2,17 @@
from __future__ import annotations
import asyncio
import re
import pytest
from .conftest import run_binary
from .log_utils import LineWaiter
from .types import CompileFunction, ConfigWriter
from .wait_utils import wait_or_fail
RESULT_RE = re.compile(r"SCHEDULER_LOOP_PHASE_RESULT elapsed=(\d+)")
@pytest.mark.asyncio
async def test_scheduler_loop_phase_timeout(
@@ -21,18 +23,17 @@ async def test_scheduler_loop_phase_timeout(
config_path = await write_yaml_config(yaml_config)
binary_path = await compile_esphome(config_path)
loop = asyncio.get_running_loop()
timeout_result: asyncio.Future[int] = loop.create_future()
waiter = LineWaiter()
def on_log_line(line: str) -> None:
match = re.search(r"SCHEDULER_LOOP_PHASE_RESULT elapsed=(\d+)", line)
if match and not timeout_result.done():
timeout_result.set_result(int(match.group(1)))
async with run_binary(binary_path, line_callback=on_log_line):
elapsed = await wait_or_fail(
timeout_result, 15.0, "loop-phase timeout did not fire within 15 seconds"
async with run_binary(binary_path, line_callback=waiter.callback):
line = await wait_or_fail(
waiter.wait_for("SCHEDULER_LOOP_PHASE_RESULT elapsed=", timeout=30.0),
15.0,
"loop-phase timeout did not fire within 15 seconds",
)
match = RESULT_RE.search(line)
assert match, line
elapsed = int(match[1])
assert elapsed < 1000, (
f"loop-phase timeout should interrupt the five-second sleep, but fired after {elapsed}ms"
+4 -14
View File
@@ -1,10 +1,10 @@
"""Test that scheduler handles NULL names safely without crashing."""
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@@ -18,19 +18,10 @@ async def test_scheduler_null_name(
) -> None:
"""Test that scheduler handles NULL names safely without crashing."""
loop = asyncio.get_running_loop()
test_complete_future: asyncio.Future[bool] = loop.create_future()
# Pattern to match test completion
test_complete_pattern = re.compile(r"Test completed successfully")
def check_output(line: str) -> None:
"""Check log output for test completion."""
if not test_complete_future.done() and test_complete_pattern.search(line):
test_complete_future.set_result(True)
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify we can connect
@@ -47,9 +38,8 @@ async def test_scheduler_null_name(
# Execute the test
await client.execute_service(test_null_name_service, {})
# Wait for test completion
await wait_or_fail(
test_complete_future,
waiter.wait_for("Test completed successfully", timeout=20.0),
10.0,
"Test did not complete within timeout - likely crashed due to NULL name",
)
@@ -1,14 +1,17 @@
"""Test scheduler numeric ID (uint32_t) overloads."""
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
INTERVAL_COUNT_PATTERN = re.compile(r"Numeric interval 2001 fired.*count: (\d+)")
FINAL_RESULTS_PATTERN = re.compile(r"Timeouts: (\d+), Intervals: (\d+), Defers: (\d+)")
@pytest.mark.asyncio
async def test_scheduler_numeric_id_test(
@@ -17,203 +20,112 @@ async def test_scheduler_numeric_id_test(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that scheduler handles numeric IDs (uint32_t) correctly."""
# Track counts
timeout_count = 0
interval_count = 0
defer_count = 0
waiter = LineWaiter()
# Events for each test completion
numeric_timeout_1001_fired = asyncio.Event()
numeric_timeout_1002_fired = asyncio.Event()
numeric_interval_2001_fired = asyncio.Event()
numeric_interval_cancelled = asyncio.Event()
numeric_timeout_cancelled = asyncio.Event()
duplicate_timeout_fired = asyncio.Event()
component_timeout_fired = asyncio.Event()
component_interval_fired = asyncio.Event()
zero_id_timeout_fired = asyncio.Event()
max_id_timeout_fired = asyncio.Event()
numeric_defer_7001_fired = asyncio.Event()
numeric_defer_7002_fired = asyncio.Event()
numeric_defer_cancelled = asyncio.Event()
final_results_logged = asyncio.Event()
# Track interval counts
numeric_interval_count = 0
def on_log_line(line: str) -> None:
nonlocal timeout_count, interval_count, defer_count
nonlocal numeric_interval_count
# Strip ANSI color codes
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
# Check for numeric timeout completions
if "Numeric timeout 1001 fired" in clean_line:
numeric_timeout_1001_fired.set()
timeout_count += 1
elif "Numeric timeout 1002 fired" in clean_line:
numeric_timeout_1002_fired.set()
timeout_count += 1
# Check for numeric interval
elif "Numeric interval 2001 fired" in clean_line:
match = re.search(r"count: (\d+)", clean_line)
if match:
numeric_interval_count = int(match.group(1))
numeric_interval_2001_fired.set()
elif "Cancelled numeric interval 2001" in clean_line:
numeric_interval_cancelled.set()
elif "Cancelled numeric timeout 3001" in clean_line:
numeric_timeout_cancelled.set()
# Check for duplicate timeout (only last should fire)
elif "Duplicate numeric timeout" in clean_line:
match = re.search(r"timeout (\d+) fired", clean_line)
if match and match.group(1) == "4":
duplicate_timeout_fired.set()
timeout_count += 1
# Check for component method tests
elif "Component numeric timeout 5001 fired" in clean_line:
component_timeout_fired.set()
timeout_count += 1
elif "Component numeric interval 5002 fired" in clean_line:
component_interval_fired.set()
interval_count += 1
# Check for edge case tests
elif "Numeric timeout with ID 0 fired" in clean_line:
zero_id_timeout_fired.set()
timeout_count += 1
elif "Numeric timeout with max ID fired" in clean_line:
max_id_timeout_fired.set()
timeout_count += 1
# Check for numeric defer tests
elif "Component numeric defer 7001 fired" in clean_line:
numeric_defer_7001_fired.set()
elif "Component numeric defer 7002 fired" in clean_line:
numeric_defer_7002_fired.set()
elif "Cancelled numeric defer 8001: true" in clean_line:
numeric_defer_cancelled.set()
# Check for final results
elif "Final results" in clean_line:
match = re.search(
r"Timeouts: (\d+), Intervals: (\d+), Defers: (\d+)",
clean_line,
)
if match:
timeout_count = int(match.group(1))
interval_count = int(match.group(2))
defer_count = int(match.group(3))
final_results_logged.set()
async def expect(text: str, timeout: float, msg: str) -> str:
return await wait_or_fail(waiter.wait_for(text), timeout, msg)
async with (
run_compiled(yaml_config, line_callback=on_log_line),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify we can connect
await assert_device_name(client, "scheduler-numeric-id-test")
# Wait for numeric timeout tests
await wait_or_fail(
numeric_timeout_1001_fired,
await expect(
"Numeric timeout 1001 fired",
0.5,
"Numeric timeout 1001 did not fire within 0.5 seconds",
)
await wait_or_fail(
numeric_timeout_1002_fired,
await expect(
"Numeric timeout 1002 fired",
0.5,
"Numeric timeout 1002 did not fire within 0.5 seconds",
)
await wait_or_fail(
numeric_interval_2001_fired,
await expect(
"Numeric interval 2001 fired",
1.0,
"Numeric interval 2001 did not fire within 1 second",
)
await wait_or_fail(
numeric_interval_cancelled,
await expect(
"Cancelled numeric interval 2001",
2.0,
"Numeric interval 2001 was not cancelled within 2 seconds",
)
# Verify numeric interval ran at least twice
interval_counts = [
int(m.group(1))
for line in waiter.lines
if (m := INTERVAL_COUNT_PATTERN.search(line))
]
numeric_interval_count = interval_counts[-1] if interval_counts else 0
assert numeric_interval_count >= 2, (
f"Expected numeric interval to run at least 2 times, got {numeric_interval_count}"
)
# Verify numeric timeout was cancelled
assert numeric_timeout_cancelled.is_set(), (
assert any("Cancelled numeric timeout 3001" in line for line in waiter.lines), (
"Numeric timeout 3001 should have been cancelled"
)
# Wait for duplicate timeout (only last one should fire)
await wait_or_fail(
duplicate_timeout_fired,
await expect(
"Duplicate numeric timeout 4 fired",
1.0,
"Duplicate numeric timeout did not fire within 1 second",
)
# Wait for component method tests
await wait_or_fail(
component_timeout_fired,
await expect(
"Component numeric timeout 5001 fired",
0.5,
"Component numeric timeout did not fire within 0.5 seconds",
)
await wait_or_fail(
component_interval_fired,
await expect(
"Component numeric interval 5002 fired",
1.0,
"Component numeric interval did not fire within 1 second",
)
# Wait for edge case tests
await wait_or_fail(
zero_id_timeout_fired,
await expect(
"Numeric timeout with ID 0 fired",
0.5,
"Zero ID timeout did not fire within 0.5 seconds",
)
await wait_or_fail(
max_id_timeout_fired, 0.5, "Max ID timeout did not fire within 0.5 seconds"
await expect(
"Numeric timeout with max ID fired",
0.5,
"Max ID timeout did not fire within 0.5 seconds",
)
# Wait for numeric defer tests
await wait_or_fail(
numeric_defer_7001_fired,
await expect(
"Component numeric defer 7001 fired",
0.5,
"Numeric defer 7001 did not fire within 0.5 seconds",
)
await wait_or_fail(
numeric_defer_7002_fired,
await expect(
"Component numeric defer 7002 fired",
0.5,
"Numeric defer 7002 did not fire within 0.5 seconds",
)
# Verify numeric defer was cancelled
await wait_or_fail(
numeric_defer_cancelled,
await expect(
"Cancelled numeric defer 8001: true",
0.5,
"Numeric defer 8001 cancel confirmation not received",
)
# Wait for final results
await wait_or_fail(
final_results_logged, 3.0, "Final results were not logged within 3 seconds"
final_line = await expect(
"Final results", 3.0, "Final results were not logged within 3 seconds"
)
assert (match := FINAL_RESULTS_PATTERN.search(final_line)), final_line
timeout_count, interval_count, defer_count = map(int, match.groups())
# Verify results
assert timeout_count >= 6, f"Expected at least 6 timeouts, got {timeout_count}"
@@ -5,10 +5,19 @@ import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
NORMAL_COUNT = re.compile(r"Normal items executed: (\d+)")
REMOVED_COUNT = re.compile(r"Removed items executed: (\d+)")
def _last_count(pattern: re.Pattern[str], lines: list[str]) -> int:
counts = [int(m.group(1)) for line in lines if (m := pattern.search(line))]
return counts[-1] if counts else 0
@pytest.mark.asyncio
async def test_scheduler_removed_item_race(
@@ -24,48 +33,10 @@ async def test_scheduler_removed_item_race(
3. This causes cancelled timeouts to run when they shouldn't
"""
loop = asyncio.get_running_loop()
test_complete_future: asyncio.Future[bool] = loop.create_future()
# Track test results
test_passed = False
removed_executed = 0
normal_executed = 0
# Patterns to match
race_pattern = re.compile(r"RACE: .* executed after being cancelled!")
passed_pattern = re.compile(r"TEST PASSED")
failed_pattern = re.compile(r"TEST FAILED")
complete_pattern = re.compile(r"=== Test Complete ===")
normal_count_pattern = re.compile(r"Normal items executed: (\d+)")
removed_count_pattern = re.compile(r"Removed items executed: (\d+)")
def check_output(line: str) -> None:
"""Check log output for test results."""
nonlocal test_passed, removed_executed, normal_executed
if race_pattern.search(line):
# Race condition detected - a cancelled item executed
test_passed = False
if passed_pattern.search(line):
test_passed = True
elif failed_pattern.search(line):
test_passed = False
normal_match = normal_count_pattern.search(line)
if normal_match:
normal_executed = int(normal_match.group(1))
removed_match = removed_count_pattern.search(line)
if removed_match:
removed_executed = int(removed_match.group(1))
if not test_complete_future.done() and complete_pattern.search(line):
test_complete_future.set_result(True)
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify we can connect
@@ -84,10 +55,15 @@ async def test_scheduler_removed_item_race(
# Wait for test completion
await wait_or_fail(
test_complete_future, 5.0, "Test did not complete within timeout"
waiter.wait_for("=== Test Complete ==="),
5.0,
"Test did not complete within timeout",
)
# Verify results
# RACE lines come before the verdict, so the verdict alone decides
test_passed = any("TEST PASSED" in line for line in waiter.lines)
normal_executed = _last_count(NORMAL_COUNT, waiter.lines)
removed_executed = _last_count(REMOVED_COUNT, waiter.lines)
assert test_passed, (
f"Test failed! Removed items executed: {removed_executed}, "
f"Normal items executed: {normal_executed}"
+50 -123
View File
@@ -1,14 +1,17 @@
"""Test scheduler string optimization with static and dynamic strings."""
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
INTERVAL_COUNT_PATTERN = re.compile(r"Static interval 1 fired.*count: (\d+)")
FINAL_RESULTS_PATTERN = re.compile(r"Timeouts: (\d+), Intervals: (\d+)")
@pytest.mark.asyncio
async def test_scheduler_string_test(
@@ -17,175 +20,99 @@ async def test_scheduler_string_test(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that scheduler handles both static and dynamic strings correctly."""
# Track counts
timeout_count = 0
interval_count = 0
waiter = LineWaiter()
# Events for each test completion
static_timeout_1_fired = asyncio.Event()
static_timeout_2_fired = asyncio.Event()
static_interval_fired = asyncio.Event()
static_interval_cancelled = asyncio.Event()
empty_string_timeout_fired = asyncio.Event()
static_timeout_cancelled = asyncio.Event()
static_defer_1_fired = asyncio.Event()
static_defer_2_fired = asyncio.Event()
dynamic_timeout_fired = asyncio.Event()
dynamic_interval_fired = asyncio.Event()
dynamic_defer_fired = asyncio.Event()
cancel_test_done = asyncio.Event()
final_results_logged = asyncio.Event()
async def expect(text: str, timeout: float, msg: str) -> str:
return await wait_or_fail(waiter.wait_for(text), timeout, msg)
# Track interval counts
static_interval_count = 0
dynamic_interval_count = 0
def on_log_line(line: str) -> None:
nonlocal \
timeout_count, \
interval_count, \
static_interval_count, \
dynamic_interval_count
# Strip ANSI color codes
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
# Check for static timeout completions
if "Static timeout 1 fired" in clean_line:
static_timeout_1_fired.set()
timeout_count += 1
elif "Static timeout 2 fired" in clean_line:
static_timeout_2_fired.set()
timeout_count += 1
# Check for static interval
elif "Static interval 1 fired" in clean_line:
match = re.search(r"count: (\d+)", clean_line)
if match:
static_interval_count = int(match.group(1))
static_interval_fired.set()
elif "Cancelled static interval 1" in clean_line:
static_interval_cancelled.set()
# Check for empty string timeout
elif "Empty string timeout fired" in clean_line:
empty_string_timeout_fired.set()
# Check for static timeout cancellation
elif "Cancelled static timeout using const char*" in clean_line:
static_timeout_cancelled.set()
# Check for static defer tests
elif "Static defer 1 fired" in clean_line:
static_defer_1_fired.set()
timeout_count += 1
elif "Static defer 2 fired" in clean_line:
static_defer_2_fired.set()
timeout_count += 1
# Check for dynamic string tests
elif "Dynamic timeout fired" in clean_line:
dynamic_timeout_fired.set()
timeout_count += 1
elif "Dynamic interval fired" in clean_line:
dynamic_interval_count += 1
dynamic_interval_fired.set()
# Check for dynamic defer test
elif "Dynamic defer fired" in clean_line:
dynamic_defer_fired.set()
timeout_count += 1
# Check for cancel test
elif "Cancelled timeout using different buffer with same content" in clean_line:
cancel_test_done.set()
# Check for final results
elif "Final results" in clean_line:
match = re.search(r"Timeouts: (\d+), Intervals: (\d+)", clean_line)
if match:
timeout_count = int(match.group(1))
interval_count = int(match.group(2))
final_results_logged.set()
def seen(text: str) -> bool:
return any(text in line for line in waiter.lines)
async with (
run_compiled(yaml_config, line_callback=on_log_line),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify we can connect
await assert_device_name(client, "scheduler-string-test")
# Wait for static string tests
await wait_or_fail(
static_timeout_1_fired,
await expect(
"Static timeout 1 fired",
0.5,
"Static timeout 1 did not fire within 0.5 seconds",
)
await wait_or_fail(
static_timeout_2_fired,
await expect(
"Static timeout 2 fired",
0.5,
"Static timeout 2 did not fire within 0.5 seconds",
)
await wait_or_fail(
static_interval_fired, 1.0, "Static interval did not fire within 1 second"
await expect(
"Static interval 1 fired",
1.0,
"Static interval did not fire within 1 second",
)
await wait_or_fail(
static_interval_cancelled,
await expect(
"Cancelled static interval 1",
2.0,
"Static interval was not cancelled within 2 seconds",
)
# Verify static interval ran at least 3 times
interval_counts = [
int(m.group(1))
for line in waiter.lines
if (m := INTERVAL_COUNT_PATTERN.search(line))
]
static_interval_count = interval_counts[-1] if interval_counts else 0
assert static_interval_count >= 2, (
f"Expected static interval to run at least 3 times, got {static_interval_count + 1}"
)
# Verify static timeout was cancelled
assert static_timeout_cancelled.is_set(), (
assert seen("Cancelled static timeout using const char*"), (
"Static timeout should have been cancelled"
)
# Wait for static defer tests
await wait_or_fail(
static_defer_1_fired, 0.5, "Static defer 1 did not fire within 0.5 seconds"
await expect(
"Static defer 1 fired",
0.5,
"Static defer 1 did not fire within 0.5 seconds",
)
await wait_or_fail(
static_defer_2_fired, 0.5, "Static defer 2 did not fire within 0.5 seconds"
await expect(
"Static defer 2 fired",
0.5,
"Static defer 2 did not fire within 0.5 seconds",
)
# Wait for dynamic string tests
await wait_or_fail(
dynamic_timeout_fired, 1.0, "Dynamic timeout did not fire within 1 second"
await expect(
"Dynamic timeout fired", 1.0, "Dynamic timeout did not fire within 1 second"
)
await wait_or_fail(
dynamic_interval_fired,
await expect(
"Dynamic interval fired",
1.5,
"Dynamic interval did not fire within 1.5 seconds",
)
# Wait for dynamic defer test
await wait_or_fail(
dynamic_defer_fired, 1.0, "Dynamic defer did not fire within 1 second"
await expect(
"Dynamic defer fired", 1.0, "Dynamic defer did not fire within 1 second"
)
# Wait for cancel test
await wait_or_fail(
cancel_test_done, 1.0, "Cancel test did not complete within 1 second"
await expect(
"Cancelled timeout using different buffer with same content",
1.0,
"Cancel test did not complete within 1 second",
)
# Wait for final results
await wait_or_fail(
final_results_logged, 4.0, "Final results were not logged within 4 seconds"
final_line = await expect(
"Final results", 4.0, "Final results were not logged within 4 seconds"
)
assert (match := FINAL_RESULTS_PATTERN.search(final_line)), final_line
timeout_count, interval_count = map(int, match.groups())
# Verify results
assert timeout_count >= 6, (
@@ -196,4 +123,4 @@ async def test_scheduler_string_test(
)
# Empty string timeout DOES fire (scheduler accepts empty names)
assert empty_string_timeout_fired.is_set(), "Empty string timeout should fire"
assert seen("Empty string timeout fired"), "Empty string timeout should fire"
+26 -33
View File
@@ -9,15 +9,22 @@ braced initializer, causing compile failures.
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
PATTERNS = {
"ints": re.compile(r"ints size=(\d+) \[0\]=(-?\d+) \[1\]=(-?\d+)"),
"floats": re.compile(r"floats size=(\d+) \[0\]=(-?\d+\.\d+) \[1\]=(-?\d+\.\d+)"),
"bools": re.compile(r"bools size=(\d+) \[0\]=(\d+) \[1\]=(\d+)"),
"strings": re.compile(r"strings size=(\d+) \[0\]=(\w+) \[1\]=(\w+)"),
}
@pytest.mark.asyncio
async def test_script_array_params(
@@ -26,46 +33,32 @@ async def test_script_array_params(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Execute a script with int[], float[], bool[], string[] parameters."""
loop = asyncio.get_running_loop()
seen: dict[str, str] = {}
done = loop.create_future()
patterns = {
"ints": re.compile(r"ints size=(\d+) \[0\]=(-?\d+) \[1\]=(-?\d+)"),
"floats": re.compile(
r"floats size=(\d+) \[0\]=(-?\d+\.\d+) \[1\]=(-?\d+\.\d+)"
),
"bools": re.compile(r"bools size=(\d+) \[0\]=(\d+) \[1\]=(\d+)"),
"strings": re.compile(r"strings size=(\d+) \[0\]=(\w+) \[1\]=(\w+)"),
}
def check_output(line: str) -> None:
for key, pat in patterns.items():
if (m := pat.search(line)) and key not in seen:
seen[key] = m.group(0)
if len(seen) == len(patterns) and not done.done():
done.set_result(True)
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
_, services = await client.list_entities_services()
service = require_service(services, "run_array_script")
await client.execute_service(service, {})
values: dict[str, tuple[str, ...]] = {}
async def collect() -> None:
for key, pattern in PATTERNS.items():
line = await waiter.wait_for(f"{key} size=", timeout=10.0)
assert (m := pattern.search(line)), line
values[key] = m.groups()
# One deadline for all four lines, as the original future had
await wait_or_fail(
done, 5.0, "Did not receive all expected log lines", seen=seen
collect(), 5.0, "Did not receive all expected log lines", values=values
)
assert (m := patterns["ints"].search(seen["ints"]))
assert m.group(1) == "2" and m.group(2) == "42" and m.group(3) == "100"
assert (m := patterns["floats"].search(seen["floats"]))
assert m.group(1) == "2" and m.group(2) == "1.50" and m.group(3) == "2.50"
assert (m := patterns["bools"].search(seen["bools"]))
assert m.group(1) == "2" and m.group(2) == "1" and m.group(3) == "0"
assert (m := patterns["strings"].search(seen["strings"]))
assert m.group(1) == "2" and m.group(2) == "hello" and m.group(3) == "world"
assert values == {
"ints": ("2", "42", "100"),
"floats": ("2", "1.50", "2.50"),
"bools": ("2", "1", "0"),
"strings": ("2", "hello", "world"),
}
+15 -39
View File
@@ -15,11 +15,11 @@ These bugs manifested as:
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@@ -36,47 +36,19 @@ async def test_script_delay_with_params(
This reproduces issues #12043 and #12044 where scripts would hang or become
zombies due to LIFO ordering bugs in ScriptWaitAction from PR #7972.
"""
test_complete = asyncio.Event()
waiter = LineWaiter()
# Patterns to match in logs
father_calling_pattern = re.compile(r"Father iteration (\d+): calling son")
son_started_pattern = re.compile(r"Son script started with iteration (\d+)")
son_delaying_pattern = re.compile(r"Son script delaying for iteration (\d+)")
son_finished_pattern = re.compile(r"Son script finished with iteration (\d+)")
father_wait_returned_pattern = re.compile(
r"Father iteration (\d+): son finished, wait returned"
)
def iterations(pattern: str) -> set[int]:
return {
int(m.group(1)) for line in waiter.lines if (m := re.search(pattern, line))
}
# Track which iterations completed
father_calling = set()
son_started = set()
son_delaying = set()
son_finished = set()
wait_returned = set()
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if test_complete.is_set():
return
if mo := father_calling_pattern.search(line):
father_calling.add(int(mo.group(1)))
elif mo := son_started_pattern.search(line):
son_started.add(int(mo.group(1)))
elif mo := son_delaying_pattern.search(line):
son_delaying.add(int(mo.group(1)))
elif mo := son_finished_pattern.search(line):
son_finished.add(int(mo.group(1)))
elif mo := father_wait_returned_pattern.search(line):
iteration = int(mo.group(1))
wait_returned.add(iteration)
# Test completes when iteration 9 finishes
if iteration == 9:
test_complete.set()
def wait_returned() -> set[int]:
return iterations(r"Father iteration (\d+): son finished, wait returned")
# Run with log monitoring
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify device info
@@ -91,7 +63,7 @@ async def test_script_delay_with_params(
# Wait for test to complete (10 iterations * ~100ms each + margin)
await wait_or_fail(
test_complete,
waiter.wait_for("Father iteration 9: son finished, wait returned"),
5.0,
"Test timed out. "
"This likely indicates the script became a zombie (issue #12044).",
@@ -100,6 +72,10 @@ async def test_script_delay_with_params(
# Verify all 10 iterations completed successfully
expected_iterations = set(range(10))
father_calling = iterations(r"Father iteration (\d+): calling son")
son_started = iterations(r"Son script started with iteration (\d+)")
son_delaying = iterations(r"Son script delaying for iteration (\d+)")
son_finished = iterations(r"Son script finished with iteration (\d+)")
assert father_calling == expected_iterations, "Not all iterations started"
assert son_started == expected_iterations, (
"Son script not started for all iterations"
@@ -107,7 +83,7 @@ async def test_script_delay_with_params(
assert son_finished == expected_iterations, (
"Son script not finished for all iterations"
)
assert wait_returned == expected_iterations, (
assert wait_returned() == expected_iterations, (
"script.wait did not return for all iterations"
)
+14 -30
View File
@@ -7,30 +7,31 @@ one fixture with a filtered sensor and one with no filters at all.
from __future__ import annotations
import asyncio
import re
from aioesphomeapi import APIClient, EntityInfo
import pytest
from .log_utils import LineWaiter
from .state_utils import require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
NO_FILTER_PATTERN = re.compile(r"NO_FILTER: state=([\d.]+) raw_state=([\d.]+)")
WITH_FILTER_PATTERN = re.compile(r"WITH_FILTER: state=([\d.]+) raw_state=([\d.]+)")
STATE_PATTERN = re.compile(r"state=([\d.]+) raw_state=([\d.]+)")
async def _press_and_read(
client: APIClient,
entities: list[EntityInfo],
button_object_id: str,
future: asyncio.Future[tuple[float, float]],
waiter: LineWaiter,
label: str,
) -> tuple[float, float]:
button = require_entity(entities, button_object_id)
client.button_command(button.key)
return await wait_or_fail(future, 5.0, f"Timeout waiting for {label} log message")
line = await waiter.wait_for(f"{label}: state=", timeout=5.0)
match = STATE_PATTERN.search(line)
assert match is not None, f"Unexpected {label} line: {line}"
return float(match.group(1)), float(match.group(2))
@pytest.mark.asyncio
@@ -40,28 +41,16 @@ async def test_sensor_raw_state(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""With filters compiled in, raw state is stored separately from state."""
loop = asyncio.get_running_loop()
no_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future()
with_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future()
def check_output(line: str) -> None:
if not no_filter_future.done() and (match := NO_FILTER_PATTERN.search(line)):
no_filter_future.set_result((float(match.group(1)), float(match.group(2))))
if not with_filter_future.done() and (
match := WITH_FILTER_PATTERN.search(line)
):
with_filter_future.set_result(
(float(match.group(1)), float(match.group(2)))
)
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
state, raw_state = await _press_and_read(
client, entities, "test_no_filter_button", no_filter_future, "NO_FILTER"
client, entities, "test_no_filter_button", waiter, "NO_FILTER"
)
assert state == 21.5
assert raw_state == 21.5
@@ -70,7 +59,7 @@ async def test_sensor_raw_state(
client,
entities,
"test_with_filter_button",
with_filter_future,
waiter,
"WITH_FILTER",
)
assert state == 43.0
@@ -84,21 +73,16 @@ async def test_sensor_raw_state_no_filter(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Without filters compiled in, get_raw_state() returns state."""
loop = asyncio.get_running_loop()
no_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future()
def check_output(line: str) -> None:
if not no_filter_future.done() and (match := NO_FILTER_PATTERN.search(line)):
no_filter_future.set_result((float(match.group(1)), float(match.group(2))))
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
state, raw_state = await _press_and_read(
client, entities, "test_no_filter_button", no_filter_future, "NO_FILTER"
client, entities, "test_no_filter_button", waiter, "NO_FILTER"
)
assert state == 21.5
assert raw_state == 21.5
@@ -24,6 +24,7 @@ from aioesphomeapi import (
import pytest
from .host_prefs import clear_host_prefs
from .log_utils import LineWaiter
from .state_utils import (
InitialStateHelper,
require_entity,
@@ -45,15 +46,10 @@ async def test_template_climate_publish_all_fields(
clear_host_prefs(DEVICE_NAME)
state_updates: list[aioesphomeapi.ClimateState] = []
on_control_count = 0
def on_log_line(line: str) -> None:
nonlocal on_control_count
if "on_control fired" in line:
on_control_count += 1
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=on_log_line),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
@@ -91,6 +87,6 @@ async def test_template_climate_publish_all_fields(
assert len(state_updates) == 1, (
f"Expected exactly one ClimateState update, got {len(state_updates)}"
)
assert on_control_count == 0, (
assert not any("on_control fired" in line for line in waiter.lines), (
"climate.template.publish must not trigger on_control"
)
+24 -140
View File
@@ -9,14 +9,15 @@ This tests:
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
STATE_PATTERN = re.compile(r"state='([^']*)'(?: raw_state='([^']*)')?")
@pytest.mark.asyncio
@@ -32,103 +33,16 @@ async def test_text_sensor_raw_state(
2. get_raw_state() with filters returns original (unfiltered) value
3. StringRef-based filters: append, prepend, substitute, map, chained
"""
loop = asyncio.get_running_loop()
waiter = LineWaiter()
# Futures to track log messages
no_filter_future: asyncio.Future[tuple[str, str]] = loop.create_future()
with_filter_future: asyncio.Future[tuple[str, str]] = loop.create_future()
append_future: asyncio.Future[str] = loop.create_future()
prepend_future: asyncio.Future[str] = loop.create_future()
substitute_future: asyncio.Future[str] = loop.create_future()
map_on_future: asyncio.Future[str] = loop.create_future()
map_off_future: asyncio.Future[str] = loop.create_future()
map_unknown_future: asyncio.Future[str] = loop.create_future()
chained_future: asyncio.Future[str] = loop.create_future()
to_lower_future: asyncio.Future[str] = loop.create_future()
lambda_future: asyncio.Future[str] = loop.create_future()
lambda_pass_future: asyncio.Future[str] = loop.create_future()
lambda_skip_future: asyncio.Future[str] = loop.create_future()
lambda_raw_state_future: asyncio.Future[tuple[str, str]] = loop.create_future()
# Patterns to match log output
# NO_FILTER: state='hello world' raw_state='hello world'
no_filter_pattern = re.compile(r"NO_FILTER: state='([^']*)' raw_state='([^']*)'")
# WITH_FILTER: state='HELLO WORLD' raw_state='hello world'
with_filter_pattern = re.compile(
r"WITH_FILTER: state='([^']*)' raw_state='([^']*)'"
)
# StringRef-based filter patterns
append_pattern = re.compile(r"APPEND: state='([^']*)'")
prepend_pattern = re.compile(r"PREPEND: state='([^']*)'")
substitute_pattern = re.compile(r"SUBSTITUTE: state='([^']*)'")
map_on_pattern = re.compile(r"MAP_ON: state='([^']*)'")
map_off_pattern = re.compile(r"MAP_OFF: state='([^']*)'")
map_unknown_pattern = re.compile(r"MAP_UNKNOWN: state='([^']*)'")
chained_pattern = re.compile(r"CHAINED: state='([^']*)'")
to_lower_pattern = re.compile(r"TO_LOWER: state='([^']*)'")
lambda_pattern = re.compile(r"LAMBDA: state='([^']*)'")
lambda_pass_pattern = re.compile(r"LAMBDA_PASS: state='([^']*)'")
lambda_skip_pattern = re.compile(r"LAMBDA_SKIP: state='([^']*)'")
lambda_raw_state_pattern = re.compile(
r"LAMBDA_RAW_STATE: state='([^']*)' raw_state='([^']*)'"
)
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if not no_filter_future.done() and (match := no_filter_pattern.search(line)):
no_filter_future.set_result((match.group(1), match.group(2)))
if not with_filter_future.done() and (
match := with_filter_pattern.search(line)
):
with_filter_future.set_result((match.group(1), match.group(2)))
if not append_future.done() and (match := append_pattern.search(line)):
append_future.set_result(match.group(1))
if not prepend_future.done() and (match := prepend_pattern.search(line)):
prepend_future.set_result(match.group(1))
if not substitute_future.done() and (match := substitute_pattern.search(line)):
substitute_future.set_result(match.group(1))
if not map_on_future.done() and (match := map_on_pattern.search(line)):
map_on_future.set_result(match.group(1))
if not map_off_future.done() and (match := map_off_pattern.search(line)):
map_off_future.set_result(match.group(1))
if not map_unknown_future.done() and (
match := map_unknown_pattern.search(line)
):
map_unknown_future.set_result(match.group(1))
if not chained_future.done() and (match := chained_pattern.search(line)):
chained_future.set_result(match.group(1))
if not to_lower_future.done() and (match := to_lower_pattern.search(line)):
to_lower_future.set_result(match.group(1))
if not lambda_future.done() and (match := lambda_pattern.search(line)):
lambda_future.set_result(match.group(1))
if not lambda_pass_future.done() and (
match := lambda_pass_pattern.search(line)
):
lambda_pass_future.set_result(match.group(1))
if not lambda_skip_future.done() and (
match := lambda_skip_pattern.search(line)
):
lambda_skip_future.set_result(match.group(1))
if not lambda_raw_state_future.done() and (
match := lambda_raw_state_pattern.search(line)
):
lambda_raw_state_future.set_result((match.group(1), match.group(2)))
async def logged_state(label: str) -> tuple[str, str | None]:
line = await waiter.wait_for(f"{label}: state='", timeout=5.0)
match = STATE_PATTERN.search(line)
assert match is not None, f"Unexpected {label} line: {line}"
return match.group(1), match.group(2)
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify device info
@@ -146,9 +60,7 @@ async def test_text_sensor_raw_state(
# get_raw_state() should return the same as state
client.button_command(no_filter_button.key)
state, raw_state = await wait_or_fail(
no_filter_future, 5.0, "Timeout waiting for NO_FILTER log message"
)
state, raw_state = await logged_state("NO_FILTER")
assert state == "hello world", f"Expected state='hello world', got '{state}'"
assert raw_state == "hello world", (
@@ -163,9 +75,7 @@ async def test_text_sensor_raw_state(
# state should be filtered (uppercase), raw_state should be original
client.button_command(with_filter_button.key)
state, raw_state = await wait_or_fail(
with_filter_future, 5.0, "Timeout waiting for WITH_FILTER log message"
)
state, raw_state = await logged_state("WITH_FILTER")
assert state == "HELLO WORLD", f"Expected state='HELLO WORLD', got '{state}'"
assert raw_state == "hello world", (
@@ -181,9 +91,7 @@ async def test_text_sensor_raw_state(
append_button = require_entity(entities, "test_append_button")
client.button_command(append_button.key)
state = await wait_or_fail(
append_future, 5.0, "Timeout waiting for APPEND log message"
)
state, _ = await logged_state("APPEND")
assert state == "test suffix", (
f"Append failed: expected 'test suffix', got '{state}'"
@@ -194,9 +102,7 @@ async def test_text_sensor_raw_state(
prepend_button = require_entity(entities, "test_prepend_button")
client.button_command(prepend_button.key)
state = await wait_or_fail(
prepend_future, 5.0, "Timeout waiting for PREPEND log message"
)
state, _ = await logged_state("PREPEND")
assert state == "prefix test", (
f"Prepend failed: expected 'prefix test', got '{state}'"
@@ -207,9 +113,7 @@ async def test_text_sensor_raw_state(
substitute_button = require_entity(entities, "test_substitute_button")
client.button_command(substitute_button.key)
state = await wait_or_fail(
substitute_future, 5.0, "Timeout waiting for SUBSTITUTE log message"
)
state, _ = await logged_state("SUBSTITUTE")
assert state == "bar says world", (
f"Substitute failed: expected 'bar says world', got '{state}'"
@@ -219,9 +123,7 @@ async def test_text_sensor_raw_state(
map_on_button = require_entity(entities, "test_map_on_button")
client.button_command(map_on_button.key)
state = await wait_or_fail(
map_on_future, 5.0, "Timeout waiting for MAP_ON log message"
)
state, _ = await logged_state("MAP_ON")
assert state == "Active", f"Map ON failed: expected 'Active', got '{state}'"
@@ -229,9 +131,7 @@ async def test_text_sensor_raw_state(
map_off_button = require_entity(entities, "test_map_off_button")
client.button_command(map_off_button.key)
state = await wait_or_fail(
map_off_future, 5.0, "Timeout waiting for MAP_OFF log message"
)
state, _ = await logged_state("MAP_OFF")
assert state == "Inactive", (
f"Map OFF failed: expected 'Inactive', got '{state}'"
@@ -242,9 +142,7 @@ async def test_text_sensor_raw_state(
map_unknown_button = require_entity(entities, "test_map_unknown_button")
client.button_command(map_unknown_button.key)
state = await wait_or_fail(
map_unknown_future, 5.0, "Timeout waiting for MAP_UNKNOWN log message"
)
state, _ = await logged_state("MAP_UNKNOWN")
assert state == "UNKNOWN", (
f"Map passthrough failed: expected 'UNKNOWN', got '{state}'"
@@ -255,9 +153,7 @@ async def test_text_sensor_raw_state(
chained_button = require_entity(entities, "test_chained_button")
client.button_command(chained_button.key)
state = await wait_or_fail(
chained_future, 5.0, "Timeout waiting for CHAINED log message"
)
state, _ = await logged_state("CHAINED")
assert state == "[value]", f"Chained failed: expected '[value]', got '{state}'"
@@ -266,9 +162,7 @@ async def test_text_sensor_raw_state(
to_lower_button = require_entity(entities, "test_to_lower_button")
client.button_command(to_lower_button.key)
state = await wait_or_fail(
to_lower_future, 5.0, "Timeout waiting for TO_LOWER log message"
)
state, _ = await logged_state("TO_LOWER")
assert state == "hello world", (
f"to_lower failed: expected 'hello world', got '{state}'"
@@ -279,9 +173,7 @@ async def test_text_sensor_raw_state(
lambda_button = require_entity(entities, "test_lambda_button")
client.button_command(lambda_button.key)
state = await wait_or_fail(
lambda_future, 5.0, "Timeout waiting for LAMBDA log message"
)
state, _ = await logged_state("LAMBDA")
assert state == "[test]", f"Lambda failed: expected '[test]', got '{state}'"
@@ -290,9 +182,7 @@ async def test_text_sensor_raw_state(
lambda_pass_button = require_entity(entities, "test_lambda_pass_button")
client.button_command(lambda_pass_button.key)
state = await wait_or_fail(
lambda_pass_future, 5.0, "Timeout waiting for LAMBDA_PASS log message"
)
state, _ = await logged_state("LAMBDA_PASS")
assert state == "value passed", (
f"Lambda pass failed: expected 'value passed', got '{state}'"
@@ -303,9 +193,7 @@ async def test_text_sensor_raw_state(
lambda_skip_button = require_entity(entities, "test_lambda_skip_button")
client.button_command(lambda_skip_button.key)
state = await wait_or_fail(
lambda_skip_future, 5.0, "Timeout waiting for LAMBDA_SKIP log message"
)
state, _ = await logged_state("LAMBDA_SKIP")
# When lambda returns {}, value should NOT be published
# State remains from previous successful publish ("value passed")
@@ -321,11 +209,7 @@ async def test_text_sensor_raw_state(
)
client.button_command(lambda_raw_state_button.key)
state, raw_state = await wait_or_fail(
lambda_raw_state_future,
5.0,
"Timeout waiting for LAMBDA_RAW_STATE log message",
)
state, raw_state = await logged_state("LAMBDA_RAW_STATE")
assert state == "original MODIFIED", (
f"Lambda raw_state test failed: expected state='original MODIFIED', "
@@ -14,6 +14,7 @@ import re
from aioesphomeapi import EntityState, SensorInfo
import pytest
from .log_utils import LineWaiter
from .state_utils import require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -29,19 +30,12 @@ async def test_uart_mock_dfrobot_sen0465(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Boot sets passive mode and the alarm before the first reading."""
loop = asyncio.get_running_loop()
alarm_set: asyncio.Future[re.Match[str]] = loop.create_future()
lines: list[str] = []
def line_callback(line: str) -> None:
lines.append(line)
if (match := ALARM_RE.search(line)) and not alarm_set.done():
alarm_set.set_result(match)
waiter = LineWaiter()
lines = waiter.lines
states: dict[int, EntityState] = {}
async with (
run_compiled(yaml_config, line_callback=line_callback),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
@@ -62,7 +56,10 @@ async def test_uart_mock_dfrobot_sen0465(
except TimeoutError:
pytest.fail(f"No reading; log: {lines[-20:]}")
match = await asyncio.wait_for(alarm_set, timeout=1.0)
line = await waiter.wait_for(
"Alarm switch ", "ms after the mode change", timeout=1.0
)
assert (match := ALARM_RE.search(line)), line
switch, threshold, alarm_type, delay_ms = (int(x) for x in match.groups())
assert (switch, threshold, alarm_type) == (1, 35, 1)
assert delay_ms >= 1000, (
@@ -145,7 +145,10 @@ async def test_uart_mock_ld2410_query_ack(
# Each number is published once per ACK: the seed value, then the value under test
for name, (seed, value) in EXPECTED.items():
assert number_states[name] == pytest.approx([seed, value]), name
assert waited_for_frame_end.done(), (
"Expected the parser to wait past the footer bytes in the values"
# The log line comes over stdout, which can trail the API states
await wait_or_fail(
waited_for_frame_end,
2.0,
"Expected the parser to wait past the footer bytes in the values",
)
assert not ack_errors, f"Unexpected ACK errors: {ack_errors}"
@@ -7,6 +7,7 @@ import re
from aioesphomeapi import ButtonInfo
import pytest
from .log_utils import LineWaiter
from .state_utils import InitialStateHelper, SensorStateCollector, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@@ -32,17 +33,16 @@ async def test_uart_mock_ld2420_config_exit(
expected_sends: int,
) -> None:
"""Test both energy reports arrive after the last config exit, and a failed exit is still sent 3 times."""
exit_sends: list[int] = []
waiter = LineWaiter()
def line_callback(line: str) -> None:
if match := EXIT_SENT.search(line):
exit_sends.append(int(match[1]))
def exit_sends() -> list[int]:
return [int(m[1]) for line in waiter.lines if (m := EXIT_SENT.search(line))]
collector = SensorStateCollector(sensor_names=["moving_distance"])
yaml_config = yaml_config.replace("EXIT_REPLY_MODE", str(mode))
async with (
run_compiled(yaml_config, line_callback=line_callback),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
@@ -72,4 +72,4 @@ async def test_uart_mock_ld2420_config_exit(
[100.0, 101.0]
)
# A failed exit is resent and then given up; it is never treated as answered
assert exit_sends == list(range(1, expected_sends + 1))
assert exit_sends() == list(range(1, expected_sends + 1))
@@ -11,14 +11,16 @@ to underflow and timeout immediately instead of waiting the specified duration.
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
DURATION = re.compile(r"wait_until completed after (\d+) ms")
@pytest.mark.asyncio
async def test_wait_until_mid_loop_timing(
@@ -33,50 +35,10 @@ async def test_wait_until_mid_loop_timing(
2. Then starts wait_until with 200ms timeout
3. Verifies timeout takes ~200ms, not <50ms (immediate timeout bug)
"""
loop = asyncio.get_running_loop()
# Track test results
test_results = {
"timeout_duration": None,
"passed": False,
"failed": False,
"bug_detected": False,
}
# Patterns for log messages
timeout_duration = re.compile(r"wait_until completed after (\d+) ms")
test_pass = re.compile(r"✓ Timeout duration correct")
test_fail = re.compile(r"✗ Timeout duration WRONG")
bug_pattern = re.compile(r"Likely BUG: Immediate timeout")
test_passed = re.compile(r"✓ Test PASSED")
test_failed = re.compile(r"✗ Test FAILED")
test_complete = loop.create_future()
def check_output(line: str) -> None:
"""Check log output for test results."""
# Extract timeout duration
match = timeout_duration.search(line)
if match:
test_results["timeout_duration"] = int(match.group(1))
if test_pass.search(line):
test_results["passed"] = True
if test_fail.search(line):
test_results["failed"] = True
if bug_pattern.search(line):
test_results["bug_detected"] = True
# Final test result
if (
test_passed.search(line)
or test_failed.search(line)
and not test_complete.done()
):
test_complete.set_result(True)
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Get the test service
@@ -86,24 +48,29 @@ async def test_wait_until_mid_loop_timing(
# Execute the test
await client.execute_service(test_service, {})
# Wait for test to complete (100ms delay + 200ms timeout + margins = ~500ms)
await asyncio.wait_for(test_complete, timeout=5.0)
# Wait for the "Test PASSED" or "Test FAILED" verdict (100ms delay + 200ms timeout)
await waiter.wait_for("Test ", ": wait_until", timeout=5.0)
# Verify results
assert test_results["timeout_duration"] is not None, (
"Timeout duration not reported"
durations = [
int(m.group(1)) for line in waiter.lines if (m := DURATION.search(line))
]
duration = durations[-1] if durations else None
passed = any("✓ Timeout duration correct" in line for line in waiter.lines)
bug_detected = any(
"Likely BUG: Immediate timeout" in line for line in waiter.lines
)
assert test_results["passed"], (
f"Test failed: wait_until took {test_results['timeout_duration']}ms, expected ~200ms. "
f"Bug detected: {test_results['bug_detected']}"
assert duration is not None, "Timeout duration not reported"
assert passed, (
f"Test failed: wait_until took {duration}ms, expected ~200ms. "
f"Bug detected: {bug_detected}"
)
assert not test_results["bug_detected"], (
f"BUG DETECTED: wait_until timed out immediately ({test_results['timeout_duration']}ms) "
assert not bug_detected, (
f"BUG DETECTED: wait_until timed out immediately ({duration}ms) "
"instead of waiting 200ms. This indicates stale loop_component_start_time."
)
# Additional validation: timeout should be ~200ms (150-250ms range)
duration = test_results["timeout_duration"]
assert 150 <= duration <= 250, (
f"Timeout duration {duration}ms outside expected range (150-250ms). "
f"This suggests timing regression from PR #11676."
+5 -32
View File
@@ -13,10 +13,10 @@ disable_loop(), undoing the enable_loop() from play_complex(), causing wait_unti
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@@ -29,38 +29,14 @@ async def test_wait_until_on_boot(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that wait_until works in on_boot automation with a condition that becomes true later."""
loop = asyncio.get_running_loop()
on_boot_started = False
on_boot_completed = False
on_boot_started_pattern = re.compile(r"on_boot: Starting wait_until test")
on_boot_complete_pattern = re.compile(r"on_boot: wait_until completed successfully")
on_boot_started_future = loop.create_future()
on_boot_complete_future = loop.create_future()
def check_output(line: str) -> None:
"""Check log output for test progress."""
nonlocal on_boot_started, on_boot_completed
if on_boot_started_pattern.search(line):
on_boot_started = True
if not on_boot_started_future.done():
on_boot_started_future.set_result(True)
if on_boot_complete_pattern.search(line):
on_boot_completed = True
if not on_boot_complete_future.done():
on_boot_complete_future.set_result(True)
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Wait for on_boot to start
await asyncio.wait_for(on_boot_started_future, timeout=10.0)
assert on_boot_started, "on_boot did not start"
await waiter.wait_for("on_boot: Starting wait_until test", timeout=10.0)
# At this point, on_boot is blocked in wait_until waiting for test_flag to become true
# If the bug exists, wait_until's loop is disabled and it will never complete
@@ -78,12 +54,9 @@ async def test_wait_until_on_boot(
# If the fix works, wait_until's loop() will check the condition and proceed
# If the bug exists, wait_until is stuck with disabled loop and will timeout
await wait_or_fail(
on_boot_complete_future,
waiter.wait_for("on_boot: wait_until completed successfully"),
2.0,
"wait_until in on_boot did not complete within 2s after condition became true. "
"This indicates the bug where WaitUntilAction::setup() disables the loop "
"after play_complex() has already enabled it.",
)
assert on_boot_completed, (
"on_boot wait_until did not complete after flag was set"
)
+16 -30
View File
@@ -9,15 +9,17 @@ LIFO ordering which is incorrect.
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
COMPLETED_PATTERN = re.compile(r"Completed iteration (\d+)")
@pytest.mark.asyncio
async def test_wait_until_fifo_ordering(
@@ -30,34 +32,19 @@ async def test_wait_until_fifo_ordering(
With the bug (using emplace_front), the order would be 4,3,2,1,0 (LIFO).
With the fix (using emplace_back), the order should be 0,1,2,3,4 (FIFO).
"""
test_complete = asyncio.Event()
waiter = LineWaiter()
expected_order = [0, 1, 2, 3, 4]
# Track completion order
completed_order = []
# Patterns to match
queuing_pattern = re.compile(r"Queueing iteration (\d+)")
completed_pattern = re.compile(r"Completed iteration (\d+)")
def check_output(line: str) -> None:
"""Check log output for completion order."""
if test_complete.is_set():
return
if mo := queuing_pattern.search(line):
iteration = int(mo.group(1))
elif mo := completed_pattern.search(line):
iteration = int(mo.group(1))
completed_order.append(iteration)
# Test completes when all 5 have completed
if len(completed_order) == 5:
test_complete.set()
def completed_order() -> list[int]:
return [
int(m.group(1))
for line in waiter.lines
if (m := COMPLETED_PATTERN.search(line))
][: len(expected_order)]
# Run with log monitoring
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify device info
@@ -72,15 +59,14 @@ async def test_wait_until_fifo_ordering(
# Wait for test to complete
await wait_or_fail(
test_complete,
waiter.wait_for_each(*(f"Completed iteration {i}" for i in expected_order)),
5.0,
"Test timed out. Expected 5 completions",
completed_order=completed_order,
)
# Verify FIFO order
expected_order = [0, 1, 2, 3, 4]
assert completed_order == expected_order, (
f"Unexpected order: {completed_order}. "
f"Expected FIFO order: {expected_order}"
order = completed_order()
assert order == expected_order, (
f"Unexpected order: {order}. Expected FIFO order: {expected_order}"
)
@@ -15,10 +15,14 @@ import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
ATTEMPT_RE = re.compile(r"attempt (\d+) done")
GATE_RE = re.compile(r"gate passed (\d+)")
@pytest.mark.asyncio
async def test_wait_until_reentrant_restart(
@@ -27,28 +31,16 @@ async def test_wait_until_reentrant_restart(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that re-entering a wait_until from its own continuation is safe."""
retry_complete = asyncio.Event()
stop_complete = asyncio.Event()
waiter = LineWaiter()
attempt_pattern = re.compile(r"attempt (\d+) done")
gate_pattern = re.compile(r"gate passed (\d+)")
def attempts() -> list[int]:
return [int(m[1]) for line in waiter.lines if (m := ATTEMPT_RE.search(line))]
attempts: list[int] = []
gate_passed: list[int] = []
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if mo := attempt_pattern.search(line):
attempts.append(int(mo.group(1)))
elif mo := gate_pattern.search(line):
gate_passed.append(int(mo.group(1)))
elif "retry test complete" in line:
retry_complete.set()
elif "stop test complete" in line:
stop_complete.set()
def gate_passed() -> list[int]:
return [int(m[1]) for line in waiter.lines if (m := GATE_RE.search(line))]
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
await assert_device_name(client, "wait-until-reentrant-restart")
@@ -61,9 +53,12 @@ async def test_wait_until_reentrant_restart(
# script five times, re-entering the same wait_until each time.
await client.execute_service(self_restart_service, {})
await wait_or_fail(
retry_complete, 10.0, "Self-restart retry did not finish", attempts=attempts
waiter.wait_for("retry test complete", timeout=20.0),
10.0,
"Self-restart retry did not finish",
attempts=attempts,
)
assert attempts == [1, 2, 3, 4, 5], attempts
assert attempts() == [1, 2, 3, 4, 5], attempts()
# Scenario 2: the first waiter through the gate stops the script while
# the other waiters are still queued in the same wait_until; both the
@@ -71,14 +66,14 @@ async def test_wait_until_reentrant_restart(
# blocker (0) must be cancelled, not fired.
await client.execute_service(stop_service, {})
await wait_or_fail(
stop_complete,
waiter.wait_for("stop test complete", timeout=20.0),
10.0,
"Stop-during-wait did not finish",
gate_passed=gate_passed,
)
assert gate_passed == [1], gate_passed
assert gate_passed() == [1], gate_passed()
# If the cancelled blocker had been kept, its 1s wait_until timeout
# would still fire - give it the chance and check it stays silent.
await asyncio.sleep(1.5)
assert gate_passed == [1], gate_passed
assert gate_passed() == [1], gate_passed()
@@ -19,15 +19,17 @@ With the fix, delta>=1 (the wake forces Phase B within one tick of the wake).
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
WAKE_RESULT = re.compile(r"WAKE_RESULT delta=(\d+) elapsed=(\d+)")
@pytest.mark.asyncio
async def test_wake_loop_forces_phase_b(
@@ -38,23 +40,21 @@ async def test_wake_loop_forces_phase_b(
"""A wake_loop_threadsafe() call from a background thread must trigger the
component phase within the next tick, even when loop_interval_ is raised
well above the observation window."""
loop = asyncio.get_running_loop()
result: asyncio.Future[tuple[int, int]] = loop.create_future()
def on_log_line(line: str) -> None:
match = re.search(r"WAKE_RESULT delta=(\d+) elapsed=(\d+)", line)
if match and not result.done():
result.set_result((int(match.group(1)), int(match.group(2))))
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=on_log_line),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
await assert_device_name(client, "wake-loop-phase-b")
delta, elapsed = await wait_or_fail(
result, 15.0, "WAKE_RESULT marker never appeared"
line = await wait_or_fail(
waiter.wait_for("WAKE_RESULT delta=", timeout=15.0),
15.0,
"WAKE_RESULT marker never appeared",
)
assert (match := WAKE_RESULT.search(line)), line
delta, elapsed = int(match.group(1)), int(match.group(2))
# Without the fix, delta would be 0 — loop_interval_=2000ms held
# Phase B off for the full 500ms observation window. With the fix
+93
View File
@@ -3,21 +3,29 @@
from __future__ import annotations
import json
from pathlib import Path
import ssl
import threading
import time
from unittest.mock import MagicMock, patch
import pytest
from esphome.components.const import CONF_VERIFY_SSL
from esphome.components.mqtt import CONF_TLS
from esphome.const import (
CONF_BIRTH_MESSAGE,
CONF_BROKER,
CONF_CERTIFICATE_AUTHORITY,
CONF_CLIENT_CERTIFICATE,
CONF_CLIENT_CERTIFICATE_KEY,
CONF_ESPHOME,
CONF_MQTT,
CONF_NAME,
CONF_PAYLOAD,
CONF_PORT,
CONF_RETAIN,
CONF_SKIP_CERT_CN_CHECK,
CONF_TOPIC,
)
from esphome.core import EsphomeError
@@ -483,6 +491,9 @@ def test_prepare_follows_the_firmware_transport(
config[CONF_MQTT]["transport"] = transport
if ws_path is not None:
config[CONF_MQTT]["ws_path"] = ws_path
if expect_tls:
# Validation switches tls on for wss
config[CONF_MQTT][CONF_TLS] = True
client = mock_client_class.return_value
assert prepare(config, [], None, None, None, None, None) is client
@@ -500,3 +511,85 @@ def test_prepare_follows_the_firmware_transport(
else:
client.tls_set_context.assert_not_called()
client.connect.assert_called_once_with("mqtt.local", 1883)
def _mqtt_config(options: dict | None = None) -> dict:
return {
CONF_MQTT: {CONF_BROKER: "broker.local", CONF_PORT: 8883, **(options or {})}
}
def _prepare_with_mocks(config: dict) -> tuple[MagicMock, MagicMock, MagicMock]:
"""Runs prepare() against a mocked paho client and SSL context factory."""
with (
patch("esphome.mqtt.mqtt.Client") as client_cls,
patch("esphome.mqtt.ssl.create_default_context") as create_context,
):
client = client_cls.return_value
context = create_context.return_value
# A real context verifies hostnames unless prepare() turns that off
context.check_hostname = True
prepare(config, [], None, None, None, None, None)
return client, create_context, context
def test_prepare_without_tls_connects_in_plaintext() -> None:
client, create_context, _ = _prepare_with_mocks(_mqtt_config({CONF_PORT: 1883}))
create_context.assert_not_called()
client.tls_set_context.assert_not_called()
client.connect.assert_called_once_with("broker.local", 1883)
def test_prepare_tls_without_ca_uses_system_trust_store() -> None:
client, create_context, context = _prepare_with_mocks(
_mqtt_config({CONF_TLS: True})
)
create_context.assert_called_once_with(cadata=None)
assert context.check_hostname is True
client.tls_set_context.assert_called_once_with(context)
def test_prepare_tls_with_ca_and_skip_cn_check() -> None:
client, create_context, context = _prepare_with_mocks(
_mqtt_config(
{
CONF_TLS: True,
CONF_CERTIFICATE_AUTHORITY: "---cert---",
CONF_SKIP_CERT_CN_CHECK: True,
}
)
)
create_context.assert_called_once_with(cadata="---cert---")
assert context.check_hostname is False
client.tls_set_context.assert_called_once_with(context)
def test_prepare_verify_ssl_false_disables_verification() -> None:
client, create_context, context = _prepare_with_mocks(
_mqtt_config({CONF_TLS: True, CONF_VERIFY_SSL: False})
)
create_context.assert_called_once_with()
assert context.check_hostname is False
assert context.verify_mode == ssl.CERT_NONE
client.tls_set_context.assert_called_once_with(context)
def test_prepare_loads_client_certificate() -> None:
_, _, context = _prepare_with_mocks(
_mqtt_config(
{
CONF_TLS: True,
CONF_CLIENT_CERTIFICATE: "---client cert---",
CONF_CLIENT_CERTIFICATE_KEY: "---client key---",
}
)
)
context.load_cert_chain.assert_called_once()
cert_file, key_file = context.load_cert_chain.call_args.args
assert not Path(cert_file).exists()
assert not Path(key_file).exists()