Skip to content

Add common alarm interface #1479

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 28 commits into from
Jan 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
0f7e007
Allow passing alarm parameter overrides
rytilahti Dec 4, 2024
8323753
Expose alarm_duration, fix play setter signature
rytilahti Dec 5, 2024
ee82c99
Add tests
rytilahti Dec 5, 2024
8a2ed72
De-nest get_alarm_configure in fake protocol
rytilahti Dec 5, 2024
000ae50
Add value checks
rytilahti Dec 5, 2024
2fed0ea
Fix tests
rytilahti Dec 5, 2024
5a7ad6e
One more try
rytilahti Dec 5, 2024
f6119e3
update
rytilahti Dec 5, 2024
a2b1193
test exceptions
rytilahti Dec 5, 2024
0c58b5e
constify max duration
rytilahti Dec 5, 2024
dbd80e6
Add mute volume and allow passing volume as integer
rytilahti Dec 13, 2024
a621761
Merge remote-tracking branch 'upstream/master' into feat/improve_alarm
sdb9696 Jan 23, 2025
b1f7754
Add range to alarm_volume feature
sdb9696 Jan 23, 2025
74cf44f
Add common alarm interface
sdb9696 Jan 23, 2025
5bc843c
Merge branch 'master' into feat/improve_alarm
sdb9696 Jan 24, 2025
d7fc557
Merge remote-tracking branch 'upstream/master' into feat/common_alarm
sdb9696 Jan 24, 2025
72750ef
Merge remote-tracking branch 'upstream/feat/improve_alarm' into feat/…
sdb9696 Jan 24, 2025
3d1a6c3
Merge branch 'master' into feat/improve_alarm
sdb9696 Jan 24, 2025
32404dd
Merge branch 'feat/improve_alarm' into feat/common_alarm
sdb9696 Jan 24, 2025
07a4e43
Merge branch 'master' into feat/improve_alarm
sdb9696 Jan 26, 2025
7f5046b
Add alarm_volume_number feature
sdb9696 Jan 26, 2025
5d3d082
Merge remote-tracking branch 'upstream/feat/improve_alarm' into feat/…
sdb9696 Jan 26, 2025
233940a
Fix tests
sdb9696 Jan 26, 2025
d819143
Merge branch 'feat/improve_alarm' into feat/common_alarm
sdb9696 Jan 26, 2025
5be4375
Rename feature
sdb9696 Jan 26, 2025
d0216cf
Merge remote-tracking branch 'upstream/feat/improve_alarm' into feat/…
sdb9696 Jan 26, 2025
d5fbb7c
Update docstrings
sdb9696 Jan 26, 2025
49300bf
Merge remote-tracking branch 'upstream/master' into feat/common_alarm
sdb9696 Jan 26, 2025
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
2 changes: 2 additions & 0 deletions kasa/interfaces/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Package for interfaces."""

from .alarm import Alarm
from .childsetup import ChildSetup
from .energy import Energy
from .fan import Fan
Expand All @@ -11,6 +12,7 @@
from .time import Time

__all__ = [
"Alarm",
"ChildSetup",
"Fan",
"Energy",
Expand Down
75 changes: 75 additions & 0 deletions kasa/interfaces/alarm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Module for base alarm module."""

from __future__ import annotations

from abc import ABC, abstractmethod
from typing import Annotated

from ..module import FeatureAttribute, Module


class Alarm(Module, ABC):
"""Base interface to represent an alarm module."""

@property
@abstractmethod
def alarm_sound(self) -> Annotated[str, FeatureAttribute()]:
"""Return current alarm sound."""

@abstractmethod
async def set_alarm_sound(self, sound: str) -> Annotated[dict, FeatureAttribute()]:
"""Set alarm sound.

See *alarm_sounds* for list of available sounds.
"""

@property
@abstractmethod
def alarm_sounds(self) -> list[str]:
"""Return list of available alarm sounds."""

@property
@abstractmethod
def alarm_volume(self) -> Annotated[int, FeatureAttribute()]:
"""Return alarm volume."""

@abstractmethod
async def set_alarm_volume(
self, volume: int
) -> Annotated[dict, FeatureAttribute()]:
"""Set alarm volume."""

