diff --git a/kasa/smartcam/modules/__init__.py b/kasa/smartcam/modules/__init__.py index 3434bddb..c31138f8 100644 --- a/kasa/smartcam/modules/__init__.py +++ b/kasa/smartcam/modules/__init__.py @@ -10,6 +10,7 @@ from .childsetup import ChildSetup from .device import DeviceModule from .glassdetection import GlassDetection from .homekit import HomeKit +from .lastalertdetection import LastAlertDetection from .led import Led from .lensmask import LensMask from .linecrossingdetection import LineCrossingDetection @@ -33,6 +34,7 @@ __all__ = [ "ChildSetup", "DeviceModule", "GlassDetection", + "LastAlertDetection", "Led", "LineCrossingDetection", "MeowDetection", diff --git a/kasa/smartcam/modules/lastalertdetection.py b/kasa/smartcam/modules/lastalertdetection.py new file mode 100644 index 00000000..b8bd333d --- /dev/null +++ b/kasa/smartcam/modules/lastalertdetection.py @@ -0,0 +1,102 @@ +"""Module for the last alert reported by the camera.""" + +from __future__ import annotations + +import logging +from datetime import datetime +from enum import StrEnum + +from ...feature import Feature +from ..smartcammodule import SmartCamModule + +_LOGGER = logging.getLogger(__name__) + + +class LastAlertType(StrEnum): + """Type of the alert reported by the camera.""" + + Motion = "motion" + Unknown = "unknown" + + +class LastAlertDetection(SmartCamModule): + """Implementation of the last alert reported by the camera. + + Backed by ``getLastAlarmInfo`` (``last_alarm_time``/``last_alarm_type``), + which is refreshed within a second of a detection and needs no SD card. + The timestamp keeps advancing while the motion continues. + + Hub children are excluded as their modules are only refreshed once a day, + which defeats the purpose of polling the last alert. + """ + + REQUIRED_COMPONENT = "detection" + QUERY_GETTER_NAME = "getLastAlarmInfo" + QUERY_MODULE_NAME = "system" + QUERY_SECTION_NAMES = "last_alarm_info" + + _logged_unknown_types: set[str] = set() + + async def _check_supported(self) -> bool: + """Additional check to see if the module is supported by the device.""" + return not self._device._is_hub_child + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + device=self._device, + id="last_alert_timestamp", + name="Last alert time", + attribute_getter="last_alert_timestamp", + container=self, + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + device=self._device, + id="last_alert_type", + name="Last alert type", + attribute_getter="last_alert_type", + container=self, + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + + @property + def last_alert_timestamp(self) -> datetime | None: + """Return timestamp of the last alert, None if nothing was reported yet. + + Devices report an empty string or 0 when nothing has been detected yet. + Unparseable values are reported as None as well. + """ + try: + timestamp = int(self.data["last_alarm_info"].get("last_alarm_time")) + if not timestamp: + return None + return datetime.fromtimestamp(timestamp, tz=self._device.timezone) + except (TypeError, ValueError, OverflowError, OSError): + return None + + @property + def last_alert_type(self) -> LastAlertType | None: + """Return the type of the last alert, None if nothing was reported yet. + + Unknown types are reported as :attr:`LastAlertType.Unknown`. + """ + alert_type = self.data["last_alarm_info"].get("last_alarm_type") + if not alert_type: + return None + try: + return LastAlertType(alert_type) + except ValueError: + if alert_type not in self._logged_unknown_types: + self._logged_unknown_types.add(alert_type) + _LOGGER.warning( + "Unknown alert type, please create an issue describing it: %s", + alert_type, + ) + return LastAlertType.Unknown diff --git a/kasa/smartcam/smartcammodule.py b/kasa/smartcam/smartcammodule.py index 7402b8cb..29019318 100644 --- a/kasa/smartcam/smartcammodule.py +++ b/kasa/smartcam/smartcammodule.py @@ -54,6 +54,10 @@ class SmartCamModule(SmartModule): SmartCamBattery: Final[ModuleName[modules.Battery]] = ModuleName("Battery") + SmartCamLastAlertDetection: Final[ModuleName[modules.LastAlertDetection]] = ( + ModuleName("LastAlertDetection") + ) + SmartCamDeviceModule: Final[ModuleName[modules.DeviceModule]] = ModuleName( "devicemodule" ) diff --git a/tests/smartcam/modules/test_lastalertdetection.py b/tests/smartcam/modules/test_lastalertdetection.py new file mode 100644 index 00000000..ee532f7b --- /dev/null +++ b/tests/smartcam/modules/test_lastalertdetection.py @@ -0,0 +1,123 @@ +"""Tests for smartcam last alert detection module.""" + +from __future__ import annotations + +import logging +from datetime import datetime + +import pytest + +from kasa import Device +from kasa.smart import SmartDevice +from kasa.smartcam.modules.lastalertdetection import ( + LastAlertDetection, + LastAlertType, +) +from kasa.smartcam.smartcammodule import SmartCamModule + +from ...device_fixtures import parametrize + +lastalertdetection_smartcam = parametrize( + "has last alert detection", + component_filter="detection", + protocol_filter={"SMARTCAM"}, +) +lastalertdetection_hub_child = parametrize( + "hub child with detection", + component_filter="detection", + protocol_filter={"SMARTCAM.CHILD"}, +) + + +def _set_last_alarm_info(dev: Device, time: str, type_: str = "") -> None: + dev._last_update["getLastAlarmInfo"]["system"]["last_alarm_info"] = { + "last_alarm_time": time, + "last_alarm_type": type_, + } + + +@lastalertdetection_smartcam +async def test_last_alert_features(dev: Device) -> None: + """Test that the module and its features are available.""" + last_alert = dev.modules.get(SmartCamModule.SmartCamLastAlertDetection) + assert last_alert + + for feat_id in ("last_alert_timestamp", "last_alert_type"): + feat = dev.features.get(feat_id) + assert feat + assert feat.value == getattr(last_alert, feat_id) + + +@lastalertdetection_smartcam +async def test_last_alert_values(dev: Device) -> None: + """Test that a reported alert is exposed as a tz-aware datetime and an enum.""" + last_alert = dev.modules.get(SmartCamModule.SmartCamLastAlertDetection) + assert last_alert + _set_last_alarm_info(dev, "1734967724", "motion") + + alert_time = last_alert.last_alert_timestamp + assert isinstance(alert_time, datetime) + assert alert_time.tzinfo == dev.timezone + assert alert_time.timestamp() == 1734967724 + assert last_alert.last_alert_type is LastAlertType.Motion + assert dev.features["last_alert_type"].value is LastAlertType.Motion + + +@lastalertdetection_smartcam +async def test_last_alert_type_unknown_logs_once( + dev: Device, caplog: pytest.LogCaptureFixture +) -> None: + """Test that an unknown alert type falls back to Unknown with a single warning.""" + last_alert = dev.modules.get(SmartCamModule.SmartCamLastAlertDetection) + assert last_alert + caplog.set_level(logging.WARNING) + # The warned-once set is process-wide, reset it so each fixture starts clean. + LastAlertDetection._logged_unknown_types.clear() + + _set_last_alarm_info(dev, "1734967724", "vehicle") + assert last_alert.last_alert_type is LastAlertType.Unknown + assert "Unknown alert type" in caplog.text + assert "vehicle" in caplog.text + + caplog.clear() + assert last_alert.last_alert_type is LastAlertType.Unknown + assert "Unknown alert type" not in caplog.text + + _set_last_alarm_info(dev, "1734967724", "person") + assert last_alert.last_alert_type is LastAlertType.Unknown + assert "person" in caplog.text + + +@lastalertdetection_smartcam +@pytest.mark.parametrize( + "time_raw", + [ + "", # never triggered (C100) + "0", # never triggered (C110, C220, ...) + "nonsense", # unexpected value must not break feature access + "99999999999999999", # out of range for datetime.fromtimestamp + ], +) +async def test_last_alert_never_triggered(dev: Device, time_raw: str) -> None: + """Test that devices that never reported an alert return None.""" + last_alert = dev.modules.get(SmartCamModule.SmartCamLastAlertDetection) + assert last_alert + + _set_last_alarm_info(dev, time_raw) + + assert last_alert.last_alert_timestamp is None + assert last_alert.last_alert_type is None + + +@lastalertdetection_hub_child +async def test_last_alert_not_exposed_on_hub_children(dev: Device) -> None: + """Test that hub children do not expose the module. + + Hub child modules are only refreshed once a day, which defeats the purpose + of polling the last alert. + """ + assert isinstance(dev, SmartDevice) + assert dev._is_hub_child + assert SmartCamModule.SmartCamLastAlertDetection not in dev.modules + assert "last_alert_timestamp" not in dev.features + assert "last_alert_type" not in dev.features