diff --git a/deebot_client/mqtt_client.py b/deebot_client/mqtt_client.py index f31326ac..4982b4b5 100644 --- a/deebot_client/mqtt_client.py +++ b/deebot_client/mqtt_client.py @@ -145,7 +145,7 @@ async def _get_client(self) -> Client: username=credentials.user_id, password=credentials.token, logger=_CLIENT_LOGGER, - client_id=client_id, + identifier=client_id, tls_context=self._config.ssl_context, ) @@ -166,9 +166,8 @@ async def mqtt() -> None: await client.subscribe(topic) async def listen() -> None: - async with client.messages() as messages: - async for message in messages: - self._handle_message(message) + async for message in client.messages: + self._handle_message(message) tasks = [ asyncio.create_task(listen()), diff --git a/deebot_client/util/continents.py b/deebot_client/util/continents.py index 48b6127f..7577403d 100644 --- a/deebot_client/util/continents.py +++ b/deebot_client/util/continents.py @@ -4,261 +4,261 @@ def get_continent(country: str | None) -> str: """Return the continent for the given country or ww.""" if not country: - return "WW" - return COUNTRIES_TO_CONTINENTS.get(country, "WW") + return "ww" + return COUNTRIES_TO_CONTINENTS.get(country.upper(), "ww") # Copied from https://github.com/mrbungle64/ecovacs-deebot.js/blob/master/countries.json on 11.01.2024 COUNTRIES_TO_CONTINENTS = { - "AD": "EU", - "AE": "AS", - "AF": "AS", - "AG": "NA", - "AI": "NA", - "AL": "EU", - "AM": "AS", - "AO": "WW", - "AQ": "WW", - "AR": "WW", - "AS": "WW", - "AT": "EU", - "AU": "WW", - "AW": "NA", - "AX": "EU", - "AZ": "AS", - "BA": "EU", - "BB": "NA", - "BD": "AS", - "BE": "EU", - "BF": "WW", - "BG": "EU", - "BH": "AS", - "BI": "WW", - "BJ": "WW", - "BL": "NA", - "BM": "NA", - "BN": "AS", - "BO": "WW", - "BQ": "NA", - "BR": "WW", - "BS": "NA", - "BT": "AS", - "BV": "WW", - "BW": "WW", - "BY": "EU", - "BZ": "NA", - "CA": "NA", - "CC": "AS", - "CD": "WW", - "CF": "WW", - "CG": "WW", - "CH": "EU", - "CI": "WW", - "CK": "WW", - "CL": "WW", - "CM": "WW", - "CN": "WW", - "CO": "WW", - "CR": "NA", - "CU": "NA", - "CV": "WW", - "CW": "NA", - "CX": "AS", - "CY": "EU", - "CZ": "EU", - "DE": "EU", - "DJ": "WW", - "DK": "EU", - "DM": "NA", - "DO": "NA", - "DZ": "WW", - "EC": "WW", - "EE": "EU", - "EG": "WW", - "EH": "WW", - "ER": "WW", - "ES": "EU", - "ET": "WW", - "FI": "EU", - "FJ": "WW", - "FK": "WW", - "FM": "WW", - "FO": "EU", - "FR": "EU", - "GA": "WW", - "GB": "EU", - "GD": "NA", - "GE": "AS", - "GF": "WW", - "GG": "EU", - "GH": "WW", - "GI": "EU", - "GL": "NA", - "GM": "WW", - "GN": "WW", - "GP": "NA", - "GQ": "WW", - "GR": "EU", - "GS": "WW", - "GT": "NA", - "GU": "WW", - "GW": "WW", - "GY": "WW", - "HK": "AS", - "HM": "WW", - "HN": "NA", - "HR": "EU", - "HT": "NA", - "HU": "EU", - "ID": "AS", - "IE": "EU", - "IL": "AS", - "IM": "EU", - "IN": "AS", - "IO": "AS", - "IQ": "AS", - "IR": "AS", - "IS": "EU", - "IT": "EU", - "JE": "EU", - "JM": "NA", - "JO": "AS", - "JP": "AS", - "KE": "WW", - "KG": "AS", - "KH": "AS", - "KI": "WW", - "KM": "WW", - "KN": "NA", - "KP": "AS", - "KR": "AS", - "KW": "AS", - "KY": "NA", - "KZ": "AS", - "LA": "AS", - "LB": "AS", - "LC": "NA", - "LI": "EU", - "LK": "AS", - "LR": "WW", - "LS": "WW", - "LT": "EU", - "LU": "EU", - "LV": "EU", - "LY": "WW", - "MA": "WW", - "MC": "EU", - "MD": "EU", - "ME": "EU", - "MF": "NA", - "MG": "WW", - "MH": "WW", - "MK": "EU", - "ML": "WW", - "MM": "AS", - "MN": "AS", - "MO": "AS", - "MP": "WW", - "MQ": "NA", - "MR": "WW", - "MS": "NA", - "MT": "EU", - "MU": "WW", - "MV": "AS", - "MW": "WW", - "MX": "NA", - "MY": "AS", - "MZ": "WW", - "NA": "WW", - "NC": "WW", - "NE": "WW", - "NF": "WW", - "NG": "WW", - "NI": "NA", - "NL": "EU", - "NO": "EU", - "NP": "AS", - "NR": "WW", - "NU": "WW", - "NZ": "WW", - "OM": "AS", - "PA": "NA", - "PE": "WW", - "PF": "WW", - "PG": "WW", - "PH": "AS", - "PK": "AS", - "PL": "EU", - "PM": "NA", - "PN": "WW", - "PR": "NA", - "PS": "AS", - "PT": "EU", - "PW": "WW", - "PY": "WW", - "QA": "AS", - "RE": "WW", - "RO": "EU", - "RS": "EU", - "RU": "EU", - "RW": "WW", - "SA": "AS", - "SB": "WW", - "SC": "WW", - "SD": "WW", - "SE": "EU", - "SG": "AS", - "SH": "WW", - "SI": "EU", - "SJ": "EU", - "SK": "EU", - "SL": "WW", - "SM": "EU", - "SN": "WW", - "SO": "WW", - "SR": "WW", - "SS": "WW", - "ST": "WW", - "SV": "NA", - "SX": "NA", - "SY": "AS", - "SZ": "WW", - "TC": "NA", - "TD": "WW", - "TF": "WW", - "TG": "WW", - "TH": "AS", - "TJ": "AS", - "TK": "WW", - "TL": "WW", - "TM": "AS", - "TN": "WW", - "TO": "WW", - "TR": "AS", - "TT": "NA", - "TV": "WW", - "TW": "AS", - "TZ": "WW", - "UA": "EU", - "UG": "WW", - "UK": "EU", - "UM": "WW", - "US": "NA", - "UY": "WW", - "UZ": "AS", - "VA": "EU", - "VC": "NA", - "VE": "WW", - "VG": "NA", - "VI": "NA", - "VN": "AS", - "VU": "WW", - "WF": "WW", - "WS": "WW", - "XK": "EU", - "YE": "AS", - "YT": "WW", - "ZA": "WW", - "ZM": "WW", - "ZW": "WW", + "AD": "eu", + "AE": "as", + "AF": "as", + "AG": "na", + "AI": "na", + "AL": "eu", + "AM": "as", + "AO": "ww", + "AQ": "ww", + "AR": "ww", + "AS": "ww", + "AT": "eu", + "AU": "ww", + "AW": "na", + "AX": "eu", + "AZ": "as", + "BA": "eu", + "BB": "na", + "BD": "as", + "BE": "eu", + "BF": "ww", + "BG": "eu", + "BH": "as", + "BI": "ww", + "BJ": "ww", + "BL": "na", + "BM": "na", + "BN": "as", + "BO": "ww", + "BQ": "na", + "BR": "ww", + "BS": "na", + "BT": "as", + "BV": "ww", + "BW": "ww", + "BY": "eu", + "BZ": "na", + "CA": "na", + "CC": "as", + "CD": "ww", + "CF": "ww", + "CG": "ww", + "CH": "eu", + "CI": "ww", + "CK": "ww", + "CL": "ww", + "CM": "ww", + "CN": "ww", + "CO": "ww", + "CR": "na", + "CU": "na", + "CV": "ww", + "CW": "na", + "CX": "as", + "CY": "eu", + "CZ": "eu", + "DE": "eu", + "DJ": "ww", + "DK": "eu", + "DM": "na", + "DO": "na", + "DZ": "ww", + "EC": "ww", + "EE": "eu", + "EG": "ww", + "EH": "ww", + "ER": "ww", + "ES": "eu", + "ET": "ww", + "FI": "eu", + "FJ": "ww", + "FK": "ww", + "FM": "ww", + "FO": "eu", + "FR": "eu", + "GA": "ww", + "GB": "eu", + "GD": "na", + "GE": "as", + "GF": "ww", + "GG": "eu", + "GH": "ww", + "GI": "eu", + "GL": "na", + "GM": "ww", + "GN": "ww", + "GP": "na", + "GQ": "ww", + "GR": "eu", + "GS": "ww", + "GT": "na", + "GU": "ww", + "GW": "ww", + "GY": "ww", + "HK": "as", + "HM": "ww", + "HN": "na", + "HR": "eu", + "HT": "na", + "HU": "eu", + "ID": "as", + "IE": "eu", + "IL": "as", + "IM": "eu", + "IN": "as", + "IO": "as", + "IQ": "as", + "IR": "as", + "IS": "eu", + "IT": "eu", + "JE": "eu", + "JM": "na", + "JO": "as", + "JP": "as", + "KE": "ww", + "KG": "as", + "KH": "as", + "KI": "ww", + "KM": "ww", + "KN": "na", + "KP": "as", + "KR": "as", + "KW": "as", + "KY": "na", + "KZ": "as", + "LA": "as", + "LB": "as", + "LC": "na", + "LI": "eu", + "LK": "as", + "LR": "ww", + "LS": "ww", + "LT": "eu", + "LU": "eu", + "LV": "eu", + "LY": "ww", + "MA": "ww", + "MC": "eu", + "MD": "eu", + "ME": "eu", + "MF": "na", + "MG": "ww", + "MH": "ww", + "MK": "eu", + "ML": "ww", + "MM": "as", + "MN": "as", + "MO": "as", + "MP": "ww", + "MQ": "na", + "MR": "ww", + "MS": "na", + "MT": "eu", + "MU": "ww", + "MV": "as", + "MW": "ww", + "MX": "na", + "MY": "as", + "MZ": "ww", + "na": "ww", + "NC": "ww", + "NE": "ww", + "NF": "ww", + "NG": "ww", + "NI": "na", + "NL": "eu", + "NO": "eu", + "NP": "as", + "NR": "ww", + "NU": "ww", + "NZ": "ww", + "OM": "as", + "PA": "na", + "PE": "ww", + "PF": "ww", + "PG": "ww", + "PH": "as", + "PK": "as", + "PL": "eu", + "PM": "na", + "PN": "ww", + "PR": "na", + "PS": "as", + "PT": "eu", + "PW": "ww", + "PY": "ww", + "QA": "as", + "RE": "ww", + "RO": "eu", + "RS": "eu", + "RU": "eu", + "RW": "ww", + "SA": "as", + "SB": "ww", + "SC": "ww", + "SD": "ww", + "SE": "eu", + "SG": "as", + "SH": "ww", + "SI": "eu", + "SJ": "eu", + "SK": "eu", + "SL": "ww", + "SM": "eu", + "SN": "ww", + "SO": "ww", + "SR": "ww", + "SS": "ww", + "ST": "ww", + "SV": "na", + "SX": "na", + "SY": "as", + "SZ": "ww", + "TC": "na", + "TD": "ww", + "TF": "ww", + "TG": "ww", + "TH": "as", + "TJ": "as", + "TK": "ww", + "TL": "ww", + "TM": "as", + "TN": "ww", + "TO": "ww", + "TR": "as", + "TT": "na", + "TV": "ww", + "TW": "as", + "TZ": "ww", + "UA": "eu", + "UG": "ww", + "UK": "eu", + "UM": "ww", + "US": "na", + "UY": "ww", + "UZ": "as", + "VA": "eu", + "VC": "na", + "VE": "ww", + "VG": "na", + "VI": "na", + "VN": "as", + "VU": "ww", + "WF": "ww", + "WS": "ww", + "XK": "eu", + "YE": "as", + "YT": "ww", + "ZA": "ww", + "ZM": "ww", + "ZW": "ww", } diff --git a/requirements.txt b/requirements.txt index a4cd5ff7..0acbf54a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ aiohttp~=3.9 -aiomqtt>=1.0.0,<2.0 +aiomqtt>=2.0.0,<3.0 cachetools>=5.0.0,<6.0 defusedxml numpy>=1.23.2,<2.0 diff --git a/tests/conftest.py b/tests/conftest.py index 2c7ce770..4ec1ae45 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -99,7 +99,7 @@ async def test_mqtt_client( async with Client( hostname=mqtt_config.hostname, port=mqtt_config.port, - client_id="Test-helper", + identifier="Test-helper", tls_context=mqtt_config.ssl_context, ) as client: yield client diff --git a/tests/mqtt_util.py b/tests/mqtt_util.py new file mode 100644 index 00000000..8fd8bab7 --- /dev/null +++ b/tests/mqtt_util.py @@ -0,0 +1,41 @@ +"""Utilities for testing MQTT.""" +import asyncio +from collections.abc import Callable +import datetime +import json +from unittest.mock import MagicMock, Mock + +from aiomqtt import Client + +from deebot_client.event_bus import EventBus +from deebot_client.models import DeviceInfo +from deebot_client.mqtt_client import MqttClient, SubscriberInfo + + +async def verify_subscribe( + test_client: Client, device_info: DeviceInfo, mock: Mock, *, expected_called: bool +) -> None: + command = "test" + data = json.dumps({"test": str(datetime.datetime.now())}).encode("utf-8") + topic = f"iot/atr/{command}/{device_info.did}/{device_info.get_class}/{device_info.resource}/j" + await test_client.publish(topic, data) + + await asyncio.sleep(0.1) + if expected_called: + mock.assert_called_with(command, data) + else: + mock.assert_not_called() + + mock.reset_mock() + + +async def subscribe( + mqtt_client: MqttClient, device_info: DeviceInfo +) -> tuple[Mock, Mock, Callable[[], None]]: + events = Mock(spec=EventBus) + callback = MagicMock() + unsubscribe = await mqtt_client.subscribe( + SubscriberInfo(device_info, events, callback) + ) + await asyncio.sleep(0.1) + return (events, callback, unsubscribe) diff --git a/tests/test_mqtt_client.py b/tests/test_mqtt_client.py index deeba579..fd808cb1 100644 --- a/tests/test_mqtt_client.py +++ b/tests/test_mqtt_client.py @@ -1,5 +1,4 @@ import asyncio -from collections.abc import Callable import datetime import json import logging @@ -16,43 +15,11 @@ from deebot_client.commands.json.battery import GetBattery from deebot_client.commands.json.volume import SetVolume from deebot_client.const import DataType -from deebot_client.event_bus import EventBus from deebot_client.exceptions import AuthenticationError from deebot_client.models import Configuration, DeviceInfo -from deebot_client.mqtt_client import MqttClient, MqttConfiguration, SubscriberInfo +from deebot_client.mqtt_client import MqttClient, MqttConfiguration -from .fixtures.mqtt_server import MqttServer - -_WAITING_AFTER_RESTART = 30 - - -async def _verify_subscribe( - test_client: Client, device_info: DeviceInfo, mock: Mock, *, expected_called: bool -) -> None: - command = "test" - data = json.dumps({"test": str(datetime.datetime.now())}).encode("utf-8") - topic = f"iot/atr/{command}/{device_info.did}/{device_info.get_class}/{device_info.resource}/j" - await test_client.publish(topic, data) - - await asyncio.sleep(0.1) - if expected_called: - mock.assert_called_with(command, data) - else: - mock.assert_not_called() - - mock.reset_mock() - - -async def _subscribe( - mqtt_client: MqttClient, device_info: DeviceInfo -) -> tuple[Mock, Mock, Callable[[], None]]: - events = Mock(spec=EventBus) - callback = MagicMock() - unsubscribe = await mqtt_client.subscribe( - SubscriberInfo(device_info, events, callback) - ) - await asyncio.sleep(0.1) - return (events, callback, unsubscribe) +from .mqtt_util import subscribe, verify_subscribe async def test_last_message_received_at( @@ -75,63 +42,6 @@ async def test_last_message_received_at( assert mqtt_client.last_message_received_at == expected -@pytest.mark.skip(reason="Wait for sbtinstruments/aiomqtt#232 be merged") -@pytest.mark.timeout(_WAITING_AFTER_RESTART + 10) -async def test_client_reconnect_on_broker_error( - mqtt_client: MqttClient, - mqtt_server: MqttServer, - device_info: DeviceInfo, - mqtt_config: MqttConfiguration, - caplog: pytest.LogCaptureFixture, -) -> None: - (_, callback, _) = await _subscribe(mqtt_client, device_info) - async with Client( - hostname=mqtt_config.hostname, - port=mqtt_config.port, - client_id="Test-helper", - tls_context=mqtt_config.ssl_context, - ) as client: - # test client cannot be used as we restart the broker in this test - await _verify_subscribe(client, device_info, callback, expected_called=True) - - caplog.clear() - mqtt_server.stop() - await asyncio.sleep(0.1) - - assert ( - "deebot_client.mqtt_client", - logging.WARNING, - "Connection lost; Reconnecting in 5 seconds ...", - ) in caplog.record_tuples - caplog.clear() - - mqtt_server.run() - - expected_log_tuple = ( - "deebot_client.mqtt_client", - logging.DEBUG, - "All mqtt tasks created", - ) - for i in range(_WAITING_AFTER_RESTART): - print(f"Wait for success reconnect... {i}/{_WAITING_AFTER_RESTART}") - if expected_log_tuple in caplog.record_tuples: - async with Client( - hostname=mqtt_config.hostname, - port=mqtt_config.port, - client_id="Test-helper", - tls_context=mqtt_config.ssl_context, - ) as client: - # test client cannot be used as we restart the broker in this test - await _verify_subscribe( - client, device_info, callback, expected_called=True - ) - return - - await asyncio.sleep(1) - - pytest.fail("Reconnect failed") - - _test_MqttConfiguration_data = [ ("cn", None, "mq.ecouser.net"), ("cn", "localhost", "localhost"), @@ -182,16 +92,16 @@ def test_MqttConfiguration_hostname_none(config: Configuration) -> None: async def test_client_bot_subscription( mqtt_client: MqttClient, device_info: DeviceInfo, test_mqtt_client: Client ) -> None: - (_, callback, unsubscribe) = await _subscribe(mqtt_client, device_info) + (_, callback, unsubscribe) = await subscribe(mqtt_client, device_info) - await _verify_subscribe( + await verify_subscribe( test_mqtt_client, device_info, callback, expected_called=True ) unsubscribe() await asyncio.sleep(0.1) - await _verify_subscribe( + await verify_subscribe( test_mqtt_client, device_info, callback, expected_called=False ) @@ -199,21 +109,21 @@ async def test_client_bot_subscription( async def test_client_reconnect_manual( mqtt_client: MqttClient, device_info: DeviceInfo, test_mqtt_client: Client ) -> None: - (_, callback, _) = await _subscribe(mqtt_client, device_info) + (_, callback, _) = await subscribe(mqtt_client, device_info) - await _verify_subscribe( + await verify_subscribe( test_mqtt_client, device_info, callback, expected_called=True ) await mqtt_client.disconnect() - await _verify_subscribe( + await verify_subscribe( test_mqtt_client, device_info, callback, expected_called=False ) await mqtt_client.connect() await asyncio.sleep(0.1) - await _verify_subscribe( + await verify_subscribe( test_mqtt_client, device_info, callback, expected_called=True ) @@ -244,7 +154,7 @@ async def test_p2p_success( test_mqtt_client: Client, ) -> None: """Test p2p workflow on SetVolume.""" - (events, _, _) = await _subscribe(mqtt_client, device_info) + (events, _, _) = await subscribe(mqtt_client, device_info) assert len(mqtt_client._received_p2p_commands) == 0 command_object = Mock(spec=SetVolume) @@ -293,7 +203,7 @@ async def test_p2p_not_supported( caplog: pytest.LogCaptureFixture, ) -> None: """Test that unsupported command will be logged.""" - await _subscribe(mqtt_client, device_info) + await subscribe(mqtt_client, device_info) command_name: str = GetBattery.name await _publish_p2p( @@ -344,7 +254,7 @@ async def test_p2p_to_late( """Test p2p when response comes in to late.""" # reduce ttl to 1 seconds mqtt_client._received_p2p_commands = TTLCache(maxsize=60 * 60, ttl=1) - await _subscribe(mqtt_client, device_info) + await subscribe(mqtt_client, device_info) assert len(mqtt_client._received_p2p_commands) == 0 command_object = Mock(spec=SetVolume) @@ -393,7 +303,7 @@ async def test_p2p_parse_error( caplog: pytest.LogCaptureFixture, ) -> None: """Test p2p parse error.""" - await _subscribe(mqtt_client, device_info) + await subscribe(mqtt_client, device_info) command_object = Mock(spec=SetVolume) command_name = SetVolume.name diff --git a/tests/test_mqtt_client_reconnect.py b/tests/test_mqtt_client_reconnect.py new file mode 100644 index 00000000..58938761 --- /dev/null +++ b/tests/test_mqtt_client_reconnect.py @@ -0,0 +1,79 @@ +import asyncio +from collections.abc import Generator +import logging + +from aiomqtt import Client +import pytest + +from deebot_client.models import DeviceInfo +from deebot_client.mqtt_client import MqttClient, MqttConfiguration + +from .fixtures.mqtt_server import MqttServer +from .mqtt_util import subscribe, verify_subscribe + +_WAITING_AFTER_RESTART = 30 + + +@pytest.fixture +def mqtt_server() -> Generator[MqttServer, None, None]: + server = MqttServer() + server.config.options["ports"] = {"1883/tcp": 54321} + server.run() + yield server + server.stop() + + +@pytest.mark.timeout(_WAITING_AFTER_RESTART + 10) +async def test_client_reconnect_on_broker_error( + mqtt_client: MqttClient, + mqtt_server: MqttServer, + device_info: DeviceInfo, + mqtt_config: MqttConfiguration, + caplog: pytest.LogCaptureFixture, +) -> None: + (_, callback, _) = await subscribe(mqtt_client, device_info) + async with Client( + hostname=mqtt_config.hostname, + port=mqtt_config.port, + identifier="Test-helper", + tls_context=mqtt_config.ssl_context, + ) as client: + # test client cannot be used as we restart the broker in this test + await verify_subscribe(client, device_info, callback, expected_called=True) + + caplog.clear() + mqtt_server.stop() + await asyncio.sleep(0.1) + + assert ( + "deebot_client.mqtt_client", + logging.WARNING, + "Connection lost; Reconnecting in 5 seconds ...", + ) in caplog.record_tuples + caplog.clear() + + mqtt_server.run() + + expected_log_tuple = ( + "deebot_client.mqtt_client", + logging.DEBUG, + "All mqtt tasks created", + ) + for i in range(_WAITING_AFTER_RESTART): + print(f"Wait for success reconnect... {i}/{_WAITING_AFTER_RESTART}") + if expected_log_tuple in caplog.record_tuples: + async with Client( + hostname=mqtt_config.hostname, + port=mqtt_config.port, + identifier="Test-helper", + tls_context=mqtt_config.ssl_context, + ) as client: + # test client cannot be used as we restart the broker in this test + await verify_subscribe( + client, device_info, callback, expected_called=True + ) + return + + await asyncio.sleep(1) + + pytest.fail("Reconnect failed") diff --git a/tests/util/test_continents.py b/tests/util/test_continents.py index afab7c3a..3a9f55d7 100644 --- a/tests/util/test_continents.py +++ b/tests/util/test_continents.py @@ -6,12 +6,13 @@ @pytest.mark.parametrize( ("continent", "expected"), [ - ("IT", "EU"), - ("DE", "EU"), - ("US", "NA"), - ("invalid", "WW"), - ("", "WW"), - ("XX", "WW"), + ("IT", "eu"), + ("it", "eu"), + ("DE", "eu"), + ("US", "na"), + ("invalid", "ww"), + ("", "ww"), + ("XX", "ww"), ], ) def test_get_continent(continent: str, expected: str) -> None: