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

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:
Julien Herr
2026-09-18 18:05:48 +02:00
committed by GitHub
parent 454e2697dc
commit b7b41f2c81
4 changed files with 231 additions and 0 deletions

View File

@@ -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",

View 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

View File

@@ -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"
)

View 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