forked from rytilahti/python-miio
-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add basic Philips Moonlight support (Closes: rytilahti#351)
- Loading branch information
Showing
3 changed files
with
349 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 = "<PhilipsMoonlightStatus power=%s, brightness=%s, " \ | ||
"color_temperature=%s, scene=%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]) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |