Add alarm module for smartcamera hubs (#1258)

This commit is contained in:
Steven B. 2024-11-15 10:19:40 +00:00 committed by GitHub
parent 5fe75cada9
commit cf77128853
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 372 additions and 43 deletions

View File

@ -1,5 +1,6 @@
"""Modules for SMARTCAMERA devices.""" """Modules for SMARTCAMERA devices."""
from .alarm import Alarm
from .camera import Camera from .camera import Camera
from .childdevice import ChildDevice from .childdevice import ChildDevice
from .device import DeviceModule from .device import DeviceModule
@ -7,6 +8,7 @@ from .led import Led
from .time import Time from .time import Time
__all__ = [ __all__ = [
"Alarm",
"Camera", "Camera",
"ChildDevice", "ChildDevice",
"DeviceModule", "DeviceModule",

View File

@ -0,0 +1,166 @@
"""Implementation of alarm module."""
from __future__ import annotations
from ...feature import Feature
from ..smartcameramodule import SmartCameraModule
DURATION_MIN = 0
DURATION_MAX = 6000
VOLUME_MIN = 0
VOLUME_MAX = 10
class Alarm(SmartCameraModule):
"""Implementation of alarm module."""
# Needs a different name to avoid clashing with SmartAlarm
NAME = "SmartCameraAlarm"
REQUIRED_COMPONENT = "siren"
QUERY_GETTER_NAME = "getSirenStatus"
QUERY_MODULE_NAME = "siren"
def query(self) -> dict:
"""Query to execute during the update cycle."""
q = super().query()
q["getSirenConfig"] = {self.QUERY_MODULE_NAME: {}}
q["getSirenTypeList"] = {self.QUERY_MODULE_NAME: {}}
return q
def _initialize_features(self) -> None:
"""Initialize features."""
device = self._device
self._add_feature(
Feature(
device,
id="alarm",
name="Alarm",
container=self,
attribute_getter="active",
icon="mdi:bell",
category=Feature.Category.Debug,
type=Feature.Type.BinarySensor,
)
)
self._add_feature(
Feature(
device,
id="alarm_sound",
name="Alarm sound",
container=self,
attribute_getter="alarm_sound",
attribute_setter="set_alarm_sound",
category=Feature.Category.Config,
type=Feature.Type.Choice,
choices_getter="alarm_sounds",
)
)
self._add_feature(
Feature(
device,
id="alarm_volume",
name="Alarm volume",
container=self,
attribute_getter="alarm_volume",
attribute_setter="set_alarm_volume",
category=Feature.Category.Config,
type=Feature.Type.Number,
range_getter=lambda: (VOLUME_MIN, VOLUME_MAX),
)
)
self._add_feature(
Feature(
device,
id="alarm_duration",
name="Alarm duration",
container=self,
attribute_getter="alarm_duration",
attribute_setter="set_alarm_duration",
category=Feature.Category.Config,
type=Feature.Type.Number,
range_getter=lambda: (DURATION_MIN, DURATION_MAX),
)
)
self._add_feature(
Feature(
device,
id="test_alarm",
name="Test alarm",
container=self,
attribute_setter="play",
type=Feature.Type.Action,
)
)
self._add_feature(
Feature(
device,
id="stop_alarm",
name="Stop alarm",
container=self,
attribute_setter="stop",
type=Feature.Type.Action,
)
)
@property
def alarm_sound(self) -> str:
"""Return current alarm sound."""
return self.data["getSirenConfig"]["siren_type"]
async def set_alarm_sound(self, sound: str) -> dict:
"""Set alarm sound.
See *alarm_sounds* for list of available sounds.
"""
if sound not in self.alarm_sounds:
raise ValueError(
f"sound must be one of {', '.join(self.alarm_sounds)}: {sound}"
)
return await self.call("setSirenConfig", {"siren": {"siren_type": sound}})
@property
def alarm_sounds(self) -> list[str]:
"""Return list of available alarm sounds."""
return self.data["getSirenTypeList"]["siren_type_list"]
@property
def alarm_volume(self) -> int:
"""Return alarm volume.
Unlike duration the device expects/returns a string for volume.
"""
return int(self.data["getSirenConfig"]["volume"])
async def set_alarm_volume(self, volume: int) -> dict:
"""Set alarm volume."""
if volume < VOLUME_MIN or volume > VOLUME_MAX:
raise ValueError(f"volume must be between {VOLUME_MIN} and {VOLUME_MAX}")
return await self.call("setSirenConfig", {"siren": {"volume": str(volume)}})
@property
def alarm_duration(self) -> int:
"""Return alarm duration."""
return self.data["getSirenConfig"]["duration"]
async def set_alarm_duration(self, duration: int) -> dict:
"""Set alarm volume."""
if duration < DURATION_MIN or duration > DURATION_MAX:
msg = f"duration must be between {DURATION_MIN} and {DURATION_MAX}"
raise ValueError(msg)
return await self.call("setSirenConfig", {"siren": {"duration": duration}})
@property
def active(self) -> bool:
"""Return true if alarm is active."""
return self.data["getSirenStatus"]["status"] != "off"
async def play(self) -> dict:
"""Play alarm."""
return await self.call("setSirenStatus", {"siren": {"status": "on"}})
async def stop(self) -> dict:
"""Stop alarm."""
return await self.call("setSirenStatus", {"siren": {"status": "off"}})

View File

@ -3,12 +3,14 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, Final, cast
from ..exceptions import DeviceError, KasaException, SmartErrorCode from ..exceptions import DeviceError, KasaException, SmartErrorCode
from ..modulemapping import ModuleName
from ..smart.smartmodule import SmartModule from ..smart.smartmodule import SmartModule
if TYPE_CHECKING: if TYPE_CHECKING:
from . import modules
from .smartcamera import SmartCamera from .smartcamera import SmartCamera
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -17,12 +19,14 @@ _LOGGER = logging.getLogger(__name__)
class SmartCameraModule(SmartModule): class SmartCameraModule(SmartModule):
"""Base class for SMARTCAMERA modules.""" """Base class for SMARTCAMERA modules."""
SmartCameraAlarm: Final[ModuleName[modules.Alarm]] = ModuleName("SmartCameraAlarm")
#: Query to execute during the main update cycle #: Query to execute during the main update cycle
QUERY_GETTER_NAME: str QUERY_GETTER_NAME: str
#: Module name to be queried #: Module name to be queried
QUERY_MODULE_NAME: str QUERY_MODULE_NAME: str
#: Section name or names to be queried #: Section name or names to be queried
QUERY_SECTION_NAMES: str | list[str] QUERY_SECTION_NAMES: str | list[str] | None = None
REGISTERED_MODULES = {} REGISTERED_MODULES = {}
@ -33,11 +37,10 @@ class SmartCameraModule(SmartModule):
Default implementation uses the raw query getter w/o parameters. Default implementation uses the raw query getter w/o parameters.
""" """
return { section_names = (
self.QUERY_GETTER_NAME: { {"name": self.QUERY_SECTION_NAMES} if self.QUERY_SECTION_NAMES else {}
self.QUERY_MODULE_NAME: {"name": self.QUERY_SECTION_NAMES} )
} return {self.QUERY_GETTER_NAME: {self.QUERY_MODULE_NAME: section_names}}
}
async def call(self, method: str, params: dict | None = None) -> dict: async def call(self, method: str, params: dict | None = None) -> dict:
"""Call a method. """Call a method.

View File

@ -105,6 +105,7 @@ class FakeSmartCameraTransport(BaseTransport):
info = info[key] info = info[key]
info[set_keys[-1]] = value info[set_keys[-1]] = value
# Setters for when there's not a simple mapping of setters to getters
SETTERS = { SETTERS = {
("system", "sys", "dev_alias"): [ ("system", "sys", "dev_alias"): [
"getDeviceInfo", "getDeviceInfo",
@ -112,36 +113,20 @@ class FakeSmartCameraTransport(BaseTransport):
"basic_info", "basic_info",
"device_alias", "device_alias",
], ],
("lens_mask", "lens_mask_info", "enabled"): [ # setTimezone maps to getClockStatus
"getLensMaskConfig",
"lens_mask",
"lens_mask_info",
"enabled",
],
("system", "clock_status", "seconds_from_1970"): [ ("system", "clock_status", "seconds_from_1970"): [
"getClockStatus", "getClockStatus",
"system", "system",
"clock_status", "clock_status",
"seconds_from_1970", "seconds_from_1970",
], ],
# setTimezone maps to getClockStatus
("system", "clock_status", "local_time"): [ ("system", "clock_status", "local_time"): [
"getClockStatus", "getClockStatus",
"system", "system",
"clock_status", "clock_status",
"local_time", "local_time",
], ],
("system", "basic", "zone_id"): [
"getTimezone",
"system",
"basic",
"zone_id",
],
("led", "config", "enabled"): [
"getLedStatus",
"led",
"config",
"enabled",
],
} }
async def _send_request(self, request_dict: dict): async def _send_request(self, request_dict: dict):
@ -154,8 +139,10 @@ class FakeSmartCameraTransport(BaseTransport):
) )
if method[:3] == "set": if method[:3] == "set":
get_method = "g" + method[1:]
for key, val in request_dict.items(): for key, val in request_dict.items():
if key != "method": if key == "method":
continue
# key is params for multi request and the actual params # key is params for multi request and the actual params
# for single requests # for single requests
if key == "params": if key == "params":
@ -165,13 +152,25 @@ class FakeSmartCameraTransport(BaseTransport):
module = key module = key
section = next(iter(val)) section = next(iter(val))
skey_val = val[section] skey_val = val[section]
if not isinstance(skey_val, dict): # single level query
section_key = section
section_val = skey_val
if (get_info := info.get(get_method)) and section_key in get_info:
get_info[section_key] = section_val
else:
return {"error_code": -1}
break
for skey, sval in skey_val.items(): for skey, sval in skey_val.items():
section_key = skey section_key = skey
section_value = sval section_value = sval
if setter_keys := self.SETTERS.get( if setter_keys := self.SETTERS.get((module, section, section_key)):
(module, section, section_key)
):
self._get_param_set_value(info, setter_keys, section_value) self._get_param_set_value(info, setter_keys, section_value)
elif (
section := info.get(get_method, {})
.get(module, {})
.get(section, {})
) and section_key in section:
section[section_key] = section_value
else: else:
return {"error_code": -1} return {"error_code": -1}
break break

View File

View File

@ -0,0 +1,159 @@
"""Tests for smart camera devices."""
from __future__ import annotations
import pytest
from kasa import Device
from kasa.smartcamera.modules.alarm import (
DURATION_MAX,
DURATION_MIN,
VOLUME_MAX,
VOLUME_MIN,
)
from kasa.smartcamera.smartcameramodule import SmartCameraModule
from ...conftest import hub_smartcamera
@hub_smartcamera
async def test_alarm(dev: Device):
"""Test device alarm."""
alarm = dev.modules.get(SmartCameraModule.SmartCameraAlarm)
assert alarm
original_duration = alarm.alarm_duration
assert original_duration is not None
original_volume = alarm.alarm_volume
assert original_volume is not None
original_sound = alarm.alarm_sound
try:
# test volume
new_volume = original_volume - 1 if original_volume > 1 else original_volume + 1
await alarm.set_alarm_volume(new_volume) # type: ignore[arg-type]
await dev.update()
assert alarm.alarm_volume == new_volume
# test duration
new_duration = (
original_duration - 1 if original_duration > 1 else original_duration + 1
)
await alarm.set_alarm_duration(new_duration)
await dev.update()
assert alarm.alarm_duration == new_duration
# test start
await alarm.play()
await dev.update()
assert alarm.active
# test stop
await alarm.stop()
await dev.update()
assert not alarm.active
# test set sound
new_sound = (
alarm.alarm_sounds[0]
if alarm.alarm_sound != alarm.alarm_sounds[0]
else alarm.alarm_sounds[1]
)
await alarm.set_alarm_sound(new_sound)
await dev.update()
assert alarm.alarm_sound == new_sound
finally:
await alarm.set_alarm_volume(original_volume)
await alarm.set_alarm_duration(original_duration)
await alarm.set_alarm_sound(original_sound)
await dev.update()
@hub_smartcamera
async def test_alarm_invalid_setters(dev: Device):
"""Test device alarm invalid setter values."""
alarm = dev.modules.get(SmartCameraModule.SmartCameraAlarm)
assert alarm
# test set sound invalid
msg = f"sound must be one of {', '.join(alarm.alarm_sounds)}: foobar"
with pytest.raises(ValueError, match=msg):
await alarm.set_alarm_sound("foobar")
# test volume invalid
msg = f"volume must be between {VOLUME_MIN} and {VOLUME_MAX}"
with pytest.raises(ValueError, match=msg):
await alarm.set_alarm_volume(-3)
# test duration invalid
msg = f"duration must be between {DURATION_MIN} and {DURATION_MAX}"
with pytest.raises(ValueError, match=msg):
await alarm.set_alarm_duration(-3)
@hub_smartcamera
async def test_alarm_features(dev: Device):
"""Test device alarm features."""
alarm = dev.modules.get(SmartCameraModule.SmartCameraAlarm)
assert alarm
original_duration = alarm.alarm_duration
assert original_duration is not None
original_volume = alarm.alarm_volume
assert original_volume is not None
original_sound = alarm.alarm_sound
try:
# test volume
new_volume = original_volume - 1 if original_volume > 1 else original_volume + 1
feature = dev.features.get("alarm_volume")
assert feature
await feature.set_value(new_volume) # type: ignore[arg-type]
await dev.update()
assert feature.value == new_volume
# test duration
feature = dev.features.get("alarm_duration")
assert feature
new_duration = (
original_duration - 1 if original_duration > 1 else original_duration + 1
)
await feature.set_value(new_duration)
await dev.update()
assert feature.value == new_duration
# test start
feature = dev.features.get("test_alarm")
assert feature
await feature.set_value(None)
await dev.update()
feature = dev.features.get("alarm")
assert feature
assert feature.value is True
# test stop
feature = dev.features.get("stop_alarm")
assert feature
await feature.set_value(None)
await dev.update()
assert dev.features["alarm"].value is False
# test set sound
feature = dev.features.get("alarm_sound")
assert feature
new_sound = (
alarm.alarm_sounds[0]
if alarm.alarm_sound != alarm.alarm_sounds[0]
else alarm.alarm_sounds[1]
)
await feature.set_value(new_sound)
await alarm.set_alarm_sound(new_sound)
await dev.update()
assert feature.value == new_sound
finally:
await alarm.set_alarm_volume(original_volume)
await alarm.set_alarm_duration(original_duration)
await alarm.set_alarm_sound(original_sound)
await dev.update()