2024-02-04 15:20:08 +00:00
|
|
|
"""Module for a SMART device."""
|
2024-04-16 18:21:20 +00:00
|
|
|
|
2024-04-17 13:39:24 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
2023-11-30 12:10:49 +00:00
|
|
|
import base64
|
|
|
|
import logging
|
2024-07-11 15:21:59 +00:00
|
|
|
import time
|
2024-06-19 18:24:12 +00:00
|
|
|
from collections.abc import Mapping, Sequence
|
2024-10-08 07:16:51 +00:00
|
|
|
from datetime import datetime, timedelta, timezone, tzinfo
|
|
|
|
from typing import TYPE_CHECKING, Any, cast
|
2023-11-30 12:10:49 +00:00
|
|
|
|
2023-12-19 14:11:59 +00:00
|
|
|
from ..aestransport import AesTransport
|
2024-02-04 15:20:08 +00:00
|
|
|
from ..device import Device, WifiNetwork
|
2024-01-29 16:11:29 +00:00
|
|
|
from ..device_type import DeviceType
|
2023-12-29 19:17:15 +00:00
|
|
|
from ..deviceconfig import DeviceConfig
|
2024-02-21 15:52:55 +00:00
|
|
|
from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode
|
2024-04-24 16:38:52 +00:00
|
|
|
from ..feature import Feature
|
2024-05-10 18:29:28 +00:00
|
|
|
from ..module import Module
|
|
|
|
from ..modulemapping import ModuleMapping, ModuleName
|
2023-12-04 18:50:05 +00:00
|
|
|
from ..smartprotocol import SmartProtocol
|
2024-04-29 17:19:44 +00:00
|
|
|
from .modules import (
|
2024-07-11 15:21:59 +00:00
|
|
|
ChildDevice,
|
2024-05-11 18:28:18 +00:00
|
|
|
Cloud,
|
2024-04-29 17:19:44 +00:00
|
|
|
DeviceModule,
|
|
|
|
Firmware,
|
2024-05-13 16:34:44 +00:00
|
|
|
Light,
|
2024-05-11 18:28:18 +00:00
|
|
|
Time,
|
2024-04-29 17:19:44 +00:00
|
|
|
)
|
2024-05-03 15:01:21 +00:00
|
|
|
from .smartmodule import SmartModule
|
2023-11-30 12:10:49 +00:00
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
2024-02-02 16:29:14 +00:00
|
|
|
|
2024-06-10 05:21:21 +00:00
|
|
|
# List of modules that non hub devices with children, i.e. ks240/P300, report on
|
2024-04-24 18:17:49 +00:00
|
|
|
# the child but only work on the parent. See longer note below in _initialize_modules.
|
|
|
|
# This list should be updated when creating new modules that could have the
|
|
|
|
# same issue, homekit perhaps?
|
2024-06-10 05:21:21 +00:00
|
|
|
NON_HUB_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud]
|
2024-04-24 18:17:49 +00:00
|
|
|
|
2024-07-11 15:21:59 +00:00
|
|
|
# Modules that are called as part of the init procedure on first update
|
|
|
|
FIRST_UPDATE_MODULES = {DeviceModule, ChildDevice, Cloud}
|
|
|
|
|
2024-04-29 17:19:44 +00:00
|
|
|
|
2024-05-02 12:55:08 +00:00
|
|
|
# Device must go last as the other interfaces also inherit Device
|
|
|
|
# and python needs a consistent method resolution order.
|
2024-05-13 16:34:44 +00:00
|
|
|
class SmartDevice(Device):
|
2024-02-04 15:20:08 +00:00
|
|
|
"""Base class to represent a SMART protocol based device."""
|
2023-11-30 12:10:49 +00:00
|
|
|
|
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
host: str,
|
|
|
|
*,
|
2024-04-17 13:39:24 +00:00
|
|
|
config: DeviceConfig | None = None,
|
|
|
|
protocol: SmartProtocol | None = None,
|
2023-11-30 12:10:49 +00:00
|
|
|
) -> None:
|
2023-12-29 19:17:15 +00:00
|
|
|
_protocol = protocol or SmartProtocol(
|
|
|
|
transport=AesTransport(config=config or DeviceConfig(host=host)),
|
|
|
|
)
|
|
|
|
super().__init__(host=host, config=config, protocol=_protocol)
|
2024-01-29 16:11:29 +00:00
|
|
|
self.protocol: SmartProtocol
|
2024-04-17 13:39:24 +00:00
|
|
|
self._components_raw: dict[str, Any] | None = None
|
|
|
|
self._components: dict[str, int] = {}
|
|
|
|
self._state_information: dict[str, Any] = {}
|
2024-05-10 18:29:28 +00:00
|
|
|
self._modules: dict[str | ModuleName[Module], SmartModule] = {}
|
2024-04-17 13:39:24 +00:00
|
|
|
self._parent: SmartDevice | None = None
|
|
|
|
self._children: Mapping[str, SmartDevice] = {}
|
2024-03-15 16:18:13 +00:00
|
|
|
self._last_update = {}
|
2024-07-11 15:21:59 +00:00
|
|
|
self._last_update_time: float | None = None
|
2024-10-02 14:04:16 +00:00
|
|
|
self._on_since: datetime | None = None
|
2024-01-29 16:11:29 +00:00
|
|
|
|
|
|
|
async def _initialize_children(self):
|
2024-02-02 16:29:14 +00:00
|
|
|
"""Initialize children for power strips."""
|
2024-03-15 16:18:13 +00:00
|
|
|
child_info_query = {
|
|
|
|
"get_child_device_component_list": None,
|
|
|
|
"get_child_device_list": None,
|
|
|
|
}
|
|
|
|
resp = await self.protocol.query(child_info_query)
|
|
|
|
self.internal_state.update(resp)
|
|
|
|
|
|
|
|
children = self.internal_state["get_child_device_list"]["child_device_list"]
|
2024-02-22 19:46:19 +00:00
|
|
|
children_components = {
|
|
|
|
child["device_id"]: {
|
|
|
|
comp["id"]: int(comp["ver_code"]) for comp in child["component_list"]
|
|
|
|
}
|
|
|
|
for child in self.internal_state["get_child_device_component_list"][
|
|
|
|
"child_component_list"
|
|
|
|
]
|
|
|
|
}
|
2024-02-04 15:20:08 +00:00
|
|
|
from .smartchilddevice import SmartChildDevice
|
2024-01-29 16:11:29 +00:00
|
|
|
|
2024-02-02 16:29:14 +00:00
|
|
|
self._children = {
|
2024-02-22 19:46:19 +00:00
|
|
|
child_info["device_id"]: await SmartChildDevice.create(
|
|
|
|
parent=self,
|
|
|
|
child_info=child_info,
|
|
|
|
child_components=children_components[child_info["device_id"]],
|
2024-02-04 15:20:08 +00:00
|
|
|
)
|
2024-02-22 19:46:19 +00:00
|
|
|
for child_info in children
|
2024-02-02 16:29:14 +00:00
|
|
|
}
|
2023-11-30 12:10:49 +00:00
|
|
|
|
2024-02-02 16:29:14 +00:00
|
|
|
@property
|
2024-04-17 13:39:24 +00:00
|
|
|
def children(self) -> Sequence[SmartDevice]:
|
2024-02-04 15:20:08 +00:00
|
|
|
"""Return list of children."""
|
2024-02-02 16:29:14 +00:00
|
|
|
return list(self._children.values())
|
|
|
|
|
2024-04-29 16:34:20 +00:00
|
|
|
@property
|
2024-05-10 18:29:28 +00:00
|
|
|
def modules(self) -> ModuleMapping[SmartModule]:
|
2024-04-29 16:34:20 +00:00
|
|
|
"""Return the device modules."""
|
2024-06-19 18:24:12 +00:00
|
|
|
return cast(ModuleMapping[SmartModule], self._modules)
|
2024-04-29 16:34:20 +00:00
|
|
|
|
2024-02-15 18:10:34 +00:00
|
|
|
def _try_get_response(self, responses: dict, request: str, default=None) -> dict:
|
|
|
|
response = responses.get(request)
|
|
|
|
if isinstance(response, SmartErrorCode):
|
|
|
|
_LOGGER.debug(
|
|
|
|
"Error %s getting request %s for device %s",
|
|
|
|
response,
|
|
|
|
request,
|
|
|
|
self.host,
|
|
|
|
)
|
|
|
|
response = None
|
|
|
|
if response is not None:
|
|
|
|
return response
|
|
|
|
if default is not None:
|
|
|
|
return default
|
2024-02-21 15:52:55 +00:00
|
|
|
raise KasaException(
|
2024-02-15 18:10:34 +00:00
|
|
|
f"{request} not found in {responses} for device {self.host}"
|
|
|
|
)
|
|
|
|
|
2024-02-19 17:01:31 +00:00
|
|
|
async def _negotiate(self):
|
2024-03-15 16:18:13 +00:00
|
|
|
"""Perform initialization.
|
|
|
|
|
|
|
|
We fetch the device info and the available components as early as possible.
|
|
|
|
If the device reports supporting child devices, they are also initialized.
|
|
|
|
"""
|
2024-04-23 11:56:32 +00:00
|
|
|
initial_query = {
|
|
|
|
"component_nego": None,
|
|
|
|
"get_device_info": None,
|
|
|
|
"get_connect_cloud_state": None,
|
|
|
|
}
|
2024-03-15 16:18:13 +00:00
|
|
|
resp = await self.protocol.query(initial_query)
|
|
|
|
|
|
|
|
# Save the initial state to allow modules access the device info already
|
|
|
|
# during the initialization, which is necessary as some information like the
|
|
|
|
# supported color temperature range is contained within the response.
|
|
|
|
self._last_update.update(resp)
|
|
|
|
self._info = self._try_get_response(resp, "get_device_info")
|
|
|
|
|
|
|
|
# Create our internal presentation of available components
|
2024-02-19 17:01:31 +00:00
|
|
|
self._components_raw = resp["component_nego"]
|
|
|
|
self._components = {
|
|
|
|
comp["id"]: int(comp["ver_code"])
|
|
|
|
for comp in self._components_raw["component_list"]
|
|
|
|
}
|
|
|
|
|
2024-03-15 16:18:13 +00:00
|
|
|
if "child_device" in self._components and not self.children:
|
|
|
|
await self._initialize_children()
|
|
|
|
|
2024-06-10 14:47:00 +00:00
|
|
|
async def update(self, update_children: bool = False):
|
2023-11-30 12:10:49 +00:00
|
|
|
"""Update the device."""
|
2024-01-03 21:46:08 +00:00
|
|
|
if self.credentials is None and self.credentials_hash is None:
|
2024-02-21 15:52:55 +00:00
|
|
|
raise AuthenticationError("Tapo plug requires authentication.")
|
2023-11-30 12:10:49 +00:00
|
|
|
|
2024-07-11 15:21:59 +00:00
|
|
|
first_update = self._last_update_time is None
|
2024-07-16 12:25:32 +00:00
|
|
|
now = time.monotonic()
|
2024-07-11 15:21:59 +00:00
|
|
|
self._last_update_time = now
|
|
|
|
|
|
|
|
if first_update:
|
2024-02-19 17:01:31 +00:00
|
|
|
await self._negotiate()
|
2024-01-03 18:04:34 +00:00
|
|
|
await self._initialize_modules()
|
2024-07-30 18:23:07 +00:00
|
|
|
# Run post update for the cloud module
|
|
|
|
if cloud_mod := self.modules.get(Module.Cloud):
|
2024-10-08 07:16:51 +00:00
|
|
|
await self._handle_module_post_update(cloud_mod, now, had_query=True)
|
2024-01-03 18:04:34 +00:00
|
|
|
|
2024-07-11 15:21:59 +00:00
|
|
|
resp = await self._modular_update(first_update, now)
|
2024-06-10 14:47:00 +00:00
|
|
|
|
2024-07-30 18:23:07 +00:00
|
|
|
if child_info := self._try_get_response(
|
|
|
|
self._last_update, "get_child_device_list", {}
|
|
|
|
):
|
|
|
|
for info in child_info["child_device_list"]:
|
|
|
|
self._children[info["device_id"]]._update_internal_state(info)
|
2024-06-10 14:47:00 +00:00
|
|
|
# Call child update which will only update module calls, info is updated
|
|
|
|
# from get_child_device_list. update_children only affects hub devices, other
|
|
|
|
# devices will always update children to prevent errors on module access.
|
2024-07-30 18:23:07 +00:00
|
|
|
# This needs to go after updating the internal state of the children so that
|
|
|
|
# child modules have access to their sysinfo.
|
2024-06-10 14:47:00 +00:00
|
|
|
if update_children or self.device_type != DeviceType.Hub:
|
|
|
|
for child in self._children.values():
|
2024-07-02 13:11:19 +00:00
|
|
|
await child._update()
|
2024-05-19 09:18:17 +00:00
|
|
|
|
2024-02-15 15:25:08 +00:00
|
|
|
# We can first initialize the features after the first update.
|
|
|
|
# We make here an assumption that every device has at least a single feature.
|
|
|
|
if not self._features:
|
|
|
|
await self._initialize_features()
|
|
|
|
|
2024-07-17 17:57:09 +00:00
|
|
|
if _LOGGER.isEnabledFor(logging.DEBUG):
|
|
|
|
updated = self._last_update if first_update else resp
|
|
|
|
_LOGGER.debug("Update completed %s: %s", self.host, list(updated.keys()))
|
2023-11-30 12:10:49 +00:00
|
|
|
|
2024-10-08 07:16:51 +00:00
|
|
|
async def _handle_module_post_update(
|
2024-07-30 18:23:07 +00:00
|
|
|
self, module: SmartModule, update_time: float, had_query: bool
|
|
|
|
):
|
|
|
|
if module.disabled:
|
|
|
|
return # pragma: no cover
|
|
|
|
if had_query:
|
|
|
|
module._last_update_time = update_time
|
2024-07-04 07:02:50 +00:00
|
|
|
try:
|
2024-10-08 07:16:51 +00:00
|
|
|
await module._post_update_hook()
|
2024-07-30 18:23:07 +00:00
|
|
|
module._set_error(None)
|
2024-07-04 07:02:50 +00:00
|
|
|
except Exception as ex:
|
2024-07-30 18:23:07 +00:00
|
|
|
# Only set the error if a query happened.
|
|
|
|
if had_query:
|
|
|
|
module._set_error(ex)
|
|
|
|
_LOGGER.warning(
|
|
|
|
"Error processing %s for device %s, module will be unavailable: %s",
|
|
|
|
module.name,
|
|
|
|
self.host,
|
|
|
|
ex,
|
|
|
|
)
|
2024-07-04 07:02:50 +00:00
|
|
|
|
2024-07-11 15:21:59 +00:00
|
|
|
async def _modular_update(
|
|
|
|
self, first_update: bool, update_time: float
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
"""Update the device with via the module queries."""
|
|
|
|
req: dict[str, Any] = {}
|
|
|
|
# Keep a track of actual module queries so we can track the time for
|
|
|
|
# modules that do not need to be updated frequently
|
|
|
|
module_queries: list[SmartModule] = []
|
|
|
|
mq = {
|
|
|
|
module: query
|
|
|
|
for module in self._modules.values()
|
2024-07-30 18:23:07 +00:00
|
|
|
if module.disabled is False and (query := module.query())
|
2024-07-11 15:21:59 +00:00
|
|
|
}
|
|
|
|
for module, query in mq.items():
|
|
|
|
if first_update and module.__class__ in FIRST_UPDATE_MODULES:
|
|
|
|
module._last_update_time = update_time
|
|
|
|
continue
|
|
|
|
if (
|
2024-07-30 18:23:07 +00:00
|
|
|
not module.update_interval
|
2024-07-11 15:21:59 +00:00
|
|
|
or not module._last_update_time
|
2024-07-30 18:23:07 +00:00
|
|
|
or (update_time - module._last_update_time) >= module.update_interval
|
2024-07-11 15:21:59 +00:00
|
|
|
):
|
|
|
|
module_queries.append(module)
|
|
|
|
req.update(query)
|
|
|
|
|
|
|
|
_LOGGER.debug(
|
|
|
|
"Querying %s for modules: %s",
|
|
|
|
self.host,
|
|
|
|
", ".join(mod.name for mod in module_queries),
|
|
|
|
)
|
|
|
|
|
|
|
|
try:
|
|
|
|
resp = await self.protocol.query(req)
|
|
|
|
except Exception as ex:
|
|
|
|
resp = await self._handle_modular_update_error(
|
|
|
|
ex, first_update, ", ".join(mod.name for mod in module_queries), req
|
|
|
|
)
|
|
|
|
|
|
|
|
info_resp = self._last_update if first_update else resp
|
|
|
|
self._last_update.update(**resp)
|
|
|
|
self._info = self._try_get_response(info_resp, "get_device_info")
|
|
|
|
|
|
|
|
# Call handle update for modules that want to update internal data
|
2024-07-30 18:23:07 +00:00
|
|
|
for module in self._modules.values():
|
2024-10-08 07:16:51 +00:00
|
|
|
await self._handle_module_post_update(
|
2024-07-30 18:23:07 +00:00
|
|
|
module, update_time, had_query=module in module_queries
|
|
|
|
)
|
2024-07-11 15:21:59 +00:00
|
|
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
async def _handle_modular_update_error(
|
|
|
|
self,
|
|
|
|
ex: Exception,
|
|
|
|
first_update: bool,
|
|
|
|
module_names: str,
|
|
|
|
requests: dict[str, Any],
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
"""Handle an error on calling module update.
|
|
|
|
|
|
|
|
Will try to call all modules individually
|
|
|
|
and any errors such as timeouts will be set as a SmartErrorCode.
|
|
|
|
"""
|
|
|
|
msg_part = "on first update" if first_update else "after first update"
|
|
|
|
|
|
|
|
_LOGGER.error(
|
|
|
|
"Error querying %s for modules '%s' %s: %s",
|
|
|
|
self.host,
|
|
|
|
module_names,
|
|
|
|
msg_part,
|
|
|
|
ex,
|
|
|
|
)
|
|
|
|
responses = {}
|
|
|
|
for meth, params in requests.items():
|
|
|
|
try:
|
|
|
|
resp = await self.protocol.query({meth: params})
|
|
|
|
responses[meth] = resp[meth]
|
|
|
|
except Exception as iex:
|
|
|
|
_LOGGER.error(
|
|
|
|
"Error querying %s individually for module query '%s' %s: %s",
|
|
|
|
self.host,
|
|
|
|
meth,
|
|
|
|
msg_part,
|
|
|
|
iex,
|
|
|
|
)
|
|
|
|
responses[meth] = SmartErrorCode.INTERNAL_QUERY_ERROR
|
|
|
|
return responses
|
|
|
|
|
2024-01-03 18:04:34 +00:00
|
|
|
async def _initialize_modules(self):
|
|
|
|
"""Initialize modules based on component negotiation response."""
|
2024-02-19 17:01:31 +00:00
|
|
|
from .smartmodule import SmartModule
|
|
|
|
|
2024-04-24 18:17:49 +00:00
|
|
|
# Some wall switches (like ks240) are internally presented as having child
|
|
|
|
# devices which report the child's components on the parent's sysinfo, even
|
|
|
|
# when they need to be accessed through the children.
|
2024-04-29 16:34:20 +00:00
|
|
|
# The logic below ensures that such devices add all but whitelisted, only on
|
|
|
|
# the child device.
|
2024-06-10 05:21:21 +00:00
|
|
|
# It also ensures that devices like power strips do not add modules such as
|
|
|
|
# firmware to the child devices.
|
2024-04-29 16:34:20 +00:00
|
|
|
skip_parent_only_modules = False
|
|
|
|
child_modules_to_skip = {}
|
2024-06-10 05:21:21 +00:00
|
|
|
if self._parent and self._parent.device_type != DeviceType.Hub:
|
2024-04-24 18:17:49 +00:00
|
|
|
skip_parent_only_modules = True
|
|
|
|
|
2024-02-19 17:01:31 +00:00
|
|
|
for mod in SmartModule.REGISTERED_MODULES.values():
|
2024-04-29 16:34:20 +00:00
|
|
|
if (
|
2024-06-10 05:21:21 +00:00
|
|
|
skip_parent_only_modules and mod in NON_HUB_PARENT_ONLY_MODULES
|
2024-04-29 16:34:20 +00:00
|
|
|
) or mod.__name__ in child_modules_to_skip:
|
2024-04-24 18:17:49 +00:00
|
|
|
continue
|
2024-05-07 18:58:18 +00:00
|
|
|
if (
|
|
|
|
mod.REQUIRED_COMPONENT in self._components
|
|
|
|
or self.sys_info.get(mod.REQUIRED_KEY_ON_PARENT) is not None
|
|
|
|
):
|
2024-02-19 17:01:31 +00:00
|
|
|
_LOGGER.debug(
|
2024-07-11 15:21:59 +00:00
|
|
|
"Device %s, found required %s, adding %s to modules.",
|
|
|
|
self.host,
|
2024-02-19 17:01:31 +00:00
|
|
|
mod.REQUIRED_COMPONENT,
|
|
|
|
mod.__name__,
|
|
|
|
)
|
|
|
|
module = mod(self, mod.REQUIRED_COMPONENT)
|
2024-04-29 16:34:20 +00:00
|
|
|
if await module._check_supported():
|
|
|
|
self._modules[module.name] = module
|
|
|
|
|
2024-05-13 16:34:44 +00:00
|
|
|
if (
|
|
|
|
Module.Brightness in self._modules
|
|
|
|
or Module.Color in self._modules
|
|
|
|
or Module.ColorTemperature in self._modules
|
|
|
|
):
|
|
|
|
self._modules[Light.__name__] = Light(self, "light")
|
|
|
|
|
2024-02-15 15:25:08 +00:00
|
|
|
async def _initialize_features(self):
|
|
|
|
"""Initialize device features."""
|
2024-04-23 17:20:12 +00:00
|
|
|
self._add_feature(
|
|
|
|
Feature(
|
|
|
|
self,
|
2024-05-07 09:13:35 +00:00
|
|
|
id="device_id",
|
|
|
|
name="Device ID",
|
2024-04-23 17:20:12 +00:00
|
|
|
attribute_getter="device_id",
|
|
|
|
category=Feature.Category.Debug,
|
2024-06-25 16:30:36 +00:00
|
|
|
type=Feature.Type.Sensor,
|
2024-04-23 17:20:12 +00:00
|
|
|
)
|
|
|
|
)
|
2024-02-19 17:01:31 +00:00
|
|
|
if "device_on" in self._info:
|
|
|
|
self._add_feature(
|
|
|
|
Feature(
|
|
|
|
self,
|
2024-05-07 09:13:35 +00:00
|
|
|
id="state",
|
|
|
|
name="State",
|
2024-02-19 17:01:31 +00:00
|
|
|
attribute_getter="is_on",
|
|
|
|
attribute_setter="set_state",
|
2024-04-24 16:38:52 +00:00
|
|
|
type=Feature.Type.Switch,
|
2024-04-23 17:20:12 +00:00
|
|
|
category=Feature.Category.Primary,
|
2024-02-19 17:01:31 +00:00
|
|
|
)
|
|
|
|
)
|
|
|
|
|
2024-02-22 19:46:19 +00:00
|
|
|
if "signal_level" in self._info:
|
|
|
|
self._add_feature(
|
|
|
|
Feature(
|
|
|
|
self,
|
2024-05-07 09:13:35 +00:00
|
|
|
id="signal_level",
|
|
|
|
name="Signal Level",
|
2024-02-22 19:46:19 +00:00
|
|
|
attribute_getter=lambda x: x._info["signal_level"],
|
|
|
|
icon="mdi:signal",
|
2024-04-23 17:20:12 +00:00
|
|
|
category=Feature.Category.Info,
|
2024-06-25 16:30:36 +00:00
|
|
|
type=Feature.Type.Sensor,
|
2024-02-22 19:46:19 +00:00
|
|
|
)
|
2024-02-15 15:25:08 +00:00
|
|
|
)
|
2024-02-22 19:46:19 +00:00
|
|
|
|
|
|
|
if "rssi" in self._info:
|
|
|
|
self._add_feature(
|
|
|
|
Feature(
|
|
|
|
self,
|
2024-05-07 09:13:35 +00:00
|
|
|
id="rssi",
|
|
|
|
name="RSSI",
|
2024-02-22 19:46:19 +00:00
|
|
|
attribute_getter=lambda x: x._info["rssi"],
|
|
|
|
icon="mdi:signal",
|
2024-07-30 18:23:07 +00:00
|
|
|
unit_getter=lambda: "dBm",
|
2024-04-23 17:20:12 +00:00
|
|
|
category=Feature.Category.Debug,
|
2024-06-25 16:30:36 +00:00
|
|
|
type=Feature.Type.Sensor,
|
2024-02-22 19:46:19 +00:00
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
if "ssid" in self._info:
|
|
|
|
self._add_feature(
|
|
|
|
Feature(
|
2024-04-23 17:20:12 +00:00
|
|
|
device=self,
|
2024-05-07 09:13:35 +00:00
|
|
|
id="ssid",
|
2024-04-23 17:20:12 +00:00
|
|
|
name="SSID",
|
|
|
|
attribute_getter="ssid",
|
|
|
|
icon="mdi:wifi",
|
|
|
|
category=Feature.Category.Debug,
|
2024-06-25 16:30:36 +00:00
|
|
|
type=Feature.Type.Sensor,
|
2024-02-22 19:46:19 +00:00
|
|
|
)
|
2024-02-15 15:25:08 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
if "overheated" in self._info:
|
|
|
|
self._add_feature(
|
|
|
|
Feature(
|
|
|
|
self,
|
2024-05-07 09:13:35 +00:00
|
|
|
id="overheated",
|
|
|
|
name="Overheated",
|
2024-02-15 15:25:08 +00:00
|
|
|
attribute_getter=lambda x: x._info["overheated"],
|
|
|
|
icon="mdi:heat-wave",
|
2024-04-24 16:38:52 +00:00
|
|
|
type=Feature.Type.BinarySensor,
|
2024-05-07 09:13:35 +00:00
|
|
|
category=Feature.Category.Info,
|
2024-02-15 15:25:08 +00:00
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
# We check for the key available, and not for the property truthiness,
|
|
|
|
# as the value is falsy when the device is off.
|
|
|
|
if "on_time" in self._info:
|
|
|
|
self._add_feature(
|
|
|
|
Feature(
|
|
|
|
device=self,
|
2024-05-07 09:13:35 +00:00
|
|
|
id="on_since",
|
2024-02-15 15:25:08 +00:00
|
|
|
name="On since",
|
|
|
|
attribute_getter="on_since",
|
|
|
|
icon="mdi:clock",
|
2024-06-21 16:42:43 +00:00
|
|
|
category=Feature.Category.Debug,
|
2024-06-25 16:30:36 +00:00
|
|
|
type=Feature.Type.Sensor,
|
2024-02-15 15:25:08 +00:00
|
|
|
)
|
|
|
|
)
|
|
|
|
|
2024-07-24 13:47:38 +00:00
|
|
|
self._add_feature(
|
|
|
|
Feature(
|
|
|
|
device=self,
|
|
|
|
id="reboot",
|
|
|
|
name="Reboot",
|
|
|
|
attribute_setter="reboot",
|
|
|
|
icon="mdi:restart",
|
|
|
|
category=Feature.Category.Debug,
|
|
|
|
type=Feature.Type.Action,
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
2024-05-13 16:34:44 +00:00
|
|
|
for module in self.modules.values():
|
2024-06-10 04:59:37 +00:00
|
|
|
module._initialize_features()
|
2024-02-19 17:01:31 +00:00
|
|
|
for feat in module._module_features.values():
|
|
|
|
self._add_feature(feat)
|
2024-05-19 09:18:17 +00:00
|
|
|
for child in self._children.values():
|
|
|
|
await child._initialize_features()
|
|
|
|
|
2024-04-23 11:56:32 +00:00
|
|
|
@property
|
2024-05-11 18:28:18 +00:00
|
|
|
def is_cloud_connected(self) -> bool:
|
2024-04-23 11:56:32 +00:00
|
|
|
"""Returns if the device is connected to the cloud."""
|
2024-05-11 18:28:18 +00:00
|
|
|
if Module.Cloud not in self.modules:
|
2024-04-23 11:56:32 +00:00
|
|
|
return False
|
2024-05-11 18:28:18 +00:00
|
|
|
return self.modules[Module.Cloud].is_connected
|
2024-04-23 11:56:32 +00:00
|
|
|
|
2023-11-30 12:10:49 +00:00
|
|
|
@property
|
2024-04-17 13:39:24 +00:00
|
|
|
def sys_info(self) -> dict[str, Any]:
|
2023-11-30 12:10:49 +00:00
|
|
|
"""Returns the device info."""
|
2023-12-29 19:17:15 +00:00
|
|
|
return self._info # type: ignore
|
2023-11-30 12:10:49 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def model(self) -> str:
|
|
|
|
"""Returns the device model."""
|
|
|
|
return str(self._info.get("model"))
|
|
|
|
|
|
|
|
@property
|
2024-04-17 13:39:24 +00:00
|
|
|
def alias(self) -> str | None:
|
2023-11-30 12:10:49 +00:00
|
|
|
"""Returns the device alias or nickname."""
|
2024-01-11 15:12:02 +00:00
|
|
|
if self._info and (nickname := self._info.get("nickname")):
|
|
|
|
return base64.b64decode(nickname).decode()
|
|
|
|
else:
|
|
|
|
return None
|
2023-11-30 12:10:49 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def time(self) -> datetime:
|
|
|
|
"""Return the time."""
|
2024-06-14 21:04:20 +00:00
|
|
|
if (self._parent and (time_mod := self._parent.modules.get(Module.Time))) or (
|
|
|
|
time_mod := self.modules.get(Module.Time)
|
|
|
|
):
|
|
|
|
return time_mod.time
|
2024-02-22 19:46:19 +00:00
|
|
|
|
2024-06-14 21:04:20 +00:00
|
|
|
# We have no device time, use current local time.
|
|
|
|
return datetime.now(timezone.utc).astimezone().replace(microsecond=0)
|
|
|
|
|
|
|
|
@property
|
|
|
|
def on_since(self) -> datetime | None:
|
2024-10-02 14:04:16 +00:00
|
|
|
"""Return the time that the device was turned on or None if turned off.
|
|
|
|
|
|
|
|
This returns a cached value if the device reported value difference is under
|
|
|
|
five seconds to avoid device-caused jitter.
|
|
|
|
"""
|
2024-06-14 21:04:20 +00:00
|
|
|
if (
|
|
|
|
not self._info.get("device_on")
|
|
|
|
or (on_time := self._info.get("on_time")) is None
|
|
|
|
):
|
2024-10-02 14:04:16 +00:00
|
|
|
self._on_since = None
|
2024-06-14 21:04:20 +00:00
|
|
|
return None
|
|
|
|
|
|
|
|
on_time = cast(float, on_time)
|
2024-10-02 14:04:16 +00:00
|
|
|
on_since = self.time - timedelta(seconds=on_time)
|
|
|
|
if not self._on_since or timedelta(
|
|
|
|
seconds=0
|
|
|
|
) < on_since - self._on_since > timedelta(seconds=5):
|
|
|
|
self._on_since = on_since
|
|
|
|
return self._on_since
|
2023-11-30 12:10:49 +00:00
|
|
|
|
|
|
|
@property
|
2024-10-08 07:16:51 +00:00
|
|
|
def timezone(self) -> tzinfo:
|
2023-11-30 12:10:49 +00:00
|
|
|
"""Return the timezone and time_difference."""
|
2024-10-08 07:16:51 +00:00
|
|
|
if TYPE_CHECKING:
|
|
|
|
assert self.time.tzinfo
|
|
|
|
return self.time.tzinfo
|
2023-11-30 12:10:49 +00:00
|
|
|
|
|
|
|
@property
|
2024-04-17 13:39:24 +00:00
|
|
|
def hw_info(self) -> dict:
|
2023-11-30 12:10:49 +00:00
|
|
|
"""Return hardware info for the device."""
|
|
|
|
return {
|
|
|
|
"sw_ver": self._info.get("fw_ver"),
|
|
|
|
"hw_ver": self._info.get("hw_ver"),
|
|
|
|
"mac": self._info.get("mac"),
|
|
|
|
"type": self._info.get("type"),
|
|
|
|
"hwId": self._info.get("device_id"),
|
|
|
|
"dev_name": self.alias,
|
|
|
|
"oemId": self._info.get("oem_id"),
|
|
|
|
}
|
|
|
|
|
|
|
|
@property
|
2024-04-17 13:39:24 +00:00
|
|
|
def location(self) -> dict:
|
2023-11-30 12:10:49 +00:00
|
|
|
"""Return the device location."""
|
|
|
|
loc = {
|
2024-01-24 12:21:37 +00:00
|
|
|
"latitude": cast(float, self._info.get("latitude", 0)) / 10_000,
|
|
|
|
"longitude": cast(float, self._info.get("longitude", 0)) / 10_000,
|
2023-11-30 12:10:49 +00:00
|
|
|
}
|
|
|
|
return loc
|
|
|
|
|
|
|
|
@property
|
2024-04-17 13:39:24 +00:00
|
|
|
def rssi(self) -> int | None:
|
2023-11-30 12:10:49 +00:00
|
|
|
"""Return the rssi."""
|
|
|
|
rssi = self._info.get("rssi")
|
|
|
|
return int(rssi) if rssi else None
|
|
|
|
|
|
|
|
@property
|
|
|
|
def mac(self) -> str:
|
|
|
|
"""Return the mac formatted with colons."""
|
|
|
|
return str(self._info.get("mac")).replace("-", ":")
|
|
|
|
|
|
|
|
@property
|
|
|
|
def device_id(self) -> str:
|
|
|
|
"""Return the device id."""
|
|
|
|
return str(self._info.get("device_id"))
|
|
|
|
|
|
|
|
@property
|
|
|
|
def internal_state(self) -> Any:
|
|
|
|
"""Return all the internal state data."""
|
2024-02-02 16:29:14 +00:00
|
|
|
return self._last_update
|
2023-11-30 12:10:49 +00:00
|
|
|
|
2024-02-22 19:46:19 +00:00
|
|
|
def _update_internal_state(self, info):
|
2024-04-17 10:07:16 +00:00
|
|
|
"""Update the internal info state.
|
2024-02-22 19:46:19 +00:00
|
|
|
|
2024-04-17 10:07:16 +00:00
|
|
|
This is used by the parent to push updates to its children.
|
2024-02-22 19:46:19 +00:00
|
|
|
"""
|
2024-04-17 10:07:16 +00:00
|
|
|
self._info = info
|
2024-02-22 19:46:19 +00:00
|
|
|
|
2023-11-30 12:10:49 +00:00
|
|
|
async def _query_helper(
|
2024-04-17 13:39:24 +00:00
|
|
|
self, method: str, params: dict | None = None, child_ids=None
|
2023-11-30 12:10:49 +00:00
|
|
|
) -> Any:
|
2024-02-04 15:20:08 +00:00
|
|
|
res = await self.protocol.query({method: params})
|
2023-11-30 12:10:49 +00:00
|
|
|
|
|
|
|
return res
|
|
|
|
|
|
|
|
@property
|
2024-02-15 15:25:08 +00:00
|
|
|
def ssid(self) -> str:
|
|
|
|
"""Return ssid of the connected wifi ap."""
|
2024-02-02 16:29:14 +00:00
|
|
|
ssid = self._info.get("ssid")
|
|
|
|
ssid = base64.b64decode(ssid).decode() if ssid else "No SSID"
|
2024-02-15 15:25:08 +00:00
|
|
|
return ssid
|
2024-02-02 16:29:14 +00:00
|
|
|
|
2024-01-03 18:04:34 +00:00
|
|
|
@property
|
|
|
|
def has_emeter(self) -> bool:
|
|
|
|
"""Return if the device has emeter."""
|
2024-05-11 18:28:18 +00:00
|
|
|
return Module.Energy in self.modules
|
2024-01-03 18:04:34 +00:00
|
|
|
|
2023-11-30 12:10:49 +00:00
|
|
|
@property
|
|
|
|
def is_on(self) -> bool:
|
|
|
|
"""Return true if the device is on."""
|
|
|
|
return bool(self._info.get("device_on"))
|
|
|
|
|
2024-02-19 17:01:31 +00:00
|
|
|
async def set_state(self, on: bool): # TODO: better name wanted.
|
|
|
|
"""Set the device state.
|
|
|
|
|
|
|
|
See :meth:`is_on`.
|
|
|
|
"""
|
|
|
|
return await self.protocol.query({"set_device_info": {"device_on": on}})
|
|
|
|
|
2023-11-30 12:10:49 +00:00
|
|
|
async def turn_on(self, **kwargs):
|
|
|
|
"""Turn on the device."""
|
2024-02-19 17:01:31 +00:00
|
|
|
await self.set_state(True)
|
2023-11-30 12:10:49 +00:00
|
|
|
|
|
|
|
async def turn_off(self, **kwargs):
|
|
|
|
"""Turn off the device."""
|
2024-02-19 17:01:31 +00:00
|
|
|
await self.set_state(False)
|
2023-11-30 12:10:49 +00:00
|
|
|
|
|
|
|
def update_from_discover_info(self, info):
|
|
|
|
"""Update state from info from the discover call."""
|
|
|
|
self._discovery_info = info
|
2023-12-29 19:17:15 +00:00
|
|
|
self._info = info
|
2024-01-03 18:04:34 +00:00
|
|
|
|
2024-04-17 13:39:24 +00:00
|
|
|
async def wifi_scan(self) -> list[WifiNetwork]:
|
2024-01-03 21:45:16 +00:00
|
|
|
"""Scan for available wifi networks."""
|
|
|
|
|
|
|
|
def _net_for_scan_info(res):
|
|
|
|
return WifiNetwork(
|
|
|
|
ssid=base64.b64decode(res["ssid"]).decode(),
|
|
|
|
cipher_type=res["cipher_type"],
|
|
|
|
key_type=res["key_type"],
|
|
|
|
channel=res["channel"],
|
|
|
|
signal_level=res["signal_level"],
|
|
|
|
bssid=res["bssid"],
|
|
|
|
)
|
|
|
|
|
2024-05-01 14:56:43 +00:00
|
|
|
_LOGGER.debug("Querying networks")
|
2024-01-03 21:45:16 +00:00
|
|
|
|
2024-05-01 14:56:43 +00:00
|
|
|
resp = await self.protocol.query({"get_wireless_scan_info": {"start_index": 0}})
|
|
|
|
networks = [
|
|
|
|
_net_for_scan_info(net) for net in resp["get_wireless_scan_info"]["ap_list"]
|
|
|
|
]
|
|
|
|
return networks
|
2024-01-03 21:45:16 +00:00
|
|
|
|
|
|
|
async def wifi_join(self, ssid: str, password: str, keytype: str = "wpa2_psk"):
|
|
|
|
"""Join the given wifi network.
|
|
|
|
|
|
|
|
This method returns nothing as the device tries to activate the new
|
|
|
|
settings immediately instead of responding to the request.
|
|
|
|
|
|
|
|
If joining the network fails, the device will return to the previous state
|
|
|
|
after some delay.
|
|
|
|
"""
|
|
|
|
if not self.credentials:
|
2024-02-21 15:52:55 +00:00
|
|
|
raise AuthenticationError("Device requires authentication.")
|
2024-01-03 21:45:16 +00:00
|
|
|
|
|
|
|
payload = {
|
|
|
|
"account": {
|
|
|
|
"username": base64.b64encode(
|
|
|
|
self.credentials.username.encode()
|
|
|
|
).decode(),
|
|
|
|
"password": base64.b64encode(
|
|
|
|
self.credentials.password.encode()
|
|
|
|
).decode(),
|
|
|
|
},
|
|
|
|
"wireless": {
|
|
|
|
"key_type": keytype,
|
|
|
|
"password": base64.b64encode(password.encode()).decode(),
|
|
|
|
"ssid": base64.b64encode(ssid.encode()).decode(),
|
|
|
|
},
|
2024-02-19 17:01:31 +00:00
|
|
|
"time": self.internal_state["get_device_time"],
|
2024-01-03 21:45:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
# The device does not respond to the request but changes the settings
|
|
|
|
# immediately which causes us to timeout.
|
|
|
|
# Thus, We limit retries and suppress the raised exception as useless.
|
|
|
|
try:
|
|
|
|
return await self.protocol.query({"set_qs_info": payload}, retry_count=0)
|
2024-02-21 15:52:55 +00:00
|
|
|
except DeviceError:
|
|
|
|
raise # Re-raise on device-reported errors
|
|
|
|
except KasaException:
|
2024-01-03 21:45:16 +00:00
|
|
|
_LOGGER.debug("Received an expected for wifi join, but this is expected")
|
2024-01-05 01:25:15 +00:00
|
|
|
|
|
|
|
async def update_credentials(self, username: str, password: str):
|
|
|
|
"""Update device credentials.
|
|
|
|
|
|
|
|
This will replace the existing authentication credentials on the device.
|
|
|
|
"""
|
2024-02-19 17:01:31 +00:00
|
|
|
time_data = self.internal_state["get_device_time"]
|
2024-01-05 01:25:15 +00:00
|
|
|
payload = {
|
|
|
|
"account": {
|
|
|
|
"username": base64.b64encode(username.encode()).decode(),
|
|
|
|
"password": base64.b64encode(password.encode()).decode(),
|
|
|
|
},
|
2024-02-19 17:01:31 +00:00
|
|
|
"time": time_data,
|
2024-01-05 01:25:15 +00:00
|
|
|
}
|
|
|
|
return await self.protocol.query({"set_qs_info": payload})
|
2024-01-23 13:26:47 +00:00
|
|
|
|
2024-01-29 10:57:32 +00:00
|
|
|
async def set_alias(self, alias: str):
|
|
|
|
"""Set the device name (alias)."""
|
|
|
|
return await self.protocol.query(
|
|
|
|
{"set_device_info": {"nickname": base64.b64encode(alias.encode()).decode()}}
|
|
|
|
)
|
|
|
|
|
2024-01-23 13:26:47 +00:00
|
|
|
async def reboot(self, delay: int = 1) -> None:
|
|
|
|
"""Reboot the device.
|
|
|
|
|
|
|
|
Note that giving a delay of zero causes this to block,
|
|
|
|
as the device reboots immediately without responding to the call.
|
|
|
|
"""
|
|
|
|
await self.protocol.query({"device_reboot": {"delay": delay}})
|
|
|
|
|
|
|
|
async def factory_reset(self) -> None:
|
|
|
|
"""Reset device back to factory settings.
|
|
|
|
|
|
|
|
Note, this does not downgrade the firmware.
|
|
|
|
"""
|
|
|
|
await self.protocol.query("device_reset")
|
2024-02-22 13:34:55 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def device_type(self) -> DeviceType:
|
|
|
|
"""Return the device type."""
|
|
|
|
if self._device_type is not DeviceType.Unknown:
|
|
|
|
return self._device_type
|
|
|
|
|
2024-03-01 18:32:45 +00:00
|
|
|
self._device_type = self._get_device_type_from_components(
|
|
|
|
list(self._components.keys()), self._info["type"]
|
|
|
|
)
|
2024-02-22 13:34:55 +00:00
|
|
|
|
|
|
|
return self._device_type
|
2024-03-01 18:32:45 +00:00
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def _get_device_type_from_components(
|
2024-04-17 13:39:24 +00:00
|
|
|
components: list[str], device_type: str
|
2024-03-01 18:32:45 +00:00
|
|
|
) -> DeviceType:
|
|
|
|
"""Find type to be displayed as a supported device category."""
|
|
|
|
if "HUB" in device_type:
|
|
|
|
return DeviceType.Hub
|
|
|
|
if "PLUG" in device_type:
|
|
|
|
if "child_device" in components:
|
|
|
|
return DeviceType.Strip
|
|
|
|
return DeviceType.Plug
|
|
|
|
if "light_strip" in components:
|
|
|
|
return DeviceType.LightStrip
|
2024-04-24 18:17:49 +00:00
|
|
|
if "SWITCH" in device_type and "child_device" in components:
|
|
|
|
return DeviceType.WallSwitch
|
2024-03-01 18:32:45 +00:00
|
|
|
if "dimmer_calibration" in components:
|
|
|
|
return DeviceType.Dimmer
|
|
|
|
if "brightness" in components:
|
|
|
|
return DeviceType.Bulb
|
|
|
|
if "SWITCH" in device_type:
|
|
|
|
return DeviceType.WallSwitch
|
2024-05-07 06:48:47 +00:00
|
|
|
if "SENSOR" in device_type:
|
|
|
|
return DeviceType.Sensor
|
|
|
|
if "ENERGY" in device_type:
|
|
|
|
return DeviceType.Thermostat
|
2024-03-01 18:32:45 +00:00
|
|
|
_LOGGER.warning("Unknown device type, falling back to plug")
|
|
|
|
return DeviceType.Plug
|