| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent 1cc1a8d commit cc23119
19 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -204,7 +204,7 @@ The following devices have been tested and confirmed as working. If your device | |||
| 204 | 204 | - **Cameras**: C100, C210, C220, C225, C325WB, C520WS, C720, D230, TC65, TC70 | |
| 205 | 205 | - **Hubs**: H100, H200 | |
| 206 | 206 | - **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315 | |
| 207 | - - **Vacuums**: RV20 Max Plus | ||
| 207 | + - **Vacuums**: RV20 Max Plus, RV30 Max | ||
| 208 | 208 | ||
| 209 | 209 | <!--SUPPORTED_END--> | |
| 210 | 210 | [^1]: Model requires authentication | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -330,6 +330,8 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros | |||
| 330 | 330 | ||
| 331 | 331 | - **RV20 Max Plus** | |
| 332 | 332 | - Hardware: 1.0 (EU) / Firmware: 1.0.7 | |
| 333 | + - **RV30 Max** | ||
| 334 | + - Hardware: 1.0 (US) / Firmware: 1.2.0 | ||
| 333 | 335 | ||
| 334 | 336 | ||
| 335 | 337 | <!--SUPPORTED_END--> | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -300,7 +300,9 @@ def capture_raw(discovered: DiscoveredRaw): | |||
| 300 | 300 | connection_type = DeviceConnectionParameters.from_values( | |
| 301 | 301 | dr.device_type, | |
| 302 | 302 | dr.mgt_encrypt_schm.encrypt_type, | |
| 303 | - dr.mgt_encrypt_schm.lv, | ||
| 303 | + login_version=dr.mgt_encrypt_schm.lv, | ||
| 304 | + https=dr.mgt_encrypt_schm.is_support_https, | ||
| 305 | + http_port=dr.mgt_encrypt_schm.http_port, | ||
| 304 | 306 | ) | |
| 305 | 307 | dc = DeviceConfig( | |
| 306 | 308 | host=host, | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -261,8 +261,11 @@ async def config(ctx: click.Context) -> DeviceDict: | |||
| 261 | 261 | host_port = host + (f":{port}" if port else "") | |
| 262 | 262 | ||
| 263 | 263 | def on_attempt(connect_attempt: ConnectAttempt, success: bool) -> None: | |
| 264 | - prot, tran, dev = connect_attempt | ||
| 265 | - key_str = f"{prot.__name__} + {tran.__name__} + {dev.__name__}" | ||
| 264 | + prot, tran, dev, https = connect_attempt | ||
| 265 | + key_str = ( | ||
| 266 | + f"{prot.__name__} + {tran.__name__} + {dev.__name__}" | ||
| 267 | + f" + {'https' if https else 'http'}" | ||
| 268 | + ) | ||
| 266 | 269 | result = "succeeded" if success else "failed" | |
| 267 | 270 | msg = f"Attempt to connect to {host_port} with {key_str} {result}" | |
| 268 | 271 | echo(msg) | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -189,6 +189,7 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol | |||
| 189 | 189 | :param config: Device config to derive protocol | |
| 190 | 190 | :param strict: Require exact match on encrypt type | |
| 191 | 191 | """ | |
| 192 | + _LOGGER.debug("Finding protocol for %s", config.host) | ||
| 192 | 193 | ctype = config.connection_type | |
| 193 | 194 | protocol_name = ctype.device_family.value.split(".")[0] | |
| 194 | 195 | _LOGGER.debug("Finding protocol for %s", ctype.device_family) | |
@@ -203,9 +204,11 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol | |||
| 203 | 204 | return None | |
| 204 | 205 | return IotProtocol(transport=LinkieTransportV2(config=config)) | |
| 205 | 206 | ||
| 206 | - if ctype.device_family is DeviceFamily.SmartTapoRobovac: | ||
| 207 | - if strict and ctype.encryption_type is not DeviceEncryptionType.Aes: | ||
| 208 | - return None | ||
| 207 | + # Older FW used a different transport | ||
| 208 | + if ( | ||
| 209 | + ctype.device_family is DeviceFamily.SmartTapoRobovac | ||
| 210 | + and ctype.encryption_type is DeviceEncryptionType.Aes | ||
| 211 | + ): | ||
| 209 | 212 | return SmartProtocol(transport=SslTransport(config=config)) | |
| 210 | 213 | ||
| 211 | 214 | protocol_transport_key = ( | |
@@ -223,6 +226,7 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol | |||
| 223 | 226 | "IOT.KLAP": (IotProtocol, KlapTransport), | |
| 224 | 227 | "SMART.AES": (SmartProtocol, AesTransport), | |
| 225 | 228 | "SMART.KLAP": (SmartProtocol, KlapTransportV2), | |
| 229 | + "SMART.KLAP.HTTPS": (SmartProtocol, KlapTransportV2), | ||
| 226 | 230 | # H200 is device family SMART.TAPOHUB and uses SmartCamProtocol so use | |
| 227 | 231 | # https to distuingish from SmartProtocol devices | |
| 228 | 232 | "SMART.AES.HTTPS": (SmartCamProtocol, SslAesTransport), | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -20,7 +20,7 @@ | |||
| 20 | 20 | {'host': '127.0.0.3', 'timeout': 5, 'credentials': {'username': 'user@example.com', \ | |
| 21 | 21 | 'password': 'great_password'}, 'connection_type'\ | |
| 22 | 22 | : {'device_family': 'SMART.TAPOBULB', 'encryption_type': 'KLAP', 'login_version': 2, \ | |
| 23 | - 'https': False}} | ||
| 23 | + 'https': False, 'http_port': 80}} | ||
| 24 | 24 | ||
| 25 | 25 | >>> later_device = await Device.connect(config=Device.Config.from_dict(config_dict)) | |
| 26 | 26 | >>> print(later_device.alias) # Alias is available as connect() calls update() | |
@@ -98,13 +98,16 @@ class DeviceConnectionParameters(_DeviceConfigBaseMixin): | |||
| 98 | 98 | encryption_type: DeviceEncryptionType | |
| 99 | 99 | login_version: int | None = None | |
| 100 | 100 | https: bool = False | |
| 101 | + http_port: int | None = None | ||
| 101 | 102 | ||
| 102 | 103 | @staticmethod | |
| 103 | 104 | def from_values( | |
| 104 | 105 | device_family: str, | |
| 105 | 106 | encryption_type: str, | |
| 107 | + *, | ||
| 106 | 108 | login_version: int | None = None, | |
| 107 | 109 | https: bool | None = None, | |
| 110 | + http_port: int | None = None, | ||
| 108 | 111 | ) -> DeviceConnectionParameters: | |
| 109 | 112 | """Return connection parameters from string values.""" | |
| 110 | 113 | try: | |
@@ -115,6 +118,7 @@ def from_values( | |||
| 115 | 118 | DeviceEncryptionType(encryption_type), | |
| 116 | 119 | login_version, | |
| 117 | 120 | https, | |
| 121 | + http_port=http_port, | ||
| 118 | 122 | ) | |
| 119 | 123 | except (ValueError, TypeError) as ex: | |
| 120 | 124 | raise KasaException( | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -146,6 +146,7 @@ class ConnectAttempt(NamedTuple): | |||
| 146 | 146 | protocol: type | |
| 147 | 147 | transport: type | |
| 148 | 148 | device: type | |
| 149 | + https: bool | ||
| 149 | 150 | ||
| 150 | 151 | ||
| 151 | 152 | class DiscoveredMeta(TypedDict): | |
@@ -637,10 +638,10 @@ async def try_connect_all( | |||
| 637 | 638 | Device.Family.IotIpCamera, | |
| 638 | 639 | } | |
| 639 | 640 | candidates: dict[ | |
| 640 | - tuple[type[BaseProtocol], type[BaseTransport], type[Device]], | ||
| 641 | + tuple[type[BaseProtocol], type[BaseTransport], type[Device], bool], | ||
| 641 | 642 | tuple[BaseProtocol, DeviceConfig], | |
| 642 | 643 | ] = { | |
| 643 | - (type(protocol), type(protocol._transport), device_class): ( | ||
| 644 | + (type(protocol), type(protocol._transport), device_class, https): ( | ||
| 644 | 645 | protocol, | |
| 645 | 646 | config, | |
| 646 | 647 | ) | |
@@ -870,8 +871,9 @@ def _get_device_instance( | |||
| 870 | 871 | config.connection_type = DeviceConnectionParameters.from_values( | |
| 871 | 872 | type_, | |
| 872 | 873 | encrypt_type, | |
| 873 | - login_version, | ||
| 874 | - encrypt_schm.is_support_https, | ||
| 874 | + login_version=login_version, | ||
| 875 | + https=encrypt_schm.is_support_https, | ||
| 876 | + http_port=encrypt_schm.http_port, | ||
| 875 | 877 | ) | |
| 876 | 878 | except KasaException as ex: | |
| 877 | 879 | raise UnsupportedDeviceError( | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -36,6 +36,18 @@ | |||
| 36 | 36 | ||
| 37 | 37 | _LOGGER = logging.getLogger(__name__) | |
| 38 | 38 | ||
| 39 | + | ||
| 40 | + def _mask_area_list(area_list: list[dict[str, Any]]) -> list[dict[str, Any]]: | ||
| 41 | + def mask_area(area: dict[str, Any]) -> dict[str, Any]: | ||
| 42 | + result = {**area} | ||
| 43 | + # Will leave empty names as blank | ||
| 44 | + if area.get("name"): | ||
| 45 | + result["name"] = "I01BU0tFRF9OQU1FIw==" # #MASKED_NAME# | ||
| 46 | + return result | ||
| 47 | + | ||
| 48 | + return [mask_area(area) for area in area_list] | ||
| 49 | + | ||
| 50 | + | ||
| 39 | 51 | REDACTORS: dict[str, Callable[[Any], Any] | None] = { | |
| 40 | 52 | "latitude": lambda x: 0, | |
| 41 | 53 | "longitude": lambda x: 0, | |
@@ -71,6 +83,10 @@ | |||
| 71 | 83 | "custom_sn": lambda _: "000000000000", | |
| 72 | 84 | "location": lambda x: "#MASKED_NAME#" if x else "", | |
| 73 | 85 | "map_data": lambda x: "#SCRUBBED_MAPDATA#" if x else "", | |
| 86 | + "map_name": lambda x: "I01BU0tFRF9OQU1FIw==", # #MASKED_NAME# | ||
| 87 | + "area_list": _mask_area_list, | ||
| 88 | + # unknown robovac binary blob in get_device_info | ||
| 89 | + "cd": lambda x: "I01BU0tFRF9CSU5BUlkj", # #MASKED_BINARY# | ||
| 74 | 90 | } | |
| 75 | 91 | ||
| 76 | 92 | # Queries that are known not to work properly when sent as a | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -120,6 +120,8 @@ def __init__( | |||
| 120 | 120 | @property | |
| 121 | 121 | def default_port(self) -> int: | |
| 122 | 122 | """Default port for the transport.""" | |
| 123 | + if port := self._config.connection_type.http_port: | ||
| 124 | + return port | ||
| 123 | 125 | return self.DEFAULT_PORT | |
| 124 | 126 | ||
| 125 | 127 | @property | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -48,6 +48,7 @@ | |||
| 48 | 48 | import hashlib | |
| 49 | 49 | import logging | |
| 50 | 50 | import secrets | |
| 51 | + import ssl | ||
| 51 | 52 | import struct | |
| 52 | 53 | import time | |
| 53 | 54 | from asyncio import Future | |
@@ -92,8 +93,21 @@ class KlapTransport(BaseTransport): | |||
| 92 | 93 | """ | |
| 93 | 94 | ||
| 94 | 95 | DEFAULT_PORT: int = 80 | |
| 96 | + DEFAULT_HTTPS_PORT: int = 4433 | ||
| 97 | + | ||
| 95 | 98 | SESSION_COOKIE_NAME = "TP_SESSIONID" | |
| 96 | 99 | TIMEOUT_COOKIE_NAME = "TIMEOUT" | |
| 100 | + # Copy & paste from sslaestransport | ||
| 101 | + CIPHERS = ":".join( | ||
| 102 | + [ | ||
| 103 | + "AES256-GCM-SHA384", | ||
| 104 | + "AES256-SHA256", | ||
| 105 | + "AES128-GCM-SHA256", | ||
| 106 | + "AES128-SHA256", | ||
| 107 | + "AES256-SHA", | ||
| 108 | + ] | ||
| 109 | + ) | ||
| 110 | + _ssl_context: ssl.SSLContext | None = None | ||
| 97 | 111 | ||
| 98 | 112 | def __init__( | |
| 99 | 113 | self, | |
@@ -125,12 +139,20 @@ def __init__( | |||
| 125 | 139 | self._session_cookie: dict[str, Any] | None = None | |
| 126 | 140 | ||
| 127 | 141 | _LOGGER.debug("Created KLAP transport for %s", self._host) | |
| 128 | - self._app_url = URL(f"http://{self._host}:{self._port}/app") | ||
| 142 | + protocol = "https" if config.connection_type.https else "http" | ||
| 143 | + self._app_url = URL(f"{protocol}://{self._host}:{self._port}/app") | ||
| 129 | 144 | self._request_url = self._app_url / "request" | |
| 130 | 145 | ||
| 131 | 146 | @property | |
| 132 | 147 | def default_port(self) -> int: | |
| 133 | 148 | """Default port for the transport.""" | |
| 149 | + config = self._config | ||
| 150 | + if port := config.connection_type.http_port: | ||
| 151 | + return port | ||
| 152 | + | ||
| 153 | + if config.connection_type.https: | ||
| 154 | + return self.DEFAULT_HTTPS_PORT | ||
| 155 | + | ||
| 134 | 156 | return self.DEFAULT_PORT | |
| 135 | 157 | ||
| 136 | 158 | @property | |
@@ -152,7 +174,9 @@ async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]: | |||
| 152 | 174 | ||
| 153 | 175 | url = self._app_url / "handshake1" | |
| 154 | 176 | ||
| 155 | - response_status, response_data = await self._http_client.post(url, data=payload) | ||
| 177 | + response_status, response_data = await self._http_client.post( | ||
| 178 | + url, data=payload, ssl=await self._get_ssl_context() | ||
| 179 | + ) | ||
| 156 | 180 | ||
| 157 | 181 | if _LOGGER.isEnabledFor(logging.DEBUG): | |
| 158 | 182 | _LOGGER.debug( | |
@@ -263,6 +287,7 @@ async def perform_handshake2( | |||
| 263 | 287 | url, | |
| 264 | 288 | data=payload, | |
| 265 | 289 | cookies_dict=self._session_cookie, | |
| 290 | + ssl=await self._get_ssl_context(), | ||
| 266 | 291 | ) | |
| 267 | 292 | ||
| 268 | 293 | if _LOGGER.isEnabledFor(logging.DEBUG): | |
@@ -337,6 +362,7 @@ async def send(self, request: str) -> Generator[Future, None, dict[str, str]]: | |||
| 337 | 362 | params={"seq": seq}, | |
| 338 | 363 | data=payload, | |
| 339 | 364 | cookies_dict=self._session_cookie, | |
| 365 | + ssl=await self._get_ssl_context(), | ||
| 340 | 366 | ) | |
| 341 | 367 | ||
| 342 | 368 | msg = ( | |
@@ -413,6 +439,23 @@ def generate_owner_hash(creds: Credentials) -> bytes: | |||
| 413 | 439 | un = creds.username | |
| 414 | 440 | return md5(un.encode()) | |
| 415 | 441 | ||
| 442 | + # Copy & paste from sslaestransport. | ||
| 443 | + def _create_ssl_context(self) -> ssl.SSLContext: | ||
| 444 | + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) | ||
| 445 | + context.set_ciphers(self.CIPHERS) | ||
| 446 | + context.check_hostname = False | ||
| 447 | + context.verify_mode = ssl.CERT_NONE | ||
| 448 | + return context | ||
| 449 | + | ||
| 450 | + # Copy & paste from sslaestransport. | ||
| 451 | + async def _get_ssl_context(self) -> ssl.SSLContext: | ||
| 452 | + if not self._ssl_context: | ||
| 453 | + loop = asyncio.get_running_loop() | ||
| 454 | + self._ssl_context = await loop.run_in_executor( | ||
| 455 | + None, self._create_ssl_context | ||
| 456 | + ) | ||
| 457 | + return self._ssl_context | ||
| 458 | + | ||
| 416 | 459 | ||
| 417 | 460 | class KlapTransportV2(KlapTransport): | |
| 418 | 461 | """Implementation of the KLAP encryption protocol with v2 hanshake hashes.""" | |
| Back | FazBrowse Home | New Git URL |
0 commit comments