mirror of
https://github.com/esphome/esphome.git
synced 2026-09-30 08:20:21 +00:00
Merge remote-tracking branch 'upstream/dev' into integration
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
esphome:
|
||||
name: api-decode-wire-types-test
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
switch:
|
||||
- platform: template
|
||||
name: "Wire Switch"
|
||||
optimistic: true
|
||||
|
||||
output:
|
||||
- platform: template
|
||||
id: wire_dim
|
||||
type: float
|
||||
write_action:
|
||||
- lambda: ""
|
||||
|
||||
light:
|
||||
- platform: monochromatic
|
||||
name: "Wire Light"
|
||||
output: wire_dim
|
||||
default_transition_length: 0s
|
||||
effects:
|
||||
- pulse:
|
||||
name: Pulse
|
||||
|
||||
text:
|
||||
- platform: template
|
||||
name: "Wire Text"
|
||||
optimistic: true
|
||||
mode: text
|
||||
min_length: 0
|
||||
max_length: 255
|
||||
|
||||
number:
|
||||
- platform: template
|
||||
name: "Wire Number"
|
||||
optimistic: true
|
||||
min_value: -1000
|
||||
max_value: 1000
|
||||
step: 0.5
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: api-empty-message-test
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
switch:
|
||||
- platform: template
|
||||
name: "Empty Message Switch"
|
||||
optimistic: true
|
||||
@@ -0,0 +1,58 @@
|
||||
esphome:
|
||||
name: api-encode-boundaries-test
|
||||
# Top-level area fills DeviceInfoResponse.suggested_area (field 16, a two-byte tag)
|
||||
area:
|
||||
id: kitchen_area
|
||||
name: Kitchen
|
||||
on_boot:
|
||||
- sensor.template.publish:
|
||||
id: zero_then_value
|
||||
state: 0.0
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "Zero Then Value"
|
||||
id: zero_then_value
|
||||
# Negative int32 takes the ten byte varint path
|
||||
accuracy_decimals: -2
|
||||
update_interval: never
|
||||
|
||||
text_sensor:
|
||||
- platform: template
|
||||
name: "Long Text"
|
||||
id: long_text
|
||||
update_interval: never
|
||||
|
||||
number:
|
||||
- platform: template
|
||||
name: "Negative Number"
|
||||
optimistic: true
|
||||
min_value: -1000
|
||||
max_value: 1000
|
||||
step: 0.5
|
||||
initial_value: -123.5
|
||||
|
||||
select:
|
||||
- platform: template
|
||||
name: "Long Option Select"
|
||||
optimistic: true
|
||||
options:
|
||||
- short
|
||||
- "option-with-a-name-long-enough-that-its-length-prefix-needs-two-varint-bytes-when-the-list-entities-response-is-encoded-xxxxxxxxxx"
|
||||
initial_option: short
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Publish Values"
|
||||
on_press:
|
||||
- sensor.template.publish:
|
||||
id: zero_then_value
|
||||
state: 12.5
|
||||
- text_sensor.template.publish:
|
||||
id: long_text
|
||||
state: !lambda return std::string(200, 'y');
|
||||
@@ -0,0 +1,38 @@
|
||||
esphome:
|
||||
name: ltspi-save
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
|
||||
preferences:
|
||||
flash_write_interval: 0s # Flush to disk on every loop
|
||||
|
||||
output:
|
||||
- platform: template
|
||||
id: mono_output
|
||||
type: float
|
||||
write_action:
|
||||
- logger.log:
|
||||
format: "mono_output: %.2f"
|
||||
args: [state]
|
||||
|
||||
light:
|
||||
- platform: monochromatic
|
||||
id: test_mono_light
|
||||
name: "Test Mono Light"
|
||||
output: mono_output
|
||||
restore_mode: RESTORE_DEFAULT_OFF
|
||||
default_transition_length: 1s
|
||||
transition_state_publish_interval: 0.2s
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
id: run_persistence_transition
|
||||
name: "Run Persistence Transition"
|
||||
on_press:
|
||||
- lambda: |-
|
||||
auto call = id(test_mono_light).turn_on();
|
||||
call.set_brightness(1.0f);
|
||||
call.set_transition_length(1000);
|
||||
call.set_save(true);
|
||||
call.perform();
|
||||
@@ -0,0 +1,121 @@
|
||||
esphome:
|
||||
name: ltspi
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
|
||||
output:
|
||||
# Monochromatic output
|
||||
- platform: template
|
||||
id: mono_output
|
||||
type: float
|
||||
write_action:
|
||||
- logger.log:
|
||||
format: "mono_output: %.2f"
|
||||
args: [state]
|
||||
# RGB outputs
|
||||
- platform: template
|
||||
id: rgb_red
|
||||
type: float
|
||||
write_action:
|
||||
- logger.log:
|
||||
format: "rgb_red: %.2f"
|
||||
args: [state]
|
||||
- platform: template
|
||||
id: rgb_green
|
||||
type: float
|
||||
write_action:
|
||||
- logger.log:
|
||||
format: "rgb_green: %.2f"
|
||||
args: [state]
|
||||
- platform: template
|
||||
id: rgb_blue
|
||||
type: float
|
||||
write_action:
|
||||
- logger.log:
|
||||
format: "rgb_blue: %.2f"
|
||||
args: [state]
|
||||
# CWWW outputs (using rgbww light for CT + cold/warm white coverage)
|
||||
- platform: template
|
||||
id: cwww_red
|
||||
type: float
|
||||
write_action:
|
||||
- logger.log:
|
||||
format: "cwww_red: %.2f"
|
||||
args: [state]
|
||||
- platform: template
|
||||
id: cwww_green
|
||||
type: float
|
||||
write_action:
|
||||
- logger.log:
|
||||
format: "cwww_green: %.2f"
|
||||
args: [state]
|
||||
- platform: template
|
||||
id: cwww_blue
|
||||
type: float
|
||||
write_action:
|
||||
- logger.log:
|
||||
format: "cwww_blue: %.2f"
|
||||
args: [state]
|
||||
- platform: template
|
||||
id: cwww_cold_white
|
||||
type: float
|
||||
write_action:
|
||||
- logger.log:
|
||||
format: "cwww_cold_white: %.2f"
|
||||
args: [state]
|
||||
- platform: template
|
||||
id: cwww_warm_white
|
||||
type: float
|
||||
write_action:
|
||||
- logger.log:
|
||||
format: "cwww_warm_white: %.2f"
|
||||
args: [state]
|
||||
- platform: template
|
||||
id: legacy_output
|
||||
type: float
|
||||
write_action:
|
||||
- logger.log:
|
||||
format: "legacy_output: %.2f"
|
||||
args: [state]
|
||||
|
||||
light:
|
||||
# Monochromatic light (brightness only)
|
||||
- platform: monochromatic
|
||||
id: test_mono_light
|
||||
name: "Test Mono Light"
|
||||
output: mono_output
|
||||
default_transition_length: 1s
|
||||
transition_state_publish_interval: 0.2s
|
||||
|
||||
# RGB light
|
||||
- platform: rgb
|
||||
id: test_rgb_light
|
||||
name: "Test RGB Light"
|
||||
red: rgb_red
|
||||
green: rgb_green
|
||||
blue: rgb_blue
|
||||
default_transition_length: 1s
|
||||
transition_state_publish_interval: 0.2s
|
||||
|
||||
# CWWW/CT-capable light
|
||||
- platform: rgbww
|
||||
id: test_cwww_light
|
||||
name: "Test CWWW Light"
|
||||
red: cwww_red
|
||||
green: cwww_green
|
||||
blue: cwww_blue
|
||||
cold_white: cwww_cold_white
|
||||
warm_white: cwww_warm_white
|
||||
cold_white_color_temperature: 6500 K
|
||||
warm_white_color_temperature: 2000 K
|
||||
constant_brightness: true
|
||||
default_transition_length: 1s
|
||||
transition_state_publish_interval: 0.2s
|
||||
|
||||
# No interval: publishes only when a call starts
|
||||
- platform: monochromatic
|
||||
id: test_legacy_light
|
||||
name: "Test Legacy Light"
|
||||
output: legacy_output
|
||||
default_transition_length: 1s
|
||||
@@ -125,11 +125,12 @@ class RawApiClient:
|
||||
await self.read_until_frame(MESSAGE_TYPE_OF[api_pb2.HelloResponse])
|
||||
|
||||
async def send_message(self, msg: message.Message) -> None:
|
||||
await self.send_raw(MESSAGE_TYPE_OF[type(msg)], msg.SerializeToString())
|
||||
|
||||
async def send_raw(self, msg_type: int, payload: bytes) -> None:
|
||||
"""Send a frame with a hand built payload, for shapes protobuf will not serialize."""
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.sock_sendall(
|
||||
self._sock,
|
||||
encode_frame(MESSAGE_TYPE_OF[type(msg)], msg.SerializeToString()),
|
||||
)
|
||||
await loop.sock_sendall(self._sock, encode_frame(msg_type, payload))
|
||||
|
||||
async def read_until_frame(self, msg_type: int, timeout: float = 10.0) -> None:
|
||||
"""Read until at least one frame of msg_type has been received."""
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Awaitable, Callable
|
||||
import logging
|
||||
from typing import TypeVar
|
||||
|
||||
@@ -57,6 +57,58 @@ async def wait_for_state(
|
||||
return await asyncio.wait_for(future, timeout=timeout)
|
||||
|
||||
|
||||
class StateWaiter:
|
||||
"""Route one state subscription to any number of predicate waits."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._waiters: list[
|
||||
tuple[Callable[[EntityState], bool], asyncio.Future[EntityState]]
|
||||
] = []
|
||||
|
||||
def on_state(self, state: EntityState) -> None:
|
||||
for predicate, future in self._waiters:
|
||||
if future.done():
|
||||
continue
|
||||
try:
|
||||
matched = predicate(state)
|
||||
except Exception as exc: # noqa: BLE001 the wait re-raises it, the callback must not die
|
||||
future.set_exception(exc)
|
||||
continue
|
||||
if matched:
|
||||
future.set_result(state)
|
||||
|
||||
def expect(
|
||||
self,
|
||||
predicate: Callable[[EntityState], bool],
|
||||
timeout: float = 5.0,
|
||||
label: str | None = None,
|
||||
) -> Awaitable[EntityState]:
|
||||
"""Arm a wait for the next state matching ``predicate`` and return the awaitable for it.
|
||||
|
||||
The wait is armed here, at call time, so it can be created before the action that produces
|
||||
the state and awaited afterwards; states seen before this call never match.
|
||||
"""
|
||||
entry = (predicate, asyncio.get_running_loop().create_future())
|
||||
self._waiters.append(entry)
|
||||
return self._wait(entry, timeout, label)
|
||||
|
||||
async def _wait(
|
||||
self,
|
||||
entry: tuple[Callable[[EntityState], bool], asyncio.Future[EntityState]],
|
||||
timeout: float,
|
||||
label: str | None,
|
||||
) -> EntityState:
|
||||
try:
|
||||
async with asyncio.timeout(timeout):
|
||||
return await entry[1]
|
||||
except TimeoutError:
|
||||
raise TimeoutError(
|
||||
f"no state matched {label or entry[0]} within {timeout}s"
|
||||
) from None
|
||||
finally:
|
||||
self._waiters.remove(entry)
|
||||
|
||||
|
||||
def find_entity[T: EntityInfo](
|
||||
entities: list[EntityInfo],
|
||||
object_id_substring: str,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""decode_field() must take fields that match their declared wire type, drop the ones that do
|
||||
not, skip unknown fields, and handle two byte tags, varints and length prefixes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import struct
|
||||
|
||||
from aioesphomeapi import (
|
||||
EntityState,
|
||||
LightState,
|
||||
NumberState,
|
||||
SwitchState,
|
||||
TextState,
|
||||
api_pb2,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient, encode_varint
|
||||
from .state_utils import InitialStateHelper, StateWaiter, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
SWITCH_COMMAND = MESSAGE_TYPE_OF[api_pb2.SwitchCommandRequest]
|
||||
WIRE_VARINT, WIRE_LENGTH, WIRE_FIXED32 = 0, 2, 5
|
||||
|
||||
|
||||
def tag(field: int, wire_type: int) -> bytes:
|
||||
return encode_varint((field << 3) | wire_type)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_decode_wire_types(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
unused_tcp_port: int,
|
||||
) -> None:
|
||||
async with (
|
||||
run_compiled(yaml_config),
|
||||
api_client_connected() as client,
|
||||
RawApiClient(unused_tcp_port) as raw,
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
switch = require_entity(entities, "wire_switch")
|
||||
light = require_entity(entities, "wire_light")
|
||||
text = require_entity(entities, "wire_text")
|
||||
number = require_entity(entities, "wire_number")
|
||||
key = tag(1, WIRE_FIXED32) + struct.pack("<I", switch.key)
|
||||
on, off = tag(2, WIRE_VARINT) + b"\x01", tag(2, WIRE_VARINT) + b"\x00"
|
||||
|
||||
switch_states: list[bool] = []
|
||||
waiter = StateWaiter()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, SwitchState) and state.key == switch.key:
|
||||
switch_states.append(state.state)
|
||||
waiter.on_state(state)
|
||||
|
||||
def switch_is(value: bool) -> Callable[[EntityState], bool]:
|
||||
return lambda s: (
|
||||
isinstance(s, SwitchState) and s.key == switch.key and s.state is value
|
||||
)
|
||||
|
||||
def number_is(value: float) -> Callable[[EntityState], bool]:
|
||||
return lambda s: (
|
||||
isinstance(s, NumberState) and s.key == number.key and s.state == value
|
||||
)
|
||||
|
||||
initial = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial.on_state_wrapper(on_state))
|
||||
await initial.wait_for_initial_states()
|
||||
await raw.connect()
|
||||
|
||||
# A well formed command: fixed32 key, varint state
|
||||
await raw.send_raw(SWITCH_COMMAND, key + on)
|
||||
await waiter.expect(switch_is(True))
|
||||
await raw.send_raw(SWITCH_COMMAND, key + off)
|
||||
await waiter.expect(switch_is(False))
|
||||
|
||||
# The same field with the wrong wire type is dropped, and a varint key never matches an
|
||||
# entity; each of these would turn the switch on if the payload were read as a varint
|
||||
seen = len(switch_states)
|
||||
await raw.send_raw(SWITCH_COMMAND, key + tag(2, WIRE_LENGTH) + b"\x01\x01")
|
||||
await raw.send_raw(
|
||||
SWITCH_COMMAND, key + tag(2, WIRE_FIXED32) + b"\x01\x00\x00\x00"
|
||||
)
|
||||
await raw.send_raw(
|
||||
SWITCH_COMMAND, tag(1, WIRE_VARINT) + encode_varint(switch.key) + on
|
||||
)
|
||||
# Ordered on the raw socket itself: this frame cannot be parsed before the bad ones, so
|
||||
# the only switch state since the marker must be the one it produces
|
||||
await raw.send_raw(SWITCH_COMMAND, key + on)
|
||||
await waiter.expect(switch_is(True), label="switch on after wrong wire types")
|
||||
assert switch_states[seen:] == [True]
|
||||
await raw.send_raw(SWITCH_COMMAND, key + off)
|
||||
await waiter.expect(switch_is(False))
|
||||
|
||||
# Truncated bodies stop the decode loop without taking the connection down: a tag with its
|
||||
# continuation bit set and nothing after it, a length prefix past the end of the payload,
|
||||
# and a fixed32 with two of its four bytes
|
||||
seen = len(switch_states)
|
||||
await raw.send_raw(SWITCH_COMMAND, key + b"\x80")
|
||||
await raw.send_raw(SWITCH_COMMAND, key + tag(2, WIRE_LENGTH) + b"\x7f" + b"ab")
|
||||
await raw.send_raw(SWITCH_COMMAND, tag(1, WIRE_FIXED32) + b"\x01\x02")
|
||||
await raw.send_raw(SWITCH_COMMAND, key + on)
|
||||
await waiter.expect(switch_is(True), label="switch on after truncated frames")
|
||||
assert switch_states[seen:] == [True]
|
||||
await raw.send_raw(SWITCH_COMMAND, key + off)
|
||||
await waiter.expect(switch_is(False))
|
||||
|
||||
# A negative number goes through the fixed32 float path of a normal client
|
||||
client.number_command(number.key, -77.5)
|
||||
await waiter.expect(number_is(-77.5))
|
||||
|
||||
# An unknown field ahead of the known ones is skipped; field 200 needs a two byte tag
|
||||
await raw.send_raw(
|
||||
SWITCH_COMMAND, tag(200, WIRE_VARINT) + encode_varint(300) + key + on
|
||||
)
|
||||
await waiter.expect(switch_is(True))
|
||||
|
||||
# Two byte tags (effect fields 18 and 19) and a two byte varint (300 ms transition)
|
||||
client.light_command(
|
||||
light.key, state=True, brightness=0.5, transition_length=0.3, effect="Pulse"
|
||||
)
|
||||
await waiter.expect(
|
||||
lambda s: (
|
||||
isinstance(s, LightState) and s.key == light.key and s.effect == "Pulse"
|
||||
)
|
||||
)
|
||||
client.light_command(light.key, effect="None", state=False)
|
||||
await waiter.expect(
|
||||
lambda s: isinstance(s, LightState) and s.key == light.key and not s.state
|
||||
)
|
||||
|
||||
# A string whose length prefix needs two varint bytes
|
||||
long_text = "w" * 200
|
||||
client.text_command(text.key, long_text)
|
||||
await waiter.expect(
|
||||
lambda s: (
|
||||
isinstance(s, TextState) and s.key == text.key and s.state == long_text
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Messages without fields go through the shared ProtoMessage entry points on both directions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aioesphomeapi import api_pb2
|
||||
import pytest
|
||||
|
||||
from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient
|
||||
from .types import RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_empty_message_roundtrip(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
unused_tcp_port: int,
|
||||
) -> None:
|
||||
async with run_compiled(yaml_config), RawApiClient(unused_tcp_port) as client:
|
||||
await client.connect()
|
||||
|
||||
# Field free request and reply on the plain send path
|
||||
await client.send_message(api_pb2.PingRequest())
|
||||
await client.read_until_frame(MESSAGE_TYPE_OF[api_pb2.PingResponse])
|
||||
|
||||
# Field free request answered by a message with fields, and a list that ends with
|
||||
# the field free ListEntitiesDoneResponse through the batching path
|
||||
await client.send_message(api_pb2.DeviceInfoRequest())
|
||||
await client.read_until_frame(MESSAGE_TYPE_OF[api_pb2.DeviceInfoResponse])
|
||||
await client.send_message(api_pb2.ListEntitiesRequest())
|
||||
await client.read_until_frame(MESSAGE_TYPE_OF[api_pb2.ListEntitiesDoneResponse])
|
||||
assert (
|
||||
client.frame_counts[MESSAGE_TYPE_OF[api_pb2.ListEntitiesSwitchResponse]]
|
||||
== 1
|
||||
)
|
||||
|
||||
await client.send_message(api_pb2.DisconnectRequest())
|
||||
await client.read_until_frame(MESSAGE_TYPE_OF[api_pb2.DisconnectResponse])
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Encode paths at their branch boundaries: zero skipped float, fixed32 state, negative int32,
|
||||
length prefixes of two varint bytes and two byte field tags."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from aioesphomeapi import (
|
||||
NumberState,
|
||||
SelectInfo,
|
||||
SensorInfo,
|
||||
SensorState,
|
||||
TextSensorState,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from .state_utils import InitialStateHelper, StateWaiter, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
LONG_OPTION = (
|
||||
"option-with-a-name-long-enough-that-its-length-prefix-needs-two-varint-bytes-"
|
||||
"when-the-list-entities-response-is-encoded-xxxxxxxxxx"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_encode_boundaries(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
device_info, (entities, _) = await asyncio.gather(
|
||||
client.device_info(), client.list_entities_services()
|
||||
)
|
||||
assert device_info.suggested_area == "Kitchen"
|
||||
|
||||
sensor = require_entity(entities, "zero_then_value", SensorInfo)
|
||||
assert sensor.accuracy_decimals == -2
|
||||
select = require_entity(entities, "long_option_select", SelectInfo)
|
||||
assert len(LONG_OPTION) >= 128
|
||||
assert select.options == ["short", LONG_OPTION]
|
||||
text = require_entity(entities, "long_text")
|
||||
number = require_entity(entities, "negative_number")
|
||||
button = require_entity(entities, "publish_values")
|
||||
|
||||
initial = InitialStateHelper(entities)
|
||||
waiter = StateWaiter()
|
||||
client.subscribe_states(initial.on_state_wrapper(waiter.on_state))
|
||||
await initial.wait_for_initial_states()
|
||||
|
||||
# A float of exactly zero is skipped on the wire and must still read as 0.0, not missing
|
||||
first = initial.initial_states[sensor.key]
|
||||
assert isinstance(first, SensorState)
|
||||
assert first.state == 0.0 and not first.missing_state
|
||||
first_number = initial.initial_states[number.key]
|
||||
assert isinstance(first_number, NumberState)
|
||||
assert first_number.state == -123.5
|
||||
|
||||
# Arm both waits before the press so no ordering of the replies can slip past them
|
||||
sensor_seen = waiter.expect(
|
||||
lambda s: (
|
||||
isinstance(s, SensorState) and s.key == sensor.key and s.state == 12.5
|
||||
),
|
||||
label="sensor 12.5",
|
||||
)
|
||||
text_seen = waiter.expect(
|
||||
lambda s: (
|
||||
isinstance(s, TextSensorState)
|
||||
and s.key == text.key
|
||||
and s.state == "y" * 200
|
||||
),
|
||||
label="text 200 x y",
|
||||
)
|
||||
client.button_command(button.key)
|
||||
await asyncio.gather(sensor_seen, text_seen)
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Integration tests for the light transition_state_publish_interval option."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from itertools import pairwise
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
ButtonInfo,
|
||||
EntityInfo,
|
||||
EntityState,
|
||||
LightInfo,
|
||||
LightState,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from .state_utils import InitialStateHelper, require_entity, wait_for_state
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
Timeline = list[tuple[float, LightState]]
|
||||
DonePredicate = Callable[[float, LightState], bool]
|
||||
|
||||
|
||||
class _Recorder:
|
||||
"""Records the states one light publishes while an action runs.
|
||||
|
||||
``run`` fires ``action`` and returns the (elapsed, state) timeline once a
|
||||
published state satisfies ``done``, after ``settle`` more seconds so late
|
||||
publishes still land in the timeline.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._event = asyncio.Event()
|
||||
self._key = 0
|
||||
self._start = 0.0
|
||||
self._done: DonePredicate | None = None
|
||||
self.timeline: Timeline = []
|
||||
|
||||
def on_state(self, state: EntityState) -> None:
|
||||
if (
|
||||
self._done is None
|
||||
or not isinstance(state, LightState)
|
||||
or state.key != self._key
|
||||
):
|
||||
return
|
||||
elapsed = self._loop.time() - self._start
|
||||
self.timeline.append((elapsed, state))
|
||||
if self._done(elapsed, state):
|
||||
self._event.set()
|
||||
|
||||
async def run(
|
||||
self,
|
||||
key: int,
|
||||
action: Callable[[], None],
|
||||
done: DonePredicate,
|
||||
settle: float = 0.0,
|
||||
) -> Timeline:
|
||||
self.timeline = []
|
||||
self._key = key
|
||||
self._done = done
|
||||
self._event.clear()
|
||||
self._start = self._loop.time()
|
||||
action()
|
||||
async with asyncio.timeout(5):
|
||||
await self._event.wait()
|
||||
if settle:
|
||||
await asyncio.sleep(settle)
|
||||
self._done = None
|
||||
return self.timeline
|
||||
|
||||
|
||||
async def _subscribe(client: APIClient) -> tuple[list[EntityInfo], _Recorder]:
|
||||
"""List entities and attach a recorder once the initial states have arrived."""
|
||||
entities, _ = await client.list_entities_services()
|
||||
helper = InitialStateHelper(entities)
|
||||
recorder = _Recorder()
|
||||
client.subscribe_states(helper.on_state_wrapper(recorder.on_state))
|
||||
await helper.wait_for_initial_states()
|
||||
return entities, recorder
|
||||
|
||||
|
||||
def _visible_brightness(state: LightState) -> float:
|
||||
"""Brightness as a remote sees it: an off light counts as zero."""
|
||||
return state.brightness if state.state else 0.0
|
||||
|
||||
|
||||
def _brightness_is(value: float) -> DonePredicate:
|
||||
return lambda _elapsed, state: (
|
||||
_visible_brightness(state) == pytest.approx(value, abs=0.01)
|
||||
)
|
||||
|
||||
|
||||
def _brightness_values(timeline: Timeline) -> list[float]:
|
||||
return [_visible_brightness(state) for _, state in timeline]
|
||||
|
||||
|
||||
def _assert_ramp(timeline: Timeline, target: float) -> None:
|
||||
"""Several states were published and the last one is ``target`` after ~1 s."""
|
||||
values = _brightness_values(timeline)
|
||||
assert len(values) >= 3, values
|
||||
assert values[-1] == pytest.approx(target, abs=0.05), values
|
||||
assert timeline[-1][0] >= 0.8, timeline[-1][0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.shared_yaml("light_transition_state_publish_interval")
|
||||
async def test_light_without_interval_publishes_target_once(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""A light without the option publishes the target once, up front."""
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
entities, recorder = await _subscribe(client)
|
||||
legacy = require_entity(entities, "test_legacy_light", LightInfo)
|
||||
|
||||
timeline = await recorder.run(
|
||||
legacy.key,
|
||||
lambda: client.light_command(
|
||||
key=legacy.key, state=True, brightness=0.8, transition_length=1.0
|
||||
),
|
||||
_brightness_is(0.8),
|
||||
settle=1.3,
|
||||
)
|
||||
|
||||
assert _brightness_values(timeline) == [pytest.approx(0.8)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.shared_yaml("light_transition_state_publish_interval")
|
||||
async def test_transition_interval_nonzero_emits_intermediate_updates(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Turning on over 1 s with a 200 ms interval publishes a rising ramp."""
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
entities, recorder = await _subscribe(client)
|
||||
mono = require_entity(entities, "test_mono_light", LightInfo)
|
||||
|
||||
timeline = await recorder.run(
|
||||
mono.key,
|
||||
lambda: client.light_command(
|
||||
key=mono.key, state=True, brightness=1.0, transition_length=1.0
|
||||
),
|
||||
_brightness_is(1.0),
|
||||
)
|
||||
|
||||
values = _brightness_values(timeline)
|
||||
assert len(values) >= 5, values
|
||||
assert values[0] == pytest.approx(0.0, abs=0.1), values
|
||||
assert values[-1] == pytest.approx(1.0, abs=0.05), values
|
||||
assert len([v for v in values if 0.1 < v < 0.9]) >= 2, values
|
||||
assert all(b >= a - 0.1 for a, b in pairwise(values)), values
|
||||
assert timeline[-1][0] >= 0.8, timeline[-1][0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.shared_yaml("light_transition_state_publish_interval")
|
||||
async def test_light_transition_state_publish_interval(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Default-length, RGB and color temperature transitions publish on the interval."""
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
entities, recorder = await _subscribe(client)
|
||||
mono = require_entity(entities, "test_mono_light", LightInfo)
|
||||
rgb = require_entity(entities, "test_rgb_light", LightInfo)
|
||||
cwww = require_entity(entities, "test_cwww_light", LightInfo)
|
||||
|
||||
# No transition_length: default_transition_length (1 s) applies
|
||||
timeline = await recorder.run(
|
||||
mono.key,
|
||||
lambda: client.light_command(key=mono.key, state=True, brightness=1.0),
|
||||
_brightness_is(1.0),
|
||||
)
|
||||
_assert_ramp(timeline, 1.0)
|
||||
|
||||
timeline = await recorder.run(
|
||||
rgb.key,
|
||||
lambda: client.light_command(
|
||||
key=rgb.key,
|
||||
state=True,
|
||||
brightness=1.0,
|
||||
rgb=(1.0, 0.0, 0.0),
|
||||
transition_length=1.0,
|
||||
),
|
||||
_brightness_is(1.0),
|
||||
)
|
||||
_assert_ramp(timeline, 1.0)
|
||||
|
||||
# Start at the cold end instantly so the fade to 300 mireds has a gradient
|
||||
await recorder.run(
|
||||
cwww.key,
|
||||
lambda: client.light_command(
|
||||
key=cwww.key,
|
||||
state=True,
|
||||
brightness=1.0,
|
||||
color_temperature=153.0,
|
||||
transition_length=0.0,
|
||||
),
|
||||
lambda _t, s: (
|
||||
s.state and s.color_temperature == pytest.approx(153.0, abs=1.0)
|
||||
),
|
||||
)
|
||||
timeline = await recorder.run(
|
||||
cwww.key,
|
||||
lambda: client.light_command(
|
||||
key=cwww.key,
|
||||
state=True,
|
||||
brightness=1.0,
|
||||
color_temperature=300.0,
|
||||
transition_length=1.0,
|
||||
),
|
||||
lambda _t, s: s.color_temperature == pytest.approx(300.0, abs=1.0),
|
||||
)
|
||||
ct_values = [state.color_temperature for _, state in timeline]
|
||||
assert len(ct_values) >= 3, ct_values
|
||||
assert min(ct_values) < max(ct_values), ct_values
|
||||
assert timeline[-1][0] >= 0.8, timeline[-1][0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.shared_yaml("light_transition_state_publish_interval")
|
||||
async def test_flash_interval_emits_intermediate_updates(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""A flash publishes its value on the interval and ends back where it started."""
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
entities, recorder = await _subscribe(client)
|
||||
mono = require_entity(entities, "test_mono_light", LightInfo)
|
||||
|
||||
await recorder.run(
|
||||
mono.key,
|
||||
lambda: client.light_command(
|
||||
key=mono.key, state=True, brightness=0.4, transition_length=0.0
|
||||
),
|
||||
_brightness_is(0.4),
|
||||
)
|
||||
timeline = await recorder.run(
|
||||
mono.key,
|
||||
lambda: client.light_command(
|
||||
key=mono.key, brightness=1.0, flash_length=1.0
|
||||
),
|
||||
lambda t, s: (
|
||||
t > 0.5 and _visible_brightness(s) == pytest.approx(0.4, abs=0.01)
|
||||
),
|
||||
)
|
||||
|
||||
values = _brightness_values(timeline)
|
||||
assert values.count(pytest.approx(1.0, abs=0.01)) >= 3, values
|
||||
assert values[-1] == pytest.approx(0.4, abs=0.05), values
|
||||
assert timeline[-1][0] >= 0.8, timeline[-1][0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.shared_yaml("light_transition_interval_save")
|
||||
async def test_transition_interval_persistence_semantics(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""A save=true interval transition saves its target, not a sample, and restores it."""
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
entities, recorder = await _subscribe(client)
|
||||
mono = require_entity(entities, "test_mono_light", LightInfo)
|
||||
button = require_entity(entities, "run_persistence_transition", ButtonInfo)
|
||||
|
||||
timeline = await recorder.run(
|
||||
mono.key,
|
||||
lambda: client.button_command(button.key),
|
||||
_brightness_is(1.0),
|
||||
)
|
||||
_assert_ramp(timeline, 1.0)
|
||||
|
||||
# The restored light fades up from off over its default transition, so wait for the end
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
entities, _ = await client.list_entities_services()
|
||||
mono = require_entity(entities, "test_mono_light", LightInfo)
|
||||
await wait_for_state(
|
||||
client,
|
||||
lambda s: (
|
||||
isinstance(s, LightState)
|
||||
and s.key == mono.key
|
||||
and _visible_brightness(s) == pytest.approx(1.0, abs=0.01)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.shared_yaml("light_transition_state_publish_interval")
|
||||
async def test_partial_call_during_interval_transition_keeps_target(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""A colour temperature change mid-fade keeps the fade's brightness target."""
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
entities, recorder = await _subscribe(client)
|
||||
cwww = require_entity(entities, "test_cwww_light", LightInfo)
|
||||
|
||||
# Let the fade from off run for a couple of samples before changing only the colour
|
||||
await recorder.run(
|
||||
cwww.key,
|
||||
lambda: client.light_command(
|
||||
key=cwww.key,
|
||||
state=True,
|
||||
brightness=1.0,
|
||||
color_temperature=153.0,
|
||||
transition_length=1.0,
|
||||
),
|
||||
lambda t, _s: t >= 0.3,
|
||||
)
|
||||
timeline = await recorder.run(
|
||||
cwww.key,
|
||||
lambda: client.light_command(
|
||||
key=cwww.key, color_temperature=300.0, transition_length=1.0
|
||||
),
|
||||
lambda _t, s: s.color_temperature == pytest.approx(300.0, abs=1.0),
|
||||
)
|
||||
|
||||
assert _brightness_values(timeline)[-1] == pytest.approx(1.0, abs=0.01)
|
||||
Reference in New Issue
Block a user