Skip to content

Commit

Permalink
Add basic Philips Moonlight support (Closes: rytilahti#351)
Browse files Browse the repository at this point in the history
  • Loading branch information
syssi committed Aug 10, 2018
1 parent 834bcb3 commit c603e3b
Show file tree
Hide file tree
Showing 3 changed files with 349 additions and 0 deletions.
1 change: 1 addition & 0 deletions miio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
180 changes: 180 additions & 0 deletions miio/philips_moonlight.py
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])
168 changes: 168 additions & 0 deletions miio/tests/test_philips_moonlight.py
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)

0 comments on commit c603e3b

Please sign in to comment.