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 - **Cameras**: C100, C210, C220, C225, C325WB, C520WS, C720, D230, TC65, TC70
- **Hubs**: H100, H200 - **Hubs**: H100, H200
- **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315 - **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315
- **Vacuums**: RV20 Max Plus - **Vacuums**: RV20 Max Plus, RV30 Max
<!--SUPPORTED_END--> <!--SUPPORTED_END-->
[^1]: Model requires authentication [^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** - **RV20 Max Plus**
- Hardware: 1.0 (EU) / Firmware: 1.0.7 - Hardware: 1.0 (EU) / Firmware: 1.0.7
- **RV30 Max**
- Hardware: 1.0 (US) / Firmware: 1.2.0
<!--SUPPORTED_END--> <!--SUPPORTED_END-->

View File

@ -300,7 +300,9 @@ async def cli(
connection_type = DeviceConnectionParameters.from_values( connection_type = DeviceConnectionParameters.from_values(
dr.device_type, dr.device_type,
dr.mgt_encrypt_schm.encrypt_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( dc = DeviceConfig(
host=host, host=host,

View File

@ -261,8 +261,11 @@ async def config(ctx: click.Context) -> DeviceDict:
host_port = host + (f":{port}" if port else "") host_port = host + (f":{port}" if port else "")
def on_attempt(connect_attempt: ConnectAttempt, success: bool) -> None: def on_attempt(connect_attempt: ConnectAttempt, success: bool) -> None:
prot, tran, dev = connect_attempt prot, tran, dev, https = connect_attempt
key_str = f"{prot.__name__} + {tran.__name__} + {dev.__name__}" key_str = (
f"{prot.__name__} + {tran.__name__} + {dev.__name__}"
f" + {'https' if https else 'http'}"
)
result = "succeeded" if success else "failed" result = "succeeded" if success else "failed"
msg = f"Attempt to connect to {host_port} with {key_str} {result}" msg = f"Attempt to connect to {host_port} with {key_str} {result}"
echo(msg) 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 config: Device config to derive protocol
:param strict: Require exact match on encrypt type :param strict: Require exact match on encrypt type
""" """
_LOGGER.debug("Finding protocol for %s", config.host)
ctype = config.connection_type ctype = config.connection_type
protocol_name = ctype.device_family.value.split(".")[0] protocol_name = ctype.device_family.value.split(".")[0]
_LOGGER.debug("Finding protocol for %s", ctype.device_family) _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 None
return IotProtocol(transport=LinkieTransportV2(config=config)) return IotProtocol(transport=LinkieTransportV2(config=config))
if ctype.device_family is DeviceFamily.SmartTapoRobovac: # Older FW used a different transport
if strict and ctype.encryption_type is not DeviceEncryptionType.Aes: if (
return None ctype.device_family is DeviceFamily.SmartTapoRobovac
and ctype.encryption_type is DeviceEncryptionType.Aes
):
return SmartProtocol(transport=SslTransport(config=config)) return SmartProtocol(transport=SslTransport(config=config))
protocol_transport_key = ( protocol_transport_key = (
@ -223,6 +226,7 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol
"IOT.KLAP": (IotProtocol, KlapTransport), "IOT.KLAP": (IotProtocol, KlapTransport),
"SMART.AES": (SmartProtocol, AesTransport), "SMART.AES": (SmartProtocol, AesTransport),
"SMART.KLAP": (SmartProtocol, KlapTransportV2), "SMART.KLAP": (SmartProtocol, KlapTransportV2),
"SMART.KLAP.HTTPS": (SmartProtocol, KlapTransportV2),
# H200 is device family SMART.TAPOHUB and uses SmartCamProtocol so use # H200 is device family SMART.TAPOHUB and uses SmartCamProtocol so use
# https to distuingish from SmartProtocol devices # https to distuingish from SmartProtocol devices
"SMART.AES.HTTPS": (SmartCamProtocol, SslAesTransport), "SMART.AES.HTTPS": (SmartCamProtocol, SslAesTransport),

View File

@ -20,7 +20,7 @@ None
{'host': '127.0.0.3', 'timeout': 5, 'credentials': {'username': 'user@example.com', \ {'host': '127.0.0.3', 'timeout': 5, 'credentials': {'username': 'user@example.com', \
'password': 'great_password'}, 'connection_type'\ 'password': 'great_password'}, 'connection_type'\
: {'device_family': 'SMART.TAPOBULB', 'encryption_type': 'KLAP', 'login_version': 2, \ : {'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)) >>> later_device = await Device.connect(config=Device.Config.from_dict(config_dict))
>>> print(later_device.alias) # Alias is available as connect() calls update() >>> print(later_device.alias) # Alias is available as connect() calls update()
@ -98,13 +98,16 @@ class DeviceConnectionParameters(_DeviceConfigBaseMixin):
encryption_type: DeviceEncryptionType encryption_type: DeviceEncryptionType
login_version: int | None = None login_version: int | None = None
https: bool = False https: bool = False
http_port: int | None = None
@staticmethod @staticmethod
def from_values( def from_values(
device_family: str, device_family: str,
encryption_type: str, encryption_type: str,
*,
login_version: int | None = None, login_version: int | None = None,
https: bool | None = None, https: bool | None = None,
http_port: int | None = None,
) -> DeviceConnectionParameters: ) -> DeviceConnectionParameters:
"""Return connection parameters from string values.""" """Return connection parameters from string values."""
try: try:
@ -115,6 +118,7 @@ class DeviceConnectionParameters(_DeviceConfigBaseMixin):
DeviceEncryptionType(encryption_type), DeviceEncryptionType(encryption_type),
login_version, login_version,
https, https,
http_port=http_port,
) )
except (ValueError, TypeError) as ex: except (ValueError, TypeError) as ex:
raise KasaException( raise KasaException(

View File

@ -146,6 +146,7 @@ class ConnectAttempt(NamedTuple):
protocol: type protocol: type
transport: type transport: type
device: type device: type
https: bool
class DiscoveredMeta(TypedDict): class DiscoveredMeta(TypedDict):
@ -637,10 +638,10 @@ class Discover:
Device.Family.IotIpCamera, Device.Family.IotIpCamera,
} }
candidates: dict[ candidates: dict[
tuple[type[BaseProtocol], type[BaseTransport], type[Device]], tuple[type[BaseProtocol], type[BaseTransport], type[Device], bool],
tuple[BaseProtocol, DeviceConfig], tuple[BaseProtocol, DeviceConfig],
] = { ] = {
(type(protocol), type(protocol._transport), device_class): ( (type(protocol), type(protocol._transport), device_class, https): (
protocol, protocol,
config, config,
) )
@ -870,8 +871,9 @@ class Discover:
config.connection_type = DeviceConnectionParameters.from_values( config.connection_type = DeviceConnectionParameters.from_values(
type_, type_,
encrypt_type, encrypt_type,
login_version, login_version=login_version,
encrypt_schm.is_support_https, https=encrypt_schm.is_support_https,
http_port=encrypt_schm.http_port,
) )
except KasaException as ex: except KasaException as ex:
raise UnsupportedDeviceError( raise UnsupportedDeviceError(

View File

@ -36,6 +36,18 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__) _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] = { REDACTORS: dict[str, Callable[[Any], Any] | None] = {
"latitude": lambda x: 0, "latitude": lambda x: 0,
"longitude": lambda x: 0, "longitude": lambda x: 0,
@ -71,6 +83,10 @@ REDACTORS: dict[str, Callable[[Any], Any] | None] = {
"custom_sn": lambda _: "000000000000", "custom_sn": lambda _: "000000000000",
"location": lambda x: "#MASKED_NAME#" if x else "", "location": lambda x: "#MASKED_NAME#" if x else "",
"map_data": lambda x: "#SCRUBBED_MAPDATA#" 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 # Queries that are known not to work properly when sent as a

View File

@ -120,6 +120,8 @@ class AesTransport(BaseTransport):
@property @property
def default_port(self) -> int: def default_port(self) -> int:
"""Default port for the transport.""" """Default port for the transport."""
if port := self._config.connection_type.http_port:
return port
return self.DEFAULT_PORT return self.DEFAULT_PORT
@property @property

View File

@ -48,6 +48,7 @@ import datetime
import hashlib import hashlib
import logging import logging
import secrets import secrets
import ssl
import struct import struct
import time import time
from asyncio import Future from asyncio import Future
@ -92,8 +93,21 @@ class KlapTransport(BaseTransport):
""" """
DEFAULT_PORT: int = 80 DEFAULT_PORT: int = 80
DEFAULT_HTTPS_PORT: int = 4433
SESSION_COOKIE_NAME = "TP_SESSIONID" SESSION_COOKIE_NAME = "TP_SESSIONID"
TIMEOUT_COOKIE_NAME = "TIMEOUT" 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__( def __init__(
self, self,
@ -125,12 +139,20 @@ class KlapTransport(BaseTransport):
self._session_cookie: dict[str, Any] | None = None self._session_cookie: dict[str, Any] | None = None
_LOGGER.debug("Created KLAP transport for %s", self._host) _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" self._request_url = self._app_url / "request"
@property @property
def default_port(self) -> int: def default_port(self) -> int:
"""Default port for the transport.""" """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 return self.DEFAULT_PORT
@property @property
@ -152,7 +174,9 @@ class KlapTransport(BaseTransport):
url = self._app_url / "handshake1" 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): if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug( _LOGGER.debug(
@ -263,6 +287,7 @@ class KlapTransport(BaseTransport):
url, url,
data=payload, data=payload,
cookies_dict=self._session_cookie, cookies_dict=self._session_cookie,
ssl=await self._get_ssl_context(),
) )
if _LOGGER.isEnabledFor(logging.DEBUG): if _LOGGER.isEnabledFor(logging.DEBUG):
@ -337,6 +362,7 @@ class KlapTransport(BaseTransport):
params={"seq": seq}, params={"seq": seq},
data=payload, data=payload,
cookies_dict=self._session_cookie, cookies_dict=self._session_cookie,
ssl=await self._get_ssl_context(),
) )
msg = ( msg = (
@ -413,6 +439,23 @@ class KlapTransport(BaseTransport):
un = creds.username un = creds.username
return md5(un.encode()) 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): class KlapTransportV2(KlapTransport):
"""Implementation of the KLAP encryption protocol with v2 hanshake hashes.""" """Implementation of the KLAP encryption protocol with v2 hanshake hashes."""

View File

@ -55,6 +55,8 @@ class LinkieTransportV2(BaseTransport):
@property @property
def default_port(self) -> int: def default_port(self) -> int:
"""Default port for the transport.""" """Default port for the transport."""
if port := self._config.connection_type.http_port:
return port
return self.DEFAULT_PORT return self.DEFAULT_PORT
@property @property

View File

@ -133,6 +133,8 @@ class SslAesTransport(BaseTransport):
@property @property
def default_port(self) -> int: def default_port(self) -> int:
"""Default port for the transport.""" """Default port for the transport."""
if port := self._config.connection_type.http_port:
return port
return self.DEFAULT_PORT return self.DEFAULT_PORT
@staticmethod @staticmethod

View File

@ -94,6 +94,8 @@ class SslTransport(BaseTransport):
@property @property
def default_port(self) -> int: def default_port(self) -> int:
"""Default port for the transport.""" """Default port for the transport."""
if port := self._config.connection_type.http_port:
return port
return self.DEFAULT_PORT return self.DEFAULT_PORT
@property @property

View File

@ -159,6 +159,7 @@ def create_discovery_mock(ip: str, fixture_data: dict):
https: bool https: bool
login_version: int | None = None login_version: int | None = None
port_override: int | None = None port_override: int | None = None
http_port: int | None = None
@property @property
def model(self) -> str: 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]) login_version = max([int(i) for i in et])
https = discovery_result["mgt_encrypt_schm"]["is_support_https"] 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( dm = _DiscoveryMock(
ip, ip,
80, default_port,
20002, 20002,
discovery_data, discovery_data,
fixture_data, fixture_data,
@ -204,6 +211,7 @@ def create_discovery_mock(ip: str, fixture_data: dict):
encrypt_type, encrypt_type,
https, https,
login_version, login_version,
http_port=http_port,
) )
else: else:
sys_info = fixture_data["system"]["get_sysinfo"] 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): async def test_post_update_hook(dev: SmartDevice, err_status: list, error: ErrorCode):
"""Test that post update hook sets error states correctly.""" """Test that post update hook sets error states correctly."""
clean = next(get_parent_and_child_modules(dev, Module.Clean)) 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 clean.data["getVacStatus"]["err_status"] = err_status
await clean._post_update_hook() 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'}" 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 expected in res.output
assert re.search( 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", ""), res.output.replace("\n", ""),
) )
assert re.search( 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", ""), res.output.replace("\n", ""),
) )

View File

@ -63,8 +63,9 @@ def _get_connection_type_device_class(discovery_info):
connection_type = DeviceConnectionParameters.from_values( connection_type = DeviceConnectionParameters.from_values(
dr.device_type, dr.device_type,
dr.mgt_encrypt_schm.encrypt_type, dr.mgt_encrypt_schm.encrypt_type,
dr.mgt_encrypt_schm.lv, login_version=dr.mgt_encrypt_schm.lv,
dr.mgt_encrypt_schm.is_support_https, https=dr.mgt_encrypt_schm.is_support_https,
http_port=dr.mgt_encrypt_schm.http_port,
) )
else: else:
connection_type = DeviceConnectionParameters.from_values( 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() # Make sure discovery does not call update()
assert update_mock.call_count == 0 assert update_mock.call_count == 0
if discovery_mock.default_port == 80: if discovery_mock.default_port != 9999:
assert x.alias is None assert x.alias is None
ct = DeviceConnectionParameters.from_values( ct = DeviceConnectionParameters.from_values(
discovery_mock.device_type, discovery_mock.device_type,
discovery_mock.encrypt_type, discovery_mock.encrypt_type,
discovery_mock.login_version, login_version=discovery_mock.login_version,
discovery_mock.https, https=discovery_mock.https,
http_port=discovery_mock.http_port,
) )
config = DeviceConfig( config = DeviceConfig(
host=host, host=host,
@ -425,9 +426,9 @@ async def test_discover_single_http_client(discovery_mock, mocker):
x: Device = await Discover.discover_single(host) 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 assert x.protocol._transport._http_client.client != http_client
x.config.http_client = http_client x.config.http_client = http_client
assert x.protocol._transport._http_client.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) devices = await Discover.discover(discovery_timeout=0)
x: Device = devices[host] 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 assert x.protocol._transport._http_client.client != http_client
x.config.http_client = http_client x.config.http_client = http_client
assert x.protocol._transport._http_client.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( cparams = DeviceConnectionParameters.from_values(
discovery_mock.device_type, discovery_mock.device_type,
discovery_mock.encrypt_type, discovery_mock.encrypt_type,
discovery_mock.login_version, login_version=discovery_mock.login_version,
discovery_mock.https, https=discovery_mock.https,
http_port=discovery_mock.http_port,
) )
protocol = get_protocol( protocol = get_protocol(
DeviceConfig(discovery_mock.ip, connection_type=cparams) DeviceConfig(discovery_mock.ip, connection_type=cparams)
@ -687,10 +689,13 @@ async def test_discover_try_connect_all(discovery_mock, mocker):
protocol_class = IotProtocol protocol_class = IotProtocol
transport_class = XorTransport transport_class = XorTransport
default_port = discovery_mock.default_port
async def _query(self, *args, **kwargs): async def _query(self, *args, **kwargs):
if ( if (
self.__class__ is protocol_class self.__class__ is protocol_class
and self._transport.__class__ is transport_class and self._transport.__class__ is transport_class
and self._transport._port == default_port
): ):
return discovery_mock.query_data return discovery_mock.query_data
raise KasaException("Unable to execute query") raise KasaException("Unable to execute query")
@ -699,6 +704,7 @@ async def test_discover_try_connect_all(discovery_mock, mocker):
if ( if (
self.protocol.__class__ is protocol_class self.protocol.__class__ is protocol_class
and self.protocol._transport.__class__ is transport_class and self.protocol._transport.__class__ is transport_class
and self.protocol._transport._port == default_port
): ):
return return