FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Add LastAlertDetection module for smartcam devices by juherr · Pull Request #1762 · python-kasa/python-kasa · GitHub

Repository navigation

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension .py  (4) All 1 file type selected
Viewed files
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Unified
Split
Hide whitespace
Diff view
Unified
Split
Hide whitespace
2 changes: 2 additions & 0 deletions kasa/smartcam/modules/__init__.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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
Expand All @@ -33,6 +34,7 @@
"ChildSetup",
"DeviceModule",
"GlassDetection",
"LastAlertDetection",
"Led",
"LineCrossingDetection",
"MeowDetection",
Expand Down
102 changes: 102 additions & 0 deletions kasa/smartcam/modules/lastalertdetection.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions kasa/smartcam/smartcammodule.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down
123 changes: 123 additions & 0 deletions tests/smartcam/modules/test_lastalertdetection.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -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
Loading

Back | FazBrowse Home | New Git URL