Allow https for klaptransport (#1415)
Some checks failed
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Has been cancelled

Later firmware versions on robovacs use `KLAP` over https instead of ssltransport (reported as AES)
This commit is contained in:
Teemu R. 2025-01-22 11:54:32 +01:00 committed by GitHub
parent fa0f7157c6
commit 7b1b14d1e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1019 additions and 28 deletions

View File

@ -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
<!--SUPPORTED_END-->
[^1]: Model requires authentication

View File

@ -330,6 +330,8 @@ All Tapo devices require authentication.<br>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
<!--SUPPORTED_END-->

View File

@ -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,

View File

@ -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)

View File

@ -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),

View File

@ -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(

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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."""

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"]

View File

@ -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"
}
}
}

View File

@ -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()

View File

@ -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", ""),
)

View File

@ -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(

View File

@ -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