@property
@abstractmethod
def alarm_duration(self) -> Annotated[int, FeatureAttribute()]:
"""Return alarm duration."""

@abstractmethod
async def set_alarm_duration(
self, duration: int
) -> Annotated[dict, FeatureAttribute()]:
"""Set alarm duration."""

@property
@abstractmethod
def active(self) -> bool:
"""Return true if alarm is active."""

@abstractmethod
async def play(
self,
*,
duration: int | None = None,
volume: int | None = None,
sound: str | None = None,
) -> dict:
"""Play alarm.

The optional *duration*, *volume*, and *sound* to override the device settings.
*duration* is in seconds.
See *alarm_sounds* for the list of sounds available for the device.
"""

@abstractmethod
async def stop(self) -> dict:
"""Stop alarm."""
2 changes: 1 addition & 1 deletion kasa/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ class Module(ABC):
"""

# Common Modules
Alarm: Final[ModuleName[interfaces.Alarm]] = ModuleName("Alarm")
ChildSetup: Final[ModuleName[interfaces.ChildSetup]] = ModuleName("ChildSetup")
Energy: Final[ModuleName[interfaces.Energy]] = ModuleName("Energy")
Fan: Final[ModuleName[interfaces.Fan]] = ModuleName("Fan")
Expand All @@ -116,7 +117,6 @@ class Module(ABC):
IotCloud: Final[ModuleName[iot.Cloud]] = ModuleName("cloud")

# SMART only Modules
Alarm: Final[ModuleName[smart.Alarm]] = ModuleName("Alarm")
AutoOff: Final[ModuleName[smart.AutoOff]] = ModuleName("AutoOff")
BatterySensor: Final[ModuleName[smart.BatterySensor]] = ModuleName("BatterySensor")
Brightness: Final[ModuleName[smart.Brightness]] = ModuleName("Brightness")
Expand Down
3 changes: 2 additions & 1 deletion kasa/smart/modules/alarm.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import TYPE_CHECKING, Annotated, Literal, TypeAlias

from ...feature import Feature
from ...interfaces import Alarm as AlarmInterface
from ...module import FeatureAttribute
from ..smartmodule import SmartModule

Expand All @@ -24,7 +25,7 @@
AlarmVolume: TypeAlias = Literal["mute", "low", "normal", "high"]


class Alarm(SmartModule):
class Alarm(SmartModule, AlarmInterface):
"""Implementation of alarm module."""

REQUIRED_COMPONENT = "alarm"
Expand Down
75 changes: 57 additions & 18 deletions kasa/smartcam/modules/alarm.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from ...feature import Feature
from ...interfaces import Alarm as AlarmInterface
from ...smart.smartmodule import allow_update_after
from ..smartcammodule import SmartCamModule

Expand All @@ -13,12 +14,9 @@
VOLUME_MAX = 10


class Alarm(SmartCamModule):
class Alarm(SmartCamModule, AlarmInterface):
"""Implementation of alarm module."""

# Needs a different name to avoid clashing with SmartAlarm
NAME = "SmartCamAlarm"

REQUIRED_COMPONENT = "siren"
QUERY_GETTER_NAME = "getSirenStatus"
QUERY_MODULE_NAME = "siren"
Expand Down Expand Up @@ -117,11 +115,8 @@ async def set_alarm_sound(self, sound: str) -> dict:

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}})
config = self._validate_and_get_config(sound=sound)
return await self.call("setSirenConfig", {"siren": config})

@property
def alarm_sounds(self) -> list[str]:
Expand All @@ -139,9 +134,8 @@ def alarm_volume(self) -> int:
@allow_update_after
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)}})
config = self._validate_and_get_config(volume=volume)
return await self.call("setSirenConfig", {"siren": config})

@property
def alarm_duration(self) -> int:
Expand All @@ -151,20 +145,65 @@ def alarm_duration(self) -> int:
@allow_update_after
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}})
config = self._validate_and_get_config(duration=duration)
return await self.call("setSirenConfig", {"siren": config})

@property
def active(self) -> bool:
"""Return true if alarm is active."""
return self.data["getSirenStatus"]["status"] != "off"

async def play(self) -> dict:
"""Play alarm."""
async def play(
self,
*,
duration: int | None = None,
volume: int | None = None,
sound: str | None = None,
) -> dict:
"""Play alarm.

