-
-
Notifications
You must be signed in to change notification settings - Fork 188
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Adding support for the new encryption protocol (updated version) #267
Changes from all commits
f6fc0de
efcbece
cacb996
901b0ce
3c2709d
fbc7335
2bd9976
b8df6ee
a74e60d
75992bf
c3d1fc6
9500d7b
d6ef3cb
2d6ce06
d9a75e9
b69ca9e
f1a4b05
3322dd6
c4ea995
f2aa66c
cef9e56
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
"""Authentication class for KASA username / passwords.""" | ||
from hashlib import md5 | ||
|
||
|
||
class Auth: | ||
"""Authentication for Kasa KLAP authentication.""" | ||
|
||
def __init__(self, user: str = "", password: str = ""): | ||
self.user = user | ||
self.password = password | ||
self.md5user = md5(user.encode()).digest() | ||
self.md5password = md5(password.encode()).digest() | ||
self.md5auth = md5(self.md5user + self.md5password).digest() | ||
|
||
def authenticator(self): | ||
"""Return the KLAP authenticator for these credentials.""" | ||
return self.md5auth | ||
|
||
def owner(self): | ||
"""Return the MD5 hash of the username in this object.""" | ||
return self.md5user |
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -1,10 +1,14 @@ | ||||||||||||||||||||||||
"""Discovery module for TP-Link Smart Home devices.""" | ||||||||||||||||||||||||
import asyncio | ||||||||||||||||||||||||
import binascii | ||||||||||||||||||||||||
import hashlib | ||||||||||||||||||||||||
import json | ||||||||||||||||||||||||
import logging | ||||||||||||||||||||||||
import socket | ||||||||||||||||||||||||
from typing import Awaitable, Callable, Dict, Optional, Type, cast | ||||||||||||||||||||||||
from typing import Awaitable, Callable, Dict, Mapping, Optional, Type, Union, cast | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
from kasa.auth import Auth | ||||||||||||||||||||||||
from kasa.klapprotocol import TPLinkKLAP | ||||||||||||||||||||||||
from kasa.protocol import TPLinkSmartHomeProtocol | ||||||||||||||||||||||||
from kasa.smartbulb import SmartBulb | ||||||||||||||||||||||||
from kasa.smartdevice import SmartDevice, SmartDeviceException | ||||||||||||||||||||||||
|
@@ -35,13 +39,17 @@ def __init__( | |||||||||||||||||||||||
target: str = "255.255.255.255", | ||||||||||||||||||||||||
discovery_packets: int = 3, | ||||||||||||||||||||||||
interface: Optional[str] = None, | ||||||||||||||||||||||||
authentication: Optional[Auth] = None, | ||||||||||||||||||||||||
): | ||||||||||||||||||||||||
self.transport = None | ||||||||||||||||||||||||
self.discovery_packets = discovery_packets | ||||||||||||||||||||||||
self.interface = interface | ||||||||||||||||||||||||
self.on_discovered = on_discovered | ||||||||||||||||||||||||
self.target = (target, Discover.DISCOVERY_PORT) | ||||||||||||||||||||||||
self.new_target = (target, Discover.NEW_DISCOVERY_PORT) | ||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||
self.discovered_devices = {} | ||||||||||||||||||||||||
self.authentication = authentication | ||||||||||||||||||||||||
self.emptyUser = hashlib.md5().digest() | ||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Not worth to introduce a new variable for this, it can be created when needed. |
||||||||||||||||||||||||
|
||||||||||||||||||||||||
def connection_made(self, transport) -> None: | ||||||||||||||||||||||||
"""Set socket options for broadcasting.""" | ||||||||||||||||||||||||
|
@@ -61,26 +69,49 @@ def do_discover(self) -> None: | |||||||||||||||||||||||
req = json.dumps(Discover.DISCOVERY_QUERY) | ||||||||||||||||||||||||
_LOGGER.debug("[DISCOVERY] %s >> %s", self.target, Discover.DISCOVERY_QUERY) | ||||||||||||||||||||||||
encrypted_req = TPLinkSmartHomeProtocol.encrypt(req) | ||||||||||||||||||||||||
new_req = binascii.unhexlify("020000010000000000000000463cb5d3") | ||||||||||||||||||||||||
for i in range(self.discovery_packets): | ||||||||||||||||||||||||
self.transport.sendto(encrypted_req[4:], self.target) # type: ignore | ||||||||||||||||||||||||
self.transport.sendto(new_req, self.new_target) # type: ignore | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
def datagram_received(self, data, addr) -> None: | ||||||||||||||||||||||||
"""Handle discovery responses.""" | ||||||||||||||||||||||||
ip, port = addr | ||||||||||||||||||||||||
if ip in self.discovered_devices: | ||||||||||||||||||||||||
return | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
info = json.loads(TPLinkSmartHomeProtocol.decrypt(data)) | ||||||||||||||||||||||||
_LOGGER.debug("[DISCOVERY] %s << %s", ip, info) | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
try: | ||||||||||||||||||||||||
if port == 9999: | ||||||||||||||||||||||||
info = json.loads(TPLinkSmartHomeProtocol.decrypt(data)) | ||||||||||||||||||||||||
device_class = Discover._get_device_class(info) | ||||||||||||||||||||||||
except SmartDeviceException as ex: | ||||||||||||||||||||||||
_LOGGER.debug("Unable to find device type from %s: %s", info, ex) | ||||||||||||||||||||||||
return | ||||||||||||||||||||||||
device = device_class(ip) | ||||||||||||||||||||||||
else: | ||||||||||||||||||||||||
info = json.loads(data[16:]) | ||||||||||||||||||||||||
device_class = Discover._get_new_device_class(info) | ||||||||||||||||||||||||
owner = Discover._get_new_owner(info) | ||||||||||||||||||||||||
if owner is not None: | ||||||||||||||||||||||||
owner_bin = bytes.fromhex(owner) | ||||||||||||||||||||||||
Comment on lines
+88
to
+92
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please separate the klap handling into its own, internal method. Maybe this (and the related methods) should be part of the klapprotocol module, preferably just offering an interface like In the current form it is hard to write unittests for its behavior. |
||||||||||||||||||||||||
|
||||||||||||||||||||||||
_LOGGER.debug( | ||||||||||||||||||||||||
"[DISCOVERY] Device owner is %s, empty owner is %s", | ||||||||||||||||||||||||
owner_bin, | ||||||||||||||||||||||||
self.emptyUser, | ||||||||||||||||||||||||
) | ||||||||||||||||||||||||
if owner is None or owner == "" or owner_bin == self.emptyUser: | ||||||||||||||||||||||||
_LOGGER.debug("[DISCOVERY] Device %s has no owner", ip) | ||||||||||||||||||||||||
device = device_class(ip, Auth()) | ||||||||||||||||||||||||
elif ( | ||||||||||||||||||||||||
self.authentication is not None | ||||||||||||||||||||||||
and owner_bin == self.authentication.owner() | ||||||||||||||||||||||||
): | ||||||||||||||||||||||||
_LOGGER.debug("[DISCOVERY] Device %s has authenticated owner", ip) | ||||||||||||||||||||||||
device = device_class(ip, self.authentication) | ||||||||||||||||||||||||
else: | ||||||||||||||||||||||||
_LOGGER.debug("[DISCOVERY] Found %s with unknown owner %s", ip, owner) | ||||||||||||||||||||||||
return | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
_LOGGER.debug("[DISCOVERY] %s << %s", ip, info) | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
device = device_class(ip) | ||||||||||||||||||||||||
device.update_from_discover_info(info) | ||||||||||||||||||||||||
asyncio.ensure_future(device.update()) | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
self.discovered_devices[ip] = device | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
|
@@ -132,6 +163,8 @@ class Discover: | |||||||||||||||||||||||
|
||||||||||||||||||||||||
DISCOVERY_PORT = 9999 | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
NEW_DISCOVERY_PORT = 20002 | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
DISCOVERY_QUERY = { | ||||||||||||||||||||||||
"system": {"get_sysinfo": None}, | ||||||||||||||||||||||||
} | ||||||||||||||||||||||||
|
@@ -144,7 +177,8 @@ async def discover( | |||||||||||||||||||||||
timeout=5, | ||||||||||||||||||||||||
discovery_packets=3, | ||||||||||||||||||||||||
interface=None, | ||||||||||||||||||||||||
) -> DeviceDict: | ||||||||||||||||||||||||
authentication=None, | ||||||||||||||||||||||||
) -> Mapping[str, Union[SmartDevice, Dict]]: | ||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mmh, why is this change necessary, what's wrong with |
||||||||||||||||||||||||
"""Discover supported devices. | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
Sends discovery message to 255.255.255.255:9999 in order | ||||||||||||||||||||||||
|
@@ -171,6 +205,7 @@ async def discover( | |||||||||||||||||||||||
on_discovered=on_discovered, | ||||||||||||||||||||||||
discovery_packets=discovery_packets, | ||||||||||||||||||||||||
interface=interface, | ||||||||||||||||||||||||
authentication=authentication, | ||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||||||||||||||||||||||||
), | ||||||||||||||||||||||||
local_addr=("0.0.0.0", 0), | ||||||||||||||||||||||||
) | ||||||||||||||||||||||||
|
@@ -187,20 +222,29 @@ async def discover( | |||||||||||||||||||||||
return protocol.discovered_devices | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
@staticmethod | ||||||||||||||||||||||||
async def discover_single(host: str) -> SmartDevice: | ||||||||||||||||||||||||
async def discover_single(host: str, authentication=None) -> SmartDevice: | ||||||||||||||||||||||||
"""Discover a single device by the given IP address. | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
:param host: Hostname of device to query | ||||||||||||||||||||||||
:rtype: SmartDevice | ||||||||||||||||||||||||
:return: Object for querying/controlling found device. | ||||||||||||||||||||||||
""" | ||||||||||||||||||||||||
protocol = TPLinkSmartHomeProtocol(host) | ||||||||||||||||||||||||
if authentication is None: | ||||||||||||||||||||||||
protocol = TPLinkSmartHomeProtocol(host) | ||||||||||||||||||||||||
else: | ||||||||||||||||||||||||
protocol = TPLinkKLAP(host, authentication) | ||||||||||||||||||||||||
# protocol = TPLinkSmartHomeProtocol(host) | ||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove. |
||||||||||||||||||||||||
|
||||||||||||||||||||||||
info = await protocol.query(Discover.DISCOVERY_QUERY) | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
device_class = Discover._get_device_class(info) | ||||||||||||||||||||||||
dev = device_class(host) | ||||||||||||||||||||||||
await dev.update() | ||||||||||||||||||||||||
if device_class is not None: | ||||||||||||||||||||||||
if authentication is None: | ||||||||||||||||||||||||
dev = device_class(host) | ||||||||||||||||||||||||
else: | ||||||||||||||||||||||||
dev = device_class(host, authentication) | ||||||||||||||||||||||||
await dev.update() | ||||||||||||||||||||||||
return dev | ||||||||||||||||||||||||
Comment on lines
+241
to
+247
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
It's better to keep the original code, and just add keyword-only |
||||||||||||||||||||||||
|
||||||||||||||||||||||||
return dev | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
|
@@ -231,3 +275,43 @@ def _get_device_class(info: dict) -> Type[SmartDevice]: | |||||||||||||||||||||||
return SmartBulb | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
raise SmartDeviceException("Unknown device type: %s" % type_) | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
@staticmethod | ||||||||||||||||||||||||
def _get_new_device_class(info: dict) -> Type[SmartDevice]: | ||||||||||||||||||||||||
"""Find SmartDevice subclass given new discovery payload.""" | ||||||||||||||||||||||||
if "result" not in info: | ||||||||||||||||||||||||
raise SmartDeviceException("No 'result' in discovery response") | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
if "device_type" not in info["result"]: | ||||||||||||||||||||||||
raise SmartDeviceException("No 'device_type' in discovery result") | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
dtype = info["result"]["device_type"] | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
if dtype == "IOT.SMARTPLUGSWITCH": | ||||||||||||||||||||||||
return SmartPlug | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
raise SmartDeviceException("Unknown device type: %s", dtype) | ||||||||||||||||||||||||
Comment on lines
+280
to
+293
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't like the fact that there are two separate paths for the discovery, we should find a way to somehow generalize the code to handle model detection for both discovery protocols. |
||||||||||||||||||||||||
|
||||||||||||||||||||||||
@staticmethod | ||||||||||||||||||||||||
def _get_new_owner(info: dict) -> Optional[str]: | ||||||||||||||||||||||||
"""Find owner given new-style discovery payload.""" | ||||||||||||||||||||||||
if "result" not in info: | ||||||||||||||||||||||||
raise SmartDeviceException("No 'result' in discovery response") | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
if "owner" not in info["result"]: | ||||||||||||||||||||||||
return None | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
return info["result"]["owner"] | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
|
||||||||||||||||||||||||
if __name__ == "__main__": | ||||||||||||||||||||||||
logging.basicConfig(level=logging.INFO) | ||||||||||||||||||||||||
loop = asyncio.get_event_loop() | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
async def _on_device(dev): | ||||||||||||||||||||||||
await dev.update() | ||||||||||||||||||||||||
_LOGGER.info("Got device: %s", dev) | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
devices = loop.run_until_complete(Discover.discover(on_discovered=_on_device)) | ||||||||||||||||||||||||
for ip, dev in devices.items(): | ||||||||||||||||||||||||
print(f"[{ip}] {dev}") | ||||||||||||||||||||||||
Comment on lines
+307
to
+317
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Let's remove this. If needed, we should improve the |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The authentication should probably be passed to all of these? Please rebase and skip the authentication for the deprecated flags (see https://github.com/python-kasa/python-kasa/blob/master/kasa/cli.py#L99).