From 7b1b14d1e6d2486f3e3d4ed8022d234112bdcd5e Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 22 Jan 2025 11:54:32 +0100 Subject: [PATCH] Allow https for klaptransport (#1415) Later firmware versions on robovacs use `KLAP` over https instead of ssltransport (reported as AES) --- README.md | 2 +- SUPPORTED.md | 2 + devtools/dump_devinfo.py | 4 +- kasa/cli/discover.py | 7 +- kasa/device_factory.py | 10 +- kasa/deviceconfig.py | 6 +- kasa/discover.py | 10 +- kasa/protocols/smartprotocol.py | 16 + kasa/transports/aestransport.py | 2 + kasa/transports/klaptransport.py | 47 +- kasa/transports/linkietransport.py | 2 + kasa/transports/sslaestransport.py | 2 + kasa/transports/ssltransport.py | 2 + tests/discovery_fixtures.py | 10 +- .../smart/RV30 Max(US)_1.0_1.2.0.json | 888 ++++++++++++++++++ tests/smart/modules/test_clean.py | 4 + tests/test_cli.py | 4 +- tests/test_device_factory.py | 5 +- tests/test_discovery.py | 24 +- 19 files changed, 1019 insertions(+), 28 deletions(-) create mode 100644 tests/fixtures/smart/RV30 Max(US)_1.0_1.2.0.json diff --git a/README.md b/README.md index c40e6666..8c7ac09a 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ The following devices have been tested and confirmed as working. If your device - **Cameras**: C100, C210, C220, C225, C325WB, C520WS, C720, D230, TC65, TC70 - **Hubs**: H100, H200 - **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315 -- **Vacuums**: RV20 Max Plus +- **Vacuums**: RV20 Max Plus, RV30 Max [^1]: Model requires authentication diff --git a/SUPPORTED.md b/SUPPORTED.md index 8785d48e..905f7ab3 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -330,6 +330,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - **RV20 Max Plus** - Hardware: 1.0 (EU) / Firmware: 1.0.7 +- **RV30 Max** + - Hardware: 1.0 (US) / Firmware: 1.2.0 diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index cee7a7bf..a0fff0e5 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -300,7 +300,9 @@ async def cli( connection_type = DeviceConnectionParameters.from_values( dr.device_type, dr.mgt_encrypt_schm.encrypt_type, - dr.mgt_encrypt_schm.lv, + login_version=dr.mgt_encrypt_schm.lv, + https=dr.mgt_encrypt_schm.is_support_https, + http_port=dr.mgt_encrypt_schm.http_port, ) dc = DeviceConfig( host=host, diff --git a/kasa/cli/discover.py b/kasa/cli/discover.py index 07500f3b..af367e32 100644 --- a/kasa/cli/discover.py +++ b/kasa/cli/discover.py @@ -261,8 +261,11 @@ async def config(ctx: click.Context) -> DeviceDict: host_port = host + (f":{port}" if port else "") def on_attempt(connect_attempt: ConnectAttempt, success: bool) -> None: - prot, tran, dev = connect_attempt - key_str = f"{prot.__name__} + {tran.__name__} + {dev.__name__}" + prot, tran, dev, https = connect_attempt + key_str = ( + f"{prot.__name__} + {tran.__name__} + {dev.__name__}" + f" + {'https' if https else 'http'}" + ) result = "succeeded" if success else "failed" msg = f"Attempt to connect to {host_port} with {key_str} {result}" echo(msg) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index b09cf655..83661038 100644 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -189,6 +189,7 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol :param config: Device config to derive protocol :param strict: Require exact match on encrypt type """ + _LOGGER.debug("Finding protocol for %s", config.host) ctype = config.connection_type protocol_name = ctype.device_family.value.split(".")[0] _LOGGER.debug("Finding protocol for %s", ctype.device_family) @@ -203,9 +204,11 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol return None return IotProtocol(transport=LinkieTransportV2(config=config)) - if ctype.device_family is DeviceFamily.SmartTapoRobovac: - if strict and ctype.encryption_type is not DeviceEncryptionType.Aes: - return None + # Older FW used a different transport + if ( + ctype.device_family is DeviceFamily.SmartTapoRobovac + and ctype.encryption_type is DeviceEncryptionType.Aes + ): return SmartProtocol(transport=SslTransport(config=config)) protocol_transport_key = ( @@ -223,6 +226,7 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol "IOT.KLAP": (IotProtocol, KlapTransport), "SMART.AES": (SmartProtocol, AesTransport), "SMART.KLAP": (SmartProtocol, KlapTransportV2), + "SMART.KLAP.HTTPS": (SmartProtocol, KlapTransportV2), # H200 is device family SMART.TAPOHUB and uses SmartCamProtocol so use # https to distuingish from SmartProtocol devices "SMART.AES.HTTPS": (SmartCamProtocol, SslAesTransport), diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index c5d5b1d5..b6325570 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -20,7 +20,7 @@ None {'host': '127.0.0.3', 'timeout': 5, 'credentials': {'username': 'user@example.com', \ 'password': 'great_password'}, 'connection_type'\ : {'device_family': 'SMART.TAPOBULB', 'encryption_type': 'KLAP', 'login_version': 2, \ -'https': False}} +'https': False, 'http_port': 80}} >>> later_device = await Device.connect(config=Device.Config.from_dict(config_dict)) >>> print(later_device.alias) # Alias is available as connect() calls update() @@ -98,13 +98,16 @@ class DeviceConnectionParameters(_DeviceConfigBaseMixin): encryption_type: DeviceEncryptionType login_version: int | None = None https: bool = False + http_port: int | None = None @staticmethod def from_values( device_family: str, encryption_type: str, + *, login_version: int | None = None, https: bool | None = None, + http_port: int | None = None, ) -> DeviceConnectionParameters: """Return connection parameters from string values.""" try: @@ -115,6 +118,7 @@ class DeviceConnectionParameters(_DeviceConfigBaseMixin): DeviceEncryptionType(encryption_type), login_version, https, + http_port=http_port, ) except (ValueError, TypeError) as ex: raise KasaException( diff --git a/kasa/discover.py b/kasa/discover.py index abcd7d5f..36d6f277 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -146,6 +146,7 @@ class ConnectAttempt(NamedTuple): protocol: type transport: type device: type + https: bool class DiscoveredMeta(TypedDict): @@ -637,10 +638,10 @@ class Discover: Device.Family.IotIpCamera, } candidates: dict[ - tuple[type[BaseProtocol], type[BaseTransport], type[Device]], + tuple[type[BaseProtocol], type[BaseTransport], type[Device], bool], tuple[BaseProtocol, DeviceConfig], ] = { - (type(protocol), type(protocol._transport), device_class): ( + (type(protocol), type(protocol._transport), device_class, https): ( protocol, config, ) @@ -870,8 +871,9 @@ class Discover: config.connection_type = DeviceConnectionParameters.from_values( type_, encrypt_type, - login_version, - encrypt_schm.is_support_https, + login_version=login_version, + https=encrypt_schm.is_support_https, + http_port=encrypt_schm.http_port, ) except KasaException as ex: raise UnsupportedDeviceError( diff --git a/kasa/protocols/smartprotocol.py b/kasa/protocols/smartprotocol.py index 5af7a81b..6b3b03be 100644 --- a/kasa/protocols/smartprotocol.py +++ b/kasa/protocols/smartprotocol.py @@ -36,6 +36,18 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) + +def _mask_area_list(area_list: list[dict[str, Any]]) -> list[dict[str, Any]]: + def mask_area(area: dict[str, Any]) -> dict[str, Any]: + result = {**area} + # Will leave empty names as blank + if area.get("name"): + result["name"] = "I01BU0tFRF9OQU1FIw==" # #MASKED_NAME# + return result + + return [mask_area(area) for area in area_list] + + REDACTORS: dict[str, Callable[[Any], Any] | None] = { "latitude": lambda x: 0, "longitude": lambda x: 0, @@ -71,6 +83,10 @@ REDACTORS: dict[str, Callable[[Any], Any] | None] = { "custom_sn": lambda _: "000000000000", "location": lambda x: "#MASKED_NAME#" if x else "", "map_data": lambda x: "#SCRUBBED_MAPDATA#" if x else "", + "map_name": lambda x: "I01BU0tFRF9OQU1FIw==", # #MASKED_NAME# + "area_list": _mask_area_list, + # unknown robovac binary blob in get_device_info + "cd": lambda x: "I01BU0tFRF9CSU5BUlkj", # #MASKED_BINARY# } # Queries that are known not to work properly when sent as a diff --git a/kasa/transports/aestransport.py b/kasa/transports/aestransport.py index 3466ca98..45b963fe 100644 --- a/kasa/transports/aestransport.py +++ b/kasa/transports/aestransport.py @@ -120,6 +120,8 @@ class AesTransport(BaseTransport): @property def default_port(self) -> int: """Default port for the transport.""" + if port := self._config.connection_type.http_port: + return port return self.DEFAULT_PORT @property diff --git a/kasa/transports/klaptransport.py b/kasa/transports/klaptransport.py index 508bba09..8253e0ae 100644 --- a/kasa/transports/klaptransport.py +++ b/kasa/transports/klaptransport.py @@ -48,6 +48,7 @@ import datetime import hashlib import logging import secrets +import ssl import struct import time from asyncio import Future @@ -92,8 +93,21 @@ class KlapTransport(BaseTransport): """ DEFAULT_PORT: int = 80 + DEFAULT_HTTPS_PORT: int = 4433 + SESSION_COOKIE_NAME = "TP_SESSIONID" TIMEOUT_COOKIE_NAME = "TIMEOUT" + # Copy & paste from sslaestransport + CIPHERS = ":".join( + [ + "AES256-GCM-SHA384", + "AES256-SHA256", + "AES128-GCM-SHA256", + "AES128-SHA256", + "AES256-SHA", + ] + ) + _ssl_context: ssl.SSLContext | None = None def __init__( self, @@ -125,12 +139,20 @@ class KlapTransport(BaseTransport): self._session_cookie: dict[str, Any] | None = None _LOGGER.debug("Created KLAP transport for %s", self._host) - self._app_url = URL(f"http://{self._host}:{self._port}/app") + protocol = "https" if config.connection_type.https else "http" + self._app_url = URL(f"{protocol}://{self._host}:{self._port}/app") self._request_url = self._app_url / "request" @property def default_port(self) -> int: """Default port for the transport.""" + config = self._config + if port := config.connection_type.http_port: + return port + + if config.connection_type.https: + return self.DEFAULT_HTTPS_PORT + return self.DEFAULT_PORT @property @@ -152,7 +174,9 @@ class KlapTransport(BaseTransport): url = self._app_url / "handshake1" - response_status, response_data = await self._http_client.post(url, data=payload) + response_status, response_data = await self._http_client.post( + url, data=payload, ssl=await self._get_ssl_context() + ) if _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( @@ -263,6 +287,7 @@ class KlapTransport(BaseTransport): url, data=payload, cookies_dict=self._session_cookie, + ssl=await self._get_ssl_context(), ) if _LOGGER.isEnabledFor(logging.DEBUG): @@ -337,6 +362,7 @@ class KlapTransport(BaseTransport): params={"seq": seq}, data=payload, cookies_dict=self._session_cookie, + ssl=await self._get_ssl_context(), ) msg = ( @@ -413,6 +439,23 @@ class KlapTransport(BaseTransport): un = creds.username return md5(un.encode()) + # Copy & paste from sslaestransport. + def _create_ssl_context(self) -> ssl.SSLContext: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.set_ciphers(self.CIPHERS) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + return context + + # Copy & paste from sslaestransport. + async def _get_ssl_context(self) -> ssl.SSLContext: + if not self._ssl_context: + loop = asyncio.get_running_loop() + self._ssl_context = await loop.run_in_executor( + None, self._create_ssl_context + ) + return self._ssl_context + class KlapTransportV2(KlapTransport): """Implementation of the KLAP encryption protocol with v2 hanshake hashes.""" diff --git a/kasa/transports/linkietransport.py b/kasa/transports/linkietransport.py index 779d182e..b817373c 100644 --- a/kasa/transports/linkietransport.py +++ b/kasa/transports/linkietransport.py @@ -55,6 +55,8 @@ class LinkieTransportV2(BaseTransport): @property def default_port(self) -> int: """Default port for the transport.""" + if port := self._config.connection_type.http_port: + return port return self.DEFAULT_PORT @property diff --git a/kasa/transports/sslaestransport.py b/kasa/transports/sslaestransport.py index eb67eda8..eeb29809 100644 --- a/kasa/transports/sslaestransport.py +++ b/kasa/transports/sslaestransport.py @@ -133,6 +133,8 @@ class SslAesTransport(BaseTransport): @property def default_port(self) -> int: """Default port for the transport.""" + if port := self._config.connection_type.http_port: + return port return self.DEFAULT_PORT @staticmethod diff --git a/kasa/transports/ssltransport.py b/kasa/transports/ssltransport.py index 4471dccb..e4fef9a3 100644 --- a/kasa/transports/ssltransport.py +++ b/kasa/transports/ssltransport.py @@ -94,6 +94,8 @@ class SslTransport(BaseTransport): @property def default_port(self) -> int: """Default port for the transport.""" + if port := self._config.connection_type.http_port: + return port return self.DEFAULT_PORT @property diff --git a/tests/discovery_fixtures.py b/tests/discovery_fixtures.py index eb843f1a..2db79e91 100644 --- a/tests/discovery_fixtures.py +++ b/tests/discovery_fixtures.py @@ -159,6 +159,7 @@ def create_discovery_mock(ip: str, fixture_data: dict): https: bool login_version: int | None = None port_override: int | None = None + http_port: int | None = None @property def model(self) -> str: @@ -194,9 +195,15 @@ def create_discovery_mock(ip: str, fixture_data: dict): ): login_version = max([int(i) for i in et]) https = discovery_result["mgt_encrypt_schm"]["is_support_https"] + http_port = discovery_result["mgt_encrypt_schm"].get("http_port") + if not http_port: # noqa: SIM108 + # Not all discovery responses set the http port, i.e. smartcam. + default_port = 443 if https else 80 + else: + default_port = http_port dm = _DiscoveryMock( ip, - 80, + default_port, 20002, discovery_data, fixture_data, @@ -204,6 +211,7 @@ def create_discovery_mock(ip: str, fixture_data: dict): encrypt_type, https, login_version, + http_port=http_port, ) else: sys_info = fixture_data["system"]["get_sysinfo"] diff --git a/tests/fixtures/smart/RV30 Max(US)_1.0_1.2.0.json b/tests/fixtures/smart/RV30 Max(US)_1.0_1.2.0.json new file mode 100644 index 00000000..9b6484da --- /dev/null +++ b/tests/fixtures/smart/RV30 Max(US)_1.0_1.2.0.json @@ -0,0 +1,888 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "quick_setup", + "ver_code": 1 + }, + { + "id": "clean", + "ver_code": 3 + }, + { + "id": "battery", + "ver_code": 1 + }, + { + "id": "consumables", + "ver_code": 2 + }, + { + "id": "direction_control", + "ver_code": 1 + }, + { + "id": "button_and_led", + "ver_code": 1 + }, + { + "id": "speaker", + "ver_code": 3 + }, + { + "id": "schedule", + "ver_code": 3 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "map", + "ver_code": 2 + }, + { + "id": "auto_change_map", + "ver_code": 2 + }, + { + "id": "mop", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "do_not_disturb", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "charge_pose_clean", + "ver_code": 1 + }, + { + "id": "continue_breakpoint_sweep", + "ver_code": 1 + }, + { + "id": "goto_point", + "ver_code": 1 + }, + { + "id": "furniture", + "ver_code": 1 + }, + { + "id": "map_cloud_backup", + "ver_code": 1 + }, + { + "id": "dev_log", + "ver_code": 1 + }, + { + "id": "map_lock", + "ver_code": 1 + }, + { + "id": "carpet_area", + "ver_code": 1 + }, + { + "id": "clean_angle", + "ver_code": 1 + }, + { + "id": "clean_percent", + "ver_code": 1 + }, + { + "id": "no_pose_config", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "RV30 Max(US)", + "device_type": "SMART.TAPOROBOVAC", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "7C-F1-7E-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 4433, + "is_support_https": true + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "getAreaUnit": { + "area_unit": 1 + }, + "getAutoChangeMap": { + "auto_change_map": true + }, + "getBatteryInfo": { + "battery_percentage": 100 + }, + "getCarpetClean": { + "carpet_clean_prefer": "boost" + }, + "getChildLockInfo": { + "child_lock_status": false + }, + "getCleanAttr": { + "cistern": 1, + "clean_number": 1, + "suction": 2 + }, + "getCleanInfo": { + "clean_area": 59, + "clean_percent": 100, + "clean_time": 56 + }, + "getCleanRecords": { + "lastest_day_record": [ + 1737387294, + 56, + 59, + 1 + ], + "record_list": [ + { + "clean_area": 59, + "clean_time": 57, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 0, + "start_type": 4, + "task_type": 0, + "timestamp": 1737041654 + }, + { + "clean_area": 39, + "clean_time": 58, + "dust_collection": false, + "error": 0, + "info_num": 1, + "map_id": 1736541042, + "message": 0, + "record_index": 1, + "start_type": 1, + "task_type": 0, + "timestamp": 1737055944 + }, + { + "clean_area": 1, + "clean_time": 3, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 2, + "start_type": 1, + "task_type": 4, + "timestamp": 1737074472 + }, + { + "clean_area": 59, + "clean_time": 58, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 3, + "start_type": 4, + "task_type": 0, + "timestamp": 1737128195 + }, + { + "clean_area": 68, + "clean_time": 78, + "dust_collection": false, + "error": 0, + "info_num": 2, + "map_id": 1736541042, + "message": 0, + "record_index": 4, + "start_type": 1, + "task_type": 1, + "timestamp": 1737216716 + }, + { + "clean_area": 3, + "clean_time": 3, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734742958, + "message": 0, + "record_index": 5, + "start_type": 1, + "task_type": 3, + "timestamp": 1737300731 + }, + { + "clean_area": 20, + "clean_time": 16, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734742958, + "message": 0, + "record_index": 6, + "start_type": 1, + "task_type": 3, + "timestamp": 1737304391 + }, + { + "clean_area": 59, + "clean_time": 56, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 7, + "start_type": 4, + "task_type": 0, + "timestamp": 1737387294 + }, + { + "clean_area": 17, + "clean_time": 16, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 8, + "start_type": 1, + "task_type": 3, + "timestamp": 1736707487 + }, + { + "clean_area": 8, + "clean_time": 10, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 9, + "start_type": 1, + "task_type": 4, + "timestamp": 1736708425 + }, + { + "clean_area": 59, + "clean_time": 54, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 10, + "start_type": 4, + "task_type": 0, + "timestamp": 1736782261 + }, + { + "clean_area": 60, + "clean_time": 56, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 11, + "start_type": 4, + "task_type": 0, + "timestamp": 1736868752 + }, + { + "clean_area": 58, + "clean_time": 68, + "dust_collection": true, + "error": 1, + "info_num": 0, + "map_id": 1736541042, + "message": 0, + "record_index": 12, + "start_type": 1, + "task_type": 1, + "timestamp": 1736881428 + }, + { + "clean_area": 59, + "clean_time": 59, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 13, + "start_type": 4, + "task_type": 0, + "timestamp": 1736955682 + }, + { + "clean_area": 36, + "clean_time": 33, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 14, + "start_type": 1, + "task_type": 4, + "timestamp": 1736960713 + } + ], + "record_list_num": 15, + "total_area": 2304, + "total_number": 85, + "total_time": 2510 + }, + "getCleanStatus": { + "clean_status": 0, + "is_mapping": false, + "is_relocating": false, + "is_working": false + }, + "getConsumablesInfo": { + "charge_contact_time": 660, + "edge_brush_time": 2743, + "filter_time": 287, + "main_brush_lid_time": 2462, + "rag_time": 0, + "roll_brush_time": 2719, + "sensor_time": 935 + }, + "getCurrentVoiceLanguage": { + "name": "bb053ca2c5605a55090fcdb952f3902b", + "version": 2 + }, + "getDoNotDisturb": { + "do_not_disturb": true, + "e_min": 480, + "s_min": 1320 + }, + "getMapData": { + "area_list": [ + { + "cistern": 1, + "clean_number": 1, + "color": 3, + "floor_texture": -1, + "id": 5, + "name": "I01BU0tFRF9OQU1FIw==", + "suction": 2, + "type": "room" + }, + { + "cistern": 1, + "clean_number": 1, + "color": 4, + "floor_texture": -1, + "id": 6, + "name": "I01BU0tFRF9OQU1FIw==", + "suction": 2, + "type": "room" + }, + { + "cistern": 1, + "clean_number": 1, + "color": 1, + "floor_texture": 0, + "id": 2, + "name": "I01BU0tFRF9OQU1FIw==", + "suction": 2, + "type": "room" + }, + { + "cistern": 1, + "clean_number": 1, + "color": 5, + "floor_texture": 90, + "id": 3, + "name": "I01BU0tFRF9OQU1FIw==", + "suction": 2, + "type": "room" + }, + { + "cistern": 1, + "clean_number": 1, + "color": 2, + "floor_texture": -1, + "id": 4, + "name": "I01BU0tFRF9OQU1FIw==", + "suction": 2, + "type": "room" + }, + { + "id": 401, + "type": "virtual_wall", + "vertexs": [ + [ + 4711, + 985 + ], + [ + 4717, + -404 + ] + ] + }, + { + "id": 301, + "type": "forbid", + "vertexs": [ + [ + 3061, + -3027 + ], + [ + 3580, + -3027 + ], + [ + 3580, + -3692 + ], + [ + 3061, + -3692 + ] + ] + }, + { + "id": 402, + "type": "virtual_wall", + "vertexs": [ + [ + 5302, + 6816 + ], + [ + 5304, + 4924 + ] + ] + }, + { + "cistern": -1, + "clean_number": 1, + "id": 501, + "suction": -1, + "type": "area", + "vertexs": [ + [ + 2889, + 6241 + ], + [ + 3721, + 6241 + ], + [ + 3721, + 4919 + ], + [ + 2889, + 4919 + ] + ] + }, + { + "carpet_strategy": 11, + "id": 101, + "type": "carpet_rectangle", + "vertexs": [ + [ + 20, + -2012 + ], + [ + 2857, + -2012 + ], + [ + 2857, + -4122 + ], + [ + 20, + -4122 + ] + ] + }, + { + "carpet_strategy": 11, + "id": 102, + "type": "carpet_rectangle", + "vertexs": [ + [ + 1327, + 3064 + ], + [ + 2428, + 3064 + ], + [ + 2428, + 2258 + ], + [ + 1327, + 2258 + ] + ] + }, + { + "carpet_strategy": 11, + "id": 103, + "type": "carpet_rectangle", + "vertexs": [ + [ + 4458, + 5974 + ], + [ + 5336, + 5974 + ], + [ + 5336, + 4903 + ], + [ + 4458, + 4903 + ] + ] + }, + { + "carpet_strategy": 11, + "id": 104, + "type": "carpet_rectangle", + "vertexs": [ + [ + -1383, + 2730 + ], + [ + -761, + 2730 + ], + [ + -761, + 1587 + ], + [ + -1383, + 1587 + ] + ] + } + ], + "auto_area_flag": true, + "bit_list": { + "auto_area": [ + 0, + 100 + ], + "barrier": 0, + "clean": 255, + "none": 127 + }, + "bitnum": 8, + "charge_coor": [ + 65, + 134, + 272 + ], + "furniture_list": [], + "height": 303, + "map_data": "#SCRUBBED_MAPDATA#", + "map_hash": "A5D8FA4487CC40312EF58D8123F0A4CC", + "map_id": 1734727686, + "map_locked": 0, + "map_name": "I01BU0tFRF9OQU1FIw==", + "origin_coor": [ + -33, + -108, + 270 + ], + "path_id": 122, + "pix_len": 66660, + "pix_lz4len": 6826, + "real_charge_coor": [ + 1599, + 1295, + 272 + ], + "real_origin_coor": [ + -1674, + -5424, + 270 + ], + "real_vac_coor": [ + 1599, + 1076, + 272 + ], + "resolution": 50, + "resolution_unit": "mm", + "vac_coor": [ + 65, + 130, + 272 + ], + "version": "LDS", + "width": 220 + }, + "getMapInfo": { + "auto_change_map": true, + "current_map_id": 1734727686, + "map_list": [ + { + "auto_area_flag": true, + "global_cleaned": -1, + "is_saved": true, + "map_id": 1734727686, + "map_locked": 0, + "map_name": "I01BU0tFRF9OQU1FIw==", + "rotate_angle": 270, + "update_time": 1737387285 + }, + { + "auto_area_flag": true, + "global_cleaned": -1, + "is_saved": true, + "map_id": 1734742958, + "map_locked": 0, + "map_name": "I01BU0tFRF9OQU1FIw==", + "rotate_angle": 0, + "update_time": 1737304392 + }, + { + "auto_area_flag": true, + "global_cleaned": -1, + "is_saved": true, + "map_id": 1736541042, + "map_locked": 0, + "map_name": "I01BU0tFRF9OQU1FIw==", + "rotate_angle": 270, + "update_time": 1737216718 + } + ], + "map_num": 3, + "version": "LDS" + }, + "getMopState": { + "mop_state": false + }, + "getVacStatus": { + "err_status": [ + 0 + ], + "errorCode_id": [ + 1144500830 + ], + "prompt": [], + "promptCode_id": [], + "status": 6 + }, + "getVolume": { + "volume": 60 + }, + "get_device_info": { + "auto_pack_ver": "0.0.131.1852", + "avatar": "", + "board_sn": "000000000000", + "cd": "I01BU0tFRF9CSU5BUlkj", + "custom_sn": "000000000000", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.0 Build 241219 Rel.163928", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "linux_ver": "V21.198.1708420747", + "location": "", + "longitude": 0, + "mac": "7C-F1-7E-00-00-00", + "mcu_ver": "1.1.2724.442", + "model": "RV30 Max", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "product_id": "1794", + "region": "America/Chicago", + "rssi": -38, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "sub_ver": "0.0.131.1852-1.4.40", + "time_diff": -360, + "total_ver": "1.4.40", + "type": "SMART.TAPOROBOVAC" + }, + "get_device_time": { + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 1737399953 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": { + "inherit_status": true + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.2.0 Build 241219 Rel.163928", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_schedule_rules": { + "enable": true, + "rule_list": [ + { + "alarm_min": 0, + "cancel": false, + "clean_attr": { + "cistern": 2, + "clean_mode": 0, + "clean_number": 1, + "clean_order": false, + "suction": 2 + }, + "day": 21, + "enable": true, + "id": "S1", + "invalid": 0, + "mode": "repeat", + "month": 1, + "s_min": 515, + "start_remind": true, + "week_day": 62, + "year": 2025 + } + ], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 1 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 5, + "wep_supported": true + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + } + ], + "extra_info": { + "device_model": "RV30 Max", + "device_type": "SMART.TAPOROBOVAC" + } + } +} diff --git a/tests/smart/modules/test_clean.py b/tests/smart/modules/test_clean.py index beae0143..70cbcb15 100644 --- a/tests/smart/modules/test_clean.py +++ b/tests/smart/modules/test_clean.py @@ -117,6 +117,10 @@ async def test_actions( async def test_post_update_hook(dev: SmartDevice, err_status: list, error: ErrorCode): """Test that post update hook sets error states correctly.""" clean = next(get_parent_and_child_modules(dev, Module.Clean)) + assert clean + + # _post_update_hook will pop an item off the status list so create a copy. + err_status = [e for e in err_status] clean.data["getVacStatus"]["err_status"] = err_status await clean._post_update_hook() diff --git a/tests/test_cli.py b/tests/test_cli.py index 2f907502..19958d55 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1308,11 +1308,11 @@ async def test_discover_config(dev: Device, mocker, runner): expected = f"--device-family {cparam.device_family.value} --encrypt-type {cparam.encryption_type.value} {'--https' if cparam.https else '--no-https'}" assert expected in res.output assert re.search( - r"Attempt to connect to 127\.0\.0\.1 with \w+ \+ \w+ \+ \w+ failed", + r"Attempt to connect to 127\.0\.0\.1 with \w+ \+ \w+ \+ \w+ \+ \w+ failed", res.output.replace("\n", ""), ) assert re.search( - r"Attempt to connect to 127\.0\.0\.1 with \w+ \+ \w+ \+ \w+ succeeded", + r"Attempt to connect to 127\.0\.0\.1 with \w+ \+ \w+ \+ \w+ \+ \w+ succeeded", res.output.replace("\n", ""), ) diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index c21c8fe9..d6bdaedf 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -63,8 +63,9 @@ def _get_connection_type_device_class(discovery_info): connection_type = DeviceConnectionParameters.from_values( dr.device_type, dr.mgt_encrypt_schm.encrypt_type, - dr.mgt_encrypt_schm.lv, - dr.mgt_encrypt_schm.is_support_https, + login_version=dr.mgt_encrypt_schm.lv, + https=dr.mgt_encrypt_schm.is_support_https, + http_port=dr.mgt_encrypt_schm.http_port, ) else: connection_type = DeviceConnectionParameters.from_values( diff --git a/tests/test_discovery.py b/tests/test_discovery.py index fbbed879..96c9e9c6 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -157,14 +157,15 @@ async def test_discover_single(discovery_mock, custom_port, mocker): ) # Make sure discovery does not call update() assert update_mock.call_count == 0 - if discovery_mock.default_port == 80: + if discovery_mock.default_port != 9999: assert x.alias is None ct = DeviceConnectionParameters.from_values( discovery_mock.device_type, discovery_mock.encrypt_type, - discovery_mock.login_version, - discovery_mock.https, + login_version=discovery_mock.login_version, + https=discovery_mock.https, + http_port=discovery_mock.http_port, ) config = DeviceConfig( host=host, @@ -425,9 +426,9 @@ async def test_discover_single_http_client(discovery_mock, mocker): x: Device = await Discover.discover_single(host) - assert x.config.uses_http == (discovery_mock.default_port == 80) + assert x.config.uses_http == (discovery_mock.default_port != 9999) - if discovery_mock.default_port == 80: + if discovery_mock.default_port != 9999: assert x.protocol._transport._http_client.client != http_client x.config.http_client = http_client assert x.protocol._transport._http_client.client == http_client @@ -442,9 +443,9 @@ async def test_discover_http_client(discovery_mock, mocker): devices = await Discover.discover(discovery_timeout=0) x: Device = devices[host] - assert x.config.uses_http == (discovery_mock.default_port == 80) + assert x.config.uses_http == (discovery_mock.default_port != 9999) - if discovery_mock.default_port == 80: + if discovery_mock.default_port != 9999: assert x.protocol._transport._http_client.client != http_client x.config.http_client = http_client assert x.protocol._transport._http_client.client == http_client @@ -674,8 +675,9 @@ async def test_discover_try_connect_all(discovery_mock, mocker): cparams = DeviceConnectionParameters.from_values( discovery_mock.device_type, discovery_mock.encrypt_type, - discovery_mock.login_version, - discovery_mock.https, + login_version=discovery_mock.login_version, + https=discovery_mock.https, + http_port=discovery_mock.http_port, ) protocol = get_protocol( DeviceConfig(discovery_mock.ip, connection_type=cparams) @@ -687,10 +689,13 @@ async def test_discover_try_connect_all(discovery_mock, mocker): protocol_class = IotProtocol transport_class = XorTransport + default_port = discovery_mock.default_port + async def _query(self, *args, **kwargs): if ( self.__class__ is protocol_class and self._transport.__class__ is transport_class + and self._transport._port == default_port ): return discovery_mock.query_data raise KasaException("Unable to execute query") @@ -699,6 +704,7 @@ async def test_discover_try_connect_all(discovery_mock, mocker): if ( self.protocol.__class__ is protocol_class and self.protocol._transport.__class__ is transport_class + and self.protocol._transport._port == default_port ): return