8000 Allow https for klaptransport (#1415) · rSffsE/python-kasa@7b1b14d · GitHub
[go: up one dir, main page]

Skip to content

Commit 7b1b14d

Browse files
authored
Allow https for klaptransport (python-kasa#1415)
Later firmware versions on robovacs use `KLAP` over https instead of ssltransport (reported as AES)
1 parent fa0f715 commit 7b1b14d

19 files changed

+1019
-28
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ The following devices have been tested and confirmed as working. If your device
204204
- **Cameras**: C100, C210, C220, C225, C325WB, C520WS, C720, D230, TC65, TC70
205205
- **Hubs**: H100, H200
206206
- **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315
207-
- **Vacuums**: RV20 Max Plus
207+
- **Vacuums**: RV20 Max Plus, RV30 Max
208208

209209
<!--SUPPORTED_END-->
210210
[^1]: Model requires authentication

SUPPORTED.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,8 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros
330330

331331
- **RV20 Max Plus**
332332
- Hardware: 1.0 (EU) / Firmware: 1.0.7
333+
- **RV30 Max**
334+
- Hardware: 1.0 (US) / Firmware: 1.2.0
333335

334336

335337
<!--SUPPORTED_END-->

devtools/dump_devinfo.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,9 @@ def capture_raw(discovered: DiscoveredRaw):
300300
connection_type = DeviceConnectionParameters.from_values(
301301
dr.device_type,
302302
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,
304306
)
305307
dc = DeviceConfig(
306308
host=host,

kasa/cli/discover.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,8 +261,11 @@ async def config(ctx: click.Context) -> DeviceDict:
261261
host_port = host + (f":{port}" if port else "")
262262

263263
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+
)
266269
result = "succeeded" if success else "failed"
267270
msg = f"Attempt to connect to {host_port} with {key_str} {result}"
268271
echo(msg)

kasa/device_factory.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol
189189
:param config: Device config to derive protocol
190190
:param strict: Require exact match on encrypt type
191191
"""
192+
_LOGGER.debug("Finding protocol for %s", config.host)
192193
ctype = config.connection_type
193194
protocol_name = ctype.device_family.value.split(".")[0]
194195
_LOGGER.debug("Finding protocol for %s", ctype.device_family)
@@ -203,9 +204,11 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol
203204
return None
204205
return IotProtocol(transport=LinkieTransportV2(config=config))
205206

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+
):
209212
return SmartProtocol(transport=SslTransport(config=config))
210213

211214
protocol_transport_key = (
@@ -223,6 +226,7 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol
223226
"IOT.KLAP": (IotProtocol, KlapTransport),
224227
"SMART.AES": (SmartProtocol, AesTransport),
225228
"SMART.KLAP": (SmartProtocol, KlapTransportV2),
229+
"SMART.KLAP.HTTPS": (SmartProtocol, KlapTransportV2),
226230
# H200 is device family SMART.TAPOHUB and uses SmartCamProtocol so use
227231
# https to distuingish from SmartProtocol devices
228232
"SMART.AES.HTTPS": (SmartCamProtocol, SslAesTransport),

kasa/deviceconfig.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
{'host': '127.0.0.3', 'timeout': 5, 'credentials': {'username': 'user@example.com', \
2121
'password': 'great_password'}, 'connection_type'\
2222
: {'device_family': 'SMART.TAPOBULB', 'encryption_type': 'KLAP', 'login_version': 2, \
23-
'https': False}}
23+
'https': False, 'http_port': 80}}
2424
2525
>>> later_device = await Device.connect(config=Device.Config.from_dict(config_dict))
2626
>>> print(later_device.alias) # Alias is available as connect() calls update()
@@ -98,13 +98,16 @@ class DeviceConnectionParameters(_DeviceConfigBaseMixin):
9898
encryption_type: DeviceEncryptionType
9999
login_version: int | None = None
100100
https: bool = False
101+
http_port: int | None = None
101102

102103
@staticmethod
103104
def from_values(
104105
device_family: str,
105106
encryption_type: str,
107+
*,
106108
login_version: int | None = None,
107109
https: bool | None = None,
110+
http_port: int | None = None,
108111
) -> DeviceConnectionParameters:
109112
"""Return connection parameters from string values."""
110113
try:
@@ -115,6 +118,7 @@ def from_values(
115118
DeviceEncryptionType(encryption_type),
116119
login_version,
117120
https,
121+
http_port=http_port,
118122
)
119123
except (ValueError, TypeError) as ex:
120124
raise KasaException(

kasa/discover.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ class ConnectAttempt(NamedTuple):
146146
protocol: type
147147
transport: type
148148
device: type
149+
https: bool
149150

150151

151152
class DiscoveredMeta(TypedDict):
@@ -637,10 +638,10 @@ async def try_connect_all(
637638
Device.Family.IotIpCamera,
638639
}
639640
candidates: dict[
640-
tuple[type[BaseProtocol], type[BaseTransport], type[Device]],
641+
tuple[type[BaseProtocol], type[BaseTransport], type[Device], bool],
641642
tuple[BaseProtocol, DeviceConfig],
642643
] = {
643-
(type(protocol), type(protocol._transport), device_class): (
644+
(type(protocol), type(protocol._transport), device_class, https): (
644645
protocol,
645646
config,
646647
)
@@ -870,8 +871,9 @@ def _get_device_instance(
870871
config.connection_type = DeviceConnectionParameters.from_values(
871872
type_,
872873
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,
875877
)
876878
except KasaException as ex:
877879
raise UnsupportedDeviceError(

kasa/protocols/smartprotocol.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@
3636

3737
_LOGGER = logging.getLogger(__name__)
3838

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+
3951
REDACTORS: dict[str, Callable[[Any], Any] | None] = {
4052
"latitude": lambda x: 0,
4153
"longitude": lambda x: 0,
@@ -71,6 +83,10 @@
7183
"custom_sn": lambda _: "000000000000",
7284
"location": lambda x: "#MASKED_NAME#" if x else "",
7385
"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#
7490
}
7591

7692
# Queries that are known not to work properly when sent as a

kasa/transports/aestransport.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ def __init__(
120120
@property
121121
def default_port(self) -> int:
122122
"""Default port for the transport."""
123+
if port := self._config.connection_type.http_port:
124+
return port
123125
return self.DEFAULT_PORT
124126

125127
@property

kasa/transports/klaptransport.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
import hashlib
4949
import logging
5050
import secrets
51+
import ssl
5152
import struct
5253
import time
5354
from asyncio import Future
@@ -92,8 +93,21 @@ class KlapTransport(BaseTransport):
9293
"""
9394

9495
DEFAULT_PORT: int = 80
96+
DEFAULT_HTTPS_PORT: int = 4433
97+< 10000 div class="diff-text-inner">
9598
SESSION_COOKIE_NAME = "TP_SESSIONID"
9699
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
97111

98112
def __init__(
99113
self,
@@ -125,12 +139,20 @@ def __init__(
125139
self._session_cookie: dict[str, Any] | None = None
126140

127141
_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")
129144
self._request_url = self._app_url / "request"
130145

131146
@property
132147
def default_port(self) -> int:
133148
"""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+
134156
return self.DEFAULT_PORT
135157

136158
@property
@@ -152,7 +174,9 @@ async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]:
152174

153175
url = self._app_url / "handshake1"
154176

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+
)
156180

157181
if _LOGGER.isEnabledFor(logging.DEBUG):
158182
_LOGGER.debug(
@@ -263,6 +287,7 @@ async def perform_handshake2(
263287
url,
264288
data=payload,
265289
cookies_dict=self._session_cookie,
290+
ssl=await self._get_ssl_context(),
266291
)
267292

268293
if _LOGGER.isEnabledFor(logging.DEBUG):
@@ -337,6 +362,7 @@ async def send(self, request: str) -> Generator[Future, None, dict[str, str]]:
337362
params={"seq": seq},
338363
data=payload,
339364
cookies_dict=self._session_cookie,
365+
ssl=await self._get_ssl_context(),
340366
)
341367

342368
msg = (
@@ -413,6 +439,23 @@ def generate_owner_hash(creds: Credentials) -> bytes:
413439
un = creds.username
414440
return md5(un.encode())
415441

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+
416459

417460
class KlapTransportV2(KlapTransport):
418461
"""Implementation of the KLAP encryption protocol with v2 hanshake hashes."""

kasa/transports/linkietransport.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ def __init__(self, *, config: DeviceConfig) -> None:
5555
@property
5656
def default_port(self) -> int:
5757
"""Default port for the transport."""
58+
if port := self._config.connection_type.http_port:
59+
return port
5860
return self.DEFAULT_PORT
5961

6062
@property

kasa/transports/sslaestransport.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@ def __init__(
133133
@property
134134
def default_port(self) -> int:
135135
"""Default port for the transport."""
136+
if port := self._config.connection_type.http_port:
137+
return port
136138
return self.DEFAULT_PORT
137139

138140
@staticmethod

kasa/transports/ssltransport.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ def __init__(
9494
@property
9595
def default_port(self) -> int:
9696
"""Default port for the transport."""
97+
if port := self._config.connection_type.http_port:
98+
return port
9799
return self.DEFAULT_PORT
98100

99101
@property

tests/discovery_fixtures.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ class _DiscoveryMock:
159159
https: bool
160160
login_version: int | None = None
161161
port_override: int | None = None
162+
http_port: int | None = None
162163

163164
@property
164165
def model(self) -> str:
@@ -194,16 +195,23 @@ def _datagram(self) -> bytes:
194195
):
195196
login_version = max([int(i) for i in et])
196197
https = discovery_result["mgt_encrypt_schm"]["is_support_https"]
198+
http_port = discovery_result["mgt_encrypt_schm"].get("http_port")
199+
if not http_port: # noqa: SIM108
200+
# Not all discovery responses set the http port, i.e. smartcam.
201+
default_port = 443 if https else 80
202+
else:
203+
default_port = http_port
197204
dm = _DiscoveryMock(
198205
ip,
199-
80,
206+
default_port,
200207
20002,
201208
discovery_data,
202209
fixture_data,
203210
device_type,
204211
encrypt_type,
205212
https,
206213
login_version,
214+
http_port=http_port,
207215
)
208216
else:
209217
sys_info = fixture_data["system"]["get_sysinfo"]

0 commit comments

Comments
 (0)
0