mirror of
https://github.com/python-kasa/python-kasa.git
synced 2026-11-06 11:57:26 +00:00
Some checks failed
CI / Perform Lint Checks (3.15) (push) Has been cancelled
CodeQL Checks / Analyze (python) (push) Has been cancelled
CI / Python 3.11 on macos-latest (push) Has been cancelled
CI / Python 3.12 on macos-latest (push) Has been cancelled
CI / Python 3.13 on macos-latest (push) Has been cancelled
CI / Python 3.14 on macos-latest (push) Has been cancelled
CI / Python 3.15 on macos-latest (push) Has been cancelled
CI / Python 3.11 on ubuntu-latest (push) Has been cancelled
CI / Python 3.12 on ubuntu-latest (push) Has been cancelled
CI / Python 3.13 on ubuntu-latest (push) Has been cancelled
CI / Python 3.14 on ubuntu-latest (push) Has been cancelled
CI / Python 3.15 on ubuntu-latest (push) Has been cancelled
CI / Python 3.11 on windows-latest (push) Has been cancelled
CI / Python 3.12 on windows-latest (push) Has been cancelled
CI / Python 3.13 on windows-latest (push) Has been cancelled
CI / Python 3.14 on windows-latest (push) Has been cancelled
CI / Python 3.15 on windows-latest (push) Has been cancelled
Stale / stale (push) Has been cancelled
Hubs keep reporting for children they have lost contact with, so callers see stale values as if they were live. Trying to write to such devices fail after timeouts with a generic error. This PR exposes the offline/online information as reported by the hub, and refuses writes to unreachable child devices without waiting for device errors. In details: - Add new API: `Device.is_available`; hub children report unavailable when the hub marks them offline or no longer lists them. - New feature `available` for child devices to make it visible to the CLI. - Refuse writes to unavailable children with the new `DeviceUnavailableError` instead of waiting for the hub to time out.
204 lines
7.3 KiB
Python
204 lines
7.3 KiB
Python
"""Child device implementation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from typing import Any
|
|
|
|
from ..device import DeviceInfo
|
|
from ..device_type import DeviceType
|
|
from ..deviceconfig import DeviceConfig
|
|
from ..exceptions import DeviceUnavailableError
|
|
from ..feature import Feature
|
|
from ..protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper
|
|
from .smartdevice import ComponentsRaw, SmartDevice
|
|
from .smartmodule import SmartModule
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
class SmartChildDevice(SmartDevice):
|
|
"""Presentation of a child device.
|
|
|
|
This wraps the protocol communications and sets internal data for the child.
|
|
"""
|
|
|
|
CHILD_DEVICE_TYPE_MAP = {
|
|
"plug.powerstrip.sub-plug": DeviceType.Plug,
|
|
"plug.powerstrip.sub-bulb": DeviceType.Bulb,
|
|
"subg.plugswitch.switch": DeviceType.WallSwitch,
|
|
"subg.trigger.contact-sensor": DeviceType.Sensor,
|
|
"subg.trigger.temp-hmdt-sensor": DeviceType.Sensor,
|
|
"subg.trigger.water-leak-sensor": DeviceType.Sensor,
|
|
"subg.trigger.motion-sensor": DeviceType.Sensor,
|
|
"kasa.switch.outlet.sub-fan": DeviceType.Fan,
|
|
"kasa.switch.outlet.sub-dimmer": DeviceType.Dimmer,
|
|
"subg.trv": DeviceType.Thermostat,
|
|
"subg.trigger.button": DeviceType.Sensor,
|
|
}
|
|
|
|
def __init__(
|
|
self,
|
|
parent: SmartDevice,
|
|
info: dict,
|
|
component_info_raw: ComponentsRaw,
|
|
*,
|
|
config: DeviceConfig | None = None,
|
|
protocol: SmartProtocol | None = None,
|
|
) -> None:
|
|
self._id = info["device_id"]
|
|
_protocol = protocol or _ChildProtocolWrapper(self._id, parent.protocol)
|
|
super().__init__(parent.host, config=parent.config, protocol=_protocol)
|
|
self._parent = parent
|
|
self._was_available = True
|
|
self._update_internal_state(info)
|
|
self._components_raw = component_info_raw
|
|
self._components = self._parse_components(self._components_raw)
|
|
|
|
@property
|
|
def device_info(self) -> DeviceInfo:
|
|
"""Return device info.
|
|
|
|
Child device does not have it info and components in _last_update so
|
|
this overrides the base implementation to call _get_device_info with
|
|
info and components combined as they would be in _last_update.
|
|
"""
|
|
return self._get_device_info(
|
|
{
|
|
"get_device_info": self._info,
|
|
"component_nego": self._components_raw,
|
|
},
|
|
None,
|
|
)
|
|
|
|
async def update(self, update_children: bool = True) -> None:
|
|
"""Update child module info.
|
|
|
|
The parent updates our internal info so just update modules with
|
|
their own queries.
|
|
"""
|
|
await self._update(update_children)
|
|
|
|
async def _update(self, update_children: bool = True) -> None:
|
|
"""Update child module info.
|
|
|
|
Internal implementation to allow patching of public update in the cli
|
|
or test framework.
|
|
"""
|
|
if (available := self.is_available) != self._was_available:
|
|
self._was_available = available
|
|
_LOGGER.debug("%s (%s) available: %s", self.alias, self._id, available)
|
|
|
|
now = time.monotonic()
|
|
module_queries: list[SmartModule] = []
|
|
req: dict[str, Any] = {}
|
|
for module in self.modules.values():
|
|
if (
|
|
module.disabled is False
|
|
and (mod_query := module.query())
|
|
and module._should_update(now)
|
|
):
|
|
module_queries.append(module)
|
|
req.update(mod_query)
|
|
if req:
|
|
first_update = self._last_update != {}
|
|
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
|
|
)
|
|
self._last_update = resp
|
|
|
|
for module in self.modules.values():
|
|
await self._handle_module_post_update(
|
|
module, now, had_query=module in module_queries
|
|
)
|
|
self._last_update_time = now
|
|
|
|
# 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()
|
|
|
|
@classmethod
|
|
async def create(
|
|
cls,
|
|
parent: SmartDevice,
|
|
child_info: dict,
|
|
child_components_raw: ComponentsRaw,
|
|
protocol: SmartProtocol | None = None,
|
|
*,
|
|
last_update: dict | None = None,
|
|
) -> SmartDevice:
|
|
"""Create a child device based on device info and component listing.
|
|
|
|
If creating a smart child from a different protocol, i.e. a camera hub,
|
|
protocol: SmartProtocol and last_update should be provided as per the
|
|
FIRST_UPDATE_MODULES expected by the update cycle as these cannot be
|
|
derived from the parent.
|
|
"""
|
|
child: SmartChildDevice = cls(
|
|
parent, child_info, child_components_raw, protocol=protocol
|
|
)
|
|
if last_update:
|
|
child._last_update = last_update
|
|
await child._initialize_modules()
|
|
return child
|
|
|
|
@property
|
|
def device_type(self) -> DeviceType:
|
|
"""Return child device type."""
|
|
if self._device_type is not DeviceType.Unknown:
|
|
return self._device_type
|
|
|
|
if self.sys_info and (category := self.sys_info.get("category")):
|
|
dev_type = self.CHILD_DEVICE_TYPE_MAP.get(category)
|
|
if dev_type is None:
|
|
_LOGGER.warning(
|
|
"Unknown child device type %s for model %s, please open issue",
|
|
category,
|
|
self.model,
|
|
)
|
|
self._device_type = DeviceType.Unknown
|
|
else:
|
|
self._device_type = dev_type
|
|
|
|
return self._device_type
|
|
|
|
@property
|
|
def is_available(self) -> bool:
|
|
"""Return False if the hub reports the device offline or no longer lists it."""
|
|
listed = self._parent is None or self._id in self._parent._children
|
|
return listed and self._info.get("status") != "offline"
|
|
|
|
async def _initialize_features(self) -> None:
|
|
"""Initialize device features."""
|
|
await super()._initialize_features()
|
|
if "status" in self._info:
|
|
self._add_feature(
|
|
Feature(
|
|
self,
|
|
id="available",
|
|
name="Available",
|
|
attribute_getter="is_available",
|
|
category=Feature.Category.Info,
|
|
type=Feature.Type.BinarySensor,
|
|
)
|
|
)
|
|
|
|
async def _query_helper(self, method: str, params: dict | None = None) -> dict:
|
|
# Fail fast on writes, the hub rejects them only after a timeout.
|
|
if not method.startswith("get") and not self.is_available:
|
|
raise DeviceUnavailableError(f"{self.alias} is not available")
|
|
|
|
return await super()._query_helper(method, params)
|
|
|
|
def __repr__(self) -> str:
|
|
if not self._parent:
|
|
return f"<{self.device_type}(child) without parent>"
|
|
if not self._parent._last_update:
|
|
return f"<{self.device_type}(child) of {self._parent}>"
|
|
return f"<{self.device_type} {self.alias} ({self.model}) of {self._parent}>"
|