The optional *duration*, *volume*, and *sound* to override the device settings.
*duration* is in seconds.
See *alarm_sounds* for the list of sounds available for the device.
"""
if config := self._validate_and_get_config(
duration=duration, volume=volume, sound=sound
):
await self.call("setSirenConfig", {"siren": config})

return await self.call("setSirenStatus", {"siren": {"status": "on"}})

async def stop(self) -> dict:
"""Stop alarm."""
return await self.call("setSirenStatus", {"siren": {"status": "off"}})

def _validate_and_get_config(
self,
*,
duration: int | None = None,
volume: int | None = None,
sound: str | None = None,
) -> dict:
if sound and sound not in self.alarm_sounds:
raise ValueError(
f"sound must be one of {', '.join(self.alarm_sounds)}: {sound}"
)

if duration is not None and (
duration < DURATION_MIN or duration > DURATION_MAX
):
msg = f"duration must be between {DURATION_MIN} and {DURATION_MAX}"
raise ValueError(msg)

if volume is not None and (volume < VOLUME_MIN or volume > VOLUME_MAX):
raise ValueError(f"volume must be between {VOLUME_MIN} and {VOLUME_MAX}")

config: dict[str, str | int] = {}
if sound:
config["siren_type"] = sound
if duration is not None:
config["duration"] = duration
if volume is not None:
config["volume"] = str(volume)

return config
12 changes: 7 additions & 5 deletions tests/fakeprotocol_smartcam.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,12 +276,14 @@ async def _send_request(self, request_dict: dict):
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:
updates = {
k: v for k, v in val.items() if k in info.get(get_method, {})
}
if len(updates) != len(val):
# All keys to update must already be in the getter
return {"error_code": -1}
info[get_method] = {**info[get_method], **updates}

break
for skey, sval in skey_val.items():
section_key = skey
Expand Down
22 changes: 17 additions & 5 deletions tests/smartcam/modules/test_alarm.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,21 @@

import pytest

from kasa import Device
from kasa import Device, Module
from kasa.smartcam.modules.alarm import (
DURATION_MAX,
DURATION_MIN,
VOLUME_MAX,
VOLUME_MIN,
)
from kasa.smartcam.smartcammodule import SmartCamModule

from ...conftest import hub_smartcam


@hub_smartcam
async def test_alarm(dev: Device):
"""Test device alarm."""
alarm = dev.modules.get(SmartCamModule.SmartCamAlarm)
alarm = dev.modules.get(Module.Alarm)
assert alarm

original_duration = alarm.alarm_duration
Expand Down Expand Up @@ -63,6 +62,19 @@ async def test_alarm(dev: Device):
await dev.update()
assert alarm.alarm_sound == new_sound

# Test play parameters
await alarm.play(
duration=original_duration, volume=original_volume, sound=original_sound
)
await dev.update()
assert alarm.active
assert alarm.alarm_sound == original_sound
assert alarm.alarm_duration == original_duration
assert alarm.alarm_volume == original_volume
await alarm.stop()
await dev.update()
assert not alarm.active

finally:
await alarm.set_alarm_volume(original_volume)
await alarm.set_alarm_duration(original_duration)
Expand All @@ -73,7 +85,7 @@ async def test_alarm(dev: Device):
@hub_smartcam
async def test_alarm_invalid_setters(dev: Device):
"""Test device alarm invalid setter values."""
alarm = dev.modules.get(SmartCamModule.SmartCamAlarm)
alarm = dev.modules.get(Module.Alarm)
assert alarm

# test set sound invalid
Expand All @@ -95,7 +107,7 @@ async def test_alarm_invalid_setters(dev: Device):
@hub_smartcam
async def test_alarm_features(dev: Device):
"""Test device alarm features."""
alarm = dev.modules.get(SmartCamModule.SmartCamAlarm)
alarm = dev.modules.get(Module.Alarm)
assert alarm

original_duration = alarm.alarm_duration
Expand Down
Loading