From bced5e40c592eea2abe2fe5b244aa42290e6e079 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 20 Feb 2024 00:26:47 +0100 Subject: [PATCH 1/2] Generalize smartdevice child support * Initialize children's modules (and features) using the child component negotiation results * Set device_type based on the device response * Print out child features in cli 'state' * Add --child option to cli 'command' to allow targeting child devices * Guard "generic" features like rssi, ssid, etc. only to devices which have this information --- kasa/cli.py | 20 ++++- kasa/device_type.py | 1 + kasa/smart/modules/childdevicemodule.py | 12 ++- kasa/smart/smartchilddevice.py | 37 +++++++-- kasa/smart/smartdevice.py | 101 +++++++++++++++--------- kasa/tests/test_smartdevice.py | 4 +- 6 files changed, 124 insertions(+), 51 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 4d3590d10..4db2b00e2 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -558,9 +558,14 @@ async def state(ctx, dev: Device): echo(f"\tPort: {dev.port}") echo(f"\tDevice state: {dev.is_on}") if dev.is_strip: - echo("\t[bold]== Plugs ==[/bold]") - for plug in dev.children: # type: ignore - echo(f"\t* Socket '{plug.alias}' state: {plug.is_on} since {plug.on_since}") + echo("\t[bold]== Children ==[/bold]") + for child in dev.children.values(): + echo(f"\t* {child.alias} ({child.model}, {child.device_type})") + for feat in child.features.values(): + try: + echo(f"\t\t{feat.name}: {feat.value}") + except Exception as ex: + echo(f"\t\t{feat.name}: got exception (%s)" % ex) echo() echo("\t[bold]== Generic information ==[/bold]") @@ -641,13 +646,20 @@ async def raw_command(ctx, dev: Device, module, command, parameters): @cli.command(name="command") @pass_dev @click.option("--module", required=False, help="Module for IOT protocol.") +@click.option("--child", required=False, help="Child ID for controlling sub-devices") @click.argument("command") @click.argument("parameters", default=None, required=False) -async def cmd_command(dev: Device, module, command, parameters): +async def cmd_command(dev: Device, module, child, command, parameters): """Run a raw command on the device.""" if parameters is not None: parameters = ast.literal_eval(parameters) + if child: + echo(f"Selecting child {child} from {dev}") + # TODO: Update required to initialize children + await dev.update() + dev = dev._children[child] + if isinstance(dev, IotDevice): res = await dev._query_helper(module, command, parameters) elif isinstance(dev, SmartDevice): diff --git a/kasa/device_type.py b/kasa/device_type.py index 162fc4f27..41dd6e363 100755 --- a/kasa/device_type.py +++ b/kasa/device_type.py @@ -14,6 +14,7 @@ class DeviceType(Enum): StripSocket = "stripsocket" Dimmer = "dimmer" LightStrip = "lightstrip" + Sensor = "sensor" Unknown = "unknown" @staticmethod diff --git a/kasa/smart/modules/childdevicemodule.py b/kasa/smart/modules/childdevicemodule.py index 991acc25b..62e024d0c 100644 --- a/kasa/smart/modules/childdevicemodule.py +++ b/kasa/smart/modules/childdevicemodule.py @@ -1,4 +1,6 @@ """Implementation for child devices.""" +from typing import Dict + from ..smartmodule import SmartModule @@ -6,4 +8,12 @@ class ChildDeviceModule(SmartModule): """Implementation for child devices.""" REQUIRED_COMPONENT = "child_device" - QUERY_GETTER_NAME = "get_child_device_list" + + def query(self) -> Dict: + """Query to execute during the update cycle.""" + # TODO: There is no need to fetch the component list every time, + # so this should be optimized only for the init. + return { + "get_child_device_list": None, + "get_child_device_component_list": None, + } diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 698982b67..6d7bfa587 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -1,4 +1,5 @@ """Child device implementation.""" +import logging from typing import Optional from ..device_type import DeviceType @@ -6,6 +7,8 @@ from ..smartprotocol import SmartProtocol, _ChildProtocolWrapper from .smartdevice import SmartDevice +_LOGGER = logging.getLogger(__name__) + class SmartChildDevice(SmartDevice): """Presentation of a child device. @@ -16,23 +19,41 @@ class SmartChildDevice(SmartDevice): def __init__( self, parent: SmartDevice, - child_id: str, + info, + component_info, config: Optional[DeviceConfig] = None, protocol: Optional[SmartProtocol] = None, ) -> None: super().__init__(parent.host, config=parent.config, protocol=parent.protocol) self._parent = parent - self._id = child_id - self.protocol = _ChildProtocolWrapper(child_id, parent.protocol) - self._device_type = DeviceType.StripSocket + self._update_internal_state(info) + self._components = component_info + self._id = info["device_id"] + self.protocol = _ChildProtocolWrapper(self._id, parent.protocol) async def update(self, update_children: bool = True): """Noop update. The parent updates our internals.""" - def update_internal_state(self, info): - """Set internal state for the child.""" - # TODO: cleanup the _last_update, _sys_info, _info, _data mess. - self._last_update = self._sys_info = self._info = info + @classmethod + async def create(cls, parent: SmartDevice, child_info, child_components): + """Create a child device based on device info and component listing.""" + child: "SmartChildDevice" = cls(parent, child_info, child_components) + await child._initialize_modules() + await child._initialize_features() + return child + + @property + def device_type(self) -> DeviceType: + """Return child device type.""" + child_device_map = { + "plug.powerstrip.sub-plug": DeviceType.Plug, + "subg.trigger.temp-hmdt-sensor": DeviceType.Sensor, + } + dev_type = child_device_map.get(self.sys_info["category"]) + if dev_type is None: + _LOGGER.warning("Unknown child device type, please open issue ") + dev_type = DeviceType.Unknown + return dev_type def __repr__(self): return f"" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 62657d816..63e4bf355 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -12,22 +12,16 @@ from ..exceptions import AuthenticationException, SmartDeviceException, SmartErrorCode from ..feature import Feature, FeatureType from ..smartprotocol import SmartProtocol -from .modules import ( # noqa: F401 +from .modules import * # noqa: F403 AutoOffModule, - ChildDeviceModule, CloudModule, - DeviceModule, - EnergyModule, LedModule, LightTransitionModule, - TimeModule, -) -from .smartmodule import SmartModule _LOGGER = logging.getLogger(__name__) if TYPE_CHECKING: - from .smartchilddevice import SmartChildDevice + from .smartmodule import SmartModule class SmartDevice(Device): @@ -49,21 +43,32 @@ def __init__( self._components: Dict[str, int] = {} self._children: Dict[str, "SmartChildDevice"] = {} self._state_information: Dict[str, Any] = {} - self.modules: Dict[str, SmartModule] = {} + self.modules: Dict[str, "SmartModule"] = {} + self._parent: Optional["SmartDevice"] = None async def _initialize_children(self): """Initialize children for power strips.""" - children = self._last_update["child_info"]["child_device_list"] - # TODO: Use the type information to construct children, - # as hubs can also have them. + children = self.internal_state["child_info"]["child_device_list"] + 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" + ] + } from .smartchilddevice import SmartChildDevice self._children = { - child["device_id"]: SmartChildDevice( - parent=self, child_id=child["device_id"] + child_info["device_id"]: await SmartChildDevice.create( + parent=self, + child_info=child_info, + child_components=children_components[child_info["device_id"]], ) - for child in children + for child_info in children } + # TODO: if all are sockets, then we are a strip, and otherwise a hub? + # doesn't work for the walldimmer with fancontrol... self._device_type = DeviceType.Strip @property @@ -153,6 +158,7 @@ async def _initialize_modules(self): async def _initialize_features(self): """Initialize device features.""" + self._add_feature(Feature(self, "Device ID", attribute_getter="device_id")) if "device_on" in self._info: self._add_feature( Feature( @@ -164,25 +170,33 @@ async def _initialize_features(self): ) ) - self._add_feature( - Feature( - self, - "Signal Level", - attribute_getter=lambda x: x._info["signal_level"], - icon="mdi:signal", + if "signal_level" in self._info: + + self._add_feature( + Feature( + self, + "Signal Level", + attribute_getter=lambda x: x._info["signal_level"], + icon="mdi:signal", + ) ) - ) - self._add_feature( - Feature( - self, - "RSSI", - attribute_getter=lambda x: x._info["rssi"], - icon="mdi:signal", + + if "rssi" in self._info: + self._add_feature( + Feature( + self, + "RSSI", + attribute_getter=lambda x: x._info["rssi"], + icon="mdi:signal", + ) + ) + + if "ssid" in self._info: + self._add_feature( + Feature( + device=self, name="SSID", attribute_getter="ssid", icon="mdi:wifi" + ) ) - ) - self._add_feature( - Feature(device=self, name="SSID", attribute_getter="ssid", icon="mdi:wifi") - ) if "overheated" in self._info: self._add_feature( @@ -232,7 +246,12 @@ def alias(self) -> Optional[str]: @property def time(self) -> datetime: """Return the time.""" - _timemod = cast(TimeModule, self.modules["TimeModule"]) + # TODO: Default to parent's time module for child devices + if self._parent and "TimeModule" in self.modules: + _timemod = cast(TimeModule, self._parent.modules["TimeModule"]) # noqa: F405 + else: + _timemod = cast(TimeModule, self.modules["TimeModule"]) # noqa: F405 + return _timemod.time @property @@ -284,6 +303,14 @@ def internal_state(self) -> Any: """Return all the internal state data.""" return self._last_update + def _update_internal_state(self, info): + """Update internal state. + + This is used by the parent to push updates to its children + """ + # TODO: cleanup the _last_update, _info mess. + self._last_update = self._info = info + async def _query_helper( self, method: str, params: Optional[Dict] = None, child_ids=None ) -> Any: @@ -347,19 +374,19 @@ async def get_emeter_realtime(self) -> EmeterStatus: @property def emeter_realtime(self) -> EmeterStatus: """Get the emeter status.""" - energy = cast(EnergyModule, self.modules["EnergyModule"]) + energy = cast(EnergyModule, self.modules["EnergyModule"]) # noqa: F405 return energy.emeter_realtime @property def emeter_this_month(self) -> Optional[float]: """Get the emeter value for this month.""" - energy = cast(EnergyModule, self.modules["EnergyModule"]) + energy = cast(EnergyModule, self.modules["EnergyModule"]) # noqa: F405 return energy.emeter_this_month @property def emeter_today(self) -> Optional[float]: """Get the emeter value for today.""" - energy = cast(EnergyModule, self.modules["EnergyModule"]) + energy = cast(EnergyModule, self.modules["EnergyModule"]) # noqa: F405 return energy.emeter_today @property @@ -372,7 +399,7 @@ def on_since(self) -> Optional[datetime]: return None on_time = cast(float, on_time) if (timemod := self.modules.get("TimeModule")) is not None: - timemod = cast(TimeModule, timemod) + timemod = cast(TimeModule, timemod) # noqa: F405 return timemod.time - timedelta(seconds=on_time) else: # We have no device time, use current local time. return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 487286dbb..61659945c 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -255,7 +255,9 @@ async def test_device_class_ctors(device_class_name_obj): klass = device_class_name_obj[1] if issubclass(klass, SmartChildDevice): parent = SmartDevice(host, config=config) - dev = klass(parent, 1) + dev = klass( + parent, {"dummy": "info", "device_id": "dummy"}, {"dummy": "components"} + ) else: dev = klass(host, config=config) assert dev.host == host From 66da963ff0aa727a97ee5dd1a6bab477bfaf4196 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 20 Feb 2024 00:32:29 +0100 Subject: [PATCH 2/2] Add initial support for H100 and T315 Adds initial support for H100 and its alarmmodule. Also implements the following modules for T315: * reportmodule (reporting interval) * battery * humidity * temperature --- kasa/device_factory.py | 3 +- kasa/deviceconfig.py | 1 + kasa/smart/modules/__init__.py | 9 ++++ kasa/smart/modules/alarmmodule.py | 87 ++++++++++++++++++++++++++++++ kasa/smart/modules/battery.py | 47 ++++++++++++++++ kasa/smart/modules/humidity.py | 47 ++++++++++++++++ kasa/smart/modules/reportmodule.py | 31 +++++++++++ kasa/smart/modules/temperature.py | 57 ++++++++++++++++++++ 8 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 kasa/smart/modules/alarmmodule.py create mode 100644 kasa/smart/modules/battery.py create mode 100644 kasa/smart/modules/humidity.py create mode 100644 kasa/smart/modules/reportmodule.py create mode 100644 kasa/smart/modules/temperature.py diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 3550539c7..d504f12f2 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -14,7 +14,7 @@ BaseProtocol, BaseTransport, ) -from .smart import SmartBulb, SmartPlug +from .smart import SmartBulb, SmartDevice, SmartPlug from .smartprotocol import SmartProtocol from .xortransport import XorTransport @@ -138,6 +138,7 @@ def get_device_class_from_family(device_type: str) -> Optional[Type[Device]]: "SMART.TAPOPLUG": SmartPlug, "SMART.TAPOBULB": SmartBulb, "SMART.TAPOSWITCH": SmartBulb, + "SMART.TAPOHUB": SmartDevice, "SMART.KASAPLUG": SmartPlug, "SMART.KASASWITCH": SmartBulb, "IOT.SMARTPLUGSWITCH": IotPlug, diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index ffb2988e3..35e63902f 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -31,6 +31,7 @@ class DeviceFamilyType(Enum): SmartTapoPlug = "SMART.TAPOPLUG" SmartTapoBulb = "SMART.TAPOBULB" SmartTapoSwitch = "SMART.TAPOSWITCH" + SmartTapoHub = "SMART.TAPOHUB" def _dataclass_from_dict(klass, in_val): diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 6031ef2ac..6f1eabd6d 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -1,18 +1,27 @@ """Modules for SMART devices.""" from .autooffmodule import AutoOffModule +from .battery import BatterySensor from .childdevicemodule import ChildDeviceModule from .cloudmodule import CloudModule from .devicemodule import DeviceModule from .energymodule import EnergyModule from .ledmodule import LedModule from .lighttransitionmodule import LightTransitionModule +from .humidity import HumiditySensor +from .reportmodule import ReportModule +from .temperature import TemperatureSensor from .timemodule import TimeModule __all__ = [ + "AlarmModule", "TimeModule", "EnergyModule", "DeviceModule", "ChildDeviceModule", + "BatterySensor", + "HumiditySensor", + "TemperatureSensor", + "ReportModule", "AutoOffModule", "LedModule", "CloudModule", diff --git a/kasa/smart/modules/alarmmodule.py b/kasa/smart/modules/alarmmodule.py new file mode 100644 index 000000000..637c44973 --- /dev/null +++ b/kasa/smart/modules/alarmmodule.py @@ -0,0 +1,87 @@ +"""Implementation of alarm module.""" +from typing import TYPE_CHECKING, Dict, List, Optional + +from ...feature import Feature, FeatureType +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class AlarmModule(SmartModule): + """Implementation of alarm module.""" + + REQUIRED_COMPONENT = "alarm" + + def query(self) -> Dict: + """Query to execute during the update cycle.""" + return { + "get_alarm_configure": None, + "get_support_alarm_type_list": None, # This should be needed only once + } + + def __init__(self, device: "SmartDevice", module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + "Alarm", + container=self, + attribute_getter="active", + icon="mdi:bell", + type=FeatureType.BinarySensor, + ) + ) + self._add_feature( + Feature( + device, + "Alarm source", + container=self, + attribute_getter="source", + icon="mdi:bell", + ) + ) + self._add_feature( + Feature( + device, "Alarm sound", container=self, attribute_getter="alarm_sound" + ) + ) + self._add_feature( + Feature( + device, "Alarm volume", container=self, attribute_getter="alarm_volume" + ) + ) + + @property + def alarm_sound(self): + """Return current alarm sound.""" + return self.data["get_alarm_configure"]["type"] + + @property + def alarm_sounds(self) -> List[str]: + """Return list of available alarm sounds.""" + return self.data["get_support_alarm_type_list"]["alarm_type_list"] + + @property + def alarm_volume(self): + """Return alarm volume.""" + return self.data["get_alarm_configure"]["volume"] + + @property + def active(self) -> bool: + """Return true if alarm is active.""" + return self._device.sys_info["in_alarm"] + + @property + def source(self) -> Optional[str]: + """Return the alarm cause.""" + src = self._device.sys_info["in_alarm_source"] + return src if src else None + + async def play(self): + """Play alarm.""" + return self.call("play_alarm") + + async def stop(self): + """Stop alarm.""" + return self.call("stop_alarm") diff --git a/kasa/smart/modules/battery.py b/kasa/smart/modules/battery.py new file mode 100644 index 000000000..accf875b2 --- /dev/null +++ b/kasa/smart/modules/battery.py @@ -0,0 +1,47 @@ +"""Implementation of battery module.""" +from typing import TYPE_CHECKING + +from ...feature import Feature, FeatureType +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class BatterySensor(SmartModule): + """Implementation of battery module.""" + + REQUIRED_COMPONENT = "battery_detect" + QUERY_GETTER_NAME = "get_battery_detect_info" + + def __init__(self, device: "SmartDevice", module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + "Battery level", + container=self, + attribute_getter="battery", + icon="mdi:battery", + ) + ) + self._add_feature( + Feature( + device, + "Battery low", + container=self, + attribute_getter="battery_low", + icon="mdi:alert", + type=FeatureType.BinarySensor, + ) + ) + + @property + def battery(self): + """Return battery level.""" + return self._device.sys_info["battery_percentage"] + + @property + def battery_low(self): + """Return True if battery is low.""" + return self._device.sys_info["at_low_battery"] diff --git a/kasa/smart/modules/humidity.py b/kasa/smart/modules/humidity.py new file mode 100644 index 000000000..454bedcda --- /dev/null +++ b/kasa/smart/modules/humidity.py @@ -0,0 +1,47 @@ +"""Implementation of humidity module.""" +from typing import TYPE_CHECKING + +from ...feature import Feature, FeatureType +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class HumiditySensor(SmartModule): + """Implementation of humidity module.""" + + REQUIRED_COMPONENT = "humidity" + QUERY_GETTER_NAME = "get_comfort_humidity_config" + + def __init__(self, device: "SmartDevice", module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + "Humidity", + container=self, + attribute_getter="humidity", + icon="mdi:water-percent", + ) + ) + self._add_feature( + Feature( + device, + "Humidity warning", + container=self, + attribute_getter="humidity_warning", + type=FeatureType.BinarySensor, + icon="mdi:alert", + ) + ) + + @property + def humidity(self): + """Return current humidity in percentage.""" + return self._device.sys_info["current_humidity"] + + @property + def humidity_warning(self) -> bool: + """Return true if humidity is outside of the wanted range.""" + return self._device.sys_info["current_humidity_exception"] != 0 diff --git a/kasa/smart/modules/reportmodule.py b/kasa/smart/modules/reportmodule.py new file mode 100644 index 000000000..04301bb4c --- /dev/null +++ b/kasa/smart/modules/reportmodule.py @@ -0,0 +1,31 @@ +"""Implementation of report module.""" +from typing import TYPE_CHECKING + +from ...feature import Feature +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class ReportModule(SmartModule): + """Implementation of report module.""" + + REQUIRED_COMPONENT = "report_mode" + QUERY_GETTER_NAME = "get_report_mode" + + def __init__(self, device: "SmartDevice", module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + "Report interval", + container=self, + attribute_getter="report_interval", + ) + ) + + @property + def report_interval(self): + """Reporting interval of a sensor device.""" + return self._device.sys_info["report_interval"] diff --git a/kasa/smart/modules/temperature.py b/kasa/smart/modules/temperature.py new file mode 100644 index 000000000..659fb7dbe --- /dev/null +++ b/kasa/smart/modules/temperature.py @@ -0,0 +1,57 @@ +"""Implementation of temperature module.""" +from typing import TYPE_CHECKING, Literal + +from ...feature import Feature, FeatureType +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class TemperatureSensor(SmartModule): + """Implementation of temperature module.""" + + REQUIRED_COMPONENT = "humidity" + QUERY_GETTER_NAME = "get_comfort_temp_config" + + def __init__(self, device: "SmartDevice", module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + "Temperature", + container=self, + attribute_getter="temperature", + icon="mdi:thermometer", + ) + ) + self._add_feature( + Feature( + device, + "Temperature warning", + container=self, + attribute_getter="temperature_warning", + type=FeatureType.BinarySensor, + icon="mdi:alert", + ) + ) + # TODO: use temperature_unit for feature creation + + @property + def temperature(self): + """Return current humidity in percentage.""" + return self._device.sys_info["current_temp"] + + @property + def temperature_warning(self) -> bool: + """Return True if humidity is outside of the wanted range.""" + return self._device.sys_info["current_temp_exception"] != 0 + + @property + def temperature_unit(self): + """Return current temperature unit.""" + return self._device.sys_info["temp_unit"] + + async def set_temperature_unit(self, unit: Literal["celsius", "fahrenheit"]): + """Set the device temperature unit.""" + return await self.call("set_temperature_unit", {"temp_unit": unit})