Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 26ce51a

Browse files
author
Zach Price
committed
Initial WIP review commit
1 parent 805e4b8 commit 26ce51a

19 files changed

+1344
-424
lines changed

devtools/dump_devinfo.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717

1818
import click
1919

20-
from kasa import TPLinkSmartHomeProtocol
20+
from kasa.cli import TYPE_TO_CLASS
21+
from kasa.credentials import Credentials
2122

2223
Call = namedtuple("Call", "module method")
2324

@@ -64,11 +65,34 @@ def default_to_regular(d):
6465
@click.command()
6566
@click.argument("host")
6667
@click.option("-d", "--debug", is_flag=True)
67-
def cli(host, debug):
68+
@click.option(
69+
"-t",
70+
"--type",
71+
envvar="KASA_TYPE",
72+
default="smartdevice",
73+
type=click.Choice(list(TYPE_TO_CLASS), case_sensitive=False),
74+
)
75+
@click.option(
76+
"--username",
77+
default=None,
78+
required=False,
79+
envvar="TPLINK_CLOUD_USERNAME",
80+
help="Username/email address to authenticate to device.",
81+
)
82+
@click.option(
83+
"--password",
84+
default=None,
85+
required=False,
86+
envvar="TPLINK_CLOUD_PASSWORD",
87+
help="Password to use to authenticate to device.",
88+
)
89+
def cli(host, debug, type, username, password):
6890
"""Generate devinfo file for given device."""
6991
if debug:
7092
logging.basicConfig(level=logging.DEBUG)
7193

94+
dev = TYPE_TO_CLASS[type](host, credentials=Credentials(username, password))
95+
7296
items = [
7397
Call(module="system", method="get_sysinfo"),
7498
Call(module="emeter", method="get_realtime"),
@@ -86,8 +110,7 @@ def cli(host, debug):
86110
for test_call in items:
87111

88112
async def _run_query(test_call):
89-
protocol = TPLinkSmartHomeProtocol(host)
90-
return await protocol.query({test_call.module: {test_call.method: None}})
113+
return await dev.protocol.query({test_call.module: {test_call.method: {}}})
91114

92115
try:
93116
click.echo(f"Testing {test_call}..", nl=False)
@@ -106,14 +129,13 @@ async def _run_query(test_call):
106129
final = defaultdict(defaultdict)
107130

108131
for succ, resp in successes:
109-
final_query[succ.module][succ.method] = None
132+
final_query[succ.module][succ.method] = {}
110133
final[succ.module][succ.method] = resp
111134

112135
final = default_to_regular(final)
113136

114137
async def _run_final_query():
115-
protocol = TPLinkSmartHomeProtocol(host)
116-
return await protocol.query(final_query)
138+
return await dev.protocol.query(final_query)
117139

118140
try:
119141
final = asyncio.run(_run_final_query())
@@ -128,6 +150,8 @@ async def _run_final_query():
128150
click.echo(click.style("## device info file ##", bold=True))
129151

130152
sysinfo = final["system"]["get_sysinfo"]
153+
if "model" not in sysinfo:
154+
sysinfo = sysinfo["system"]
131155
model = sysinfo["model"]
132156
hw_version = sysinfo["hw_ver"]
133157
sw_version = sysinfo["sw_ver"]

kasa/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
SmartDeviceException,
2222
UnsupportedDeviceException,
2323
)
24+
from kasa.kasacamera import KasaCam
2425
from kasa.protocol import TPLinkSmartHomeProtocol
2526
from kasa.smartbulb import SmartBulb, SmartBulbPreset, TurnOnBehavior, TurnOnBehaviors
2627
from kasa.smartdevice import DeviceType, SmartDevice
@@ -34,6 +35,7 @@
3435

3536
__all__ = [
3637
"Discover",
38+
"KasaCam",
3739
"TPLinkSmartHomeProtocol",
3840
"SmartBulb",
3941
"SmartBulbPreset",

kasa/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from kasa import (
1414
Credentials,
1515
Discover,
16+
KasaCam,
1617
SmartBulb,
1718
SmartDevice,
1819
SmartDimmer,
@@ -47,6 +48,7 @@ def wrapper(message=None, *args, **kwargs):
4748
"bulb": SmartBulb,
4849
"dimmer": SmartDimmer,
4950
"strip": SmartStrip,
51+
"kasacam": KasaCam,
5052
"lightstrip": SmartLightStrip,
5153
}
5254

kasa/discover.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from kasa.exceptions import UnsupportedDeviceException
1414
from kasa.json import dumps as json_dumps
1515
from kasa.json import loads as json_loads
16+
from kasa.kasacamera import KasaCam
1617
from kasa.protocol import TPLinkSmartHomeProtocol
1718
from kasa.smartbulb import SmartBulb
1819
from kasa.smartdevice import SmartDevice, SmartDeviceException
@@ -354,9 +355,13 @@ def _get_device_class(info: dict) -> Type[SmartDevice]:
354355
raise SmartDeviceException("No 'system' or 'get_sysinfo' in response")
355356

356357
sysinfo = info["system"]["get_sysinfo"]
358+
if "system" in sysinfo:
359+
sysinfo = sysinfo["system"]
357360
type_ = sysinfo.get("type", sysinfo.get("mic_type"))
358361
if type_ is None:
359-
raise SmartDeviceException("Unable to find the device type field!")
362+
raise SmartDeviceException(
363+
f"Unable to find the device type field in {sysinfo}"
364+
)
360365

361366
if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]:
362367
return SmartDimmer
@@ -373,4 +378,7 @@ def _get_device_class(info: dict) -> Type[SmartDevice]:
373378

