mirror of
https://github.com/esphome/aioesphomeapi.git
synced 2024-11-28 13:05:12 +01:00
Add pytest unit testing (#64)
This commit is contained in:
parent
06a254c82e
commit
3a7a47f649
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@ -22,6 +22,8 @@ jobs:
|
|||||||
name: Check import order with isort
|
name: Check import order with isort
|
||||||
- id: mypy
|
- id: mypy
|
||||||
name: Check typing with mypy
|
name: Check typing with mypy
|
||||||
|
- id: pytest
|
||||||
|
name: Run tests with pytest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
@ -52,6 +54,7 @@ jobs:
|
|||||||
echo "::add-matcher::.github/workflows/matchers/pylint.json"
|
echo "::add-matcher::.github/workflows/matchers/pylint.json"
|
||||||
echo "::add-matcher::.github/workflows/matchers/isort.json"
|
echo "::add-matcher::.github/workflows/matchers/isort.json"
|
||||||
echo "::add-matcher::.github/workflows/matchers/mypy.json"
|
echo "::add-matcher::.github/workflows/matchers/mypy.json"
|
||||||
|
echo "::add-matcher::.github/workflows/matchers/pytest.json"
|
||||||
|
|
||||||
- run: flake8 aioesphomeapi
|
- run: flake8 aioesphomeapi
|
||||||
if: ${{ matrix.id == 'flake8' }}
|
if: ${{ matrix.id == 'flake8' }}
|
||||||
@ -63,3 +66,5 @@ jobs:
|
|||||||
if: ${{ matrix.id == 'isort' }}
|
if: ${{ matrix.id == 'isort' }}
|
||||||
- run: mypy aioesphomeapi
|
- run: mypy aioesphomeapi
|
||||||
if: ${{ matrix.id == 'mypy' }}
|
if: ${{ matrix.id == 'mypy' }}
|
||||||
|
- run: pytest -vv --tb=native tests
|
||||||
|
if: ${{ matrix.id == 'pytest' }}
|
||||||
|
20
.github/workflows/matchers/pytest.json
vendored
Normal file
20
.github/workflows/matchers/pytest.json
vendored
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"problemMatcher": [
|
||||||
|
{
|
||||||
|
"owner": "pytest",
|
||||||
|
"fileLocation": "absolute",
|
||||||
|
"pattern": [
|
||||||
|
{
|
||||||
|
"regexp": "^\\s+File \"(.*)\", line (\\d+), in (.*)$",
|
||||||
|
"file": 1,
|
||||||
|
"line": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"regexp": "^\\s+(.*)$",
|
||||||
|
"message": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
10
README.rst
10
README.rst
@ -104,9 +104,17 @@ For development is recommended to use a Python virtual environment (``venv``).
|
|||||||
|
|
||||||
.. code:: bash
|
.. code:: bash
|
||||||
|
|
||||||
|
# Setup virtualenv (optional)
|
||||||
$ python3 -m venv .
|
$ python3 -m venv .
|
||||||
$ source bin/activate
|
$ source bin/activate
|
||||||
$ python3 setup.py develop
|
# Install aioesphomeapi and development depenencies
|
||||||
|
$ pip3 install -e .
|
||||||
|
$ pip3 install -r requirements_test.txt
|
||||||
|
|
||||||
|
# Run linters & test
|
||||||
|
$ script/lint
|
||||||
|
# Update protobuf _pb2.py definitions (requires a protobuf compiler installation)
|
||||||
|
$ script/gen-protoc
|
||||||
|
|
||||||
License
|
License
|
||||||
-------
|
-------
|
||||||
|
@ -375,13 +375,15 @@ class APIClient:
|
|||||||
if stop:
|
if stop:
|
||||||
req.stop = stop
|
req.stop = stop
|
||||||
else:
|
else:
|
||||||
req.has_legacy_command = True
|
|
||||||
if stop:
|
if stop:
|
||||||
req.legacy_command = LegacyCoverCommand.STOP
|
req.legacy_command = LegacyCoverCommand.STOP
|
||||||
|
req.has_legacy_command = True
|
||||||
elif position == 1.0:
|
elif position == 1.0:
|
||||||
req.legacy_command = LegacyCoverCommand.OPEN
|
req.legacy_command = LegacyCoverCommand.OPEN
|
||||||
else:
|
req.has_legacy_command = True
|
||||||
|
elif position == 0.0:
|
||||||
req.legacy_command = LegacyCoverCommand.CLOSE
|
req.legacy_command = LegacyCoverCommand.CLOSE
|
||||||
|
req.has_legacy_command = True
|
||||||
assert self._connection is not None
|
assert self._connection is not None
|
||||||
await self._connection.send_message(req)
|
await self._connection.send_message(req)
|
||||||
|
|
||||||
|
@ -7,6 +7,8 @@ from typing import Any, Awaitable, Callable, List, Optional, cast
|
|||||||
|
|
||||||
from google.protobuf import message
|
from google.protobuf import message
|
||||||
|
|
||||||
|
import aioesphomeapi.host_resolver as hr
|
||||||
|
|
||||||
from .api_pb2 import ( # type: ignore
|
from .api_pb2 import ( # type: ignore
|
||||||
ConnectRequest,
|
ConnectRequest,
|
||||||
ConnectResponse,
|
ConnectResponse,
|
||||||
@ -20,7 +22,6 @@ from .api_pb2 import ( # type: ignore
|
|||||||
PingResponse,
|
PingResponse,
|
||||||
)
|
)
|
||||||
from .core import MESSAGE_TYPE_TO_PROTO, APIConnectionError
|
from .core import MESSAGE_TYPE_TO_PROTO, APIConnectionError
|
||||||
from .host_resolver import ZeroconfInstanceType, async_resolve_host
|
|
||||||
from .model import APIVersion
|
from .model import APIVersion
|
||||||
from .util import bytes_to_varuint, varuint_to_bytes
|
from .util import bytes_to_varuint, varuint_to_bytes
|
||||||
|
|
||||||
@ -35,7 +36,7 @@ class ConnectionParams:
|
|||||||
password: Optional[str]
|
password: Optional[str]
|
||||||
client_info: str
|
client_info: str
|
||||||
keepalive: float
|
keepalive: float
|
||||||
zeroconf_instance: ZeroconfInstanceType
|
zeroconf_instance: hr.ZeroconfInstanceType
|
||||||
|
|
||||||
|
|
||||||
class APIConnection:
|
class APIConnection:
|
||||||
@ -112,7 +113,7 @@ class APIConnection:
|
|||||||
raise APIConnectionError(f"Already connected for {self.log_name}!")
|
raise APIConnectionError(f"Already connected for {self.log_name}!")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
coro = async_resolve_host(
|
coro = hr.async_resolve_host(
|
||||||
self._params.eventloop,
|
self._params.eventloop,
|
||||||
self._params.address,
|
self._params.address,
|
||||||
self._params.port,
|
self._params.port,
|
||||||
|
@ -4,12 +4,11 @@ from dataclasses import dataclass
|
|||||||
from typing import List, Tuple, Union, cast
|
from typing import List, Tuple, Union, cast
|
||||||
|
|
||||||
import zeroconf
|
import zeroconf
|
||||||
from zeroconf import Zeroconf
|
import zeroconf.asyncio
|
||||||
from zeroconf.asyncio import AsyncZeroconf
|
|
||||||
|
|
||||||
from .core import APIConnectionError
|
from .core import APIConnectionError
|
||||||
|
|
||||||
ZeroconfInstanceType = Union[Zeroconf, AsyncZeroconf, None]
|
ZeroconfInstanceType = Union[zeroconf.Zeroconf, zeroconf.asyncio.AsyncZeroconf, None]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -49,18 +48,18 @@ async def _async_resolve_host_zeroconf( # pylint: disable=too-many-branches
|
|||||||
# Use or create zeroconf instance, ensure it's an AsyncZeroconf
|
# Use or create zeroconf instance, ensure it's an AsyncZeroconf
|
||||||
if zeroconf_instance is None:
|
if zeroconf_instance is None:
|
||||||
try:
|
try:
|
||||||
zc = AsyncZeroconf()
|
zc = zeroconf.asyncio.AsyncZeroconf()
|
||||||
except Exception:
|
except Exception:
|
||||||
raise APIConnectionError(
|
raise APIConnectionError(
|
||||||
"Cannot start mDNS sockets, is this a docker container without "
|
"Cannot start mDNS sockets, is this a docker container without "
|
||||||
"host network mode?"
|
"host network mode?"
|
||||||
)
|
)
|
||||||
do_close = True
|
do_close = True
|
||||||
elif isinstance(zeroconf_instance, AsyncZeroconf):
|
elif isinstance(zeroconf_instance, zeroconf.asyncio.AsyncZeroconf):
|
||||||
zc = zeroconf_instance
|
zc = zeroconf_instance
|
||||||
do_close = False
|
do_close = False
|
||||||
elif isinstance(zeroconf_instance, Zeroconf):
|
elif isinstance(zeroconf_instance, zeroconf.Zeroconf):
|
||||||
zc = AsyncZeroconf(zc=zeroconf_instance)
|
zc = zeroconf.asyncio.AsyncZeroconf(zc=zeroconf_instance)
|
||||||
do_close = False
|
do_close = False
|
||||||
else:
|
else:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
|
@ -168,9 +168,9 @@ class CoverState(EntityState):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def is_closed(self, api_version: APIVersion) -> bool:
|
def is_closed(self, api_version: APIVersion) -> bool:
|
||||||
if api_version >= APIVersion(1, 1):
|
if api_version < APIVersion(1, 1):
|
||||||
return self.position == 0.0
|
|
||||||
return self.legacy_state == LegacyCoverState.CLOSED
|
return self.legacy_state == LegacyCoverState.CLOSED
|
||||||
|
return self.position == 0.0
|
||||||
|
|
||||||
|
|
||||||
# ==================== FAN ====================
|
# ==================== FAN ====================
|
||||||
@ -398,7 +398,7 @@ class ClimateState(EntityState):
|
|||||||
)
|
)
|
||||||
custom_fan_mode: str = ""
|
custom_fan_mode: str = ""
|
||||||
preset: Optional[ClimatePreset] = converter_field(
|
preset: Optional[ClimatePreset] = converter_field(
|
||||||
default=ClimatePreset.HOME, converter=ClimatePreset.convert
|
default=ClimatePreset.NONE, converter=ClimatePreset.convert
|
||||||
)
|
)
|
||||||
custom_preset: str = ""
|
custom_preset: str = ""
|
||||||
|
|
||||||
|
@ -4,3 +4,6 @@ flake8==3.9.2
|
|||||||
isort==5.9.2
|
isort==5.9.2
|
||||||
mypy==0.910
|
mypy==0.910
|
||||||
types-protobuf==3.17.0
|
types-protobuf==3.17.0
|
||||||
|
pytest>=6.2.4,<7
|
||||||
|
pytest-asyncio>=0.15.1,<1
|
||||||
|
mock>=4.0.3,<5
|
||||||
|
@ -3,8 +3,9 @@
|
|||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
|
|
||||||
black --safe aioesphomeapi
|
black --safe aioesphomeapi tests
|
||||||
pylint aioesphomeapi
|
pylint aioesphomeapi
|
||||||
flake8 aioesphomeapi
|
flake8 aioesphomeapi
|
||||||
isort aioesphomeapi
|
isort aioesphomeapi tests
|
||||||
mypy aioesphomeapi
|
mypy aioesphomeapi
|
||||||
|
pytest tests
|
||||||
|
3
setup.py
3
setup.py
@ -42,9 +42,10 @@ setup(
|
|||||||
description='Python API for interacting with ESPHome devices.',
|
description='Python API for interacting with ESPHome devices.',
|
||||||
long_description=long_description,
|
long_description=long_description,
|
||||||
license=PROJECT_LICENSE,
|
license=PROJECT_LICENSE,
|
||||||
packages=find_packages(),
|
packages=find_packages("aioesphomeapi"),
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
zip_safe=False,
|
zip_safe=False,
|
||||||
install_requires=REQUIRES,
|
install_requires=REQUIRES,
|
||||||
python_requires='>=3.7',
|
python_requires='>=3.7',
|
||||||
|
test_suite="tests",
|
||||||
)
|
)
|
||||||
|
474
tests/test_client.py
Normal file
474
tests/test_client.py
Normal file
@ -0,0 +1,474 @@
|
|||||||
|
from sys import version
|
||||||
|
from mock import MagicMock, AsyncMock, call, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from aioesphomeapi.api_pb2 import (
|
||||||
|
BinarySensorStateResponse,
|
||||||
|
CameraImageRequest,
|
||||||
|
CameraImageResponse,
|
||||||
|
ClimateCommandRequest,
|
||||||
|
CoverCommandRequest,
|
||||||
|
ExecuteServiceArgument,
|
||||||
|
ExecuteServiceRequest,
|
||||||
|
FanCommandRequest,
|
||||||
|
LightCommandRequest,
|
||||||
|
ListEntitiesBinarySensorResponse,
|
||||||
|
ListEntitiesDoneResponse,
|
||||||
|
ListEntitiesServicesResponse,
|
||||||
|
NumberCommandRequest,
|
||||||
|
SwitchCommandRequest,
|
||||||
|
)
|
||||||
|
from aioesphomeapi.client import APIClient
|
||||||
|
from aioesphomeapi.model import (
|
||||||
|
APIVersion,
|
||||||
|
BinarySensorInfo,
|
||||||
|
BinarySensorState,
|
||||||
|
CameraState,
|
||||||
|
ClimateFanMode,
|
||||||
|
ClimateMode,
|
||||||
|
ClimatePreset,
|
||||||
|
ClimateSwingMode,
|
||||||
|
FanDirection,
|
||||||
|
FanSpeed,
|
||||||
|
LegacyCoverCommand,
|
||||||
|
UserService,
|
||||||
|
UserServiceArg,
|
||||||
|
UserServiceArgType,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_client(event_loop):
|
||||||
|
client = APIClient(
|
||||||
|
eventloop=event_loop,
|
||||||
|
address="fake.address",
|
||||||
|
port=6052,
|
||||||
|
password=None,
|
||||||
|
)
|
||||||
|
with patch.object(client, "_connection") as conn:
|
||||||
|
conn.is_connected = True
|
||||||
|
conn.is_authenticated = True
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
def patch_response_complex(client: APIClient, messages):
|
||||||
|
async def patched(req, app, stop, timeout=5.0):
|
||||||
|
resp = []
|
||||||
|
for msg in messages:
|
||||||
|
if app(msg):
|
||||||
|
resp.append(msg)
|
||||||
|
if stop(msg):
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise ValueError("Response never stopped")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
client._connection.send_message_await_response_complex = patched
|
||||||
|
|
||||||
|
|
||||||
|
def patch_response_callback(client: APIClient):
|
||||||
|
on_message = None
|
||||||
|
|
||||||
|
async def patched(req, callback):
|
||||||
|
nonlocal on_message
|
||||||
|
on_message = callback
|
||||||
|
|
||||||
|
client._connection.send_message_callback_response = patched
|
||||||
|
|
||||||
|
async def ret(send):
|
||||||
|
on_message(send)
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def patch_send(client: APIClient):
|
||||||
|
send = client._connection.send_message = AsyncMock()
|
||||||
|
return send
|
||||||
|
|
||||||
|
|
||||||
|
def patch_api_version(client: APIClient, version: APIVersion):
|
||||||
|
client._connection.api_version = version
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"input, output",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
[ListEntitiesBinarySensorResponse(), ListEntitiesDoneResponse()],
|
||||||
|
([BinarySensorInfo()], []),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[ListEntitiesServicesResponse(), ListEntitiesDoneResponse()],
|
||||||
|
([], [UserService()]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_list_entities(auth_client, input, output):
|
||||||
|
patch_response_complex(auth_client, input)
|
||||||
|
resp = await auth_client.list_entities_services()
|
||||||
|
assert resp == output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subscribe_states(auth_client):
|
||||||
|
send = patch_response_callback(auth_client)
|
||||||
|
on_state = MagicMock()
|
||||||
|
await auth_client.subscribe_states(on_state)
|
||||||
|
on_state.assert_not_called()
|
||||||
|
|
||||||
|
await send(BinarySensorStateResponse())
|
||||||
|
on_state.assert_called_once_with(BinarySensorState())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subscribe_states_camera(auth_client):
|
||||||
|
send = patch_response_callback(auth_client)
|
||||||
|
on_state = MagicMock()
|
||||||
|
await auth_client.subscribe_states(on_state)
|
||||||
|
await send(CameraImageResponse(key=1, data=b"asdf"))
|
||||||
|
on_state.assert_not_called()
|
||||||
|
|
||||||
|
await send(CameraImageResponse(key=1, data=b"qwer", done=True))
|
||||||
|
on_state.assert_called_once_with(CameraState(key=1, data=b"asdfqwer"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"cmd, req",
|
||||||
|
[
|
||||||
|
(dict(key=1), dict(key=1)),
|
||||||
|
(
|
||||||
|
dict(key=1, position=1.0),
|
||||||
|
dict(
|
||||||
|
key=1, has_legacy_command=True, legacy_command=LegacyCoverCommand.OPEN
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
dict(key=1, position=0.0),
|
||||||
|
dict(
|
||||||
|
key=1, has_legacy_command=True, legacy_command=LegacyCoverCommand.CLOSE
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
dict(key=1, stop=True),
|
||||||
|
dict(
|
||||||
|
key=1, has_legacy_command=True, legacy_command=LegacyCoverCommand.STOP
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_cover_command_legacy(auth_client, cmd, req):
|
||||||
|
send = patch_send(auth_client)
|
||||||
|
patch_api_version(auth_client, APIVersion(1, 0))
|
||||||
|
|
||||||
|
await auth_client.cover_command(**cmd)
|
||||||
|
send.assert_called_once_with(CoverCommandRequest(**req))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"cmd, req",
|
||||||
|
[
|
||||||
|
(dict(key=1), dict(key=1)),
|
||||||
|
(dict(key=1, position=0.5), dict(key=1, has_position=True, position=0.5)),
|
||||||
|
(dict(key=1, position=0.0), dict(key=1, has_position=True, position=0.0)),
|
||||||
|
(dict(key=1, stop=True), dict(key=1, stop=True)),
|
||||||
|
(
|
||||||
|
dict(key=1, position=1.0, tilt=0.8),
|
||||||
|
dict(key=1, has_position=True, position=1.0, has_tilt=True, tilt=0.8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_cover_command(auth_client, cmd, req):
|
||||||
|
send = patch_send(auth_client)
|
||||||
|
patch_api_version(auth_client, APIVersion(1, 1))
|
||||||
|
|
||||||
|
await auth_client.cover_command(**cmd)
|
||||||
|
send.assert_called_once_with(CoverCommandRequest(**req))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"cmd, req",
|
||||||
|
[
|
||||||
|
(dict(key=1), dict(key=1)),
|
||||||
|
(dict(key=1, state=True), dict(key=1, has_state=True, state=True)),
|
||||||
|
(
|
||||||
|
dict(key=1, speed=FanSpeed.LOW),
|
||||||
|
dict(key=1, has_speed=True, speed=FanSpeed.LOW),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
dict(key=1, speed_level=10),
|
||||||
|
dict(key=1, has_speed_level=True, speed_level=10),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
dict(key=1, oscillating=False),
|
||||||
|
dict(key=1, has_oscillating=True, oscillating=False),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
dict(key=1, direction=FanDirection.REVERSE),
|
||||||
|
dict(key=1, has_direction=True, direction=FanDirection.REVERSE),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_fan_command(auth_client, cmd, req):
|
||||||
|
send = patch_send(auth_client)
|
||||||
|
|
||||||
|
await auth_client.fan_command(**cmd)
|
||||||
|
send.assert_called_once_with(FanCommandRequest(**req))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"cmd, req",
|
||||||
|
[
|
||||||
|
(dict(key=1), dict(key=1)),
|
||||||
|
(dict(key=1, state=True), dict(key=1, has_state=True, state=True)),
|
||||||
|
(dict(key=1, brightness=0.8), dict(key=1, has_brightness=True, brightness=0.8)),
|
||||||
|
(
|
||||||
|
dict(key=1, rgb=(0.1, 0.5, 1.0)),
|
||||||
|
dict(key=1, has_rgb=True, red=0.1, green=0.5, blue=1.0),
|
||||||
|
),
|
||||||
|
(dict(key=1, white=0.0), dict(key=1, has_white=True, white=0.0)),
|
||||||
|
(
|
||||||
|
dict(key=1, color_temperature=0.0),
|
||||||
|
dict(key=1, has_color_temperature=True, color_temperature=0.0),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
dict(key=1, transition_length=0.1),
|
||||||
|
dict(key=1, has_transition_length=True, transition_length=100),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
dict(key=1, flash_length=0.1),
|
||||||
|
dict(key=1, has_flash_length=True, flash_length=100),
|
||||||
|
),
|
||||||
|
(dict(key=1, effect="special"), dict(key=1, has_effect=True, effect="special")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_light_command(auth_client, cmd, req):
|
||||||
|
send = patch_send(auth_client)
|
||||||
|
|
||||||
|
await auth_client.light_command(**cmd)
|
||||||
|
send.assert_called_once_with(LightCommandRequest(**req))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"cmd, req",
|
||||||
|
[
|
||||||
|
(dict(key=1, state=False), dict(key=1, state=False)),
|
||||||
|
(dict(key=1, state=True), dict(key=1, state=True)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_switch_command(auth_client, cmd, req):
|
||||||
|
send = patch_send(auth_client)
|
||||||
|
|
||||||
|
await auth_client.switch_command(**cmd)
|
||||||
|
send.assert_called_once_with(SwitchCommandRequest(**req))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"cmd, req",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
dict(key=1, preset=ClimatePreset.HOME),
|
||||||
|
dict(key=1, has_legacy_away=True, legacy_away=False),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
dict(key=1, preset=ClimatePreset.AWAY),
|
||||||
|
dict(key=1, has_legacy_away=True, legacy_away=True),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_climate_command_legacy(auth_client, cmd, req):
|
||||||
|
send = patch_send(auth_client)
|
||||||
|
patch_api_version(auth_client, APIVersion(1, 4))
|
||||||
|
|
||||||
|
await auth_client.climate_command(**cmd)
|
||||||
|
send.assert_called_once_with(ClimateCommandRequest(**req))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"cmd, req",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
dict(key=1, mode=ClimateMode.HEAT),
|
||||||
|
dict(key=1, has_mode=True, mode=ClimateMode.HEAT),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
dict(key=1, target_temperature=21.0),
|
||||||
|
dict(key=1, has_target_temperature=True, target_temperature=21.0),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
dict(key=1, target_temperature_low=21.0),
|
||||||
|
dict(key=1, has_target_temperature_low=True, target_temperature_low=21.0),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
dict(key=1, target_temperature_high=21.0),
|
||||||
|
dict(key=1, has_target_temperature_high=True, target_temperature_high=21.0),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
dict(key=1, fan_mode=ClimateFanMode.LOW),
|
||||||
|
dict(key=1, has_fan_mode=True, fan_mode=ClimateFanMode.LOW),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
dict(key=1, swing_mode=ClimateSwingMode.OFF),
|
||||||
|
dict(key=1, has_swing_mode=True, swing_mode=ClimateSwingMode.OFF),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
dict(key=1, custom_fan_mode="asdf"),
|
||||||
|
dict(key=1, has_custom_fan_mode=True, custom_fan_mode="asdf"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
dict(key=1, preset=ClimatePreset.AWAY),
|
||||||
|
dict(key=1, has_preset=True, preset=ClimatePreset.AWAY),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
dict(key=1, custom_preset="asdf"),
|
||||||
|
dict(key=1, has_custom_preset=True, custom_preset="asdf"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_climate_command(auth_client, cmd, req):
|
||||||
|
send = patch_send(auth_client)
|
||||||
|
patch_api_version(auth_client, APIVersion(1, 5))
|
||||||
|
|
||||||
|
await auth_client.climate_command(**cmd)
|
||||||
|
send.assert_called_once_with(ClimateCommandRequest(**req))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"cmd, req",
|
||||||
|
[
|
||||||
|
(dict(key=1, state=0.0), dict(key=1, state=0.0)),
|
||||||
|
(dict(key=1, state=100.0), dict(key=1, state=100.0)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_number_command(auth_client, cmd, req):
|
||||||
|
send = patch_send(auth_client)
|
||||||
|
|
||||||
|
await auth_client.number_command(**cmd)
|
||||||
|
send.assert_called_once_with(NumberCommandRequest(**req))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_service(auth_client):
|
||||||
|
send = patch_send(auth_client)
|
||||||
|
patch_api_version(auth_client, APIVersion(1, 3))
|
||||||
|
|
||||||
|
service = UserService(
|
||||||
|
name="my_service",
|
||||||
|
key=1,
|
||||||
|
args=[
|
||||||
|
UserServiceArg(name="arg1", type=UserServiceArgType.BOOL),
|
||||||
|
UserServiceArg(name="arg2", type=UserServiceArgType.INT),
|
||||||
|
UserServiceArg(name="arg3", type=UserServiceArgType.FLOAT),
|
||||||
|
UserServiceArg(name="arg4", type=UserServiceArgType.STRING),
|
||||||
|
UserServiceArg(name="arg5", type=UserServiceArgType.BOOL_ARRAY),
|
||||||
|
UserServiceArg(name="arg6", type=UserServiceArgType.INT_ARRAY),
|
||||||
|
UserServiceArg(name="arg7", type=UserServiceArgType.FLOAT_ARRAY),
|
||||||
|
UserServiceArg(name="arg8", type=UserServiceArgType.STRING_ARRAY),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
await auth_client.execute_service(service, data={})
|
||||||
|
|
||||||
|
await auth_client.execute_service(
|
||||||
|
service,
|
||||||
|
data={
|
||||||
|
"arg1": False,
|
||||||
|
"arg2": 42,
|
||||||
|
"arg3": 99.0,
|
||||||
|
"arg4": "asdf",
|
||||||
|
"arg5": [False, True, False],
|
||||||
|
"arg6": [42, 10, 9],
|
||||||
|
"arg7": [0.0, -100.0],
|
||||||
|
"arg8": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
send.assert_called_once_with(
|
||||||
|
ExecuteServiceRequest(
|
||||||
|
key=1,
|
||||||
|
args=[
|
||||||
|
ExecuteServiceArgument(bool_=False),
|
||||||
|
ExecuteServiceArgument(int_=42),
|
||||||
|
ExecuteServiceArgument(float_=99.0),
|
||||||
|
ExecuteServiceArgument(string_="asdf"),
|
||||||
|
ExecuteServiceArgument(bool_array=[False, True, False]),
|
||||||
|
ExecuteServiceArgument(int_array=[42, 10, 9]),
|
||||||
|
ExecuteServiceArgument(float_array=[0.0, -100.0]),
|
||||||
|
ExecuteServiceArgument(string_array=[]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
send.reset_mock()
|
||||||
|
|
||||||
|
patch_api_version(auth_client, APIVersion(1, 2))
|
||||||
|
service = UserService(
|
||||||
|
name="my_service",
|
||||||
|
key=2,
|
||||||
|
args=[
|
||||||
|
UserServiceArg(name="arg1", type=UserServiceArgType.BOOL),
|
||||||
|
UserServiceArg(name="arg2", type=UserServiceArgType.INT),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test legacy_int
|
||||||
|
await auth_client.execute_service(
|
||||||
|
service,
|
||||||
|
data={
|
||||||
|
"arg1": False,
|
||||||
|
"arg2": 42,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
send.assert_called_once_with(
|
||||||
|
ExecuteServiceRequest(
|
||||||
|
key=2,
|
||||||
|
args=[
|
||||||
|
ExecuteServiceArgument(bool_=False),
|
||||||
|
ExecuteServiceArgument(legacy_int=42),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
send.reset_mock()
|
||||||
|
|
||||||
|
# Test arg order
|
||||||
|
await auth_client.execute_service(
|
||||||
|
service,
|
||||||
|
data={
|
||||||
|
"arg2": 42,
|
||||||
|
"arg1": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
send.assert_called_once_with(
|
||||||
|
ExecuteServiceRequest(
|
||||||
|
key=2,
|
||||||
|
args=[
|
||||||
|
ExecuteServiceArgument(bool_=False),
|
||||||
|
ExecuteServiceArgument(legacy_int=42),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
send.reset_mock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_request_single_image(auth_client):
|
||||||
|
send = patch_send(auth_client)
|
||||||
|
|
||||||
|
await auth_client.request_single_image()
|
||||||
|
send.assert_called_once_with(CameraImageRequest(single=True, stream=False))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_request_image_stream(auth_client):
|
||||||
|
send = patch_send(auth_client)
|
||||||
|
|
||||||
|
await auth_client.request_image_stream()
|
||||||
|
send.assert_called_once_with(CameraImageRequest(single=False, stream=True))
|
63
tests/test_connection.py
Normal file
63
tests/test_connection.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import asyncio
|
||||||
|
import socket
|
||||||
|
from mock import AsyncMock, MagicMock, Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from aioesphomeapi.api_pb2 import ConnectResponse, HelloResponse
|
||||||
|
from aioesphomeapi.connection import APIConnection, ConnectionParams
|
||||||
|
from aioesphomeapi.core import APIConnectionError
|
||||||
|
from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def connection_params() -> ConnectionParams:
|
||||||
|
return ConnectionParams(
|
||||||
|
eventloop=asyncio.get_event_loop(),
|
||||||
|
address="fake.address",
|
||||||
|
port=6052,
|
||||||
|
password=None,
|
||||||
|
client_info="Tests client",
|
||||||
|
keepalive=15.0,
|
||||||
|
zeroconf_instance=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def conn(connection_params) -> APIConnection:
|
||||||
|
async def on_stop():
|
||||||
|
pass
|
||||||
|
|
||||||
|
return APIConnection(connection_params, on_stop)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def resolve_host():
|
||||||
|
with patch("aioesphomeapi.host_resolver.async_resolve_host") as func:
|
||||||
|
func.return_value = AddrInfo(
|
||||||
|
family=socket.AF_INET,
|
||||||
|
type=socket.SOCK_STREAM,
|
||||||
|
proto=socket.IPPROTO_TCP,
|
||||||
|
sockaddr=IPv4Sockaddr("10.0.0.512", 6052),
|
||||||
|
)
|
||||||
|
yield func
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def socket_socket():
|
||||||
|
with patch("socket.socket") as func:
|
||||||
|
yield func
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connect(conn, resolve_host, socket_socket, event_loop):
|
||||||
|
with patch.object(event_loop, "sock_connect"), patch(
|
||||||
|
"asyncio.open_connection", return_value=(None, None)
|
||||||
|
), patch.object(conn, "run_forever"), patch.object(
|
||||||
|
conn, "_start_ping"
|
||||||
|
), patch.object(
|
||||||
|
conn, "send_message_await_response", return_value=HelloResponse()
|
||||||
|
):
|
||||||
|
await conn.connect()
|
||||||
|
|
||||||
|
assert conn.is_connected
|
147
tests/test_host_resolver.py
Normal file
147
tests/test_host_resolver.py
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import socket
|
||||||
|
from mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import aioesphomeapi.host_resolver as hr
|
||||||
|
from aioesphomeapi.core import APIConnectionError
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def async_zeroconf():
|
||||||
|
with patch("zeroconf.asyncio.AsyncZeroconf") as klass:
|
||||||
|
yield klass.return_value
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def addr_infos():
|
||||||
|
return [
|
||||||
|
hr.AddrInfo(
|
||||||
|
family=socket.AF_INET,
|
||||||
|
type=socket.SOCK_STREAM,
|
||||||
|
proto=socket.IPPROTO_TCP,
|
||||||
|
sockaddr=hr.IPv4Sockaddr(address="10.0.0.42", port=6052),
|
||||||
|
),
|
||||||
|
hr.AddrInfo(
|
||||||
|
family=socket.AF_INET6,
|
||||||
|
type=socket.SOCK_STREAM,
|
||||||
|
proto=socket.IPPROTO_TCP,
|
||||||
|
sockaddr=hr.IPv6Sockaddr(
|
||||||
|
address="2001:db8:85a3::8a2e:370:7334",
|
||||||
|
port=6052,
|
||||||
|
flowinfo=0,
|
||||||
|
scope_id=0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_host_zeroconf(async_zeroconf, addr_infos):
|
||||||
|
info = MagicMock()
|
||||||
|
info.addresses_by_version.return_value = [
|
||||||
|
b"\n\x00\x00*",
|
||||||
|
b" \x01\r\xb8\x85\xa3\x00\x00\x00\x00\x8a.\x03ps4",
|
||||||
|
]
|
||||||
|
async_zeroconf.async_get_service_info = AsyncMock(return_value=info)
|
||||||
|
async_zeroconf.async_close = AsyncMock()
|
||||||
|
|
||||||
|
ret = await hr._async_resolve_host_zeroconf("asdf", 6052)
|
||||||
|
|
||||||
|
async_zeroconf.async_get_service_info.assert_called_once_with(
|
||||||
|
"_esphomelib._tcp.local.", "asdf._esphomelib._tcp.local.", 3000
|
||||||
|
)
|
||||||
|
async_zeroconf.async_close.assert_called_once_with()
|
||||||
|
|
||||||
|
assert ret == addr_infos
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_host_zeroconf_empty(async_zeroconf):
|
||||||
|
async_zeroconf.async_get_service_info = AsyncMock(return_value=None)
|
||||||
|
async_zeroconf.async_close = AsyncMock()
|
||||||
|
|
||||||
|
ret = await hr._async_resolve_host_zeroconf("asdf.local", 6052)
|
||||||
|
assert ret == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_host_getaddrinfo(addr_infos):
|
||||||
|
eventloop = AsyncMock()
|
||||||
|
eventloop.getaddrinfo.return_value = [
|
||||||
|
(
|
||||||
|
socket.AF_INET,
|
||||||
|
socket.SOCK_STREAM,
|
||||||
|
socket.IPPROTO_TCP,
|
||||||
|
"canon1",
|
||||||
|
("10.0.0.42", 6052),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
socket.AF_INET6,
|
||||||
|
socket.SOCK_STREAM,
|
||||||
|
socket.IPPROTO_TCP,
|
||||||
|
"canon2",
|
||||||
|
("2001:db8:85a3::8a2e:370:7334", 6052, 0, 0),
|
||||||
|
),
|
||||||
|
(-1, socket.SOCK_STREAM, socket.IPPROTO_TCP, "canon3", ("10.0.0.42", 6052)),
|
||||||
|
]
|
||||||
|
ret = await hr._async_resolve_host_getaddrinfo(eventloop, "example.com", 6052)
|
||||||
|
|
||||||
|
assert ret == addr_infos
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_host_getaddrinfo_oserror():
|
||||||
|
eventloop = AsyncMock()
|
||||||
|
eventloop.getaddrinfo.side_effect = OSError()
|
||||||
|
with pytest.raises(APIConnectionError):
|
||||||
|
await hr._async_resolve_host_getaddrinfo(eventloop, "example.com", 6052)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("aioesphomeapi.host_resolver._async_resolve_host_zeroconf")
|
||||||
|
@patch("aioesphomeapi.host_resolver._async_resolve_host_getaddrinfo")
|
||||||
|
async def test_resolve_host_mdns(resolve_addr, resolve_zc, addr_infos):
|
||||||
|
resolve_zc.return_value = addr_infos
|
||||||
|
ret = await hr.async_resolve_host(None, "example.local", 6052)
|
||||||
|
|
||||||
|
resolve_zc.assert_called_once_with("example", 6052, zeroconf_instance=None)
|
||||||
|
resolve_addr.assert_not_called()
|
||||||
|
assert ret == addr_infos[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("aioesphomeapi.host_resolver._async_resolve_host_zeroconf")
|
||||||
|
@patch("aioesphomeapi.host_resolver._async_resolve_host_getaddrinfo")
|
||||||
|
async def test_resolve_host_mdns_empty(resolve_addr, resolve_zc, addr_infos):
|
||||||
|
resolve_zc.return_value = []
|
||||||
|
resolve_addr.return_value = addr_infos
|
||||||
|
ret = await hr.async_resolve_host(None, "example.local", 6052)
|
||||||
|
|
||||||
|
resolve_zc.assert_called_once_with("example", 6052, zeroconf_instance=None)
|
||||||
|
resolve_addr.assert_called_once_with(None, "example.local", 6052)
|
||||||
|
assert ret == addr_infos[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("aioesphomeapi.host_resolver._async_resolve_host_zeroconf")
|
||||||
|
@patch("aioesphomeapi.host_resolver._async_resolve_host_getaddrinfo")
|
||||||
|
async def test_resolve_host_addrinfo(resolve_addr, resolve_zc, addr_infos):
|
||||||
|
resolve_addr.return_value = addr_infos
|
||||||
|
ret = await hr.async_resolve_host(None, "example.com", 6052)
|
||||||
|
|
||||||
|
resolve_zc.assert_not_called()
|
||||||
|
resolve_addr.assert_called_once_with(None, "example.com", 6052)
|
||||||
|
assert ret == addr_infos[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("aioesphomeapi.host_resolver._async_resolve_host_zeroconf")
|
||||||
|
@patch("aioesphomeapi.host_resolver._async_resolve_host_getaddrinfo")
|
||||||
|
async def test_resolve_host_addrinfo_empty(resolve_addr, resolve_zc, addr_infos):
|
||||||
|
resolve_addr.return_value = []
|
||||||
|
with pytest.raises(APIConnectionError):
|
||||||
|
await hr.async_resolve_host(None, "example.com", 6052)
|
||||||
|
|
||||||
|
resolve_zc.assert_not_called()
|
||||||
|
resolve_addr.assert_called_once_with(None, "example.com", 6052)
|
302
tests/test_model.py
Normal file
302
tests/test_model.py
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from aioesphomeapi.api_pb2 import (
|
||||||
|
BinarySensorStateResponse,
|
||||||
|
ClimateStateResponse,
|
||||||
|
CoverStateResponse,
|
||||||
|
DeviceInfoResponse,
|
||||||
|
FanStateResponse,
|
||||||
|
HomeassistantServiceMap,
|
||||||
|
HomeassistantServiceResponse,
|
||||||
|
LightStateResponse,
|
||||||
|
ListEntitiesBinarySensorResponse,
|
||||||
|
ListEntitiesClimateResponse,
|
||||||
|
ListEntitiesCoverResponse,
|
||||||
|
ListEntitiesFanResponse,
|
||||||
|
ListEntitiesLightResponse,
|
||||||
|
ListEntitiesNumberResponse,
|
||||||
|
ListEntitiesSensorResponse,
|
||||||
|
ListEntitiesServicesArgument,
|
||||||
|
ListEntitiesServicesResponse,
|
||||||
|
ListEntitiesSwitchResponse,
|
||||||
|
ListEntitiesTextSensorResponse,
|
||||||
|
NumberStateResponse,
|
||||||
|
SensorStateResponse,
|
||||||
|
ServiceArgType,
|
||||||
|
SwitchStateResponse,
|
||||||
|
TextSensorStateResponse,
|
||||||
|
)
|
||||||
|
from aioesphomeapi.model import (
|
||||||
|
APIIntEnum,
|
||||||
|
APIModelBase,
|
||||||
|
APIVersion,
|
||||||
|
BinarySensorInfo,
|
||||||
|
BinarySensorState,
|
||||||
|
ClimateInfo,
|
||||||
|
ClimatePreset,
|
||||||
|
ClimateState,
|
||||||
|
CoverInfo,
|
||||||
|
CoverState,
|
||||||
|
DeviceInfo,
|
||||||
|
FanInfo,
|
||||||
|
FanState,
|
||||||
|
HomeassistantServiceCall,
|
||||||
|
LegacyCoverState,
|
||||||
|
LightInfo,
|
||||||
|
LightState,
|
||||||
|
NumberInfo,
|
||||||
|
NumberState,
|
||||||
|
SensorInfo,
|
||||||
|
SensorState,
|
||||||
|
SwitchInfo,
|
||||||
|
SwitchState,
|
||||||
|
TextSensorInfo,
|
||||||
|
TextSensorState,
|
||||||
|
UserService,
|
||||||
|
UserServiceArg,
|
||||||
|
UserServiceArgType,
|
||||||
|
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: Optional[DummyIntEnum] = 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),
|
||||||
|
(HomeassistantServiceCall, HomeassistantServiceResponse),
|
||||||
|
(UserServiceArg, ListEntitiesServicesArgument),
|
||||||
|
(UserService, ListEntitiesServicesResponse),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
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)]
|
||||||
|
)
|
22
tests/test_util.py
Normal file
22
tests/test_util.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from aioesphomeapi import util
|
||||||
|
|
||||||
|
VARUINT_TESTCASES = [
|
||||||
|
(0, b"\x00"),
|
||||||
|
(42, b"\x2a"),
|
||||||
|
(127, b"\x7f"),
|
||||||
|
(128, b"\x80\x01"),
|
||||||
|
(300, b"\xac\x02"),
|
||||||
|
(65536, b"\x80\x80\x04"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("val, encoded", VARUINT_TESTCASES)
|
||||||
|
def test_varuint_to_bytes(val, encoded):
|
||||||
|
assert util.varuint_to_bytes(val) == encoded
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("val, encoded", VARUINT_TESTCASES)
|
||||||
|
def test_bytes_to_varuint(val, encoded):
|
||||||
|
assert util.bytes_to_varuint(encoded) == val
|
Loading…
Reference in New Issue
Block a user