diff --git a/README.rst b/README.rst index d756a241f..ddbd75987 100644 --- a/README.rst +++ b/README.rst @@ -26,6 +26,7 @@ Supported devices - :doc:`Xiaomi Philips LED Ceiling Lamp ` (:class:`miio.ceil`) - Xiaomi Philips LED Ball Lamp (:class:`miio.philips_bulb`) - Xiaomi Philips Zhirui Smart LED Bulb E14 Candle Lamp (:class:`miio.philips_bulb`) +- Xiaomi Philips Zhirui Bedroom Smart Lamp (:class:`miio.philips_moonlight`) - Xiaomi Universal IR Remote Controller (Chuangmi IR) (:class:`miio.chuangmi_ir`) - Xiaomi Mi Smart Pedestal Fan V2, V3, SA1 and ZA1 (:class:`miio.fan`) - Xiaomi Mi Air Humidifier (:class:`miio.airhumidifier`) diff --git a/miio/__init__.py b/miio/__init__.py index dbfc4c1f4..729253362 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -12,6 +12,7 @@ from miio.fan import (Fan, FanV2, FanSA1) 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/discovery.py b/miio/discovery.py index 87116f3a2..957cdc02f 100644 --- a/miio/discovery.py +++ b/miio/discovery.py @@ -8,9 +8,9 @@ import zeroconf from . import (Device, Vacuum, ChuangmiPlug, PowerStrip, AirPurifier, AirFresh, Ceil, - PhilipsBulb, PhilipsEyecare, ChuangmiIr, AirHumidifier, - WaterPurifier, WifiSpeaker, WifiRepeater, Yeelight, Fan, Cooker, - AirConditioningCompanion) + PhilipsBulb, PhilipsEyecare, PhilipsMoonlight, ChuangmiIr, + AirHumidifier, WaterPurifier, WifiSpeaker, WifiRepeater, + Yeelight, Fan, Cooker, AirConditioningCompanion) from .chuangmi_plug import (MODEL_CHUANGMI_PLUG_V1, MODEL_CHUANGMI_PLUG_V3, MODEL_CHUANGMI_PLUG_M1, ) @@ -51,6 +51,7 @@ "philips-light-ceiling": Ceil, "philips-light-zyceiling": Ceil, "philips-light-sread1": PhilipsEyecare, # name needs to be checked + "philips-light-moonlight": PhilipsMoonlight, # name needs to be checked "xiaomi-wifispeaker-v1": WifiSpeaker, # name needs to be checked "xiaomi-repeater-v1": WifiRepeater, # name needs to be checked "xiaomi-repeater-v3": WifiRepeater, # name needs to be checked diff --git a/miio/philips_moonlight.py b/miio/philips_moonlight.py new file mode 100644 index 000000000..366d65e17 --- /dev/null +++ b/miio/philips_moonlight.py @@ -0,0 +1,251 @@ +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 rgb(self) -> int: + return self.data["rgb"] + + @property + def scene(self) -> int: + return self.data["snm"] + + @property + def sleep_assistant(self) -> int: + """ + Example values: + + 0: Unknown + 1: Unknown + 2: Sleep assistant enabled + 3: Awake + """ + return self.data["sta"] + + @property + def sleep_off_time(self) -> int: + return self.data["spr"] + + @property + def total_assistant_sleep_time(self) -> int: + return self.data["spt"] + + @property + def brand_sleep(self) -> bool: + # sp_sleep_open? + return self.data["ms"] == 1 + + @property + def brand(self) -> bool: + # sp_xm_bracelet? + return self.data["mb"] == 1 + + @property + def wake_up_time(self) -> [int, int, int]: + # Example: [weekdays?, hour, minute] + return self.data["wkp"] + + def __repr__(self) -> str: + s = "" % \ + (self.power, + self.brightness, + self.color_temperature, + self.rgb, + 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 + + """ + + @command( + default_output=format_output( + "", + "Power: {result.power}\n" + "Brightness: {result.brightness}\n" + "Color temperature: {result.color_temperature}\n" + "RGB: {result.rgb}\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("rgb", type=int), + default_output=format_output("Setting color to {rgb}") + ) + def set_rgb(self, rgb): + """Set color in encoded RGB.""" + if rgb < 0 or rgb > 16777215: + raise PhilipsMoonlightException("Invalid color: %s" % rgb) + + return self.send("set_rgb", [rgb]) + + @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("brightness", type=int), + click.argument("rgb", type=int), + default_output=format_output( + "Setting brightness to {brightness} and color to {rgb}") + ) + def set_brightness_and_rgb(self, brightness: int, rgb: int): + """Set brightness level and the color.""" + if brightness < 1 or brightness > 100: + raise PhilipsMoonlightException("Invalid brightness: %s" % brightness) + + if rgb < 0 or rgb > 16777215: + raise PhilipsMoonlightException("Invalid color: %s" % rgb) + + return self.send("set_brirgb", [brightness, rgb]) + + @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..4f4e06c16 --- /dev/null +++ b/miio/tests/test_philips_moonlight.py @@ -0,0 +1,223 @@ +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': 'on', + '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("pow", x), + 'set_bright': lambda x: self._set_state("bri", x), + 'set_cct': lambda x: self._set_state("cct", x), + 'set_rgb': lambda x: self._set_state("rgb", x), + 'apply_fixed_scene': lambda x: self._set_state("snm", x), + 'set_bricct': lambda x: ( + self._set_state('bri', [x[0]]), + self._set_state('cct', [x[1]]) + ), + 'set_brirgb': lambda x: ( + self._set_state('bri', [x[0]]), + self._set_state('rgb', [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().rgb == self.device.start_state["rgb"] + 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_rgb(self): + def rgb(): + return self.device.status().rgb + + self.device.set_rgb(1) + assert rgb() == 1 + self.device.set_rgb(16711680) + assert rgb() == 16711680 + self.device.set_rgb(16777215) + assert rgb() == 16777215 + + with pytest.raises(PhilipsMoonlightException): + self.device.set_rgb(-1) + + with pytest.raises(PhilipsMoonlightException): + self.device.set_rgb(16777216) + + 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_brightness_and_rgb(self): + def brightness(): + return self.device.status().brightness + + def rgb(): + return self.device.status().rgb + + self.device.set_brightness_and_rgb(20, 0) + assert brightness() == 20 + assert rgb() == 0 + self.device.set_brightness_and_rgb(31, 16711680) + assert brightness() == 31 + assert rgb() == 16711680 + self.device.set_brightness_and_rgb(100, 16777215) + assert brightness() == 100 + assert rgb() == 16777215 + + with pytest.raises(PhilipsMoonlightException): + self.device.set_brightness_and_rgb(-1, 10) + + with pytest.raises(PhilipsMoonlightException): + self.device.set_brightness_and_rgb(10, -1) + + with pytest.raises(PhilipsMoonlightException): + self.device.set_brightness_and_rgb(0, 10) + + with pytest.raises(PhilipsMoonlightException): + self.device.set_brightness_and_rgb(101, 10) + + with pytest.raises(PhilipsMoonlightException): + self.device.set_brightness_and_rgb(10, 16777216) + + 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)