Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions SUPPORTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-->
Expand Down
4 changes: 3 additions & 1 deletion devtools/dump_devinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,9 @@ def capture_raw(discovered: DiscoveredRaw):
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,
Expand Down
7 changes: 5 additions & 2 deletions kasa/cli/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 7 additions & 3 deletions kasa/device_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 = (
Expand All @@ -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),
Expand Down
6 changes: 5 additions & 1 deletion kasa/deviceconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
{'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()
Expand Down Expand Up @@ -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:
Expand All @@ -115,6 +118,7 @@ def from_values(
DeviceEncryptionType(encryption_type),
login_version,
https,
http_port=http_port,
)
except (ValueError, TypeError) as ex:
raise KasaException(
Expand Down
10 changes: 6 additions & 4 deletions kasa/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ class ConnectAttempt(NamedTuple):
protocol: type
transport: type
device: type
https: bool


class DiscoveredMeta(TypedDict):
Expand Down Expand Up @@ -637,10 +638,10 @@ async def try_connect_all(
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,
)
Expand Down Expand Up @@ -870,8 +871,9 @@ def _get_device_instance(
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(
Expand Down
16 changes: 16 additions & 0 deletions kasa/protocols/smartprotocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@

_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,
Expand Down Expand Up @@ -71,6 +83,10 @@
"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
Expand Down
2 changes: 2 additions & 0 deletions kasa/transports/aestransport.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ def __init__(
@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
Expand Down
47 changes: 45 additions & 2 deletions kasa/transports/klaptransport.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
import hashlib
import logging
import secrets
import ssl
import struct
import time
from asyncio import Future
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -125,12 +139,20 @@ def __init__(
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
Expand All @@ -152,7 +174,9 @@ async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]:

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(
Expand Down Expand Up @@ -263,6 +287,7 @@ async def perform_handshake2(
url,
data=payload,
cookies_dict=self._session_cookie,
ssl=await self._get_ssl_context(),
)

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

msg = (
Expand Down Expand Up @@ -413,6 +439,23 @@ def generate_owner_hash(creds: Credentials) -> bytes:
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."""
Expand Down
2 changes: 2 additions & 0 deletions kasa/transports/linkietransport.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
@property
def default_port(self) -> int:
"""Default port for the transport."""
if port := self._config.connection_type.http_port:
return port

Check warning on line 59 in kasa/transports/linkietransport.py

View check run for this annotation

Codecov / codecov/patch

kasa/transports/linkietransport.py#L59

Added line #L59 was not covered by tests
return self.DEFAULT_PORT

@property
Expand Down
2 changes: 2 additions & 0 deletions kasa/transports/sslaestransport.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@
@property
def default_port(self) -> int:
"""Default port for the transport."""
if port := self._config.connection_type.http_port:
return port

Check warning on line 137 in kasa/transports/sslaestransport.py

View check run for this annotation

Codecov / codecov/patch

kasa/transports/sslaestransport.py#L137

Added line #L137 was not covered by tests
return self.DEFAULT_PORT

@staticmethod
Expand Down
2 changes: 2 additions & 0 deletions kasa/transports/ssltransport.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ def __init__(
@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
Expand Down
10 changes: 9 additions & 1 deletion tests/discovery_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ class _DiscoveryMock:
https: bool
login_version: int | None = None
port_override: int | None = None
http_port: int | None = None

@property
def model(self) -> str:
Expand Down Expand Up @@ -194,16 +195,23 @@ def _datagram(self) -> bytes:
):
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,
device_type,
encrypt_type,
https,
login_version,
http_port=http_port,
)
else:
sys_info = fixture_data["system"]["get_sysinfo"]
Expand Down
Loading
Loading