123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914 |
- # switchbot-mqtt - MQTT client controlling SwitchBot button & curtain automators,
- # compatible with home-assistant.io's MQTT Switch & Cover platform
- #
- # Copyright (C) 2020 Fabian Peter Hammerle <fabian@hammerle.me>
- #
- # This program is free software: you can redistribute it and/or modify
- # it under the terms of the GNU General Public License as published by
- # the Free Software Foundation, either version 3 of the License, or
- # any later version.
- #
- # This program is distributed in the hope that it will be useful,
- # but WITHOUT ANY WARRANTY; without even the implied warranty of
- # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- # GNU General Public License for more details.
- #
- # You should have received a copy of the GNU General Public License
- # along with this program. If not, see <https://www.gnu.org/licenses/>.
- import logging
- import socket
- import ssl
- import typing
- import unittest.mock
- import _pytest.logging # pylint: disable=import-private-name; typing
- import pytest
- import aiomqtt
- import bleak
- import bleak.backends.device
- from paho.mqtt.client import MQTT_ERR_NO_CONN
- # pylint: disable=import-private-name; internal
- import switchbot_mqtt
- import switchbot_mqtt._actors
- from switchbot_mqtt._actors import _ButtonAutomator, _CurtainMotor
- from switchbot_mqtt._actors.base import _MQTTControlledActor
- from switchbot_mqtt._utils import _MQTTTopicLevel, _MQTTTopicPlaceholder
- # pylint: disable=protected-access
- # pylint: disable=too-many-arguments; these are tests, no API
- @pytest.mark.asyncio
- async def test__listen(caplog: _pytest.logging.LogCaptureFixture) -> None:
- mqtt_client = unittest.mock.AsyncMock()
- messages_mock = unittest.mock.AsyncMock()
- async def _msg_iter() -> typing.AsyncIterator[aiomqtt.Message]:
- for topic, payload in [
- ("/foo", b"foo1"),
- ("/baz/21/bar", b"42/2"),
- ("/baz/bar", b"nope"),
- ("/foo", b"foo2"),
- ]:
- yield aiomqtt.Message(
- topic=topic,
- payload=payload,
- qos=0,
- retain=False,
- mid=0,
- properties=None,
- )
- messages_mock.__aenter__.return_value.__aiter__.side_effect = _msg_iter
- mqtt_client.messages = lambda: messages_mock
- callback_foo = unittest.mock.AsyncMock()
- callback_bar = unittest.mock.AsyncMock()
- with caplog.at_level(logging.DEBUG):
- await switchbot_mqtt._listen(
- mqtt_client=mqtt_client,
- topic_callbacks=(("/foo", callback_foo), ("/baz/+/bar", callback_bar)),
- mqtt_topic_prefix="whatever/",
- retry_count=3,
- device_passwords={},
- fetch_device_info=False,
- )
- mqtt_client.publish.assert_awaited_once_with(
- topic="whatever/switchbot-mqtt/status", payload="online", retain=True
- )
- messages_mock.__aenter__.assert_awaited_once_with()
- assert callback_foo.await_count == 2
- assert not callback_foo.await_args_list[0].args
- kwargs = callback_foo.await_args_list[0].kwargs
- assert kwargs["message"].topic.value == "/foo"
- assert kwargs["message"].payload == b"foo1"
- del kwargs["message"] # type: ignore
- assert kwargs == {
- "mqtt_client": mqtt_client,
- "mqtt_topic_prefix": "whatever/",
- "retry_count": 3,
- "device_passwords": {},
- "fetch_device_info": False,
- }
- assert callback_foo.await_args_list[1].kwargs["message"].payload == b"foo2"
- assert callback_bar.await_count == 1
- assert (
- callback_bar.await_args_list[0].kwargs["message"].topic.value == "/baz/21/bar"
- )
- assert callback_bar.await_args_list[0].kwargs["message"].payload == b"42/2"
- @pytest.mark.parametrize(
- ("socket_family", "peername", "peername_log"),
- [
- (socket.AF_INET, ("mqtt-broker.local", 1883), "mqtt-broker.local:1883"),
- # https://github.com/fphammerle/switchbot-mqtt/issues/42#issuecomment-1173909335
- (socket.AF_INET6, ("::1", 1883, 0, 0), "[::1]:1883"),
- ],
- )
- def test__log_mqtt_connected(
- caplog: _pytest.logging.LogCaptureFixture,
- socket_family: int, # socket.AddressFamily,
- peername: typing.Tuple[typing.Union[str, int]],
- peername_log: str,
- ) -> None:
- mqtt_client = unittest.mock.MagicMock()
- mqtt_client._client.socket().family = socket_family
- mqtt_client._client.socket().getpeername.return_value = peername
- with caplog.at_level(logging.INFO):
- switchbot_mqtt._log_mqtt_connected(mqtt_client)
- assert not caplog.records
- with caplog.at_level(logging.DEBUG):
- switchbot_mqtt._log_mqtt_connected(mqtt_client)
- assert caplog.record_tuples[0] == (
- "switchbot_mqtt",
- logging.DEBUG,
- f"connected to MQTT broker {peername_log}",
- )
- @pytest.mark.asyncio()
- @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
- @pytest.mark.parametrize("mqtt_port", [1234])
- @pytest.mark.parametrize("retry_count", [3, 21])
- @pytest.mark.parametrize(
- "device_passwords",
- [{}, {"11:22:33:44:55:66": "password", "aa:bb:cc:dd:ee:ff": "secret"}],
- )
- @pytest.mark.parametrize("fetch_device_info", [True, False])
- async def test__run(
- caplog: _pytest.logging.LogCaptureFixture,
- mqtt_host: str,
- mqtt_port: int,
- retry_count: int,
- device_passwords: typing.Dict[str, str],
- fetch_device_info: bool,
- ) -> None:
- with unittest.mock.patch("aiomqtt.Client") as mqtt_client_mock, unittest.mock.patch(
- "switchbot_mqtt._log_mqtt_connected"
- ) as log_connected_mock, unittest.mock.patch(
- "switchbot_mqtt._listen"
- ) as listen_mock, caplog.at_level(
- logging.DEBUG
- ):
- await switchbot_mqtt._run(
- mqtt_host=mqtt_host,
- mqtt_port=mqtt_port,
- mqtt_disable_tls=False,
- mqtt_username=None,
- mqtt_password=None,
- mqtt_topic_prefix="home/",
- retry_count=retry_count,
- device_passwords=device_passwords,
- fetch_device_info=fetch_device_info,
- )
- mqtt_client_mock.assert_called_once()
- assert not mqtt_client_mock.call_args.args
- init_kwargs = mqtt_client_mock.call_args.kwargs
- assert isinstance(init_kwargs.pop("tls_context"), ssl.SSLContext)
- assert init_kwargs.pop("will") == aiomqtt.Will(
- topic="home/switchbot-mqtt/status",
- payload="offline",
- qos=0,
- retain=True,
- properties=None,
- )
- assert init_kwargs == {
- "hostname": mqtt_host,
- "port": mqtt_port,
- "username": None,
- "password": None,
- }
- log_connected_mock.assert_called_once()
- subscribe_mock = mqtt_client_mock().__aenter__.return_value.subscribe
- assert subscribe_mock.await_count == (5 if fetch_device_info else 3)
- subscribe_mock.assert_has_awaits(
- (
- unittest.mock.call(topic)
- for topic in [
- "home/switch/switchbot/+/set",
- "home/cover/switchbot-curtain/+/set",
- "home/cover/switchbot-curtain/+/position/set-percent",
- ]
- ),
- any_order=True,
- )
- if fetch_device_info:
- subscribe_mock.assert_has_awaits(
- (
- unittest.mock.call("home/switch/switchbot/+/request-device-info"),
- unittest.mock.call(
- "home/cover/switchbot-curtain/+/request-device-info"
- ),
- ),
- any_order=True,
- )
- listen_mock.assert_awaited_once()
- assert listen_mock.await_args is not None # for mypy
- assert not listen_mock.await_args.args
- listen_kwargs = listen_mock.await_args.kwargs
- assert (
- listen_kwargs.pop("mqtt_client") # type: ignore
- == mqtt_client_mock().__aenter__.return_value
- )
- topic_callbacks = listen_kwargs.pop("topic_callbacks") # type: ignore
- assert len(topic_callbacks) == (5 if fetch_device_info else 3)
- assert (
- "home/switch/switchbot/+/set",
- switchbot_mqtt._actors._ButtonAutomator._mqtt_command_callback,
- ) in topic_callbacks
- assert (
- "home/cover/switchbot-curtain/+/set",
- switchbot_mqtt._actors._CurtainMotor._mqtt_command_callback,
- ) in topic_callbacks
- assert (
- "home/cover/switchbot-curtain/+/position/set-percent",
- switchbot_mqtt._actors._CurtainMotor._mqtt_set_position_callback,
- ) in topic_callbacks
- if fetch_device_info:
- assert (
- "home/switch/switchbot/+/request-device-info",
- switchbot_mqtt._actors._ButtonAutomator._mqtt_update_device_info_callback,
- ) in topic_callbacks
- assert (
- "home/cover/switchbot-curtain/+/request-device-info",
- switchbot_mqtt._actors._CurtainMotor._mqtt_update_device_info_callback,
- ) in topic_callbacks
- assert listen_kwargs == {
- "device_passwords": device_passwords,
- "fetch_device_info": fetch_device_info,
- "mqtt_topic_prefix": "home/",
- "retry_count": retry_count,
- }
- assert caplog.record_tuples[0] == (
- "switchbot_mqtt",
- logging.INFO,
- f"connecting to MQTT broker {mqtt_host}:{mqtt_port} (TLS enabled)",
- )
- assert len(caplog.record_tuples) == (5 if fetch_device_info else 3) + 1
- assert (
- "switchbot_mqtt._actors.base",
- logging.INFO,
- "subscribing to MQTT topic 'home/switch/switchbot/+/set'",
- ) in caplog.record_tuples
- assert (
- "switchbot_mqtt._actors.base",
- logging.INFO,
- "subscribing to MQTT topic 'home/cover/switchbot-curtain/+/set'",
- ) in caplog.record_tuples
- @pytest.mark.asyncio
- @pytest.mark.parametrize("mqtt_disable_tls", [True, False])
- async def test__run_tls(
- caplog: _pytest.logging.LogCaptureFixture, mqtt_disable_tls: bool
- ) -> None:
- with unittest.mock.patch("aiomqtt.Client") as mqtt_client_mock, unittest.mock.patch(
- "switchbot_mqtt._listen"
- ), caplog.at_level(logging.INFO):
- await switchbot_mqtt._run(
- mqtt_host="mqtt.local",
- mqtt_port=1234,
- mqtt_disable_tls=mqtt_disable_tls,
- mqtt_username=None,
- mqtt_password=None,
- mqtt_topic_prefix="prfx",
- retry_count=21,
- device_passwords={},
- fetch_device_info=True,
- )
- mqtt_client_mock.assert_called_once()
- assert not mqtt_client_mock.call_args.args
- kwargs = mqtt_client_mock.call_args.kwargs
- if mqtt_disable_tls:
- assert kwargs["tls_context"] is None
- assert caplog.record_tuples[0][2].endswith(" (TLS disabled)")
- else:
- assert isinstance(kwargs["tls_context"], ssl.SSLContext)
- assert caplog.record_tuples[0][2].endswith(" (TLS enabled)")
- @pytest.mark.asyncio
- @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
- @pytest.mark.parametrize("mqtt_port", [1833])
- @pytest.mark.parametrize("mqtt_username", ["me"])
- @pytest.mark.parametrize("mqtt_password", [None, "secret"])
- async def test__run_authentication(
- mqtt_host: str,
- mqtt_port: int,
- mqtt_username: str,
- mqtt_password: typing.Optional[str],
- ) -> None:
- with unittest.mock.patch("aiomqtt.Client") as mqtt_client_mock, unittest.mock.patch(
- "switchbot_mqtt._listen"
- ):
- await switchbot_mqtt._run(
- mqtt_host=mqtt_host,
- mqtt_port=mqtt_port,
- mqtt_disable_tls=True,
- mqtt_username=mqtt_username,
- mqtt_password=mqtt_password,
- mqtt_topic_prefix="prfx",
- retry_count=7,
- device_passwords={},
- fetch_device_info=True,
- )
- mqtt_client_mock.assert_called_once()
- assert not mqtt_client_mock.call_args.args
- kwargs = mqtt_client_mock.call_args.kwargs
- assert kwargs["username"] == mqtt_username
- assert kwargs["password"] == mqtt_password
- @pytest.mark.asyncio
- @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
- @pytest.mark.parametrize("mqtt_port", [1833])
- @pytest.mark.parametrize("mqtt_password", ["secret"])
- async def test__run_authentication_missing_username(
- mqtt_host: str, mqtt_port: int, mqtt_password: str
- ) -> None:
- with pytest.raises(ValueError, match=r"^Missing MQTT username$"):
- await switchbot_mqtt._run(
- mqtt_host=mqtt_host,
- mqtt_port=mqtt_port,
- mqtt_disable_tls=True,
- mqtt_username=None,
- mqtt_password=mqtt_password,
- mqtt_topic_prefix="whatever",
- retry_count=3,
- device_passwords={},
- fetch_device_info=True,
- )
- def _mock_actor_class(
- *,
- command_topic_levels: typing.Tuple[_MQTTTopicLevel, ...] = NotImplemented,
- request_info_levels: typing.Tuple[_MQTTTopicLevel, ...] = NotImplemented,
- ) -> typing.Type:
- class _ActorMock(_MQTTControlledActor):
- MQTT_COMMAND_TOPIC_LEVELS = command_topic_levels
- _MQTT_UPDATE_DEVICE_INFO_TOPIC_LEVELS = request_info_levels
- def __init__(
- self,
- device: bleak.backends.device.BLEDevice,
- retry_count: int,
- password: typing.Optional[str],
- ) -> None:
- super().__init__(device=device, retry_count=retry_count, password=password)
- async def execute_command(
- self,
- *,
- mqtt_message_payload: bytes,
- mqtt_client: aiomqtt.Client,
- update_device_info: bool,
- mqtt_topic_prefix: str,
- ) -> None:
- pass
- def _get_device(self) -> None:
- return None
- return _ActorMock
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- ("topic_levels", "topic", "expected_mac_address"),
- [
- (
- switchbot_mqtt._actors._ButtonAutomator._MQTT_UPDATE_DEVICE_INFO_TOPIC_LEVELS,
- "prfx/switch/switchbot/aa:bb:cc:dd:ee:ff/request-device-info",
- "aa:bb:cc:dd:ee:ff",
- ),
- ],
- )
- @pytest.mark.parametrize("payload", [b"", b"whatever"])
- async def test__mqtt_update_device_info_callback(
- caplog: _pytest.logging.LogCaptureFixture,
- topic_levels: typing.Tuple[_MQTTTopicLevel, ...],
- topic: str,
- expected_mac_address: str,
- payload: bytes,
- ) -> None:
- ActorMock = _mock_actor_class(request_info_levels=topic_levels)
- message = aiomqtt.Message(
- topic=topic, payload=payload, qos=0, retain=False, mid=0, properties=None
- )
- device = unittest.mock.Mock()
- with unittest.mock.patch.object(
- bleak.BleakScanner, "find_device_by_address", return_value=device
- ) as find_device_mock, unittest.mock.patch.object(
- ActorMock, "__init__", return_value=None
- ) as init_mock, unittest.mock.patch.object(
- ActorMock, "_update_and_report_device_info"
- ) as update_mock, caplog.at_level(
- logging.DEBUG
- ):
- await ActorMock._mqtt_update_device_info_callback(
- mqtt_client="client_dummy",
- message=message,
- mqtt_topic_prefix="prfx/",
- retry_count=21, # tested in test__mqtt_command_callback
- device_passwords={},
- fetch_device_info=True,
- )
- find_device_mock.assert_awaited_once_with(expected_mac_address)
- init_mock.assert_called_once_with(device=device, retry_count=21, password=None)
- update_mock.assert_called_once_with(
- mqtt_client="client_dummy", mqtt_topic_prefix="prfx/"
- )
- assert caplog.record_tuples == [
- (
- "switchbot_mqtt._actors.base",
- logging.DEBUG,
- f"received topic={topic} payload={payload!r}",
- )
- ]
- @pytest.mark.asyncio
- async def test__mqtt_update_device_info_callback_ignore_retained(
- caplog: _pytest.logging.LogCaptureFixture,
- ) -> None:
- ActorMock = _mock_actor_class(
- request_info_levels=(_MQTTTopicPlaceholder.MAC_ADDRESS, "request")
- )
- message = aiomqtt.Message(
- topic="aa:bb:cc:dd:ee:ff/request",
- payload=b"",
- qos=0,
- retain=True,
- mid=0,
- properties=None,
- )
- with unittest.mock.patch.object(
- ActorMock, "__init__", return_value=None
- ) as init_mock, unittest.mock.patch.object(
- ActorMock, "execute_command"
- ) as execute_command_mock, caplog.at_level(
- logging.DEBUG
- ):
- await ActorMock._mqtt_update_device_info_callback(
- mqtt_client="client_dummy",
- message=message,
- mqtt_topic_prefix="ignored",
- retry_count=21,
- device_passwords={},
- fetch_device_info=True,
- )
- init_mock.assert_not_called()
- execute_command_mock.assert_not_called()
- execute_command_mock.assert_not_awaited()
- assert caplog.record_tuples == [
- (
- "switchbot_mqtt._actors.base",
- logging.DEBUG,
- "received topic=aa:bb:cc:dd:ee:ff/request payload=b''",
- ),
- ("switchbot_mqtt._actors.base", logging.INFO, "ignoring retained message"),
- ]
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- (
- "topic_prefix",
- "command_topic_levels",
- "topic",
- "payload",
- "expected_mac_address",
- ),
- [
- (
- "homeassistant/",
- _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
- "homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
- b"ON",
- "aa:bb:cc:dd:ee:ff",
- ),
- (
- "homeassistant/",
- _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
- "homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
- b"OFF",
- "aa:bb:cc:dd:ee:ff",
- ),
- (
- "homeassistant/",
- _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
- "homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
- b"on",
- "aa:bb:cc:dd:ee:ff",
- ),
- (
- "homeassistant/",
- _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
- "homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
- b"off",
- "aa:bb:cc:dd:ee:ff",
- ),
- (
- "prefix-",
- _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
- "prefix-switch/switchbot/aa:01:23:45:67:89/set",
- b"ON",
- "aa:01:23:45:67:89",
- ),
- (
- "",
- ["switchbot", _MQTTTopicPlaceholder.MAC_ADDRESS],
- "switchbot/aa:01:23:45:67:89",
- b"ON",
- "aa:01:23:45:67:89",
- ),
- (
- "homeassistant/",
- _CurtainMotor.MQTT_COMMAND_TOPIC_LEVELS,
- "homeassistant/cover/switchbot-curtain/aa:01:23:45:67:89/set",
- b"OPEN",
- "aa:01:23:45:67:89",
- ),
- ],
- )
- @pytest.mark.parametrize("retry_count", (3, 42))
- @pytest.mark.parametrize("fetch_device_info", [True, False])
- async def test__mqtt_command_callback(
- caplog: _pytest.logging.LogCaptureFixture,
- topic_prefix: str,
- command_topic_levels: typing.Tuple[_MQTTTopicLevel, ...],
- topic: str,
- payload: bytes,
- expected_mac_address: str,
- retry_count: int,
- fetch_device_info: bool,
- ) -> None:
- ActorMock = _mock_actor_class(command_topic_levels=command_topic_levels)
- message = aiomqtt.Message(
- topic=topic, payload=payload, qos=0, retain=False, mid=0, properties=None
- )
- device = unittest.mock.Mock()
- device.address = expected_mac_address
- with unittest.mock.patch.object(
- bleak.BleakScanner, "find_device_by_address", return_value=device
- ) as find_device_mock, unittest.mock.patch.object(
- ActorMock, "__init__", return_value=None
- ) as init_mock, unittest.mock.patch.object(
- ActorMock, "execute_command"
- ) as execute_command_mock, caplog.at_level(
- logging.DEBUG
- ):
- await ActorMock._mqtt_command_callback(
- mqtt_client="client_dummy",
- message=message,
- retry_count=retry_count,
- device_passwords={},
- fetch_device_info=fetch_device_info,
- mqtt_topic_prefix=topic_prefix,
- )
- find_device_mock.assert_awaited_once_with(expected_mac_address)
- init_mock.assert_called_once_with(
- device=device, retry_count=retry_count, password=None
- )
- execute_command_mock.assert_awaited_once_with(
- mqtt_client="client_dummy",
- mqtt_message_payload=payload,
- update_device_info=fetch_device_info,
- mqtt_topic_prefix=topic_prefix,
- )
- assert caplog.record_tuples == [
- (
- "switchbot_mqtt._actors.base",
- logging.DEBUG,
- f"received topic={topic} payload={payload!r}",
- )
- ]
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- ("mac_address", "expected_password"),
- [
- ("11:22:33:44:55:66", None),
- ("aa:bb:cc:dd:ee:ff", "secret"),
- ("11:22:33:dd:ee:ff", "äöü"),
- ],
- )
- async def test__mqtt_command_callback_password(
- mac_address: str, expected_password: typing.Optional[str]
- ) -> None:
- ActorMock = _mock_actor_class(
- command_topic_levels=("switchbot", _MQTTTopicPlaceholder.MAC_ADDRESS)
- )
- message = aiomqtt.Message(
- topic="prefix-switchbot/" + mac_address,
- payload=b"whatever",
- qos=0,
- retain=False,
- mid=0,
- properties=None,
- )
- device = unittest.mock.Mock()
- device.address = mac_address
- with unittest.mock.patch.object(
- bleak.BleakScanner, "find_device_by_address", return_value=device
- ) as find_device_mock, unittest.mock.patch.object(
- ActorMock, "__init__", return_value=None
- ) as init_mock, unittest.mock.patch.object(
- ActorMock, "execute_command"
- ) as execute_command_mock:
- await ActorMock._mqtt_command_callback(
- mqtt_client="client_dummy",
- message=message,
- retry_count=3,
- device_passwords={
- "11:22:33:44:55:77": "test",
- "aa:bb:cc:dd:ee:ff": "secret",
- "11:22:33:dd:ee:ff": "äöü",
- },
- fetch_device_info=True,
- mqtt_topic_prefix="prefix-",
- )
- find_device_mock.assert_awaited_once_with(mac_address)
- init_mock.assert_called_once_with(
- device=device, retry_count=3, password=expected_password
- )
- execute_command_mock.assert_awaited_once_with(
- mqtt_client="client_dummy",
- mqtt_message_payload=b"whatever",
- update_device_info=True,
- mqtt_topic_prefix="prefix-",
- )
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- ("topic", "payload"),
- [
- ("homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff", b"on"),
- ("homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/change", b"ON"),
- ("homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set/suffix", b"ON"),
- ],
- )
- async def test__mqtt_command_callback_unexpected_topic(
- caplog: _pytest.logging.LogCaptureFixture, topic: str, payload: bytes
- ) -> None:
- ActorMock = _mock_actor_class(
- command_topic_levels=_ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
- )
- message = aiomqtt.Message(
- topic=topic, payload=payload, qos=0, retain=False, mid=0, properties=None
- )
- with unittest.mock.patch.object(
- ActorMock, "__init__", return_value=None
- ) as init_mock, unittest.mock.patch.object(
- ActorMock, "execute_command"
- ) as execute_command_mock, caplog.at_level(
- logging.DEBUG
- ):
- await ActorMock._mqtt_command_callback(
- mqtt_client="client_dummy",
- message=message,
- retry_count=3,
- device_passwords={},
- fetch_device_info=True,
- mqtt_topic_prefix="homeassistant/",
- )
- init_mock.assert_not_called()
- execute_command_mock.assert_not_called()
- execute_command_mock.assert_not_awaited()
- assert caplog.record_tuples == [
- (
- "switchbot_mqtt._actors.base",
- logging.DEBUG,
- f"received topic={topic} payload={payload!r}",
- ),
- (
- "switchbot_mqtt._actors.base",
- logging.WARNING,
- f"unexpected topic {topic}",
- ),
- ]
- @pytest.mark.asyncio
- @pytest.mark.parametrize(("mac_address", "payload"), [("aa:01:23:4E:RR:OR", b"ON")])
- async def test__mqtt_command_callback_invalid_mac_address(
- caplog: _pytest.logging.LogCaptureFixture, mac_address: str, payload: bytes
- ) -> None:
- ActorMock = _mock_actor_class(
- command_topic_levels=_ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
- )
- topic = f"mqttprefix-switch/switchbot/{mac_address}/set"
- message = aiomqtt.Message(
- topic=topic, payload=payload, qos=0, retain=False, mid=0, properties=None
- )
- with unittest.mock.patch.object(
- ActorMock, "__init__", return_value=None
- ) as init_mock, unittest.mock.patch.object(
- ActorMock, "execute_command"
- ) as execute_command_mock, caplog.at_level(
- logging.DEBUG
- ):
- await ActorMock._mqtt_command_callback(
- mqtt_client="client_dummy",
- message=message,
- retry_count=3,
- device_passwords={},
- fetch_device_info=True,
- mqtt_topic_prefix="mqttprefix-",
- )
- init_mock.assert_not_called()
- execute_command_mock.assert_not_called()
- assert caplog.record_tuples == [
- (
- "switchbot_mqtt._actors.base",
- logging.DEBUG,
- f"received topic={topic} payload={payload!r}",
- ),
- (
- "switchbot_mqtt._actors.base",
- logging.WARNING,
- f"invalid mac address {mac_address}",
- ),
- ]
- @pytest.mark.asyncio
- @pytest.mark.parametrize("mac_address", ["00:11:22:33:44:55", "aa:bb:cc:dd:ee:ff"])
- @pytest.mark.parametrize("payload", [b"ON"])
- async def test__mqtt_command_callback_device_not_found(
- caplog: _pytest.logging.LogCaptureFixture, mac_address: str, payload: bytes
- ) -> None:
- ActorMock = _mock_actor_class(
- command_topic_levels=_ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
- )
- topic = f"prefix/switch/switchbot/{mac_address}/set"
- message = aiomqtt.Message(
- topic=topic, payload=payload, qos=0, retain=False, mid=0, properties=None
- )
- with unittest.mock.patch.object(
- bleak.BleakScanner, "find_device_by_address", return_value=None
- ), unittest.mock.patch.object(
- ActorMock, "__init__", return_value=None
- ) as init_mock, unittest.mock.patch.object(
- ActorMock, "execute_command"
- ) as execute_command_mock, caplog.at_level(
- logging.DEBUG
- ):
- await ActorMock._mqtt_command_callback(
- mqtt_client="client_dummy",
- message=message,
- retry_count=3,
- device_passwords={},
- fetch_device_info=True,
- mqtt_topic_prefix="prefix/",
- )
- init_mock.assert_not_called()
- execute_command_mock.assert_not_called()
- assert caplog.record_tuples == [
- (
- "switchbot_mqtt._actors.base",
- logging.DEBUG,
- f"received topic={topic} payload={payload!r}",
- ),
- (
- "switchbot_mqtt._actors.base",
- logging.ERROR,
- f"failed to find bluetooth low energy device with mac address {mac_address}",
- ),
- ]
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- ("topic", "payload"),
- [("homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set", b"ON")],
- )
- async def test__mqtt_command_callback_ignore_retained(
- caplog: _pytest.logging.LogCaptureFixture, topic: str, payload: bytes
- ) -> None:
- ActorMock = _mock_actor_class(
- command_topic_levels=_ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
- )
- message = aiomqtt.Message(
- topic=topic, payload=payload, qos=0, retain=True, mid=0, properties=None
- )
- with unittest.mock.patch.object(
- ActorMock, "__init__", return_value=None
- ) as init_mock, unittest.mock.patch.object(
- ActorMock, "execute_command"
- ) as execute_command_mock, caplog.at_level(
- logging.DEBUG
- ):
- await ActorMock._mqtt_command_callback(
- mqtt_client="client_dummy",
- message=message,
- retry_count=4,
- device_passwords={},
- fetch_device_info=True,
- mqtt_topic_prefix="homeassistant/",
- )
- init_mock.assert_not_called()
- execute_command_mock.assert_not_called()
- execute_command_mock.assert_not_awaited()
- assert caplog.record_tuples == [
- (
- "switchbot_mqtt._actors.base",
- logging.DEBUG,
- f"received topic={topic} payload={payload!r}",
- ),
- ("switchbot_mqtt._actors.base", logging.INFO, "ignoring retained message"),
- ]
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- ("topic_prefix", "state_topic_levels", "mac_address", "expected_topic"),
- # https://www.home-assistant.io/docs/mqtt/discovery/#switches
- [
- (
- "homeassistant/",
- _ButtonAutomator.MQTT_STATE_TOPIC_LEVELS,
- "aa:bb:cc:dd:ee:ff",
- "homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/state",
- ),
- (
- "",
- ["switchbot", _MQTTTopicPlaceholder.MAC_ADDRESS, "state"],
- "aa:bb:cc:dd:ee:gg",
- "switchbot/aa:bb:cc:dd:ee:gg/state",
- ),
- ],
- )
- @pytest.mark.parametrize("state", [b"ON", b"CLOSE"])
- @pytest.mark.parametrize("mqtt_publish_fails", [False, True])
- async def test__report_state(
- caplog: _pytest.logging.LogCaptureFixture,
- topic_prefix: str,
- state_topic_levels: typing.Tuple[_MQTTTopicLevel, ...],
- mac_address: str,
- expected_topic: str,
- state: bytes,
- mqtt_publish_fails: bool,
- ) -> None:
- # pylint: disable=too-many-arguments
- class _ActorMock(_MQTTControlledActor):
- MQTT_STATE_TOPIC_LEVELS = state_topic_levels
- def __init__(
- self,
- device: bleak.backends.device.BLEDevice,
- retry_count: int,
- password: typing.Optional[str],
- ) -> None:
- super().__init__(device=device, retry_count=retry_count, password=password)
- async def execute_command(
- self,
- *,
- mqtt_message_payload: bytes,
- mqtt_client: aiomqtt.Client,
- update_device_info: bool,
- mqtt_topic_prefix: str,
- ) -> None:
- pass
- def _get_device(self) -> None:
- return None
- mqtt_client_mock = unittest.mock.AsyncMock()
- if mqtt_publish_fails:
- # https://github.com/sbtinstruments/aiomqtt/blob/v1.2.1/aiomqtt/client.py#L678
- mqtt_client_mock.publish.side_effect = aiomqtt.MqttCodeError(
- MQTT_ERR_NO_CONN, "Could not publish message"
- )
- device = unittest.mock.Mock()
- device.address = mac_address
- with caplog.at_level(logging.DEBUG):
- actor = _ActorMock(device=device, retry_count=3, password=None)
- await actor.report_state(
- state=state, mqtt_client=mqtt_client_mock, mqtt_topic_prefix=topic_prefix
- )
- mqtt_client_mock.publish.assert_awaited_once_with(
- topic=expected_topic, payload=state, retain=True
- )
- assert caplog.record_tuples[0] == (
- "switchbot_mqtt._actors.base",
- logging.DEBUG,
- f"publishing topic={expected_topic} payload={state!r}",
- )
- if not mqtt_publish_fails:
- assert not caplog.records[1:]
- else:
- assert caplog.record_tuples[1:] == [
- (
- "switchbot_mqtt._actors.base",
- logging.ERROR,
- f"Failed to publish MQTT message on topic {expected_topic}:"
- " aiomqtt.MqttCodeError [code:4] The client is not currently connected.",
- )
- ]
|