| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent 805e4b8 commit 7f83115
19 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -17,7 +17,8 @@ | |||
| 17 | 17 | ||
| 18 | 18 | import click | |
| 19 | 19 | ||
| 20 | - from kasa import TPLinkSmartHomeProtocol | ||
| 20 | + from kasa.cli import TYPE_TO_CLASS | ||
| 21 | + from kasa.credentials import Credentials | ||
| 21 | 22 | ||
| 22 | 23 | Call = namedtuple("Call", "module method") | |
| 23 | 24 | ||
@@ -64,11 +65,34 @@ def default_to_regular(d): | |||
| 64 | 65 | @click.command() | |
| 65 | 66 | @click.argument("host") | |
| 66 | 67 | @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): | ||
| 68 | 90 | """Generate devinfo file for given device.""" | |
| 69 | 91 | if debug: | |
| 70 | 92 | logging.basicConfig(level=logging.DEBUG) | |
| 71 | 93 | ||
| 94 | + dev = TYPE_TO_CLASS[type](host, credentials=Credentials(username, password)) | ||
| 95 | + | ||
| 72 | 96 | items = [ | |
| 73 | 97 | Call(module="system", method="get_sysinfo"), | |
| 74 | 98 | Call(module="emeter", method="get_realtime"), | |
@@ -86,8 +110,7 @@ def cli(host, debug): | |||
| 86 | 110 | for test_call in items: | |
| 87 | 111 | ||
| 88 | 112 | 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: {}}}) | ||
| 91 | 114 | ||
| 92 | 115 | try: | |
| 93 | 116 | click.echo(f"Testing {test_call}..", nl=False) | |
@@ -106,14 +129,13 @@ async def _run_query(test_call): | |||
| 106 | 129 | final = defaultdict(defaultdict) | |
| 107 | 130 | ||
| 108 | 131 | for succ, resp in successes: | |
| 109 | - final_query[succ.module][succ.method] = None | ||
| 132 | + final_query[succ.module][succ.method] = {} | ||
| 110 | 133 | final[succ.module][succ.method] = resp | |
| 111 | 134 | ||
| 112 | 135 | final = default_to_regular(final) | |
| 113 | 136 | ||
| 114 | 137 | async def _run_final_query(): | |
| 115 | - protocol = TPLinkSmartHomeProtocol(host) | ||
| 116 | - return await protocol.query(final_query) | ||
| 138 | + return await dev.protocol.query(final_query) | ||
| 117 | 139 | ||
| 118 | 140 | try: | |
| 119 | 141 | final = asyncio.run(_run_final_query()) | |
@@ -128,6 +150,8 @@ async def _run_final_query(): | |||
| 128 | 150 | click.echo(click.style("## device info file ##", bold=True)) | |
| 129 | 151 | ||
| 130 | 152 | sysinfo = final["system"]["get_sysinfo"] | |
| 153 | + if "model" not in sysinfo: | ||
| 154 | + sysinfo = sysinfo["system"] | ||
| 131 | 155 | model = sysinfo["model"] | |
| 132 | 156 | hw_version = sysinfo["hw_ver"] | |
| 133 | 157 | sw_version = sysinfo["sw_ver"] | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -21,6 +21,7 @@ | |||
| 21 | 21 | SmartDeviceException, | |
| 22 | 22 | UnsupportedDeviceException, | |
| 23 | 23 | ) | |
| 24 | + from kasa.kasacamera import KasaCam | ||
| 24 | 25 | from kasa.protocol import TPLinkSmartHomeProtocol | |
| 25 | 26 | from kasa.smartbulb import SmartBulb, SmartBulbPreset, TurnOnBehavior, TurnOnBehaviors | |
| 26 | 27 | from kasa.smartdevice import DeviceType, SmartDevice | |
@@ -34,6 +35,7 @@ | |||
| 34 | 35 | ||
| 35 | 36 | __all__ = [ | |
| 36 | 37 | "Discover", | |
| 38 | + "KasaCam", | ||
| 37 | 39 | "TPLinkSmartHomeProtocol", | |
| 38 | 40 | "SmartBulb", | |
| 39 | 41 | "SmartBulbPreset", | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -13,6 +13,7 @@ | |||
| 13 | 13 | from kasa import ( | |
| 14 | 14 | Credentials, | |
| 15 | 15 | Discover, | |
| 16 | + KasaCam, | ||
| 16 | 17 | SmartBulb, | |
| 17 | 18 | SmartDevice, | |
| 18 | 19 | SmartDimmer, | |
@@ -47,6 +48,7 @@ def wrapper(message=None, *args, **kwargs): | |||
| 47 | 48 | "bulb": SmartBulb, | |
| 48 | 49 | "dimmer": SmartDimmer, | |
| 49 | 50 | "strip": SmartStrip, | |
| 51 | + "kasacam": KasaCam, | ||
| 50 | 52 | "lightstrip": SmartLightStrip, | |
| 51 | 53 | } | |
| 52 | 54 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -13,6 +13,7 @@ | |||
| 13 | 13 | from kasa.exceptions import UnsupportedDeviceException | |
| 14 | 14 | from kasa.json import dumps as json_dumps | |
| 15 | 15 | from kasa.json import loads as json_loads | |
| 16 | + from kasa.kasacamera import KasaCam | ||
| 16 | 17 | from kasa.protocol import TPLinkSmartHomeProtocol | |
| 17 | 18 | from kasa.smartbulb import SmartBulb | |
| 18 | 19 | from kasa.smartdevice import SmartDevice, SmartDeviceException | |
@@ -354,9 +355,13 @@ def _get_device_class(info: dict) -> Type[SmartDevice]: | |||
| 354 | 355 | raise SmartDeviceException("No 'system' or 'get_sysinfo' in response") | |
| 355 | 356 | ||
| 356 | 357 | sysinfo = info["system"]["get_sysinfo"] | |
| 358 | + if "system" in sysinfo: | ||
| 359 | + sysinfo = sysinfo["system"] | ||
| 357 | 360 | type_ = sysinfo.get("type", sysinfo.get("mic_type")) | |
| 358 | 361 | 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 | + ) | ||
| 360 | 365 | ||
| 361 | 366 | if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]: | |
| 362 | 367 | return SmartDimmer | |
@@ -373,4 +378,7 @@ def _get_device_class(info: dict) -> Type[SmartDevice]: | |||
| 373 | 378 | ||
| 374 | 379 | return SmartBulb | |
| 375 | 380 | ||
| 381 | + if "ipcamera" in type_.lower(): | ||
| 382 | + return KasaCam | ||
| 383 | + | ||
| 376 | 384 | raise SmartDeviceException("Unknown device type: %s" % type_) | |
| Original file line number | Diff line number | Diff 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_patrolling | ||
| 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) | ||
| Original file line number | Diff line number | Diff 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() | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments