aioesphomeapi/tests/test_model.py
2024-09-17 09:00:13 +12:00

713 lines
21 KiB
Python

from __future__ import annotations
from dataclasses import dataclass, field
from google.protobuf import message
import pytest
from aioesphomeapi.api_pb2 import (
AlarmControlPanelStateResponse,
BinarySensorStateResponse,
BluetoothGATTCharacteristic,
BluetoothGATTDescriptor,
BluetoothGATTGetServicesResponse,
ClimateStateResponse,
CoverStateResponse,
DateStateResponse,
DateTimeStateResponse,
DeviceInfoResponse,
EventResponse,
FanStateResponse,
HomeassistantServiceMap,
HomeassistantServiceResponse,
LightStateResponse,
ListEntitiesAlarmControlPanelResponse,
ListEntitiesBinarySensorResponse,
ListEntitiesButtonResponse,
ListEntitiesClimateResponse,
ListEntitiesCoverResponse,
ListEntitiesDateResponse,
ListEntitiesDateTimeResponse,
ListEntitiesEventResponse,
ListEntitiesFanResponse,
ListEntitiesLightResponse,
ListEntitiesLockResponse,
ListEntitiesMediaPlayerResponse,
ListEntitiesNumberResponse,
ListEntitiesSelectResponse,
ListEntitiesSensorResponse,
ListEntitiesServicesArgument,
ListEntitiesServicesResponse,
ListEntitiesSwitchResponse,
ListEntitiesTextSensorResponse,
ListEntitiesTimeResponse,
ListEntitiesUpdateResponse,
ListEntitiesValveResponse,
LockStateResponse,
MediaPlayerStateResponse,
MediaPlayerSupportedFormat,
NumberStateResponse,
SelectStateResponse,
SensorStateResponse,
ServiceArgType,
SwitchStateResponse,
TextSensorStateResponse,
TextStateResponse,
TimeStateResponse,
UpdateStateResponse,
ValveStateResponse,
)
from aioesphomeapi.model import (
_TYPE_TO_NAME,
AlarmControlPanelEntityState,
AlarmControlPanelInfo,
APIIntEnum,
APIModelBase,
APIVersion,
BinarySensorInfo,
BinarySensorState,
BluetoothGATTCharacteristic as BluetoothGATTCharacteristicModel,
BluetoothGATTDescriptor as BluetoothGATTDescriptorModel,
BluetoothGATTService as BluetoothGATTServiceModel,
BluetoothGATTServices as BluetoothGATTServicesModel,
BluetoothProxyFeature,
ButtonInfo,
CameraInfo,
ClimateInfo,
ClimatePreset,
ClimateState,
CoverInfo,
CoverState,
DateInfo,
DateState,
DateTimeInfo,
DateTimeState,
DeviceInfo,
Event,
EventInfo,
FanInfo,
FanState,
HomeassistantServiceCall,
LegacyCoverState,
LightColorCapability,
LightInfo,
LightState,
LockEntityState,
LockInfo,
MediaPlayerEntityState,
MediaPlayerInfo,
NumberInfo,
NumberState,
SelectInfo,
SelectState,
SensorInfo,
SensorState,
SirenInfo,
SwitchInfo,
SwitchState,
TextInfo,
TextSensorInfo,
TextSensorState,
TextState,
TimeInfo,
TimeState,
UpdateInfo,
UpdateState,
UserService,
UserServiceArg,
UserServiceArgType,
ValveInfo,
ValveState,
VoiceAssistantConfigurationResponse,
VoiceAssistantFeature,
VoiceAssistantWakeWord,
build_unique_id,
converter_field,
)
class DummyIntEnum(APIIntEnum):
DEFAULT = 0
MY_VAL = 1
@pytest.mark.parametrize(
"input, output",
[
(0, DummyIntEnum.DEFAULT),
(1, DummyIntEnum.MY_VAL),
(2, None),
(-1, None),
(DummyIntEnum.DEFAULT, DummyIntEnum.DEFAULT),
(DummyIntEnum.MY_VAL, DummyIntEnum.MY_VAL),
],
)
def test_api_int_enum_convert(input, output):
v = DummyIntEnum.convert(input)
assert v == output
assert v is None or isinstance(v, DummyIntEnum)
@pytest.mark.parametrize(
"input, output",
[
([], []),
([1], [DummyIntEnum.MY_VAL]),
([0, 1], [DummyIntEnum.DEFAULT, DummyIntEnum.MY_VAL]),
([-1], []),
([0, -1], [DummyIntEnum.DEFAULT]),
([DummyIntEnum.DEFAULT], [DummyIntEnum.DEFAULT]),
],
)
def test_api_int_enum_convert_list(input, output):
v = DummyIntEnum.convert_list(input)
assert v == output
assert all(isinstance(x, DummyIntEnum) for x in v)
@dataclass(frozen=True)
class DummyAPIModel(APIModelBase):
val1: int = 0
val2: DummyIntEnum | None = converter_field(
default=DummyIntEnum.DEFAULT, converter=DummyIntEnum.convert
)
@dataclass(frozen=True)
class ListAPIModel(APIModelBase):
val: list[DummyAPIModel] = field(default_factory=list)
def test_api_model_base_converter():
assert DummyAPIModel().val2 == DummyIntEnum.DEFAULT
assert isinstance(DummyAPIModel().val2, DummyIntEnum)
assert DummyAPIModel(val2=0).val2 == DummyIntEnum.DEFAULT
assert isinstance(DummyAPIModel().val2, DummyIntEnum)
assert DummyAPIModel(val2=-1).val2 is None
def test_api_model_base_to_dict():
assert DummyAPIModel().to_dict() == {
"val1": 0,
"val2": 0,
}
assert DummyAPIModel(val1=-1, val2=1).to_dict() == {
"val1": -1,
"val2": 1,
}
assert ListAPIModel(val=[DummyAPIModel()]).to_dict() == {
"val": [
{
"val1": 0,
"val2": 0,
}
]
}
def test_api_model_base_from_dict():
assert DummyAPIModel.from_dict({}) == DummyAPIModel()
assert DummyAPIModel.from_dict(
{
"val1": -1,
"val2": -1,
}
) == DummyAPIModel(val1=-1, val2=None)
assert DummyAPIModel.from_dict(
{
"val1": -1,
"unknown": 100,
}
) == DummyAPIModel(val1=-1)
assert ListAPIModel.from_dict({}) == ListAPIModel()
assert ListAPIModel.from_dict({"val": []}) == ListAPIModel()
def test_api_model_base_from_pb():
class DummyPB:
def __init__(self, val1=0, val2=0):
self.val1 = val1
self.val2 = val2
assert DummyAPIModel.from_pb(DummyPB()) == DummyAPIModel()
assert DummyAPIModel.from_pb(DummyPB(val1=-1, val2=-1)) == DummyAPIModel(
val1=-1, val2=None
)
def test_api_version_ord():
assert APIVersion(1, 0) == APIVersion(1, 0)
assert APIVersion(1, 0) < APIVersion(1, 1)
assert APIVersion(1, 1) <= APIVersion(1, 1)
assert APIVersion(1, 0) < APIVersion(2, 0)
assert not (APIVersion(2, 1) <= APIVersion(2, 0))
assert APIVersion(2, 1) > APIVersion(2, 0)
@pytest.mark.parametrize(
"model, pb",
[
(DeviceInfo, DeviceInfoResponse),
(BinarySensorInfo, ListEntitiesBinarySensorResponse),
(BinarySensorState, BinarySensorStateResponse),
(CoverInfo, ListEntitiesCoverResponse),
(CoverState, CoverStateResponse),
(FanInfo, ListEntitiesFanResponse),
(FanState, FanStateResponse),
(LightInfo, ListEntitiesLightResponse),
(LightState, LightStateResponse),
(SensorInfo, ListEntitiesSensorResponse),
(SensorState, SensorStateResponse),
(SwitchInfo, ListEntitiesSwitchResponse),
(SwitchState, SwitchStateResponse),
(TextSensorInfo, ListEntitiesTextSensorResponse),
(TextSensorState, TextSensorStateResponse),
(ClimateInfo, ListEntitiesClimateResponse),
(ClimateState, ClimateStateResponse),
(NumberInfo, ListEntitiesNumberResponse),
(NumberState, NumberStateResponse),
(DateInfo, ListEntitiesDateResponse),
(DateState, DateStateResponse),
(SelectInfo, ListEntitiesSelectResponse),
(SelectState, SelectStateResponse),
(HomeassistantServiceCall, HomeassistantServiceResponse),
(UserServiceArg, ListEntitiesServicesArgument),
(UserService, ListEntitiesServicesResponse),
(ButtonInfo, ListEntitiesButtonResponse),
(LockInfo, ListEntitiesLockResponse),
(LockEntityState, LockStateResponse),
(ValveInfo, ListEntitiesValveResponse),
(ValveState, ValveStateResponse),
(MediaPlayerInfo, ListEntitiesMediaPlayerResponse),
(MediaPlayerEntityState, MediaPlayerStateResponse),
(AlarmControlPanelInfo, ListEntitiesAlarmControlPanelResponse),
(AlarmControlPanelEntityState, AlarmControlPanelStateResponse),
(TextState, TextStateResponse),
(TimeInfo, ListEntitiesTimeResponse),
(TimeState, TimeStateResponse),
(DateTimeInfo, ListEntitiesDateTimeResponse),
(DateTimeState, DateTimeStateResponse),
(EventInfo, ListEntitiesEventResponse),
(Event, EventResponse),
(UpdateInfo, ListEntitiesUpdateResponse),
(UpdateState, UpdateStateResponse),
],
)
def test_basic_pb_conversions(model, pb):
assert model.from_pb(pb()) == model()
@pytest.mark.parametrize(
"state, version, out",
[
(CoverState(legacy_state=LegacyCoverState.OPEN), (1, 0), False),
(CoverState(legacy_state=LegacyCoverState.CLOSED), (1, 0), True),
(CoverState(position=1.0), (1, 1), False),
(CoverState(position=0.5), (1, 1), False),
(CoverState(position=0.0), (1, 1), True),
],
)
def test_cover_state_legacy_state(state, version, out):
assert state.is_closed(APIVersion(*version)) is out
@pytest.mark.parametrize(
"state, version, out",
[
(ClimateInfo(legacy_supports_away=False), (1, 4), []),
(
ClimateInfo(legacy_supports_away=True),
(1, 4),
[ClimatePreset.HOME, ClimatePreset.AWAY],
),
(ClimateInfo(supported_presets=[ClimatePreset.HOME]), (1, 4), []),
(ClimateInfo(supported_presets=[], legacy_supports_away=True), (1, 5), []),
(
ClimateInfo(supported_presets=[ClimatePreset.HOME]),
(1, 5),
[ClimatePreset.HOME],
),
],
)
def test_climate_info_supported_presets_compat(state, version, out):
assert state.supported_presets_compat(APIVersion(*version)) == out
@pytest.mark.parametrize(
"state, version, out",
[
(ClimateState(legacy_away=False), (1, 4), ClimatePreset.HOME),
(ClimateState(legacy_away=True), (1, 4), ClimatePreset.AWAY),
(
ClimateState(legacy_away=True, preset=ClimatePreset.HOME),
(1, 4),
ClimatePreset.AWAY,
),
(ClimateState(preset=ClimatePreset.HOME), (1, 5), ClimatePreset.HOME),
(ClimateState(preset=ClimatePreset.BOOST), (1, 5), ClimatePreset.BOOST),
(
ClimateState(legacy_away=True, preset=ClimatePreset.BOOST),
(1, 5),
ClimatePreset.BOOST,
),
],
)
def test_climate_state_preset_compat(state, version, out):
assert state.preset_compat(APIVersion(*version)) == out
def test_homeassistant_service_map_conversion():
assert HomeassistantServiceCall.from_pb(
HomeassistantServiceResponse(
data=[HomeassistantServiceMap(key="key", value="value")]
)
) == HomeassistantServiceCall(data={"key": "value"})
assert HomeassistantServiceCall.from_dict(
{"data": {"key": "value"}}
) == HomeassistantServiceCall(data={"key": "value"})
def test_user_service_conversion():
assert UserService.from_pb(
ListEntitiesServicesResponse(
args=[
ListEntitiesServicesArgument(
name="arg", type=ServiceArgType.SERVICE_ARG_TYPE_INT
)
]
)
) == UserService(args=[UserServiceArg(name="arg", type=UserServiceArgType.INT)])
assert UserService.from_dict({"args": [{"name": "arg", "type": 1}]}) == UserService(
args=[UserServiceArg(name="arg", type=UserServiceArgType.INT)]
)
assert UserService.from_dict(
{"args": [{"name": "arg", "type_": 1}]}
) == UserService(args=[UserServiceArg(name="arg", type=UserServiceArgType.INT)])
@pytest.mark.parametrize(
"model",
[
BinarySensorInfo,
ButtonInfo,
CoverInfo,
FanInfo,
LightInfo,
NumberInfo,
DateInfo,
SelectInfo,
SensorInfo,
SirenInfo,
SwitchInfo,
TextSensorInfo,
CameraInfo,
ClimateInfo,
LockInfo,
ValveInfo,
MediaPlayerInfo,
AlarmControlPanelInfo,
TextInfo,
TimeInfo,
],
)
def test_build_unique_id(model):
obj = model(object_id="id")
assert build_unique_id("mac", obj) == f"mac-{_TYPE_TO_NAME[type(obj)]}-id"
@pytest.mark.parametrize(
("version", "flags"),
[
(1, BluetoothProxyFeature.PASSIVE_SCAN),
(
2,
BluetoothProxyFeature.PASSIVE_SCAN
| BluetoothProxyFeature.ACTIVE_CONNECTIONS,
),
(
3,
BluetoothProxyFeature.PASSIVE_SCAN
| BluetoothProxyFeature.ACTIVE_CONNECTIONS
| BluetoothProxyFeature.REMOTE_CACHING,
),
(
4,
BluetoothProxyFeature.PASSIVE_SCAN
| BluetoothProxyFeature.ACTIVE_CONNECTIONS
| BluetoothProxyFeature.REMOTE_CACHING
| BluetoothProxyFeature.PAIRING,
),
(
5,
BluetoothProxyFeature.PASSIVE_SCAN
| BluetoothProxyFeature.ACTIVE_CONNECTIONS
| BluetoothProxyFeature.REMOTE_CACHING
| BluetoothProxyFeature.PAIRING
| BluetoothProxyFeature.CACHE_CLEARING,
),
],
)
def test_bluetooth_backcompat_for_device_info(
version: int, flags: BluetoothProxyFeature
) -> None:
info = DeviceInfo(
legacy_bluetooth_proxy_version=version, bluetooth_proxy_feature_flags=42
)
assert info.bluetooth_proxy_feature_flags_compat(APIVersion(1, 8)) is flags
assert info.bluetooth_proxy_feature_flags_compat(APIVersion(1, 9)) == 42
# Add va compat test
@pytest.mark.parametrize(
("version", "flags"),
[
(1, VoiceAssistantFeature.VOICE_ASSISTANT),
(2, VoiceAssistantFeature.VOICE_ASSISTANT | VoiceAssistantFeature.SPEAKER),
],
)
def test_voice_assistant_backcompat_for_device_info(
version: int, flags: VoiceAssistantFeature
) -> None:
info = DeviceInfo(
legacy_voice_assistant_version=version, voice_assistant_feature_flags=42
)
assert info.voice_assistant_feature_flags_compat(APIVersion(1, 9)) is flags
assert info.voice_assistant_feature_flags_compat(APIVersion(1, 10)) == 42
@pytest.mark.parametrize(
(
"legacy_supports_brightness",
"legacy_supports_rgb",
"legacy_supports_white_value",
"legacy_supports_color_temperature",
"capability",
),
[
(False, False, False, False, [LightColorCapability.ON_OFF]),
(
True,
False,
False,
False,
[LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS],
),
(
True,
False,
False,
True,
[
LightColorCapability.ON_OFF
| LightColorCapability.BRIGHTNESS
| LightColorCapability.COLOR_TEMPERATURE
],
),
(
True,
True,
False,
False,
[
LightColorCapability.ON_OFF
| LightColorCapability.BRIGHTNESS
| LightColorCapability.RGB
],
),
(
True,
True,
True,
False,
[
LightColorCapability.ON_OFF
| LightColorCapability.BRIGHTNESS
| LightColorCapability.RGB
| LightColorCapability.WHITE
],
),
(
True,
True,
False,
True,
[
LightColorCapability.ON_OFF
| LightColorCapability.BRIGHTNESS
| LightColorCapability.RGB
| LightColorCapability.COLOR_TEMPERATURE
],
),
(
True,
True,
True,
True,
[
LightColorCapability.ON_OFF
| LightColorCapability.BRIGHTNESS
| LightColorCapability.RGB
| LightColorCapability.WHITE
| LightColorCapability.COLOR_TEMPERATURE
],
),
],
)
def test_supported_color_modes_compat(
legacy_supports_brightness: bool,
legacy_supports_rgb: bool,
legacy_supports_white_value: bool,
legacy_supports_color_temperature: bool,
capability: list[LightColorCapability],
) -> None:
info = LightInfo(
legacy_supports_brightness=legacy_supports_brightness,
legacy_supports_rgb=legacy_supports_rgb,
legacy_supports_white_value=legacy_supports_white_value,
legacy_supports_color_temperature=legacy_supports_color_temperature,
supported_color_modes=[42],
)
assert info.supported_color_modes_compat(APIVersion(1, 5)) == capability
assert info.supported_color_modes_compat(APIVersion(1, 9)) == [42]
@pytest.mark.asyncio
async def test_bluetooth_gatt_services_from_dict() -> None:
"""Test bluetooth_gatt_get_services success case."""
services: message.Message = BluetoothGATTGetServicesResponse(
address=1234,
services=[
{
"uuid": [1, 1],
"handle": 1,
"characteristics": [
{
"uuid": [1, 2],
"handle": 2,
"properties": 1,
"descriptors": [
{"uuid": [1, 3], "handle": 3},
],
},
],
}
],
)
services = BluetoothGATTServicesModel.from_pb(services)
assert services.services[0] == BluetoothGATTServiceModel(
uuid=[1, 1],
handle=1,
characteristics=[
BluetoothGATTCharacteristic(
uuid=[1, 2],
handle=2,
properties=1,
descriptors=[BluetoothGATTDescriptor(uuid=[1, 3], handle=3)],
)
],
)
services == BluetoothGATTServicesModel.from_dict(
{
"services": [
{
"uuid": [1, 1],
"handle": 1,
"characteristics": [
{
"uuid": [1, 2],
"handle": 2,
"properties": 1,
"descriptors": [
{"uuid": [1, 3], "handle": 3},
],
},
],
}
]
}
)
assert services.services[0] == BluetoothGATTServiceModel(
uuid=[1, 1],
handle=1,
characteristics=[
BluetoothGATTCharacteristic(
uuid=[1, 2],
handle=2,
properties=1,
descriptors=[BluetoothGATTDescriptor(uuid=[1, 3], handle=3)],
)
],
)
assert BluetoothGATTCharacteristicModel.from_dict(
{
"uuid": [1, 2],
"handle": 2,
"properties": 1,
"descriptors": [],
}
) == BluetoothGATTCharacteristicModel(
uuid=[1, 2],
handle=2,
properties=1,
descriptors=[],
)
assert BluetoothGATTDescriptorModel.from_dict(
{"uuid": [1, 3], "handle": 3},
) == BluetoothGATTDescriptorModel(uuid=[1, 3], handle=3)
def test_media_player_supported_format_convert_list() -> None:
"""Test list conversion for MediaPlayerSupportedFormat."""
assert MediaPlayerInfo.from_dict(
{
"supports_pause": False,
"supported_formats": [
{
"format": "flac",
"sample_rate": 48000,
"num_channels": 2,
"purpose": 1,
"sample_bytes": 2,
}
],
}
) == MediaPlayerInfo(
supports_pause=False,
supported_formats=[
MediaPlayerSupportedFormat(
format="flac",
sample_rate=48000,
num_channels=2,
purpose=1,
sample_bytes=2,
)
],
)
def test_voice_assistant_wake_word_convert_list() -> None:
"""Test list conversion for VoiceAssistantWakeWord."""
assert VoiceAssistantConfigurationResponse.from_dict(
{
"available_wake_words": [
{
"id": 1,
"wake_word": "okay nabu",
"trained_languages": ["en"],
}
],
"active_wake_words": ["1234"],
"max_active_wake_words": 1,
}
) == VoiceAssistantConfigurationResponse(
available_wake_words=[
VoiceAssistantWakeWord(
id=1,
wake_word="okay nabu",
trained_languages=["en"],
)
],
active_wake_words=["1234"],
max_active_wake_words=1,
)