diff --git a/miio/__init__.py b/miio/__init__.py index a60148c7e..16db1697a 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -11,6 +11,7 @@ from miio.fan import Fan from miio.philips_bulb import PhilipsBulb from miio.philips_eyecare import PhilipsEyecare +from miio.philips_moonlight import PhilipsMoonlight from miio.powerstrip import PowerStrip from miio.protocol import Message, Utils from miio.vacuum import Vacuum, VacuumException diff --git a/miio/philips_moonlight.py b/miio/philips_moonlight.py new file mode 100644 index 000000000..6fb926289 --- /dev/null +++ b/miio/philips_moonlight.py @@ -0,0 +1,180 @@ +import logging +from collections import defaultdict +from typing import Any, Dict + +import click + +from .click_common import command, format_output +from .device import Device, DeviceException + +_LOGGER = logging.getLogger(__name__) + + +class PhilipsMoonlightException(DeviceException): + pass + + +class PhilipsMoonlightStatus: + """Container for status reports from Xiaomi Philips Zhirui Bedside Lamp.""" + + def __init__(self, data: Dict[str, Any]) -> None: + """ + Response of a Moonlight (philips.light.moonlight): + + {'pow': 'off', 'sta': 0, 'bri': 1, 'rgb': 16741971, 'cct': 1, 'snm': 0, 'spr': 0, + 'spt': 15, 'wke': 0, 'bl': 1, 'ms': 1, 'mb': 1, 'wkp': [0, 24, 0]} + """ + self.data = data + + @property + def power(self) -> str: + return self.data["pow"] + + @property + def is_on(self) -> bool: + return self.power == "on" + + @property + def brightness(self) -> int: + return self.data["bri"] + + @property + def color_temperature(self) -> int: + return self.data["cct"] + + @property + def scene(self) -> int: + return self.data["snm"] + + def __repr__(self) -> str: + s = "" % \ + (self.power, self.brightness, + self.color_temperature, self.scene) + return s + + def __json__(self): + return self.data + + +class PhilipsMoonlight(Device): + """Main class representing Xiaomi Philips Zhirui Bedside Lamp. + + Not yet implemented features/methods: + + add_mb # Add miband + get_band_period # Bracelet work time + get_mb_rssi # Miband RSSI + get_mb_mac # Miband MAC address + enable_mibs + set_band_period + miIO.bleStartSearchBand + miIO.bleGetNearbyBandList + + enable_sub_voice # Sub voice control? + enable_voice # Voice control + + skip_breath + set_sleep_time + set_wakeup_time + en_sleep + en_wakeup + go_night # Night light / read mode + get_wakeup_time + enable_bl # Night light + set_brirgb # Brightness & RGB + set_rgb # RGB + + """ + + @command( + default_output=format_output( + "", + "Power: {result.power}\n" + "Brightness: {result.brightness}\n" + "Color temperature: {result.color_temperature}\n" + "Scene: {result.scene}\n" + ) + ) + def status(self) -> PhilipsMoonlightStatus: + """Retrieve properties.""" + properties = ['pow', 'sta', 'bri', 'rgb', 'cct', 'snm', 'spr', 'spt', 'wke', 'bl', 'ms', + 'mb', 'wkp'] + values = self.send( + "get_prop", + properties + ) + + properties_count = len(properties) + values_count = len(values) + if properties_count != values_count: + _LOGGER.debug( + "Count (%s) of requested properties does not match the " + "count (%s) of received values.", + properties_count, values_count) + + return PhilipsMoonlightStatus( + defaultdict(lambda: None, zip(properties, values))) + + @command( + default_output=format_output("Powering on"), + ) + def on(self): + """Power on.""" + return self.send("set_power", ["on"]) + + @command( + default_output=format_output("Powering off"), + ) + def off(self): + """Power off.""" + return self.send("set_power", ["off"]) + + @command( + click.argument("level", type=int), + default_output=format_output("Setting brightness to {level}") + ) + def set_brightness(self, level: int): + """Set brightness level.""" + if level < 1 or level > 100: + raise PhilipsMoonlightException("Invalid brightness: %s" % level) + + return self.send("set_bright", [level]) + + @command( + click.argument("level", type=int), + default_output=format_output("Setting color temperature to {level}") + ) + def set_color_temperature(self, level: int): + """Set Correlated Color Temperature.""" + if level < 1 or level > 100: + raise PhilipsMoonlightException("Invalid color temperature: %s" % level) + + return self.send("set_cct", [level]) + + @command( + click.argument("brightness", type=int), + click.argument("cct", type=int), + default_output=format_output( + "Setting brightness to {brightness} and color temperature to {cct}") + ) + def set_brightness_and_color_temperature(self, brightness: int, cct: int): + """Set brightness level and the correlated color temperature.""" + if brightness < 1 or brightness > 100: + raise PhilipsMoonlightException("Invalid brightness: %s" % brightness) + + if cct < 1 or cct > 100: + raise PhilipsMoonlightException("Invalid color temperature: %s" % cct) + + return self.send("set_bricct", [brightness, cct]) + + @command( + click.argument("number", type=int), + default_output=format_output("Setting fixed scene to {number}") + ) + def set_scene(self, number: int): + """Set scene number.""" + if number < 1 or number > 4: + raise PhilipsMoonlightException("Invalid fixed scene number: %s" % number) + + return self.send("apply_fixed_scene", [number]) diff --git a/miio/tests/test_philips_moonlight.py b/miio/tests/test_philips_moonlight.py new file mode 100644 index 000000000..b5f3100e4 --- /dev/null +++ b/miio/tests/test_philips_moonlight.py @@ -0,0 +1,168 @@ +from unittest import TestCase + +import pytest + +from miio import PhilipsMoonlight +from miio.philips_moonlight import PhilipsMoonlightStatus, PhilipsMoonlightException +from .dummies import DummyDevice + + +class DummyPhilipsMoonlight(DummyDevice, PhilipsMoonlight): + def __init__(self, *args, **kwargs): + self.state = { + 'pow': 'off', + 'sta': 0, + 'bri': 1, + 'rgb': 16741971, + 'cct': 1, + 'snm': 0, + 'spr': 0, + 'spt': 15, + 'wke': 0, + 'bl': 1, + 'ms': 1, + 'mb': 1, + 'wkp': [0, 24, 0] + } + self.return_values = { + 'get_prop': self._get_state, + 'set_power': lambda x: self._set_state("power", x), + 'set_bright': lambda x: self._set_state("bright", x), + 'set_cct': lambda x: self._set_state("cct", x), + 'apply_fixed_scene': lambda x: self._set_state("snm", x), + 'set_bricct': lambda x: ( + self._set_state('bright', [x[0]]), + self._set_state('cct', [x[1]]) + ) + } + super().__init__(args, kwargs) + + +@pytest.fixture(scope="class") +def philips_moonlight(request): + request.cls.device = DummyPhilipsMoonlight() + # TODO add ability to test on a real device + + +@pytest.mark.usefixtures("philips_moonlight") +class TestPhilipsMoonlight(TestCase): + def is_on(self): + return self.device.status().is_on + + def state(self): + return self.device.status() + + def test_on(self): + self.device.off() # ensure off + assert self.is_on() is False + + self.device.on() + assert self.is_on() is True + + def test_off(self): + self.device.on() # ensure on + assert self.is_on() is True + + self.device.off() + assert self.is_on() is False + + def test_status(self): + self.device._reset_state() + + assert repr(self.state()) == repr(PhilipsMoonlightStatus(self.device.start_state)) + + assert self.is_on() is True + assert self.state().brightness == self.device.start_state["bri"] + assert self.state().color_temperature == self.device.start_state["cct"] + assert self.state().scene == self.device.start_state["snm"] + + def test_set_brightness(self): + def brightness(): + return self.device.status().brightness + + self.device.set_brightness(1) + assert brightness() == 1 + self.device.set_brightness(50) + assert brightness() == 50 + self.device.set_brightness(100) + + with pytest.raises(PhilipsMoonlightException): + self.device.set_brightness(-1) + + with pytest.raises(PhilipsMoonlightException): + self.device.set_brightness(0) + + with pytest.raises(PhilipsMoonlightException): + self.device.set_brightness(101) + + def test_set_color_temperature(self): + def color_temperature(): + return self.device.status().color_temperature + + self.device.set_color_temperature(20) + assert color_temperature() == 20 + self.device.set_color_temperature(30) + assert color_temperature() == 30 + self.device.set_color_temperature(10) + + with pytest.raises(PhilipsMoonlightException): + self.device.set_color_temperature(-1) + + with pytest.raises(PhilipsMoonlightException): + self.device.set_color_temperature(0) + + with pytest.raises(PhilipsMoonlightException): + self.device.set_color_temperature(101) + + def test_set_brightness_and_color_temperature(self): + def color_temperature(): + return self.device.status().color_temperature + + def brightness(): + return self.device.status().brightness + + self.device.set_brightness_and_color_temperature(20, 21) + assert brightness() == 20 + assert color_temperature() == 21 + self.device.set_brightness_and_color_temperature(31, 30) + assert brightness() == 31 + assert color_temperature() == 30 + self.device.set_brightness_and_color_temperature(10, 11) + assert brightness() == 10 + assert color_temperature() == 11 + + with pytest.raises(PhilipsMoonlightException): + self.device.set_brightness_and_color_temperature(-1, 10) + + with pytest.raises(PhilipsMoonlightException): + self.device.set_brightness_and_color_temperature(10, -1) + + with pytest.raises(PhilipsMoonlightException): + self.device.set_brightness_and_color_temperature(0, 10) + + with pytest.raises(PhilipsMoonlightException): + self.device.set_brightness_and_color_temperature(10, 0) + + with pytest.raises(PhilipsMoonlightException): + self.device.set_brightness_and_color_temperature(101, 10) + + with pytest.raises(PhilipsMoonlightException): + self.device.set_brightness_and_color_temperature(10, 101) + + def test_set_scene(self): + def scene(): + return self.device.status().scene + + self.device.set_scene(1) + assert scene() == 1 + self.device.set_scene(2) + assert scene() == 2 + + with pytest.raises(PhilipsMoonlightException): + self.device.set_scene(-1) + + with pytest.raises(PhilipsMoonlightException): + self.device.set_scene(0) + + with pytest.raises(PhilipsMoonlightException): + self.device.set_scene(5)