mirror of
https://github.com/python-kasa/python-kasa.git
synced 2024-12-23 03:33:35 +00:00
Add alarm module for smartcamera hubs (#1258)
This commit is contained in:
parent
5fe75cada9
commit
cf77128853
@ -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",
|
||||||
|
166
kasa/smartcamera/modules/alarm.py
Normal file
166
kasa/smartcamera/modules/alarm.py
Normal 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"}})
|
@ -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.
|
||||||
|
@ -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
|
||||||
|
0
tests/smartcamera/modules/__init__.py
Normal file
0
tests/smartcamera/modules/__init__.py
Normal file
159
tests/smartcamera/modules/test_alarm.py
Normal file
159
tests/smartcamera/modules/test_alarm.py
Normal 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()
|
Loading…
Reference in New Issue
Block a user