mirror of
https://github.com/python-kasa/python-kasa.git
synced 2026-09-19 12:33:51 +00:00
Add LastAlertDetection module for smartcam devices (#1762)
Some checks are pending
CI / Perform Lint Checks (3.14) (push) Waiting to run
CI / Python 3.11 on macos-latest (push) Blocked by required conditions
CI / Python 3.12 on macos-latest (push) Blocked by required conditions
CI / Python 3.13 on macos-latest (push) Blocked by required conditions
CI / Python 3.14 on macos-latest (push) Blocked by required conditions
CI / Python 3.11 on ubuntu-latest (push) Blocked by required conditions
CI / Python 3.12 on ubuntu-latest (push) Blocked by required conditions
CI / Python 3.13 on ubuntu-latest (push) Blocked by required conditions
CI / Python 3.14 on ubuntu-latest (push) Blocked by required conditions
CI / Python 3.11 on windows-latest (push) Blocked by required conditions
CI / Python 3.12 on windows-latest (push) Blocked by required conditions
CI / Python 3.13 on windows-latest (push) Blocked by required conditions
CI / Python 3.14 on windows-latest (push) Blocked by required conditions
CodeQL Checks / Analyze (python) (push) Waiting to run
Some checks are pending
CI / Perform Lint Checks (3.14) (push) Waiting to run
CI / Python 3.11 on macos-latest (push) Blocked by required conditions
CI / Python 3.12 on macos-latest (push) Blocked by required conditions
CI / Python 3.13 on macos-latest (push) Blocked by required conditions
CI / Python 3.14 on macos-latest (push) Blocked by required conditions
CI / Python 3.11 on ubuntu-latest (push) Blocked by required conditions
CI / Python 3.12 on ubuntu-latest (push) Blocked by required conditions
CI / Python 3.13 on ubuntu-latest (push) Blocked by required conditions
CI / Python 3.14 on ubuntu-latest (push) Blocked by required conditions
CI / Python 3.11 on windows-latest (push) Blocked by required conditions
CI / Python 3.12 on windows-latest (push) Blocked by required conditions
CI / Python 3.13 on windows-latest (push) Blocked by required conditions
CI / Python 3.14 on windows-latest (push) Blocked by required conditions
CodeQL Checks / Analyze (python) (push) Waiting to run
Add a `LastAlertDetection` module for SMARTCAM devices exposing `getLastAlarmInfo` as two `Info` sensor features: - `last_alert_timestamp` — tz-aware `datetime` of the last alert, `None` when the device has never detected anything (`last_alarm_time` is `""` or `"0"` on such devices, and an unexpected value is also mapped to `None` - `last_alert_type` — `LastAlertType` enum (`Motion`; `Unknown` fallback with a one-time warning per unknown raw value), `None` when unset.
This commit is contained in:
@@ -10,6 +10,7 @@ from .childsetup import ChildSetup
|
|||||||
from .device import DeviceModule
|
from .device import DeviceModule
|
||||||
from .glassdetection import GlassDetection
|
from .glassdetection import GlassDetection
|
||||||
from .homekit import HomeKit
|
from .homekit import HomeKit
|
||||||
|
from .lastalertdetection import LastAlertDetection
|
||||||
from .led import Led
|
from .led import Led
|
||||||
from .lensmask import LensMask
|
from .lensmask import LensMask
|
||||||
from .linecrossingdetection import LineCrossingDetection
|
from .linecrossingdetection import LineCrossingDetection
|
||||||
@@ -33,6 +34,7 @@ __all__ = [
|
|||||||
"ChildSetup",
|
"ChildSetup",
|
||||||
"DeviceModule",
|
"DeviceModule",
|
||||||
"GlassDetection",
|
"GlassDetection",
|
||||||
|
"LastAlertDetection",
|
||||||
"Led",
|
"Led",
|
||||||
"LineCrossingDetection",
|
"LineCrossingDetection",
|
||||||
"MeowDetection",
|
"MeowDetection",
|
||||||
|
|||||||
102
kasa/smartcam/modules/lastalertdetection.py
Normal file
102
kasa/smartcam/modules/lastalertdetection.py
Normal file
@@ -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
|
||||||
@@ -54,6 +54,10 @@ class SmartCamModule(SmartModule):
|
|||||||
|
|
||||||
SmartCamBattery: Final[ModuleName[modules.Battery]] = ModuleName("Battery")
|
SmartCamBattery: Final[ModuleName[modules.Battery]] = ModuleName("Battery")
|
||||||
|
|
||||||
|
SmartCamLastAlertDetection: Final[ModuleName[modules.LastAlertDetection]] = (
|
||||||
|
ModuleName("LastAlertDetection")
|
||||||
|
)
|
||||||
|
|
||||||
SmartCamDeviceModule: Final[ModuleName[modules.DeviceModule]] = ModuleName(
|
SmartCamDeviceModule: Final[ModuleName[modules.DeviceModule]] = ModuleName(
|
||||||
"devicemodule"
|
"devicemodule"
|
||||||
)
|
)
|
||||||
|
|||||||
123
tests/smartcam/modules/test_lastalertdetection.py
Normal file
123
tests/smartcam/modules/test_lastalertdetection.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user