374379
return SmartBulb
375380

381+
if "ipcamera" in type_.lower():
382+
return KasaCam
383+
376384
raise SmartDeviceException("Unknown device type: %s" % type_)

kasa/kasacamera.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""Module for KasaCams (EC70)."""
2+
from typing import Any, Dict, Optional
3+
4+
from .credentials import Credentials
5+
from .kasacamprotocol import KasaCamProtocol
6+
from .modules import PTZ, Cloud, Time
7+
from .modules.ptz import Direction, Position
8+
from .smartdevice import DeviceType, SmartDevice, requires_update
9+
10+
11+
class KasaCam(SmartDevice):
12+
"""Representation of a TP-Link Kasa Camera.
13+
14+
To initialize, you have to await :func:`update()` at least once.
15+
This will allow accessing the properties using the exposed properties.
16+
17+
All changes to the device are done using awaitable methods,
18+
which will not change the cached values, but you must await :func:`update()`
19+
separately.
20+
21+
Errors reported by the device are raised as
22+
:class:`SmartDeviceExceptions <kasa.exceptions.SmartDeviceException>`, and should be
23+
handled by the user of the library.
24+
25+
Examples:
26+
>>> import asyncio
27+
>>> camera = KasaCam("127.0.0.1")
28+
>>> asyncio.run(camera.update())
29+
>>> print(camera.alias)
30+
Camera2
31+
32+
Cameras, like any other supported devices, can be turned on and off:
33+
34+
>>> asyncio.run(camera.turn_off())
35+
>>> asyncio.run(camera.turn_on())
36+
>>> asyncio.run(camera.update())
37+
>>> print(camera.is_on)
38+
True
39+
"""
40+
41+
def __init__(
42+
self,
43+
host: str,
44+
credentials: Credentials,
45+
*,
46+
port: Optional[int] = None,
47+
timeout: Optional[int] = None,
48+
) -> None:
49+
super().__init__(host, port=port, credentials=credentials, timeout=timeout)
50+
self._device_type = DeviceType.KasaCam
51+
self.protocol = KasaCamProtocol(
52+
host, credentials=credentials, port=port, timeout=timeout
53+
)
54+
self.add_module("cloud", Cloud(self, "smartlife.cam.ipcamera.cloud"))
55+
self.add_module("ptz", PTZ(self, "smartlife.cam.ipcamera.ptz"))
56+
self.add_module("time", Time(self, "smartlife.cam.ipcamera.dateTime"))
57+
58+
def _create_request(
59+
self,
60+
target: str,
61+
cmd: str,
62+
arg: Optional[Dict] = None,
63+
child_ids=None,
64+
):
65+
# While most devices accept None for an empty arg, Kasa Cameras require {}
66+
# all other devices seem to accept this as well
67+
return {target: {cmd: {} if arg is None else arg}}
68+
69+
@property # type: ignore
70+
@requires_update
71+
def is_on(self) -> bool:
72+
"""Return whether device is on."""
73+
return self.sys_info["camera_switch"] == "on"
74+
75+
@property # type: ignore
76+
@requires_update
77+
def state_information(self) -> Dict[str, Any]:
78+
"""Return camera-specific state information.
79+
80+
:return: Strip information dict, keys in user-presentable form.
81+
"""
82+
return {
83+
"Position": self.position,
84+
"Patrolling": self.is_patrol_enabled,
85+
}
86+
87+
@property
88+
@requires_update
89+
def position(self):
90+
"""The camera's current x,y position."""
91+
return self.modules["ptz"].position
92+
93+
@property
94+
@requires_update
95+
def is_patrol_enabled(self):
96+
"""Whether patrol mode is currently enabled."""
97+
return self.modules["ptz"].is_patrol_enabled
98+
99+
async def turn_on(self, **kwargs):
100+
"""Turn the switch on."""
101+
return await self._query_helper(
102+
"smartlife.cam.ipcamera.switch", "set_is_enable", {"value": "on"}
103+
)
104+
105+
async def turn_off(self, **kwargs):
106+
"""Turn the switch off."""
107+
return await self._query_helper(
108+
"smartlife.cam.ipcamera.switch", "set_is_enable", {"value": "off"}
109+
)
110+
111+
async def go_to(
112+
self,
113+
position: Optional[Position] = None,
114+
x: Optional[int] = None,
115+
y: Optional[int] = None,
116+
):
117+
"""Send the camera to an x,y position."""
118+
return await self.modules["ptz"].go_to(position=position, x=x, y=y)
119+
120+
async def stop(self):
121+
"""Stop the camera where it is."""
122+
return await self.modules["ptz"].stop()
123+
124+
async def move(self, direction: Direction, speed: int):
125+
"""Move the camera a relative direction at a given speed."""
126+
return await self.modules["ptz"].move(direction, speed)
127+
128+
async def set_enable_patrol(self, enabled: bool):
129+
"""Enable or disable Patrol mode."""
130+
return await self.modules["ptz"].set_enable_patrol(enabled)

