8000 Add alarm module for smartcamera hubs by sdb9696 · Pull Request #1258 · python-kasa/python-kasa · GitHub
[go: up one dir, main page]

Skip to content

Add alarm module for smartcamera hubs #1258

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Nov 15, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions kasa/cli/feature.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@

feat = dev.features[name]

if value is None and feat.type is Feature.Type.Action:
echo(f"Changing {name} from {feat.value} to {value}")
response = await dev.features[name].set_value(value)
echo(response)
return response

Check warning on line 129 in kasa/cli/feature.py

View check run for this annotation

Codecov / codecov/patch

kasa/cli/feature.py#L126-L129

Added lines #L126 - L129 were not covered by tests

if value is None:
unit = f" {feat.unit}" if feat.unit else ""
echo(f"{feat.name} ({name}): {feat.value}{unit}")
Expand Down
2 changes: 2 additions & 0 deletions kasa/smartcamera/modules/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"""Modules for SMARTCAMERA devices."""

from .alarm import Alarm
from .camera import Camera
from .childdevice import ChildDevice
from .device import DeviceModule
from .led import Led
from .time import Time

__all__ = [
"Alarm",
"Camera",
"ChildDevice",
"DeviceModule",
Expand Down
166 changes: 166 additions & 0 deletions kasa/smartcamera/modules/alarm.py
8000
Original file line number Diff line number Diff line change
@@ -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.

This is implemented as some features depend on device responses.
"""
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:
msg = f"sound must be one of {', '.join(self.alarm_sounds)}: {sound}"
raise ValueError(msg)

Check warning on line 123 in kasa/smartcamera/modules/alarm.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcamera/modules/alarm.py#L122-L123

Added lines #L122 - L123 were not covered by tests
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."""
return int(self.data["getSirenConfig"]["volume"]) # type: ignore[return-value]

async def set_alarm_volume(self, volume: int) -> dict:
"""Set alarm volume."""
if volume < VOLUME_MIN or volume > VOLUME_MAX:
msg = f"volume must be between {VOLUME_MIN} and {VOLUME_MAX}"
raise ValueError(msg)

Check warning on line 140 in kasa/smartcamera/modules/alarm.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcamera/modules/alarm.py#L139-L140

Added lines #L139 - L140 were not covered by tests
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)

Check warning on line 152 in kasa/smartcamera/modules/alarm.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcamera/modules/alarm.py#L151-L152

Added lines #L151 - L152 were not covered by tests
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"}})
17 changes: 10 additions & 7 deletions kasa/smartcamera/smartcameramodule.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any, Final, cast

from ..exceptions import DeviceError, KasaException, SmartErrorCode
from ..modulemapping import ModuleName
from ..smart.smartmodule import SmartModule

if TYPE_CHECKING:
from . import modules
from .smartcamera import SmartCamera

_LOGGER = logging.getLogger(__name__)
Expand All @@ -17,12 +19,14 @@
class SmartCameraModule(SmartModule):
"""Base class for SMARTCAMERA modules."""

SmartCameraAlarm: Final[ModuleName[modules.Alarm]] = ModuleName("SmartCameraAlarm")

#: Query to execute during the main update cycle
QUERY_GETTER_NAME: str
#: Module name to be queried
QUERY_MODULE_NAME: str
#: Section name or names to be queried
QUERY_SECTION_NAMES: str | list[str]
QUERY_SECTION_NAMES: str | list[str] | None = None

REGISTERED_MODULES = {}

Expand All @@ -33,11 +37,10 @@ def query(self) -> dict:

Default implementation uses the raw query getter w/o parameters.
"""
return {
self.QUERY_GETTER_NAME: {
self.QUERY_MODULE_NAME: {"name": self.QUERY_SECTION_NAMES}
}
}
section_names = (
{"name": self.QUERY_SECTION_NAMES} if self.QUERY_SECTION_NAMES else {}
)
return {self.QUERY_GETTER_NAME: {self.QUERY_MODULE_NAME: section_names}}

async def call(self, method: str, params: dict | None = None) -> dict:
"""Call a method.
Expand Down
71 changes: 35 additions & 36 deletions tests/fakeprotocol_smartcamera.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,43 +105,28 @@ def _get_param_set_value(info: dict, set_keys: list[str], value):
info = info[key]
info[set_keys[-1]] = value

# Setters for when there's not a simple mapping of setters to getters
SETTERS = {
("system", "sys", "dev_alias"): [
"getDeviceInfo",
"device_info",
"basic_info",
"device_alias",
],
("lens_mask", "lens_mask_info", "enabled"): [
"getLensMaskConfig",
"lens_mask",
"lens_mask_info",
"enabled",
],
# setTimezone maps to getClockStatus
("system", "clock_status", "seconds_from_1970"): [
"getClockStatus",
"system",
"clock_status",
"seconds_from_1970",
],
# setTimezone maps to getClockStatus
("system", "clock_status", "local_time"): [
"getClockStatus",
"system",
"clock_status",
"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):
Expand All @@ -154,27 +139,41 @@ async def _send_request(self, request_dict: dict):
)

if method[:3] == "set":
get_method = "g" + method[1:]
for key, val in request_dict.items():
if key != "method":
# key is params for multi request and the actual params
# for single requests
if key == "params":
module = next(iter(val))
val = val[module]
if key == "method":
continue
# key is params for multi request and the actual params
# for single requests
if key == "params":
module = next(iter(val))
val = val[module]
else:
module = key
section = next(iter(val))
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:
module = key
section = next(iter(val))
skey_val = val[section]
for skey, sval in skey_val.items():
section_key = skey
section_value = sval
if setter_keys := self.SETTERS.get(
(module, section, section_key)
):
self._get_param_set_value(info, setter_keys, section_value)
else:
return {"error_code": -1}
return {"error_code": -1}
break
for skey, sval in skey_val.items():
section_key = skey
section_value = sval
if setter_keys := self.SETTERS.get((module, section, section_key)):
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:
return {"error_code": -1}
break
return {"error_code": 0}
elif method[:3] == "get":
params = request_dict.get("params")
Expand Down
Empty file.
40 changes: 40 additions & 0 deletions tests/smartcamera/modules/test_alarm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Tests for smart camera devices."""

from __future__ import annotations

from kasa import Device
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

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

finally:
await alarm.set_alarm_volume(original_volume)
await alarm.set_alarm_duration(original_duration)
await dev.update()
Loading
0