FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

WIP: Add preliminary support for tapo plugs by rytilahti · Pull Request #499 · python-kasa/python-kasa · GitHub

Repository navigation

1 change: 1 addition & 0 deletions kasa/__init__.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from kasa.smartlightstrip import SmartLightStrip
from kasa.smartplug import SmartPlug
from kasa.smartstrip import SmartStrip
from kasa.tapoplug import TapoPlug

__version__ = version("python-kasa")

Expand Down
2 changes: 2 additions & 0 deletions kasa/cli.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
SmartLightStrip,
SmartPlug,
SmartStrip,
TapoPlug,
)

try:
Expand All @@ -43,6 +44,7 @@ def wrapper(message=None, *args, **kwargs):
echo = _do_echo

TYPE_TO_CLASS = {
"tapoplug": TapoPlug,
"plug": SmartPlug,
"bulb": SmartBulb,
"dimmer": SmartDimmer,
Expand Down
31 changes: 26 additions & 5 deletions kasa/discover.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from kasa.smartlightstrip import SmartLightStrip
from kasa.smartplug import SmartPlug
from kasa.smartstrip import SmartStrip
from kasa.tapoplug import TapoPlug

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -104,20 +105,24 @@ def datagram_received(self, data, addr) -> None:

if port == self.discovery_port:
info = json_loads(TPLinkSmartHomeProtocol.decrypt(data))
_LOGGER.debug("[DISCOVERY] %s << %s", ip, info)

elif port == Discover.DISCOVERY_PORT_2:
info = json_loads(data[16:])
else:
raise SmartDeviceException(
"Received response from unexpected port %s" % port
)

_LOGGER.debug("[DISCOVERY] %s << %s", ip, info)
try:
device_class = Discover._get_device_class(info)
except UnsupportDeviceException as ex:
self.unsupported_devices[ip] = info
if self.on_unsupported is not None:
asyncio.ensure_future(self.on_unsupported(info))
_LOGGER.debug("[DISCOVERY] Unsupported device found at %s << %s", ip, info)
if self.discovered_event is not None:
self.discovered_event.set()
return

try:
device_class = Discover._get_device_class(info)
except SmartDeviceException as ex:
_LOGGER.debug(
"[DISCOVERY] Unable to find device type from %s: %s", info, ex
Expand All @@ -131,6 +136,9 @@ def datagram_received(self, data, addr) -> None:
ip, port=port, credentials=self.credentials, timeout=self.timeout
)
device.update_from_discover_info(info)
# TODO: hack to force update on non-authenticated discoveries
# this is not optimal, and needs rewiring when authentication parts are in-place
asyncio.ensure_future(device.update())

self.discovered_devices[ip] = device

Expand Down Expand Up @@ -377,6 +385,19 @@ async def connect_single(
@staticmethod
def _get_device_class(info: dict) -> Type[SmartDevice]:
"""Find SmartDevice subclass for device described by passed data."""
# 1. Check for the discovery payloads from port 20002/udp
if "result" in info:
supported_device_types = {
"SMART.TAPOPLUG": TapoPlug,
}
if (
device_type := info["result"].get("device_type")
) in supported_device_types:
return supported_device_types[device_type]
else:
raise UnsupportDeviceException("Found unsupported device: %s" % info)

# 2. Fallback to old 9999/udp discovery
if "system" not in info or "get_sysinfo" not in info["system"]:
raise SmartDeviceException("No 'system' or 'get_sysinfo' in response")

Expand Down
207 changes: 207 additions & 0 deletions kasa/tapoplug.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import logging
import os
from datetime import datetime, timedelta
from typing import Any, Dict, Optional, Set

from plugp100.api.plug_device import PlugDevice
from plugp100.api.tapo_client import TapoClient
from plugp100.common.credentials import AuthCredential
from plugp100.responses.device_state import DeviceInfo, PlugDeviceState
from plugp100.responses.device_usage_info import DeviceUsageInfo
from plugp100.responses.energy_info import EnergyInfo
from plugp100.responses.power_info import PowerInfo
from plugp100.responses.time_info import TimeInfo

from .emeterstatus import EmeterStatus
from .exceptions import AuthenticationException
from .smartdevice import DeviceType, SmartDevice
from .smartplug import SmartPlug

_LOGGER = logging.getLogger(__name__)


# TODO: there should be a baseclass for plugs that does not initialize modules etc. that are related only to some implementations
class TapoPlug(SmartPlug):
def __init__(self, host: str, *, port: Optional[int] = None, credentials) -> None:
# TODO: we are calling smartdevice here to avoid smartplug internal handling
SmartDevice.__init__(self, host, port=port, credentials=credentials)
# TODO: this is needed as we don't call smartplug ctor
self._device_type = DeviceType.Plug
self._tapo_client = None
self._tapo_device = None
self._state = None

async def update(self, update_children: bool = True):
if self.credentials is None or self.credentials.username is None:
raise AuthenticationException("Tapo plug requires authentication.")

if self._tapo_client is None:
user = self.credentials.username
pw = self.credentials.password
self._tapo_client = TapoClient(
AuthCredential(username=user, password=pw), self.host
)
await self._tapo_client.initialize()
self._tapo_device = PlugDevice(self._tapo_client)

self._state = (await self._tapo_device.get_state()).value
self._info: DeviceInfo = self._state.info

self._usage: DeviceUsageInfo = (
await self._tapo_device.get_device_usage()
).value
self._energy: EnergyInfo = (await self._tapo_device.get_energy_usage()).value
self._emeter: PowerInfo = (await self._tapo_device.get_current_power()).value
self._time: TimeInfo = (await self._tapo_device.get_device_time()).value
self._last_update = self._data = {
"state": self._state,
"usage": self._usage,
"emeter": self._emeter,
"energy": self._energy,
"time": self._time,
}

_LOGGER.debug("Got an update: %s", self._data)

@property
def sys_info(self) -> Dict[str, Any]:
return self._state

@property
def model(self) -> str:
return self._info.model

@property
def alias(self) -> str:
return self._info.nickname

@property
def time(self) -> datetime:
"""Disabled as 'region' is '' on my test device
File "/x/python-kasa/kasa/tapoplug.py", line 80, in time
return self._time.local_time()
^^^^^^^^^^^^^^^^^^^^^^^
File "x/plugp100/responses/time_info.py", line 19, in local_time
return datetime.fromtimestamp(self.timestamp, tz=ZoneInfo(self.region))
^^^^^^^^^^^^^^^^^^^^^
"""
return None
return self._time.local_time()

@property
def timezone(self) -> Dict:
"""Disabled as 'region' is '' on my test device
File "/x/python-kasa/kasa/tapoplug.py", line 80, in time
return self._time.local_time()
^^^^^^^^^^^^^^^^^^^^^^^
File "x/plugp100/responses/time_info.py", line 19, in local_time
return datetime.fromtimestamp(self.timestamp, tz=ZoneInfo(self.region))
^^^^^^^^^^^^^^^^^^^^^
"""
return None
return {"timezone": self._info.timezone, "timediff": self._info.time_difference}

def has_emeter(self) -> bool:
return True

@property
def emeter_realtime(self) -> EmeterStatus:
return EmeterStatus({"power_mw": self._energy.current_power})

@property
def emeter_today(self) -> Optional[float]:
return None

@property
def emeter_this_month(self) -> Optional[float]:
return None

@property
def hw_info(self) -> Dict:
# TODO: check that the keys match to kasa-infos
return {
"sw_ver": self._info.firmware_version,
"hw_ver": self._info.hardware_version,
"mac": self._info.mac,
"type": self._info.type,
"hwId": self._info.device_id,
"dev_name": self._info.nickname,
"oemId": self._info.oem_id,
}

@property
def location(self) -> Dict:
loc = {
"latitude": self._info.latitude / 10_000,
"longitude": self._info.longitude / 10_000,
}
return loc

@property
def rssi(self) -> Optional[int]:
return self._info.rssi

@property
def mac(self) -> str:
return self._info.mac.replace("-", ":")

@property
def device_id(self) -> str:
return self._info.device_id

@property
def internal_state(self) -> Any:
return self._data

@property
def is_on(self) -> bool:
return self._state.device_on

async def turn_on(self, **kwargs):
return await self._tapo_device.on()

async def turn_off(self, **kwargs):
return await self._tapo_device.off()

async def _query_helper(
self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None
) -> Any:
res = await self._tapo_device.raw_command(cmd, arg)
if res.is_left():
raise res.error
return res.value

@property
def led(self) -> bool:
return None

async def set_led(self, state: bool):
return await super().set_led(state)

@property
def on_since(self) -> Optional[datetime]:
on_time = self._state.on_time
return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time)

@property
def state_information(self) -> Dict[str, Any]:
return {
"is_hw_v2": self._info.is_hardware_v2,
"overheated": self._info.overheated,
"signal_level": self._info.signal_level,
"auto_off": self._state.auto_off,
"auto_off_remaining": self._state.auto_off_time_remaining,
"On since": self.on_since,
"SSID": self._info.ssid,
}

@property
def features(self) -> Set[str]:
# TODO:
return set()

def update_from_discover_info(self, info):
"""This can be used to update the state from discovery responses.

As this works only on the unauthenticated discovery responses, we do nothing here.
"""

Back | FazBrowse Home | New Git URL