kasa/kasacamprotocol.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Protocol implementation for controlling TP-Link Kasa Cam devices."""
2+
3+
import errno
4+
import logging
5+
from base64 import b64decode, b64encode
6+
from pprint import pformat as pf
7+
from typing import Dict
8+
from urllib.parse import quote
9+
10+
import httpx
11+
12+
# When support for cpython older than 3.11 is dropped
13+
# async_timeout can be replaced with asyncio.timeout
14+
from async_timeout import timeout as asyncio_timeout
15+
16+
from .exceptions import SmartDeviceException
17+
from .json import loads as json_loads
18+
from .protocol import TPLinkSmartHomeProtocol
19+
20+
_LOGGER = logging.getLogger(__name__)
21+
_NO_RETRY_ERRORS = {errno.EHOSTDOWN, errno.EHOSTUNREACH, errno.ECONNREFUSED}
22+
23+
24+
class KasaCamProtocol(TPLinkSmartHomeProtocol):
25+
"""Implementation of the Kasa Cam protocol."""
26+
27+
DEFAULT_PORT = 10443
28+
29+
def __init__(self, *args, **kwargs) -> None:
30+
self.credentials = kwargs.pop("credentials")
31+
super().__init__(*args, **kwargs)
32+
self.session: httpx.Client = None
33+
34+
async def _connect(self, timeout: int) -> None:
35+
if self.session:
36+
return
37+
38+
self.session = httpx.Client(
39+
base_url=f"https://{self.host}:{self.port}",
40+
auth=(
41+
self.credentials.username,
42+
b64encode(self.credentials.password.encode()),
43+
),
44+
verify=False, # noqa: S501 - Device certs are self-signed
45+
)
46+
47+
async def _execute_query(self, request: str) -> Dict:
48+
"""Execute a query on the device and wait for the response."""
49+
_LOGGER.debug("%s >> %s", self.host, request)
50+
print("%s >> %s", self.host, request)
51+
52+
encrypted_cmd = self.encrypt(request)[4:]
53+
b64_cmd = b64encode(encrypted_cmd).decode()
54+
url_safe_cmd = quote(b64_cmd, safe="!~*'()")
55+
56+
r = self.session.post("/data/LINKIE.json", data=f"content={url_safe_cmd}")
57+
json_payload = json_loads(self.decrypt(b64decode(r.read())))
58+
_LOGGER.debug("%s << %s", self.host, pf(json_payload))
59+
return json_payload
60+
61+
async def _query(self, request: str, retry_count: int, timeout: int) -> Dict:
62+
for retry in range(retry_count + 1):
63+
try:
64+
await self._connect(timeout)
65+
except ConnectionRefusedError as ex:
66+
await self.close()
67+
raise SmartDeviceException(
68+
f"Unable to connect to the device: {self.host}:{self.port}"
69+
) from ex
70+
except OSError as ex:
71+
await self.close()
72+
if ex.errno in _NO_RETRY_ERRORS or retry >= retry_count:
73+
raise SmartDeviceException(
74+
f"Unable to connect to the device: {self.host}:{self.port}"
75+
) from ex
76+
continue
77+
except Exception as ex:
78+
await self.close()
79+
if retry >= retry_count:
80+
_LOGGER.debug("Giving up on %s after %s retries", self.host, retry)
81+
raise SmartDeviceException(
82+
f"Unable to connect to the device: {self.host}:{self.port}"
83+
) from ex
84+
continue
85+
86+
try:
87+
async with asyncio_timeout(timeout):
88+
return await self._execute_query(request)
89+
except Exception as ex:
90+
await self.close()
91+
if retry >= retry_count:
92+
_LOGGER.debug("Giving up on %s after %s retries", self.host, retry)
93+
raise SmartDeviceException(
94+
f"Unable to query the device {self.host}:{self.port}: {ex}"
95+
) from ex
96+
97+
_LOGGER.debug(
98+
"Unable to query the device %s, retrying: %s", self.host, ex
99+
)
100+
# Make Mypy happy
101+
raise SmartDeviceException("Query reached the unreachable.")
102+
103+
def _reset(self) -> None:
104+
"""Clear any varibles that should not survive between loops."""
105+
self.session = None
106+
107+
def __del__(self) -> None:
108+
if self.session and self.loop and self.loop.is_running():
109+
# Since __del__ will be called when python does
110+
# garbage collection is can happen in the event loop thread
111+
# or in another thread so we need to make sure the call to
112+
# close is called safely with call_soon_threadsafe
113+
self.loop.call_soon_threadsafe(self.session.close)
114+
self._reset()

0 commit comments

Comments
 (0)