Merge remote-tracking branch 'origin/dev' into HEAD

This commit is contained in:
J. Nick Koston committed 2026-10-09 18:27:54 -10:00
commit adfc3fa667
2462 files changed
+27655 -7311

No files matched your search

@@ -0,0 +1,62 @@
esphome:
name: ha-entities
host:
api:
actions:
- action: dump_select
variables:
tag: string
then:
- logger.log:
format: "dump %s: has_state=%d index=%d option='%s'"
args:
- tag.c_str()
- id(ha_select).has_state()
- (int) id(ha_select).active_index().value_or(-1)
- id(ha_select).current_option().c_str()
logger:
level: DEBUG
select:
- platform: homeassistant
name: HA Select
id: ha_select
entity_id: select.mode
internal: false
max_options: 4
options_buffer_size: 40
on_value:
- logger.log:
format: "select value '%s' at %u"
args: [x.c_str(), (unsigned) i]
- platform: homeassistant
name: HA Input Select
entity_id: input_select.colour
internal: false
text:
- platform: homeassistant
name: HA Text
entity_id: text.note
internal: false
on_value:
- logger.log:
format: "text value '%s'"
args: [x.c_str()]
- platform: homeassistant
name: HA Input Text
entity_id: input_text.name
internal: false
button:
- platform: homeassistant
name: HA Button
entity_id: button.doorbell
internal: false
- platform: homeassistant
name: HA Input Button
entity_id: input_button.reset
internal: false
@@ -0,0 +1,14 @@
esphome:
name: outgoing-conn-test
host:
logger:
api:
encryption:
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
outgoing_connection:
host: 127.0.0.1
port: OUTGOING_PORT
delay: 1s
@@ -0,0 +1,13 @@
esphome:
name: outgoing-conn-test
host:
logger:
api:
encryption:
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
outgoing_connection:
port: OUTGOING_PORT
delay: 1s
@@ -37,7 +37,7 @@ api:
args: [input_number]
# Test that int lambdas still work correctly with to_string
# The TemplatableStringValue should automatically convert int to string
# Home Assistant action lambdas convert an int result to a string
- homeassistant.event:
event: esphome.test_int_lambda
data:
@@ -54,7 +54,7 @@ api:
args: [input_float]
# Test that float lambdas still work correctly with to_string
# The TemplatableStringValue should automatically convert float to string
# Home Assistant action lambdas convert a float result to a string
- homeassistant.event:
event: esphome.test_float_lambda
data:
@@ -0,0 +1,261 @@
esphome:
name: test-branch-resume-async
host:
logger:
level: DEBUG
script:
- id: delay_in_if
then:
- if:
condition:
lambda: 'return true;'
then:
- delay: 50ms
- logger.log: "if-branch-after-delay"
else:
- logger.log: "if-else-should-not-run"
- logger.log: "after-if"
- id: delay_in_else
then:
- if:
condition:
lambda: 'return false;'
then:
- logger.log: "if-then-should-not-run"
else:
- delay: 50ms
- logger.log: "else-branch-after-delay"
- logger.log: "after-else"
- id: delay_in_while
then:
- lambda: 'id(counter) = 0;'
- while:
condition:
lambda: 'return id(counter) < 3;'
then:
- delay: 20ms
- lambda: 'id(counter) += 1;'
- logger.log:
format: "while-body-%d"
args: ['id(counter)']
- logger.log: "after-while"
- id: stop_in_branch
then:
- if:
condition:
lambda: 'return true;'
then:
- delay: 500ms
- logger.log: "stopped-branch-should-not-run"
- logger.log: "stopped-after-if-should-not-run"
- id: parallel_branch
mode: parallel
parameters:
run: int
then:
- if:
condition:
lambda: 'return run >= 0;'
then:
- delay: 100ms
- logger.log:
format: "parallel-branch-done-%d"
args: ['run']
- logger.log:
format: "parallel-after-if-%d"
args: ['run']
- id: nested_if_last_in_while
then:
- lambda: 'id(counter) = 0;'
- while:
condition:
lambda: 'return id(counter) < 2;'
then:
- lambda: 'id(counter) += 1;'
- if:
condition:
lambda: 'return true;'
then:
- delay: 20ms
- logger.log:
format: "nested-if-in-while-%d"
args: ['id(counter)']
- logger.log: "after-nested-while"
- id: stop_mid_while
then:
- lambda: 'id(counter) = 0;'
- while:
condition:
lambda: 'return id(counter) < 5;'
then:
- delay: 200ms
- lambda: 'id(counter) += 1;'
- logger.log:
format: "stop-while-body-%d"
args: ['id(counter)']
- logger.log: "stop-while-done"
- id: queued_branch
mode: queued
parameters:
run: int
then:
- if:
condition:
lambda: 'return true;'
then:
- delay: 30ms
- logger.log:
format: "queued-branch-%d"
args: ['run']
- logger.log:
format: "queued-after-if-%d"
args: ['run']
- id: restart_branch
mode: restart
parameters:
run: int
then:
- if:
condition:
lambda: 'return true;'
then:
- delay: 100ms
- logger.log:
format: "restart-branch-%d"
args: ['run']
- logger.log:
format: "restart-after-if-%d"
args: ['run']
- id: delay_last_in_branch
then:
- if:
condition:
lambda: 'return true;'
then:
- logger.log: "delay-last-start"
- delay: 30ms
- logger.log: "after-delay-last"
- id: stop_in_if_condition
then:
- if:
condition:
lambda: |-
id(stop_in_if_condition).stop();
return true;
then:
- logger.log: "if-stopped-by-condition-should-not-run"
else:
- logger.log: "if-else-stopped-by-condition-should-not-run"
- logger.log: "after-if-stopped-by-condition-should-not-run"
- id: stop_in_if_else_condition
then:
- if:
condition:
lambda: |-
id(stop_in_if_else_condition).stop();
return false;
else:
- logger.log: "else-stopped-by-condition-should-not-run"
- logger.log: "after-else-stopped-by-condition-should-not-run"
- id: stop_in_while_condition
then:
- while:
condition:
lambda: |-
id(stop_in_while_condition).stop();
return true;
then:
- logger.log: "while-stopped-by-condition-should-not-run"
- logger.log: "after-while-stopped-by-condition-should-not-run"
globals:
- id: counter
type: int
initial_value: '0'
api:
actions:
- action: run_delay_in_if
then:
- script.execute: delay_in_if
- action: run_delay_in_else
then:
- script.execute: delay_in_else
- action: run_delay_in_while
then:
- script.execute: delay_in_while
- action: run_stop_in_branch
then:
- script.execute: stop_in_branch
- delay: 100ms
- script.stop: stop_in_branch
- lambda: |-
ESP_LOGI("test", "stop-running=%d", id(stop_in_branch).is_running() ? 1 : 0);
- delay: 600ms
- logger.log: "stop-check-done"
- action: run_nested_if_last_in_while
then:
- script.execute: nested_if_last_in_while
- action: run_stop_mid_while
then:
- script.execute: stop_mid_while
- delay: 300ms
- script.stop: stop_mid_while
- lambda: |-
ESP_LOGI("test", "stop-while-running=%d", id(stop_mid_while).is_running() ? 1 : 0);
- delay: 400ms
- script.execute: stop_mid_while
- action: run_queued_branch
then:
- script.execute:
id: queued_branch
run: 1
- script.execute:
id: queued_branch
run: 2
- script.execute:
id: queued_branch
run: 3
- action: run_restart_branch
then:
- script.execute:
id: restart_branch
run: 1
- delay: 30ms
- script.execute:
id: restart_branch
run: 2
- action: run_delay_last_in_branch
then:
- script.execute: delay_last_in_branch
- action: run_stop_in_conditions
then:
- script.execute: stop_in_if_condition
- script.execute: stop_in_if_else_condition
- script.execute: stop_in_while_condition
- delay: 200ms
- logger.log: "stop-in-conditions-done"
- action: run_parallel_branch
then:
- script.execute:
id: parallel_branch
run: 1
- script.execute:
id: parallel_branch
run: 2
- script.execute:
id: parallel_branch
run: 3
@@ -0,0 +1,25 @@
"""Host-only stand-in for remote_transmitter used by the IR/RF integration tests."""
import esphome.codegen as cg
from esphome.components import remote_base
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/tests"]
MULTI_CONF = True
AUTO_LOAD = ["remote_base"]
remote_transmitter_mock_ns = cg.esphome_ns.namespace("remote_transmitter_mock")
MockRemoteTransmitter = remote_transmitter_mock_ns.class_(
"MockRemoteTransmitter", remote_base.RemoteTransmitterBase, cg.Component
)
CONFIG_SCHEMA = cv.Schema(
{cv.GenerateID(): cv.declare_id(MockRemoteTransmitter)}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -0,0 +1,40 @@
#include "remote_transmitter_mock.h"
#include "esphome/core/log.h"
#include <cinttypes>
namespace esphome::remote_transmitter_mock {
static const char *const TAG = "remote_transmitter_mock";
void MockRemoteTransmitter::dump_config() { ESP_LOGCONFIG(TAG, "Mock Remote Transmitter"); }
void MockRemoteTransmitter::flush_pending_completion() {
if (!this->busy_)
return;
// The RMT backend blocks here until the hardware is idle; the mock only reports the overlap
ESP_LOGW(TAG, "Overlap: a frame was requested while seq=%" PRIu16 " was in flight", this->current_seq_);
this->cancel_timeout("complete");
this->finish_();
}
void MockRemoteTransmitter::send_internal(uint32_t send_times, uint32_t send_wait) {
uint64_t total_us = static_cast<uint64_t>(send_wait) * (send_times - 1);
for (int32_t value : this->temp_.get_data()) {
total_us += static_cast<uint64_t>(value < 0 ? -value : value) * send_times;
}
const auto duration_ms = static_cast<uint32_t>(total_us / 1000);
this->busy_ = true;
ESP_LOGI(TAG, "TX seq=%" PRIu16 " timings=%zu repeat=%" PRIu32 " duration=%" PRIu32 "ms", this->current_seq_,
this->temp_.get_data().size(), send_times, duration_ms);
this->set_timeout("complete", duration_ms, [this]() { this->finish_(); });
}
void MockRemoteTransmitter::finish_() {
this->busy_ = false;
ESP_LOGI(TAG, "Complete seq=%" PRIu16, this->current_seq_);
this->notify_complete_(true);
}
} // namespace esphome::remote_transmitter_mock
@@ -0,0 +1,31 @@
#pragma once
// ============================================================================
// HOST-ONLY TEST COMPONENT — DO NOT COPY TO PRODUCTION CODE
//
// Emulates a non-blocking remote transmitter: a frame "occupies" the
// transmitter for its real duration and the completion callback fires from a
// scheduler timeout, the same way the ESP32 RMT backend reports completion.
// A frame submitted while another is in flight is logged as an overlap; the
// real backends block the main loop in that case.
// ============================================================================
#include "esphome/core/component.h"
#include "esphome/components/remote_base/remote_base.h"
namespace esphome::remote_transmitter_mock {
class MockRemoteTransmitter : public remote_base::RemoteTransmitterBase, public Component {
public:
MockRemoteTransmitter() : remote_base::RemoteTransmitterBase(nullptr) {}
void dump_config() override;
protected:
void flush_pending_completion() override;
void send_internal(uint32_t send_times, uint32_t send_wait) override;
void finish_();
bool busy_{false};
};
} // namespace esphome::remote_transmitter_mock
@@ -16,4 +16,16 @@ void WakeTestComponent::start_async_wake() {
}).detach();
}
void WakeTestComponent::start_async_timeout(uint32_t delay_ms) {
const uint32_t start_time = millis();
std::thread([this, start_time, delay_ms] {
std::this_thread::sleep_for(std::chrono::milliseconds(50));
const int start_loop_count = this->get_loop_count();
this->set_timeout(delay_ms, [this, start_time, start_loop_count, delay_ms] {
ESP_LOGI(TAG, "SCHEDULER_WAKE_RESULT delay=%u elapsed=%u loop_delta=%d", static_cast<unsigned int>(delay_ms),
static_cast<unsigned int>(millis() - start_time), this->get_loop_count() - start_loop_count);
});
}).detach();
}
} // namespace esphome::wake_test_component
@@ -18,6 +18,10 @@ class WakeTestComponent : public Component {
// loop_interval_ has been raised high enough to gate it off otherwise.
void start_async_wake();
// Spawn a detached thread that inserts a timeout (or a defer when delay_ms
// is 0) while the main loop sleeps, and report how soon it ran.
void start_async_timeout(uint32_t delay_ms);
float get_setup_priority() const override { return setup_priority::DATA; }
protected:
@@ -0,0 +1,30 @@
esphome:
name: ir-rf-transmit-complete-test
host:
api:
logger:
level: DEBUG
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
remote_transmitter_mock:
- id: rf_tx
- id: ir_tx
# two entities on one transmitter: each must get only its own completion
radio_frequency:
- platform: ir_rf_proxy
name: RF Transmitter A
remote_transmitter_id: rf_tx
- platform: ir_rf_proxy
name: RF Transmitter B
remote_transmitter_id: rf_tx
infrared:
- platform: ir_rf_proxy
name: IR Transmitter
remote_transmitter_id: ir_tx
@@ -0,0 +1,31 @@
esphome:
name: scheduler-background-wake
on_boot:
priority: -100
then:
- lambda: |-
App.set_loop_interval(5000);
host:
api:
logger:
level: INFO
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
components: [wake_test_component]
wake_test_component:
id: wake_test
button:
- platform: template
name: Start Scheduler Timeout
on_press:
- lambda: id(wake_test)->start_async_timeout(100);
- platform: template
name: Start Scheduler Defer
on_press:
- lambda: id(wake_test)->start_async_timeout(0);
@@ -0,0 +1,67 @@
esphome:
name: test-timeout-throttle
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: DEBUG
sensor:
- platform: template
name: "Source"
id: source_sensor
accuracy_decimals: 2
# The default radar pair; codegen merges it into one TimeoutThrottleFilter
- platform: copy
source_id: source_sensor
name: "Merged"
filters:
- timeout:
timeout: 200ms
value: last
- throttle_with_priority: 200ms
# Same chain with an identity filter in between, which keeps the two filter objects
- platform: copy
source_id: source_sensor
name: "Reference"
filters:
- timeout:
timeout: 200ms
value: last
- multiply: 1.0
- throttle_with_priority: 200ms
script:
- id: drive
then:
# Burst faster than the period: first passes, the rest are dropped, the timeout republishes the last
- sensor.template.publish: {id: source_sensor, state: 1.0}
- delay: 30ms
- sensor.template.publish: {id: source_sensor, state: 2.0}
- delay: 30ms
- sensor.template.publish: {id: source_sensor, state: 3.0}
- delay: 450ms
# NaN inside a burst always passes
- sensor.template.publish: {id: source_sensor, state: 4.0}
- delay: 30ms
- sensor.template.publish: {id: source_sensor, state: !lambda "return NAN;"}
- delay: 30ms
- sensor.template.publish: {id: source_sensor, state: 5.0}
- delay: 450ms
# Values right around the period
- sensor.template.publish: {id: source_sensor, state: 6.0}
- delay: 250ms
- sensor.template.publish: {id: source_sensor, state: 7.0}
- delay: 190ms
- sensor.template.publish: {id: source_sensor, state: 8.0}
- delay: 600ms
- sensor.template.publish: {id: source_sensor, state: 99.0}
button:
- platform: template
name: "Drive"
on_press:
- script.execute: drive
@@ -0,0 +1,40 @@
esphome:
name: status-momentary-test
host:
api:
actions:
- action: momentary_warning
variables:
length: int
then:
- lambda: "id(probe)->status_momentary_warning(length);"
- action: momentary_error
variables:
length: int
then:
- lambda: "id(probe)->status_momentary_error(length);"
- action: momentary_warning_message
then:
- lambda: 'id(probe)->status_momentary_warning(LOG_STR("probe warning reason"), 300);'
- action: momentary_error_message
then:
- lambda: 'id(probe)->status_momentary_error(LOG_STR("probe error reason"), 300);'
logger:
sensor:
- platform: template
id: probe
update_interval: 24h
lambda: return 1.0;
binary_sensor:
- platform: template
name: Probe Warning
id: probe_warning
lambda: return id(probe)->status_has_warning();
- platform: template
name: Probe Error
id: probe_error
lambda: return id(probe)->status_has_error();
@@ -12,6 +12,10 @@ uart:
- id: uart_bus
baud_rate: 115200
port: PTY_PATH
# uart_tcp needs its own UART.
- id: denied_bus
baud_rate: 115200
port: DENIED_PTY_PATH
uart_tcp:
- id: bridge
@@ -23,7 +27,7 @@ uart_tcp:
connected:
name: Bridge Connected
- id: denied_bridge
uart_id: uart_bus
uart_id: denied_bus
role: server
port: 18127
allowed_ips:
@@ -0,0 +1,213 @@
"""Test the homeassistant select, text and button platforms against a fake Home Assistant."""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from aioesphomeapi import APIClient, HomeassistantServiceCall, UserService
import pytest
from .log_utils import LineWaiter
from .types import APIClientConnectedFactory, RunCompiledFunction
SUBSCRIPTIONS = {
("select.mode", "options"),
("select.mode", None),
("input_select.colour", "options"),
("input_select.colour", None),
("text.note", None),
("text.note", "min"),
("text.note", "max"),
("text.note", "mode"),
("input_text.name", None),
("input_text.name", "min"),
("input_text.name", "max"),
("input_text.name", "mode"),
}
# Each is rejected by the options parser, leaving the previous options in place
MALFORMED_OPTIONS = (
"None",
"",
"[",
"['a'",
"['a',",
"['a' 'b']",
"['a'] x",
"[1]",
"['a\\q']",
"['a\\",
"['\\x4']",
"['\\x0']",
"['\\x00']",
"['\\U00110000']",
)
@pytest.mark.asyncio
async def test_api_homeassistant_entities(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Options, state and actions of the homeassistant select, text and button platforms."""
loop = asyncio.get_running_loop()
waiter = LineWaiter()
subscribed: set[tuple[str, str | None]] = set()
all_subscribed = loop.create_future()
calls: asyncio.Queue[HomeassistantServiceCall] = asyncio.Queue()
def on_state_sub(entity_id: str, attribute: str | None) -> None:
subscribed.add((entity_id, attribute or None))
if not all_subscribed.done() and subscribed >= SUBSCRIPTIONS:
all_subscribed.set_result(None)
async def next_call() -> HomeassistantServiceCall:
return await asyncio.wait_for(calls.get(), timeout=5.0)
async with (
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
entities, services = await client.list_entities_services()
keys = {entity.name: entity.key for entity in entities}
dump_select = next(s for s in services if s.name == "dump_select")
client.subscribe_service_calls(calls.put_nowait)
client.subscribe_home_assistant_states(on_state_sub)
try:
await asyncio.wait_for(all_subscribed, timeout=5.0)
except TimeoutError:
pytest.fail(f"never subscribed: {SUBSCRIPTIONS - subscribed}")
await _check_select(client, waiter, keys, dump_select, next_call)
await _check_text(client, waiter, keys, next_call)
client.button_command(keys["HA Button"])
call = await next_call()
assert call.service == "button.press"
assert call.data == {"entity_id": "button.doorbell"}
client.button_command(keys["HA Input Button"])
call = await next_call()
assert call.service == "input_button.press"
assert call.data == {"entity_id": "input_button.reset"}
assert calls.empty()
async def _check_select(
client: APIClient,
waiter: LineWaiter,
keys: dict[str, int],
dump_select: UserService,
next_call: Callable[[], Awaitable[HomeassistantServiceCall]],
) -> None:
async def dump(tag: str) -> str:
await client.execute_service(dump_select, {"tag": tag})
return await waiter.wait_for(f"dump {tag}:", timeout=5.0)
# Built with str() exactly as Home Assistant does, so every escape it emits is covered
options = ["Low", "It's", "café\t\x7f\\", "\u200b\U000e0001\r\n'\""]
client.send_home_assistant_state("select.mode", "options", str(options))
await waiter.wait_for("'select.mode': Got 4 options", timeout=5.0)
client.send_home_assistant_state("select.mode", "", "It's")
await waiter.wait_for("select value 'It's' at 1", timeout=5.0)
assert "has_state=1 index=1 option='It's'" in await dump("first")
# Decoded options round trip through select.select_option unchanged
for option in options[2:]:
client.select_command(keys["HA Select"], option)
call = await next_call()
assert call.service == "select.select_option"
assert call.data == {"entity_id": "select.mode", "option": option}
# The active option keeps its state when it moves; double quoted options parse too
client.send_home_assistant_state("select.mode", "options", "[\"It's\", 'L\\\"ow']")
await waiter.wait_for("'select.mode': Got 2 options", timeout=5.0)
assert "has_state=1 index=0 option='It's'" in await dump("moved")
client.select_command(keys["HA Select"], 'L"ow')
assert (await next_call()).data["option"] == 'L"ow'
# Dropping the active option clears the state
client.send_home_assistant_state(
"select.mode", "options", "[ 'Low' , 'Max' , 'High' ]"
)
await waiter.wait_for("'select.mode': Got 3 options", timeout=5.0)
assert "has_state=0" in await dump("dropped")
client.send_home_assistant_state("select.mode", "", "unavailable")
await waiter.wait_for("State 'unavailable' is not one of the options", timeout=5.0)
# Rejected lists keep the previous options
client.send_home_assistant_state("select.mode", "options", str(list("abcde")))
await waiter.wait_for("5 options exceed max_options (4)", timeout=5.0)
client.send_home_assistant_state("select.mode", "options", str(["x" * 50]))
await waiter.wait_for(
"Options need 51 bytes, more than options_buffer_size (40)", timeout=5.0
)
for malformed in MALFORMED_OPTIONS:
client.send_home_assistant_state("select.mode", "options", malformed)
client.send_home_assistant_state("select.mode", "", "Max")
await waiter.wait_for("select value 'Max' at 1", timeout=5.0)
assert sum("Can't parse options" in line for line in waiter.lines) == len(
MALFORMED_OPTIONS
)
# Repeating the active state does not publish again
client.send_home_assistant_state("select.mode", "", "Max")
client.send_home_assistant_state("select.mode", "options", "[]")
await waiter.wait_for("'select.mode': Got 0 options", timeout=5.0)
assert sum("select value 'Max'" in line for line in waiter.lines) == 1
assert "has_state=0 index=-1" in await dump("empty")
client.send_home_assistant_state(
"input_select.colour", "options", "['Red', 'Green']"
)
await waiter.wait_for("'input_select.colour': Got 2 options", timeout=5.0)
client.select_command(keys["HA Input Select"], "Green")
call = await next_call()
assert call.service == "input_select.select_option"
assert call.data == {"entity_id": "input_select.colour", "option": "Green"}
async def _check_text(
client: APIClient,
waiter: LineWaiter,
keys: dict[str, int],
next_call: Callable[[], Awaitable[HomeassistantServiceCall]],
) -> None:
client.send_home_assistant_state("text.note", "min", "2")
client.send_home_assistant_state("text.note", "max", "10")
client.send_home_assistant_state("text.note", "mode", "password")
await waiter.wait_for("'text.note': Mode retrieved: password", timeout=5.0)
client.send_home_assistant_state("input_text.name", "min", "abc")
client.send_home_assistant_state("input_text.name", "max", "x")
client.send_home_assistant_state("input_text.name", "mode", "fancy")
await waiter.wait_for(
"'input_text.name': Unknown 'mode' value 'fancy'", timeout=5.0
)
await waiter.wait_for("Can't convert 'min' value 'abc'", timeout=5.0)
await waiter.wait_for("Can't convert 'max' value 'x'", timeout=5.0)
client.send_home_assistant_state("input_text.name", "mode", "text")
await waiter.wait_for("'input_text.name': Mode retrieved: text", timeout=5.0)
client.send_home_assistant_state("text.note", "", "hello")
client.send_home_assistant_state("text.note", "", "hello")
client.send_home_assistant_state("text.note", "", "there")
await waiter.wait_for("text value 'there'", timeout=5.0)
assert sum("text value 'hello'" in line for line in waiter.lines) == 1
# The limits from Home Assistant apply: too short and too long are refused
client.text_command(keys["HA Text"], "a")
client.text_command(keys["HA Text"], "abcdefghijk")
client.text_command(keys["HA Text"], "fine")
call = await next_call()
assert call.service == "text.set_value"
assert call.data == {"entity_id": "text.note", "value": "fine"}
client.text_command(keys["HA Input Text"], "Bob")
call = await next_call()
assert call.service == "input_text.set_value"
assert call.data == {"entity_id": "input_text.name", "value": "Bob"}
@@ -0,0 +1,175 @@
"""Integration tests for the api outgoing_connection option.
The device dials out to the test's listener when no dial-back target client is
connected. The listener plays the Home Assistant side over the accepted socket
using aioesphomeapi's sans-IO Noise handshake: the device sends its server
hello first so the listener could pick the right key, and the NNpsk0 handshake
then verifies both sides. Protocol roles stay unchanged, so the client speaks
exactly the same frames as over a normal connection. A client becomes the
remembered dial-back target by setting the outgoing_connection_target flag in
its hello.
"""
from __future__ import annotations
import asyncio
import socket
from typing import Any
from aioesphomeapi import api_pb2
import pytest
from .raw_api_client import MESSAGE_TYPE_OF
from .types import RunCompiledFunction
KEY = "bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU="
DEVICE_NAME = "outgoing-conn-test"
HA_CLIENT_INFO = "Home Assistant 2026.8.0"
# HelloRequest field 4 (outgoing_connection_target) as raw protobuf bytes; the
# installed aioesphomeapi's api_pb2 predates the field, so append it manually.
HELLO_TARGET_FLAG = b"\x20\x01"
def _frame(payload: bytes) -> bytes:
return bytes((0x01, len(payload) >> 8, len(payload) & 0xFF)) + payload
async def _read_frame(reader: asyncio.StreamReader, timeout: float = 10.0) -> bytes:
header = await asyncio.wait_for(reader.readexactly(3), timeout)
assert header[0] == 0x01, f"Bad frame indicator: {header[0]}"
return await asyncio.wait_for(
reader.readexactly((header[1] << 8) | header[2]), timeout
)
def _check_server_hello(server_hello: bytes) -> None:
assert server_hello[0] == 0x01, "Bad chosen proto in server hello"
name, mac, _rest = server_hello[1:].split(b"\x00", 2)
assert name.decode() == DEVICE_NAME
assert len(mac) == 12, f"Expected bare MAC, got {mac!r}"
async def _run_ha_session(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
*,
device_dialed_out: bool,
) -> None:
"""Handshake and exchange the usual first messages as Home Assistant would."""
# Lazy import per the module's own contract (pulls in the noise stack)
from aioesphomeapi.noise import NoiseHandshake
if device_dialed_out:
# On an outgoing connection the device announces itself first so the
# peer can pick the matching key before its PSK-mixed first message.
_check_server_hello(await _read_frame(reader))
handshake = NoiseHandshake(KEY, b"NoiseAPIInit\x00\x00")
writer.write(b"\x01\x00\x00" + _frame(b"\x00" + handshake.write_message()))
await writer.drain()
if not device_dialed_out:
_check_server_hello(await _read_frame(reader))
reply = await _read_frame(reader)
assert reply[0] == 0, f"Handshake rejected: {reply[1:].decode(errors='replace')}"
handshake.read_message(reply[1:])
encrypt_cipher, decrypt_cipher = handshake.get_ciphers()
async def transact(msg: Any, response_cls: Any, extra_payload: bytes = b"") -> Any:
msg_type = MESSAGE_TYPE_OF[type(msg)]
payload = msg.SerializeToString() + extra_payload
plaintext = (
bytes(
(msg_type >> 8, msg_type & 0xFF, len(payload) >> 8, len(payload) & 0xFF)
)
+ payload
)
writer.write(_frame(encrypt_cipher.encrypt(plaintext)))
await writer.drain()
want = MESSAGE_TYPE_OF[response_cls]
while True:
plain = decrypt_cipher.decrypt(await _read_frame(reader))
if ((plain[0] << 8) | plain[1]) == want:
response = response_cls()
response.ParseFromString(bytes(plain[4:]))
return response
# Declare this client a dial-back target in the hello
await transact(
api_pb2.HelloRequest(client_info=HA_CLIENT_INFO),
api_pb2.HelloResponse,
extra_payload=HELLO_TARGET_FLAG,
)
device_info = await transact(
api_pb2.DeviceInfoRequest(), api_pb2.DeviceInfoResponse
)
assert device_info.name == DEVICE_NAME
async def _serve_home_assistant(listener: socket.socket) -> None:
"""Accept one dial-in from the device and run the client side over it."""
loop = asyncio.get_running_loop()
conn, _ = await asyncio.wait_for(loop.sock_accept(listener), timeout=30)
reader, writer = await asyncio.open_connection(sock=conn)
try:
await _run_ha_session(reader, writer, device_dialed_out=True)
finally:
writer.close()
@pytest.mark.asyncio
async def test_api_outgoing_connection(
yaml_config: str,
run_compiled: RunCompiledFunction,
) -> None:
"""With a configured host the device dials out and speaks the normal API."""
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.bind(("127.0.0.1", 0))
listener.listen(2)
listener.setblocking(False)
port = listener.getsockname()[1]
try:
yaml = yaml_config.replace("OUTGOING_PORT", str(port))
async with run_compiled(yaml):
await _serve_home_assistant(listener)
finally:
listener.close()
@pytest.mark.asyncio
async def test_api_outgoing_connection_remembered(
yaml_config: str,
run_compiled: RunCompiledFunction,
unused_tcp_port: int,
) -> None:
"""No host configured: the device remembers the client whose hello carried
the dial-back flag and dials that address after a restart."""
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Bound but not yet listening so first-phase dials cannot queue stale
# connections; whether the device attempts any dial before the restart
# is timing dependent and not asserted here.
listener.bind(("127.0.0.1", 0))
port = listener.getsockname()[1]
try:
yaml = yaml_config.replace("OUTGOING_PORT", str(port))
async with run_compiled(yaml):
# Connect inbound with the dial-back flag; the device persists the
# peer address during the hello.
reader, writer = await asyncio.open_connection("127.0.0.1", unused_tcp_port)
try:
await _run_ha_session(reader, writer, device_dialed_out=False)
finally:
writer.close()
# Restart with the same preferences: the device now dials the
# remembered address on its own.
listener.listen(2)
listener.setblocking(False)
async with run_compiled(yaml):
await _serve_home_assistant(listener)
finally:
listener.close()
+2 -2
View File
@@ -1,4 +1,4 @@
"""Integration test for TemplatableStringValue with string lambdas."""
"""Integration test for Home Assistant action lambdas returning strings and numbers."""
from __future__ import annotations
@@ -16,7 +16,7 @@ async def test_api_string_lambda(
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test TemplatableStringValue works with lambdas that return different types."""
"""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
@@ -0,0 +1,140 @@
"""Test that if/else and while branches resume correctly after async actions, stop and parallel runs."""
from __future__ import annotations
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_branch_resume_async(
yaml_config: str,
run_compiled: RunCompiledFunction,
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]]] = []
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)
async with (
run_compiled(yaml_config, line_callback=check_output),
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)
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)
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)
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)
assert count("stop-running=0") == 1
assert count("stopped-branch-should-not-run") == 0
assert count("stopped-after-if-should-not-run") == 0
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)
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)
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]
order = [
"queued-branch-1",
"queued-after-if-1",
"queued-branch-2",
"queued-after-if-2",
"queued-branch-3",
"queued-after-if-3",
]
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 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)
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 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)
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
assert count("else-stopped-by-condition-should-not-run") == 0
assert count("after-else-stopped-by-condition-should-not-run") == 0
assert count("while-stopped-by-condition-should-not-run") == 0
assert count("after-while-stopped-by-condition-should-not-run") == 0
@@ -0,0 +1,131 @@
"""IR/RF transmit completion replies (API 1.18) and the client pacing built on them.
The transmitter is a host-only mock that takes a frame's real duration to
"send" it and reports completion from a scheduler timeout, like the ESP32 RMT
backend. The client is expected to hold the next frame until the device
replies, so the mock never sees an overlapping frame.
"""
from __future__ import annotations
import asyncio
import re
from aioesphomeapi import InfraredInfo, RadioFrequencyInfo
from aioesphomeapi.api_pb2 import InfraredRFTransmitCompleteResponse
import pytest
from .state_utils import find_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
FRAME_COUNT = 5
# 10 marks and 10 spaces of 500 us, sent twice: 20 ms per frame
TIMINGS = [500, -500] * 10
REPEAT = 2
MOCK_EVENT = re.compile(
r"remote_transmitter_mock[^\]]*\]: (TX|Complete|Overlap)\b.*?seq=(\d+)"
)
@pytest.mark.shared_yaml("ir_rf_transmit_complete")
@pytest.mark.asyncio
async def test_ir_rf_transmit_complete_boot(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""The host build with the mock transmitters boots and lists all entities on any client."""
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
assert find_entity(entities, "rf_transmitter_a", RadioFrequencyInfo) is not None
assert find_entity(entities, "rf_transmitter_b", RadioFrequencyInfo) is not None
assert find_entity(entities, "ir_transmitter", InfraredInfo) is not None
@pytest.mark.shared_yaml("ir_rf_transmit_complete")
@pytest.mark.asyncio
async def test_ir_rf_transmit_complete(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Frames are answered once they leave the transmitter, never overlap, and a refused
request is answered at once. Two RF entities share a transmitter and each gets its own
reply; the infrared entity on its own transmitter is answered the same way."""
loop = asyncio.get_running_loop()
events: list[tuple[str, int]] = []
all_sent = loop.create_future()
all_logged = loop.create_future()
def line_callback(line: str) -> None:
if (match := MOCK_EVENT.search(line)) is None:
return
events.append((match.group(1), int(match.group(2))))
if (
match.group(1) == "Complete"
and not all_sent.done()
and sum(kind == "Complete" for kind, _ in events) == FRAME_COUNT
):
all_sent.set_result(None)
# the log reader stops with the device, so wait for the last line before asserting on it
if len(events) == 2 * (FRAME_COUNT + 1) and not all_logged.done():
all_logged.set_result(None)
completions: list[InfraredRFTransmitCompleteResponse] = []
all_replied = loop.create_future()
ir_replied = loop.create_future()
refused = loop.create_future()
def on_complete(msg: InfraredRFTransmitCompleteResponse) -> None:
if not msg.success:
if not refused.done():
refused.set_result(msg)
return
completions.append(msg)
if len(completions) == FRAME_COUNT and not all_replied.done():
all_replied.set_result(None)
if len(completions) == FRAME_COUNT + 1 and not ir_replied.done():
ir_replied.set_result(None)
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
rf = find_entity(entities, "rf_transmitter_a", RadioFrequencyInfo)
rf_b = find_entity(entities, "rf_transmitter_b", RadioFrequencyInfo)
ir = find_entity(entities, "ir_transmitter", InfraredInfo)
assert rf is not None and rf_b is not None, "RF transmitter entities not found"
assert ir is not None, "IR transmitter entity not found"
client._connection.add_message_callback(
on_complete, (InfraredRFTransmitCompleteResponse,)
)
# alternate between the two entities sharing the transmitter
keys = [rf.key if i % 2 == 0 else rf_b.key for i in range(FRAME_COUNT)]
for key in keys:
client.radio_frequency_transmit_raw_timings(
key, 433920000, TIMINGS, repeat_count=REPEAT
)
await asyncio.wait_for(all_replied, timeout=10)
await asyncio.wait_for(all_sent, timeout=10)
# the infrared entity goes through the same path on its own transmitter
client.infrared_rf_transmit_raw_timings(ir.key, 38000, TIMINGS)
await asyncio.wait_for(ir_replied, timeout=10)
await asyncio.wait_for(all_logged, timeout=10)
# A request the entity refuses is answered right away with success false;
# no timings, so the proxy rejects it before it reaches the transmitter
client.radio_frequency_transmit_raw_timings(rf.key, 433920000, [])
refused_msg = await asyncio.wait_for(refused, timeout=10)
assert [msg.key for msg in completions] == [*keys, ir.key]
assert refused_msg.key == rf.key
# The mocks saw one frame at a time: every transmit follows the previous completion
kinds = [kind for kind, _ in events]
assert kinds == ["TX", "Complete"] * (FRAME_COUNT + 1), events
seqs = [seq for _, seq in events]
assert seqs[::2] == seqs[1::2], events
@@ -0,0 +1,84 @@
"""A background scheduler insertion must interrupt the loop's current sleep.
Covers both set_timeout and defer: a zero-delay insert from another thread
takes the separate defer queue on multi-threaded builds.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
import re
from aioesphomeapi import ButtonInfo
import pytest
from .state_utils import require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_scheduler_background_wake(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
yaml_config = yaml_config.replace(
"EXTERNAL_COMPONENT_PATH", external_components_path
)
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))))
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "scheduler-background-wake"
entities, _ = await client.list_entities_services()
for object_id, delay in (
("start_scheduler_timeout", 100),
("start_scheduler_defer", 0),
):
start_button = require_entity(
entities,
object_id,
ButtonInfo,
description=f"{object_id} button",
)
client.button_command(start_button.key)
try:
elapsed, loop_delta = await asyncio.wait_for(
results[delay], timeout=10.0
)
except TimeoutError:
pytest.fail(f"background insert with delay={delay} did not fire")
assert elapsed < 1000, (
f"background insert with delay={delay} should interrupt the "
f"five-second sleep; it fired after {elapsed}ms"
)
assert loop_delta == 0, (
f"scheduler-only wake must not run component loops; observed "
f"loop_delta={loop_delta} for delay={delay}"
)
@@ -0,0 +1,61 @@
"""The merged timeout + throttle_with_priority filter publishes exactly what the two filter chain does."""
from __future__ import annotations
import asyncio
import math
from aioesphomeapi import ButtonInfo, EntityState, SensorState
import pytest
from .state_utils import InitialStateHelper, build_key_to_entity_mapping
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_sensor_timeout_throttle_filter(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
loop = asyncio.get_running_loop()
values: dict[str, list[float]] = {"merged": [], "reference": []}
done = {name: loop.create_future() for name in values}
def on_state(state: EntityState) -> None:
if not isinstance(state, SensorState):
return
name = key_to_sensor.get(state.key)
if name not in values:
return
values[name].append(math.nan if state.missing_state else state.state)
# 99 is published last and republished once by the timeout
if values[name].count(99.0) == 2 and not done[name].done():
done[name].set_result(True)
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
key_to_sensor = build_key_to_entity_mapping(
entities, {"merged": "Merged", "reference": "Reference"}
)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
button = next(
e.key for e in entities if isinstance(e, ButtonInfo) and e.name == "Drive"
)
client.button_command(button)
try:
await asyncio.wait_for(asyncio.gather(*done.values()), timeout=5.0)
except TimeoutError:
pytest.fail(f"Timed out: {values}")
merged, reference = values["merged"], values["reference"]
assert len(merged) == len(reference), (merged, reference)
for got, want in zip(merged, reference, strict=True):
assert (math.isnan(got) and math.isnan(want)) or got == want, (
merged,
reference,
)
assert any(math.isnan(v) for v in merged)
@@ -0,0 +1,87 @@
"""Integration test for status_momentary_warning/error without a name."""
from __future__ import annotations
import asyncio
from aioesphomeapi import BinarySensorState, EntityState
import pytest
from .state_utils import InitialStateHelper
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_status_momentary(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
loop = asyncio.get_running_loop()
warning_logged = loop.create_future()
error_logged = loop.create_future()
def on_log_line(line: str) -> None:
if (
"set Warning flag: probe warning reason" in line
and not warning_logged.done()
):
warning_logged.set_result(True)
if "set Error flag: probe error reason" in line and not error_logged.done():
error_logged.set_result(True)
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
entities, services = await client.list_entities_services()
svc = {s.name: s for s in services}
keys = {e.object_id: e.key for e in entities}
warning_key = keys["probe_warning"]
error_key = keys["probe_error"]
changes: asyncio.Queue[tuple[int, bool, float]] = asyncio.Queue()
def on_state(state: EntityState) -> None:
if isinstance(state, BinarySensorState):
changes.put_nowait((state.key, state.state, loop.time()))
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
assert initial_state_helper.initial_states[warning_key].state is False
assert initial_state_helper.initial_states[error_key].state is False
async def next_change(key: int) -> tuple[bool, float]:
while True:
k, value, when = await asyncio.wait_for(changes.get(), timeout=5)
if k == key:
return value, when
# The flag sets at once and clears after the length
for service, key in (
("momentary_warning", warning_key),
("momentary_error", error_key),
):
start = loop.time()
await client.execute_service(svc[service], {"length": 300})
assert (await next_change(key))[0] is True
value, cleared = await next_change(key)
assert value is False
assert cleared - start >= 0.25
# A second call restarts the timeout instead of adding a second one
start = loop.time()
await client.execute_service(svc["momentary_warning"], {"length": 1000})
assert (await next_change(warning_key))[0] is True
await asyncio.sleep(0.6)
await client.execute_service(svc["momentary_warning"], {"length": 1000})
value, cleared = await next_change(warning_key)
assert value is False
assert cleared - start >= 1.4
# A message replaces "unspecified" in the log line
await client.execute_service(svc["momentary_warning_message"], {})
await asyncio.wait_for(warning_logged, timeout=5)
await client.execute_service(svc["momentary_error_message"], {})
await asyncio.wait_for(error_logged, timeout=5)
@@ -27,13 +27,17 @@ async def test_uart_tcp_bridge(
server_port = unused_tcp_port_factory()
denied_port = unused_tcp_port_factory()
controller_fd, device_fd = os.openpty()
denied_controller_fd, denied_device_fd = os.openpty()
os.set_blocking(controller_fd, False)
# uart's validate_port wants a two segment device path; Linux ptys live at
# /dev/pts/N, so hand the config a /tmp symlink instead.
pty_link = f"/tmp/uart-tcp-pty-{os.getpid()}"
pathlib.Path(pty_link).symlink_to(os.ttyname(device_fd))
denied_pty_link = f"/tmp/uart-tcp-denied-pty-{os.getpid()}"
pathlib.Path(denied_pty_link).symlink_to(os.ttyname(denied_device_fd))
yaml_config = yaml_config.replace("port: 18126", f"port: {server_port}")
yaml_config = yaml_config.replace("port: 18127", f"port: {denied_port}")
yaml_config = yaml_config.replace("DENIED_PTY_PATH", denied_pty_link)
yaml_config = yaml_config.replace("PTY_PATH", pty_link)
lines = LineWaiter()
@@ -117,4 +121,7 @@ async def test_uart_tcp_bridge(
loop.remove_reader(controller_fd)
os.close(controller_fd)
os.close(device_fd)
os.close(denied_controller_fd)
os.close(denied_device_fd)
pathlib.Path(pty_link).unlink()
pathlib.Path(denied_pty_link).unlink()