diff --git a/docs/source/reference.md b/docs/source/reference.md index 90493c9c..b4d098f7 100644 --- a/docs/source/reference.md +++ b/docs/source/reference.md @@ -122,6 +122,12 @@ :undoc-members: ``` +```{eval-rst} +.. autoclass:: kasa.exceptions.DeviceUnavailableError + :members: + :undoc-members: +``` + ```{eval-rst} .. autoclass:: kasa.exceptions.UnsupportedDeviceError :members: diff --git a/docs/source/topics.md b/docs/source/topics.md index f7d0cdd5..34660a0d 100644 --- a/docs/source/topics.md +++ b/docs/source/topics.md @@ -137,6 +137,8 @@ The base exception for all library errors is {class}`KasaException ` which will usually contain an ``error_code`` with the detail. - If the device fails to authenticate the library raises an {class}`AuthenticationError ` which is derived from {class}`DeviceError ` and could contain an ``error_code`` depending on the type of failure. +- If a command is sent to a hub child that is not available (see {attr}`Device.is_available `) the library raises a {class}`DeviceUnavailableError `, + derived from {class}`DeviceError `, without contacting the device. - If the library encounters and unsupported deviceit raises an {class}`UnsupportedDeviceError `. - If the device fails to respond within a timeout the library raises a {class}`TimeoutError `. - All other failures will raise the base {class}`KasaException ` class. diff --git a/kasa/__init__.py b/kasa/__init__.py index b8871f99..4950cbc1 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -30,6 +30,7 @@ from kasa.emeterstatus import EmeterStatus from kasa.exceptions import ( AuthenticationError, DeviceError, + DeviceUnavailableError, KasaException, TimeoutError, UnsupportedDeviceError, @@ -68,6 +69,7 @@ __all__ = [ "KasaException", "AuthenticationError", "DeviceError", + "DeviceUnavailableError", "UnsupportedDeviceError", "TimeoutError", "Credentials", diff --git a/kasa/cli/hub.py b/kasa/cli/hub.py index 0bc45bf5..61c47c59 100644 --- a/kasa/cli/hub.py +++ b/kasa/cli/hub.py @@ -34,8 +34,12 @@ async def hub(dev: Device): @pass_dev async def hub_list(dev: Device): """List hub paired child devices.""" - for c in dev.children: - echo(f"{c.device_id}: {c}") + echo(f"{'INDEX':<5} {'DEVICE ID':<40} {'MODEL':<9} {'AVAILABLE':<9} ALIAS") + for index, child in enumerate(dev.children): + echo( + f"{index:<5} {child.device_id:<40} {child.model:<9} " + f"{child.is_available!s:<9} {child.alias}" + ) @hub.command(name="supported") diff --git a/kasa/device.py b/kasa/device.py index d5f0b85f..b1aaa12a 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -277,6 +277,11 @@ class Device(ABC): """Return True if device is off.""" return not self.is_on + @property + def is_available(self) -> bool: + """Return False if the device is known to be unreachable.""" + return True + @abstractmethod async def turn_on(self, **kwargs) -> dict: """Turn on the device.""" diff --git a/kasa/exceptions.py b/kasa/exceptions.py index 98e893f9..7499eef4 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -55,6 +55,10 @@ class AuthenticationError(DeviceError): """Base exception for device authentication errors.""" +class DeviceUnavailableError(DeviceError): + """Exception for commands to an unreachable device.""" + + class _RetryableError(DeviceError): """Retryable exception for device errors.""" diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index f4e01ada..8fac6a63 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -9,6 +9,8 @@ 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 @@ -49,6 +51,7 @@ class SmartChildDevice(SmartDevice): _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) @@ -83,6 +86,10 @@ class SmartChildDevice(SmartDevice): 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] = {} @@ -160,6 +167,34 @@ class SmartChildDevice(SmartDevice): 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>" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index e6402131..dd8a1ead 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -730,7 +730,7 @@ class SmartDevice(Device): See :meth:`is_on`. """ - return await self.protocol.query({"set_device_info": {"device_on": on}}) + return await self._query_helper("set_device_info", {"device_on": on}) async def turn_on(self, **kwargs: Any) -> dict: """Turn on the device.""" @@ -833,8 +833,8 @@ class SmartDevice(Device): async def set_alias(self, alias: str) -> dict: """Set the device name (alias).""" - return await self.protocol.query( - {"set_device_info": {"nickname": base64.b64encode(alias.encode()).decode()}} + return await self._query_helper( + "set_device_info", {"nickname": base64.b64encode(alias.encode()).decode()} ) async def reboot(self, delay: int = 1) -> None: @@ -843,14 +843,14 @@ class SmartDevice(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}}) + await self._query_helper("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") + await self._query_helper("device_reset") @property def device_type(self) -> DeviceType: diff --git a/kasa/smartcam/smartcamchild.py b/kasa/smartcam/smartcamchild.py index cb9d8e98..d4c13280 100644 --- a/kasa/smartcam/smartcamchild.py +++ b/kasa/smartcam/smartcamchild.py @@ -85,6 +85,12 @@ class SmartCamChild(SmartChildDevice, SmartCamDevice): # devices self._info = self._map_child_info_from_parent(info) + @property + def is_available(self) -> bool: + """Return False if the hub reports the camera offline or no longer lists it.""" + # Camera children report "online" instead of an "offline" status. + return super().is_available and self._info.get("online") is not False + @property def device_type(self) -> DeviceType: """Return the device type.""" diff --git a/kasa/smartcam/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py index 649a5a29..7f3b7065 100644 --- a/kasa/smartcam/smartcamdevice.py +++ b/kasa/smartcam/smartcamdevice.py @@ -215,7 +215,7 @@ class SmartCamDevice(SmartDevice): async def _query_setter_helper( self, method: str, module: str, section: str, params: dict | None = None ) -> dict: - res = await self.protocol.query({method: {module: {section: params}}}) + res = await self._query_helper(method, {module: {section: params}}) return res @@ -293,10 +293,8 @@ class SmartCamDevice(SmartDevice): async def set_alias(self, alias: str) -> dict: """Set the device name (alias).""" - return await self.protocol.query( - { - "setDeviceAlias": {"system": {"sys": {"dev_alias": alias}}}, - } + return await self._query_helper( + "setDeviceAlias", {"system": {"sys": {"dev_alias": alias}}} ) @property diff --git a/tests/cli/test_hub.py b/tests/cli/test_hub.py index c3530878..932c4589 100644 --- a/tests/cli/test_hub.py +++ b/tests/cli/test_hub.py @@ -5,7 +5,8 @@ from pytest_mock import MockerFixture from kasa import Device, DeviceType, Module from kasa.cli.hub import hub -from ..device_fixtures import hubs, plug_iot +from ..device_fixtures import hubs, hubs_smart, plug_iot +from ..fakeprotocol_smart import FakeSmartTransport @hubs @@ -51,6 +52,28 @@ async def test_hub_unpair( assert res.exit_code == 0 +@hubs_smart +@pytest.mark.requires_dummy +async def test_hub_list(dev: Device, runner: CliRunner) -> None: + """Test that list shows each child with its index and availability.""" + if not dev.children: + pytest.skip("Cannot test without child devices") + + assert isinstance(dev.protocol._transport, FakeSmartTransport) + child_list = dev.protocol._transport.info["get_child_device_list"] + child_list["child_device_list"][0]["status"] = "offline" + await dev.update() + + res = await runner.invoke(hub, ["list"], obj=dev, catch_exceptions=False) + + rows = {line.split()[1]: line.split() for line in res.output.splitlines()[1:]} + for index, child in enumerate(dev.children): + row = rows[child.device_id] + assert row[0] == str(index) + assert row[3] == str(index != 0) + assert res.exit_code == 0 + + @plug_iot async def test_non_hub(dev: Device, mocker: MockerFixture, runner: CliRunner) -> None: """Test that hub commands return an error if executed on a non-hub.""" diff --git a/tests/smartcam/test_smartcamdevice.py b/tests/smartcam/test_smartcamdevice.py index 46f74946..db91954b 100644 --- a/tests/smartcam/test_smartcamdevice.py +++ b/tests/smartcam/test_smartcamdevice.py @@ -14,11 +14,17 @@ from freezegun.api import FrozenDateTimeFactory from kasa import Credentials, Device, DeviceType, Module from kasa.credentials import DEFAULT_CREDENTIALS, get_default_credentials -from kasa.exceptions import AuthenticationError, DeviceError, KasaException -from kasa.smartcam import SmartCamDevice +from kasa.exceptions import ( + AuthenticationError, + DeviceError, + DeviceUnavailableError, + KasaException, +) +from kasa.smartcam import SmartCamChild, SmartCamDevice from kasa.smartcam.modules.time import Time from ..conftest import device_smartcam, hub_smartcam +from ..fakeprotocol_smartcam import FakeSmartCamTransport class _ChangeAdminPasswordPayload(TypedDict): @@ -96,6 +102,28 @@ async def test_hub(dev: Device) -> None: assert child.device_id +@hub_smartcam +@pytest.mark.requires_dummy +@pytest.mark.parametrize("online", [True, False]) +async def test_camera_child_is_available(dev: Device, online: bool) -> None: + """Test a camera child reports the reachability the hub reports for it.""" + child = next((c for c in dev.children if isinstance(c, SmartCamChild)), None) + if child is None: + pytest.skip(f"Device {dev} fixture does not have camera children") + + assert isinstance(dev.protocol._transport, FakeSmartCamTransport) + for info in dev.protocol._transport.info["getChildDeviceList"]["child_device_list"]: + if info["device_id"] == child.device_id: + info["online"] = online + await dev.update() + + assert child.is_available is online + assert child.features["available"].value is online + if not online: + with pytest.raises(DeviceUnavailableError): + await child.set_alias("new") + + @device_smartcam async def test_wifi_scan(dev: SmartCamDevice) -> None: fake_scan_data = { diff --git a/tests/test_childdevice.py b/tests/test_childdevice.py index 892fedaf..f95c38a6 100644 --- a/tests/test_childdevice.py +++ b/tests/test_childdevice.py @@ -1,11 +1,12 @@ import inspect +import logging from datetime import UTC, datetime import pytest from freezegun.api import FrozenDateTimeFactory from pytest_mock import MockerFixture -from kasa import Device +from kasa import Device, DeviceUnavailableError from kasa.device_type import DeviceType from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper from kasa.smart.smartchilddevice import SmartChildDevice @@ -18,6 +19,7 @@ from .conftest import ( strip_iot, strip_smart, ) +from .fakeprotocol_smart import FakeSmartTransport has_children_smart = parametrize( "has children", component_filter="control_child", protocol_filter={"SMART"} @@ -126,6 +128,104 @@ async def test_parent_property(dev: Device) -> None: assert child.parent == dev +@hub_smart +@pytest.mark.requires_dummy +@pytest.mark.parametrize( + ("status", "is_available"), [("online", True), ("offline", False)] +) +async def test_child_is_available(dev: Device, status: str, is_available: bool) -> None: + """Test a hub child reports the reachability status from the hub.""" + if not dev.children: + pytest.skip(f"Device {dev} fixture does not have any children") + + assert isinstance(dev.protocol._transport, FakeSmartTransport) + child_list = dev.protocol._transport.info["get_child_device_list"] + child_list["child_device_list"][0]["status"] = status + + await dev.update() + + child = dev.children[0] + assert child.is_available is is_available + assert child.features["available"].value is is_available + assert dev.is_available is True + + +@hub_smart +@pytest.mark.requires_dummy +@pytest.mark.parametrize( + "write", + [ + pytest.param(lambda child: child.set_alias("new"), id="set_alias"), + pytest.param(lambda child: child.reboot(), id="reboot"), + pytest.param( + lambda child: next(iter(child.modules.values())).call( + "set_device_info", {"a": 1} + ), + id="module_call", + ), + ], +) +@pytest.mark.parametrize("removed", [False, True], ids=["offline", "removed"]) +async def test_unavailable_child_refuses_writes( + dev: Device, write, removed: bool, mocker: MockerFixture +) -> None: + """Test writes to an unreachable hub child fail without contacting the hub.""" + if not dev.children: + pytest.skip(f"Device {dev} fixture does not have any children") + + child = dev.children[0] + assert isinstance(child, SmartChildDevice) + assert isinstance(dev.protocol._transport, FakeSmartTransport) + info = dev.protocol._transport.info + child_list = info["get_child_device_list"] + if removed: + child_list["child_device_list"].pop(0) + child_list["sum"] -= 1 + info["get_child_device_component_list"]["child_component_list"] = [ + c + for c in info["get_child_device_component_list"]["child_component_list"] + if c["device_id"] != child.device_id + ] + else: + child_list["child_device_list"][0]["status"] = "offline" + await dev.update() + query = mocker.spy(dev.protocol, "query") + + assert child.is_available is False + with pytest.raises(DeviceUnavailableError): + await write(child) + query.assert_not_called() + + await child._query_helper("get_device_info") + query.assert_called_once() + + +@hub_smart +@pytest.mark.requires_dummy +@pytest.mark.xdist_group(name="caplog") +async def test_child_availability_change_logged_once( + dev: Device, caplog: pytest.LogCaptureFixture +) -> None: + """Test going offline and coming back are each logged once.""" + if not dev.children: + pytest.skip(f"Device {dev} fixture does not have any children") + + assert isinstance(dev.protocol._transport, FakeSmartTransport) + child_info = dev.protocol._transport.info["get_child_device_list"] + child_id = child_info["child_device_list"][0]["device_id"] + caplog.set_level(logging.DEBUG) + + child_info["child_device_list"][0]["status"] = "offline" + await dev.update() + await dev.update() + assert caplog.text.count(f"({child_id}) available: False") == 1 + + child_info["child_device_list"][0]["status"] = "online" + await dev.update() + await dev.update() + assert caplog.text.count(f"({child_id}) available: True") == 1 + + @has_children_smart @pytest.mark.requires_dummy async def test_child_time(dev: Device, freezer: FrozenDateTimeFactory) -> None: