diff --git a/kasa/device.py b/kasa/device.py index 79bc5182..ecd3b052 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -210,12 +210,12 @@ class Device(ABC): self.protocol: BaseProtocol = protocol or IotProtocol( transport=XorTransport(config=config or DeviceConfig(host=host)), ) - _LOGGER.debug("Initializing %s of type %s", self.host, type(self)) + self._last_update: Any = None + _LOGGER.debug("Initializing %s of type %s", host, type(self)) self._device_type = DeviceType.Unknown # TODO: typing Any is just as using Optional[Dict] would require separate # checks in accessors. the @updated_required decorator does not ensure # mypy that these are not accessed incorrectly. - self._last_update: Any = None self._discovery_info: dict[str, Any] | None = None self._features: dict[str, Feature] = {} @@ -492,6 +492,8 @@ class Device(ABC): def __repr__(self) -> str: update_needed = " - update() needed" if not self._last_update else "" + if not self._last_update and not self._discovery_info: + return f"<{self.device_type} at {self.host}{update_needed}>" return ( f"<{self.device_type} at {self.host} -" f" {self.alias} ({self.model}){update_needed}>" diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 1fd8ba39..37f00ae6 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -42,7 +42,9 @@ def requires_update(f: Callable) -> Any: @functools.wraps(f) async def wrapped(*args: Any, **kwargs: Any) -> Any: self = args[0] - if self._last_update is None and f.__name__ not in self._sys_info: + if self._last_update is None and ( + self._sys_info is None or f.__name__ not in self._sys_info + ): raise KasaException("You need to await update() to access the data") return await f(*args, **kwargs) @@ -51,7 +53,9 @@ def requires_update(f: Callable) -> Any: @functools.wraps(f) def wrapped(*args: Any, **kwargs: Any) -> Any: self = args[0] - if self._last_update is None and f.__name__ not in self._sys_info: + if self._last_update is None and ( + self._sys_info is None or f.__name__ not in self._sys_info + ): raise KasaException("You need to await update() to access the data") return f(*args, **kwargs) diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index c50f1f2f..db3319f3 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -107,16 +107,26 @@ class SmartChildDevice(SmartDevice): @property def device_type(self) -> DeviceType: """Return child device type.""" - category = self.sys_info["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, - ) - dev_type = DeviceType.Unknown - return dev_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 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}>" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index b92b1c37..270b2959 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -757,6 +757,10 @@ class SmartDevice(Device): # Fallback to device_type (from disco info) type_str = self._info.get("type", self._info.get("device_type")) + + if not type_str: # no update or discovery info + return self._device_type + self._device_type = self._get_device_type_from_components( list(self._components.keys()), type_str ) diff --git a/kasa/smartcamera/smartcamera.py b/kasa/smartcamera/smartcamera.py index ee804ab6..b99945b3 100644 --- a/kasa/smartcamera/smartcamera.py +++ b/kasa/smartcamera/smartcamera.py @@ -25,8 +25,11 @@ class SmartCamera(SmartDevice): @staticmethod def _get_device_type_from_sysinfo(sysinfo: dict[str, Any]) -> DeviceType: """Find type to be displayed as a supported device category.""" - device_type = sysinfo["device_type"] - if device_type.endswith("HUB"): + if ( + sysinfo + and (device_type := sysinfo.get("device_type")) + and device_type.endswith("HUB") + ): return DeviceType.Hub return DeviceType.Camera diff --git a/tests/test_childdevice.py b/tests/test_childdevice.py index 3aa605c4..a2d78047 100644 --- a/tests/test_childdevice.py +++ b/tests/test_childdevice.py @@ -9,7 +9,7 @@ from kasa import Device from kasa.device_type import DeviceType from kasa.protocols.smartprotocol import _ChildProtocolWrapper from kasa.smart.smartchilddevice import SmartChildDevice -from kasa.smart.smartdevice import NON_HUB_PARENT_ONLY_MODULES +from kasa.smart.smartdevice import NON_HUB_PARENT_ONLY_MODULES, SmartDevice from .conftest import ( parametrize, @@ -139,3 +139,19 @@ async def test_child_time(dev: Device, freezer: FrozenDateTimeFactory): assert dev.parent is None for child in dev.children: assert child.time != fallback_time + + +async def test_child_device_type_unknown(caplog): + """Test for device type when category is unknown.""" + + class DummyDevice(SmartChildDevice): + def __init__(self): + super().__init__( + SmartDevice("127.0.0.1"), + {"device_id": "1", "category": "foobar"}, + {"device", 1}, + ) + + assert DummyDevice().device_type is DeviceType.Unknown + msg = "Unknown child device type foobar for model None, please open issue" + assert msg in caplog.text diff --git a/tests/test_device.py b/tests/test_device.py index 5f527287..1b943fa4 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -14,7 +14,15 @@ import zoneinfo import kasa from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module -from kasa.iot import IotDevice +from kasa.iot import ( + IotBulb, + IotDevice, + IotDimmer, + IotLightStrip, + IotPlug, + IotStrip, + IotWallSwitch, +) from kasa.iot.iottimezone import ( TIMEZONE_INDEX, get_timezone, @@ -22,6 +30,7 @@ from kasa.iot.iottimezone import ( ) from kasa.iot.modules import IotLightPreset from kasa.smart import SmartChildDevice, SmartDevice +from kasa.smartcamera import SmartCamera def _get_subclasses(of_class): @@ -80,6 +89,41 @@ async def test_device_class_ctors(device_class_name_obj): assert dev.credentials == credentials +@device_classes +async def test_device_class_repr(device_class_name_obj): + """Test device repr when update() not called and no discovery info.""" + host = "127.0.0.2" + port = 1234 + credentials = Credentials("foo", "bar") + config = DeviceConfig(host, port_override=port, credentials=credentials) + klass = device_class_name_obj[1] + if issubclass(klass, SmartChildDevice): + parent = SmartDevice(host, config=config) + dev = klass( + parent, {"dummy": "info", "device_id": "dummy"}, {"dummy": "components"} + ) + else: + dev = klass(host, config=config) + + CLASS_TO_DEFAULT_TYPE = { + IotDevice: DeviceType.Unknown, + IotBulb: DeviceType.Bulb, + IotPlug: DeviceType.Plug, + IotDimmer: DeviceType.Dimmer, + IotStrip: DeviceType.Strip, + IotWallSwitch: DeviceType.WallSwitch, + IotLightStrip: DeviceType.LightStrip, + SmartChildDevice: DeviceType.Unknown, + SmartDevice: DeviceType.Unknown, + SmartCamera: DeviceType.Camera, + } + type_ = CLASS_TO_DEFAULT_TYPE[klass] + child_repr = ">" + not_child_repr = f"<{type_} at 127.0.0.2 - update() needed>" + expected_repr = child_repr if klass is SmartChildDevice else not_child_repr + assert repr(dev) == expected_repr + + async def test_create_device_with_timeout(): """Make sure timeout is passed to the protocol.""" host = "127.0.0.1"