mirror of
https://github.com/esphome/esphome.git
synced 2026-10-22 13:56:10 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
431c872976 | ||
|
|
3f3e2a22d3 | ||
|
|
63b610bf17 | ||
|
|
62c9e05541 | ||
|
|
671ac3e0bc | ||
|
|
6671ded245 | ||
|
|
9010c4bb2e | ||
|
|
e0a48ff683 | ||
|
|
e3d7b957a7 | ||
|
|
9ab230f98d |
+1
-1
@@ -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 \
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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, {})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user