diff --git a/.gitignore b/.gitignore index 753c426f5..1d698d1bc 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ xknx.egg-info/ .coverage venv .idea/ +.vscode/ diff --git a/home-assistant-plugin/custom_components/light/xknx.py b/home-assistant-plugin/custom_components/light/xknx.py index 25785e04c..47e18ec80 100644 --- a/home-assistant-plugin/custom_components/light/xknx.py +++ b/home-assistant-plugin/custom_components/light/xknx.py @@ -9,8 +9,9 @@ from custom_components.xknx import ATTR_DISCOVER_DEVICES, DATA_XKNX from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_HS_COLOR, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, Light) + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_WHITE_VALUE, PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_WHITE_VALUE, + Light) from homeassistant.const import CONF_NAME from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -22,10 +23,21 @@ CONF_BRIGHTNESS_STATE_ADDRESS = 'brightness_state_address' CONF_COLOR_ADDRESS = 'color_address' CONF_COLOR_STATE_ADDRESS = 'color_state_address' +CONF_KELVIN_ADDRESS = 'color_temperature_address' +CONF_KELVIN_STATE_ADDRESS = 'color_temperature_state_address' +CONF_WHITE_VALUE_ADDRESS = 'white_value_address' +CONF_WHITE_VALUE_STATE_ADDRESS = 'white_value_state_address' +CONF_MIN_KELVIN = 'min_kelvin' +CONF_MAX_KELVIN = 'max_kelvin' DEFAULT_NAME = 'XKNX Light' DEFAULT_COLOR = [255, 255, 255] DEFAULT_BRIGHTNESS = 255 +DEFAULT_COLOR_TEMPERATURE = 333 # 3000 K +DEFAULT_MIN_MIREDS = 166 # 6000 K +DEFAULT_MAX_MIREDS = 370 # 2700 K +DEFAULT_MIN_KELVIN = 2700 +DEFAULT_MAX_KELVIN = 6000 DEPENDENCIES = ['xknx'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -36,6 +48,12 @@ vol.Optional(CONF_BRIGHTNESS_STATE_ADDRESS): cv.string, vol.Optional(CONF_COLOR_ADDRESS): cv.string, vol.Optional(CONF_COLOR_STATE_ADDRESS): cv.string, + vol.Optional(CONF_KELVIN_ADDRESS): cv.string, + vol.Optional(CONF_KELVIN_STATE_ADDRESS): cv.string, + vol.Optional(CONF_WHITE_VALUE_ADDRESS): cv.string, + vol.Optional(CONF_WHITE_VALUE_STATE_ADDRESS): cv.string, + vol.Optional(CONF_MIN_KELVIN): vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional(CONF_MAX_KELVIN): vol.All(vol.Coerce(int), vol.Range(min=1)), }) @@ -71,7 +89,13 @@ def async_add_entities_config(hass, config, async_add_entities): group_address_brightness_state=config.get( CONF_BRIGHTNESS_STATE_ADDRESS), group_address_color=config.get(CONF_COLOR_ADDRESS), - group_address_color_state=config.get(CONF_COLOR_STATE_ADDRESS)) + group_address_color_state=config.get(CONF_COLOR_STATE_ADDRESS), + group_address_tunable_white=config.get(CONF_WHITE_VALUE_ADDRESS), + group_address_tunable_white_state=config.get(CONF_WHITE_VALUE_STATE_ADDRESS), + group_address_color_temperature=config.get(CONF_KELVIN_ADDRESS), + group_address_color_temperature_state=config.get(CONF_KELVIN_STATE_ADDRESS), + min_kelvin=config.get(CONF_MIN_KELVIN), + max_kelvin=config.get(CONF_MAX_KELVIN)) hass.data[DATA_XKNX].xknx.devices.add(light) async_add_entities([KNXLight(light)]) @@ -133,12 +157,54 @@ def hs_color(self): @property def color_temp(self): - """Return the CT color temperature.""" + """Return the color temperature in mireds.""" + if self.device.supports_color_temperature: + kelvin = self.device.current_color_temperature + return color_util.color_temperature_kelvin_to_mired(kelvin) \ + if kelvin is not None else DEFAULT_COLOR_TEMPERATURE return None @property def white_value(self): """Return the white value of this light between 0..255.""" + if self.device.supports_tunable_white: + return self.device.current_tunable_white + return None + + @property + def min_mireds(self): + """Return the coldest color temperature this light supports in mireds.""" + if self.device.supports_color_temperature: + kelvin = self.device.max_kelvin + return color_util.color_temperature_kelvin_to_mired(kelvin) \ + if kelvin is not None else DEFAULT_MIN_MIREDS + return None + + @property + def max_mireds(self): + """Return the warmest color temperature this light supports in mireds.""" + if self.device.supports_color_temperature: + kelvin = self.device.min_kelvin + return color_util.color_temperature_kelvin_to_mired(kelvin) \ + if kelvin is not None else DEFAULT_MAX_MIREDS + return None + + @property + def min_kelvin(self): + """Return the warmest color temperature this light supports in kelvin.""" + if self.device.supports_color_temperature: + kelvin = self.device.min_kelvin + return kelvin \ + if kelvin is not None else DEFAULT_MIN_KELVIN + return None + + @property + def max_kelvin(self): + """Return the coldest color temperature this light supports in kelvin.""" + if self.device.supports_color_temperature: + kelvin = self.device.max_kelvin + return kelvin \ + if kelvin is not None else DEFAULT_MAX_KELVIN return None @property @@ -164,12 +230,18 @@ def supported_features(self): flags |= SUPPORT_BRIGHTNESS if self.device.supports_color: flags |= SUPPORT_COLOR | SUPPORT_BRIGHTNESS + if self.device.supports_color_temperature: + flags |= SUPPORT_COLOR_TEMP + if self.device.supports_tunable_white: + flags |= SUPPORT_WHITE_VALUE return flags async def async_turn_on(self, **kwargs): """Turn the light on.""" brightness = int(kwargs.get(ATTR_BRIGHTNESS, self.brightness)) hs_color = kwargs.get(ATTR_HS_COLOR, self.hs_color) + mireds = kwargs.get(ATTR_COLOR_TEMP, self.color_temp) + tunable_white = int(kwargs.get(ATTR_WHITE_VALUE, self.white_value)) # fall back to default values, if required if brightness is None: @@ -179,6 +251,8 @@ async def async_turn_on(self, **kwargs): update_brightness = ATTR_BRIGHTNESS in kwargs update_color = ATTR_HS_COLOR in kwargs + update_color_temp = ATTR_COLOR_TEMP in kwargs + update_tunable_white = ATTR_WHITE_VALUE in kwargs # always only go one path for turning on (avoid conflicting changes # and weird effects) @@ -193,6 +267,18 @@ async def async_turn_on(self, **kwargs): # change RGB color (includes brightness) await self.device.set_color( color_util.color_hsv_to_RGB(*hs_color, brightness * 100 / 255)) + elif self.device.supports_color_temperature and \ + update_color_temp: + # change color temperature without ON telegram + kelvin = int(color_util.color_temperature_mired_to_kelvin(mireds)) + if kelvin > self.max_kelvin: + kelvin = self.max_kelvin + elif kelvin < self.min_kelvin: + kelvin = self.min_kelvin + await self.device.set_color_temperature(kelvin) + elif self.device.supports_tunable_white and \ + update_tunable_white: + await self.device.set_tunable_white(tunable_white) else: # no color/brightness change requested, so just turn it on await self.device.set_on() diff --git a/test/light_test.py b/test/light_test.py index be362b269..4fd76a601 100644 --- a/test/light_test.py +++ b/test/light_test.py @@ -65,6 +65,46 @@ def test_supports_color_false(self): group_address_switch='1/6/4') self.assertFalse(light.supports_color) + # + # TEST SUPPORT TUNABLE WHITE + # + def test_supports_tw_yes(self): + """Test supports_tw attribute with a light with tunable white function.""" + xknx = XKNX(loop=self.loop) + light = Light(xknx, + 'Diningroom.Light_1', + group_address_switch='1/6/4', + group_address_tunable_white='1/6/6') + self.assertTrue(light.supports_tunable_white) + + def test_supports_tw_no(self): + """Test supports_tw attribute with a Light without tunable white function.""" + xknx = XKNX(loop=self.loop) + light = Light(xknx, + 'Diningroom.Light_1', + group_address_switch='1/6/4') + self.assertFalse(light.supports_tunable_white) + + # + # TEST SUPPORT COLOR TEMPERATURE + # + def test_supports_color_temp_true(self): + """Test supports_color_temp attribute with a light with color temperature function.""" + xknx = XKNX(loop=self.loop) + light = Light(xknx, + 'Diningroom.Light_1', + group_address_switch='1/6/4', + group_address_color_temperature='1/6/6') + self.assertTrue(light.supports_color_temperature) + + def test_supports_color_temp_false(self): + """Test supports_color_temp attribute with a Light without color temperature function.""" + xknx = XKNX(loop=self.loop) + light = Light(xknx, + 'Diningroom.Light_1', + group_address_switch='1/6/4') + self.assertFalse(light.supports_color_temperature) + # # SYNC # @@ -75,10 +115,12 @@ def test_sync(self): name="TestLight", group_address_switch_state='1/2/3', group_address_brightness_state='1/2/5', - group_address_color_state='1/2/6') + group_address_color_state='1/2/6', + group_address_tunable_white_state='1/2/7', + group_address_color_temperature_state='1/2/8') self.loop.run_until_complete(asyncio.Task(light.sync(False))) - self.assertEqual(xknx.telegrams.qsize(), 3) + self.assertEqual(xknx.telegrams.qsize(), 5) telegram1 = xknx.telegrams.get_nowait() self.assertEqual(telegram1, @@ -92,6 +134,14 @@ def test_sync(self): self.assertEqual(telegram3, Telegram(GroupAddress('1/2/5'), TelegramType.GROUP_READ)) + telegram4 = xknx.telegrams.get_nowait() + self.assertEqual(telegram4, + Telegram(GroupAddress('1/2/7'), TelegramType.GROUP_READ)) + + telegram5 = xknx.telegrams.get_nowait() + self.assertEqual(telegram5, + Telegram(GroupAddress('1/2/8'), TelegramType.GROUP_READ)) + # # SYNC WITH STATE ADDRESS # @@ -105,10 +155,14 @@ def test_sync_state_address(self): group_address_brightness='1/2/5', group_address_brightness_state='1/2/6', group_address_color='1/2/7', - group_address_color_state='1/2/8') + group_address_color_state='1/2/8', + group_address_tunable_white='1/2/9', + group_address_tunable_white_state='1/2/10', + group_address_color_temperature='1/2/11', + group_address_color_temperature_state='1/2/12') self.loop.run_until_complete(asyncio.Task(light.sync(False))) - self.assertEqual(xknx.telegrams.qsize(), 3) + self.assertEqual(xknx.telegrams.qsize(), 5) telegram1 = xknx.telegrams.get_nowait() self.assertEqual(telegram1, @@ -119,6 +173,12 @@ def test_sync_state_address(self): telegram3 = xknx.telegrams.get_nowait() self.assertEqual(telegram3, Telegram(GroupAddress('1/2/6'), TelegramType.GROUP_READ)) + telegram4 = xknx.telegrams.get_nowait() + self.assertEqual(telegram4, + Telegram(GroupAddress('1/2/10'), TelegramType.GROUP_READ)) + telegram5 = xknx.telegrams.get_nowait() + self.assertEqual(telegram5, + Telegram(GroupAddress('1/2/12'), TelegramType.GROUP_READ)) # # TEST SET ON @@ -209,6 +269,62 @@ def test_set_color_not_possible(self): self.assertEqual(xknx.telegrams.qsize(), 0) mock_warn.assert_called_with('Colors not supported for device %s', 'TestLight') + # + # TEST SET TUNABLE WHITE + # + def test_set_tw(self): + """Test setting the tunable white value of a Light.""" + xknx = XKNX(loop=self.loop) + light = Light(xknx, + name="TestLight", + group_address_switch='1/2/3', + group_address_tunable_white='1/2/5') + self.loop.run_until_complete(asyncio.Task(light.set_tunable_white(23))) + self.assertEqual(xknx.telegrams.qsize(), 1) + telegram = xknx.telegrams.get_nowait() + self.assertEqual(telegram, + Telegram(GroupAddress('1/2/5'), payload=DPTArray(23))) + + def test_set_tw_unsupported(self): + """Test setting the tunable white value of a non tw Light.""" + # pylint: disable=invalid-name + xknx = XKNX(loop=self.loop) + light = Light(xknx, + name="TestLight", + group_address_switch='1/2/3') + with patch('logging.Logger.warning') as mock_warn: + self.loop.run_until_complete(asyncio.Task(light.set_tunable_white(23))) + self.assertEqual(xknx.telegrams.qsize(), 0) + mock_warn.assert_called_with('Tunable white not supported for device %s', 'TestLight') + + # + # TEST SET COLOR TEMPERATURE + # + def test_set_color_temp(self): + """Test setting the color temperature value of a Light.""" + xknx = XKNX(loop=self.loop) + light = Light(xknx, + name="TestLight", + group_address_switch='1/2/3', + group_address_color_temperature='1/2/5') + self.loop.run_until_complete(asyncio.Task(light.set_color_temperature(4000))) + self.assertEqual(xknx.telegrams.qsize(), 1) + telegram = xknx.telegrams.get_nowait() + self.assertEqual(telegram, + Telegram(GroupAddress('1/2/5'), payload=DPTArray((0x0F, 0xA0, )))) + + def test_set_color_temp_unsupported(self): + """Test setting the color temperature value of an unsupported Light.""" + # pylint: disable=invalid-name + xknx = XKNX(loop=self.loop) + light = Light(xknx, + name="TestLight", + group_address_switch='1/2/3') + with patch('logging.Logger.warning') as mock_warn: + self.loop.run_until_complete(asyncio.Task(light.set_color_temperature(4000))) + self.assertEqual(xknx.telegrams.qsize(), 0) + mock_warn.assert_called_with('Absolute Color Temperature not supported for device %s', 'TestLight') + # # TEST PROCESS # @@ -299,6 +415,78 @@ def test_process_color(self): self.loop.run_until_complete(asyncio.Task(light.process(telegram))) self.assertEqual(light.current_color, (23, 24, 25)) + def test_process_tunable_white(self): + """Test process / reading telegrams from telegram queue. Test if tunable white is processed.""" + xknx = XKNX(loop=self.loop) + light = Light(xknx, + name="TestLight", + group_address_switch='1/2/3', + group_address_tunable_white='1/2/5') + self.assertEqual(light.current_tunable_white, None) + + telegram = Telegram(GroupAddress('1/2/5'), payload=DPTArray(23)) + self.loop.run_until_complete(asyncio.Task(light.process(telegram))) + self.assertEqual(light.current_tunable_white, 23) + + def test_process_tunable_white_wrong_payload(self): + """Test process wrong telegrams. (wrong payload type).""" + xknx = XKNX(loop=self.loop) + light = Light(xknx, + name="TestLight", + group_address_switch='1/2/3', + group_address_tunable_white='1/2/5') + telegram = Telegram(GroupAddress('1/2/5'), payload=DPTBinary(1)) + with self.assertRaises(CouldNotParseTelegram): + self.loop.run_until_complete(asyncio.Task(light.process(telegram))) + + def test_process_tunable_white_payload_invalid_length(self): + """Test process wrong telegrams. (wrong payload length).""" + # pylint: disable=invalid-name + xknx = XKNX(loop=self.loop) + light = Light(xknx, + name="TestLight", + group_address_switch='1/2/3', + group_address_tunable_white='1/2/5') + telegram = Telegram(GroupAddress('1/2/5'), payload=DPTArray((23, 24))) + with self.assertRaises(CouldNotParseTelegram): + self.loop.run_until_complete(asyncio.Task(light.process(telegram))) + + def test_process_color_temperature(self): + """Test process / reading telegrams from telegram queue. Test if color temperature is processed.""" + xknx = XKNX(loop=self.loop) + light = Light(xknx, + name="TestLight", + group_address_switch='1/2/3', + group_address_color_temperature='1/2/5') + self.assertEqual(light.current_color_temperature, None) + + telegram = Telegram(GroupAddress('1/2/5'), payload=DPTArray((0x0F, 0xA0, ))) + self.loop.run_until_complete(asyncio.Task(light.process(telegram))) + self.assertEqual(light.current_color_temperature, 4000) + + def test_process_color_temperature_wrong_payload(self): + """Test process wrong telegrams. (wrong payload type).""" + xknx = XKNX(loop=self.loop) + light = Light(xknx, + name="TestLight", + group_address_switch='1/2/3', + group_address_color_temperature='1/2/5') + telegram = Telegram(GroupAddress('1/2/5'), payload=DPTBinary(1)) + with self.assertRaises(CouldNotParseTelegram): + self.loop.run_until_complete(asyncio.Task(light.process(telegram))) + + def test_process_color_temperature_payload_invalid_length(self): + """Test process wrong telegrams. (wrong payload length).""" + # pylint: disable=invalid-name + xknx = XKNX(loop=self.loop) + light = Light(xknx, + name="TestLight", + group_address_switch='1/2/3', + group_address_color_temperature='1/2/5') + telegram = Telegram(GroupAddress('1/2/5'), payload=DPTArray((23))) + with self.assertRaises(CouldNotParseTelegram): + self.loop.run_until_complete(asyncio.Task(light.process(telegram))) + # # TEST DO # @@ -308,11 +496,17 @@ def test_do(self): light = Light(xknx, name="TestLight", group_address_switch='1/2/3', - group_address_brightness='1/2/5') + group_address_brightness='1/2/5', + group_address_tunable_white='1/2/9', + group_address_color_temperature='1/2/11') self.loop.run_until_complete(asyncio.Task(light.do("on"))) self.assertTrue(light.state) self.loop.run_until_complete(asyncio.Task(light.do("brightness:80"))) self.assertEqual(light.current_brightness, 80) + self.loop.run_until_complete(asyncio.Task(light.do("tunable_white:80"))) + self.assertEqual(light.current_tunable_white, 80) + self.loop.run_until_complete(asyncio.Task(light.do("color_temperature:3750"))) + self.assertEqual(light.current_color_temperature, 3750) self.loop.run_until_complete(asyncio.Task(light.do("off"))) self.assertFalse(light.state) @@ -339,11 +533,19 @@ def test_has_group_address(self): group_address_brightness='1/7/3', group_address_brightness_state='1/7/4', group_address_color='1/7/5', - group_address_color_state='1/7/6') + group_address_color_state='1/7/6', + group_address_tunable_white='1/7/7', + group_address_tunable_white_state='1/7/8', + group_address_color_temperature='1/7/9', + group_address_color_temperature_state='1/7/10') self.assertTrue(light.has_group_address(GroupAddress('1/7/1'))) self.assertTrue(light.has_group_address(GroupAddress('1/7/2'))) self.assertTrue(light.has_group_address(GroupAddress('1/7/3'))) self.assertTrue(light.has_group_address(GroupAddress('1/7/4'))) self.assertTrue(light.has_group_address(GroupAddress('1/7/5'))) self.assertTrue(light.has_group_address(GroupAddress('1/7/6'))) - self.assertFalse(light.has_group_address(GroupAddress('1/7/7'))) + self.assertTrue(light.has_group_address(GroupAddress('1/7/7'))) + self.assertTrue(light.has_group_address(GroupAddress('1/7/8'))) + self.assertTrue(light.has_group_address(GroupAddress('1/7/9'))) + self.assertTrue(light.has_group_address(GroupAddress('1/7/10'))) + self.assertFalse(light.has_group_address(GroupAddress('1/7/11'))) diff --git a/test/remote_value_dpt_2_byte_unsigned_test.py b/test/remote_value_dpt_2_byte_unsigned_test.py new file mode 100644 index 000000000..a1672d757 --- /dev/null +++ b/test/remote_value_dpt_2_byte_unsigned_test.py @@ -0,0 +1,99 @@ +"""Unit test for RemoteValueDpt2ByteUnsigned objects.""" +import asyncio +import unittest + +from xknx import XKNX +from xknx.devices import RemoteValueDpt2ByteUnsigned +from xknx.exceptions import ConversionError, CouldNotParseTelegram +from xknx.knx import DPTArray, DPTBinary, GroupAddress, Telegram + + +class TestRemoteValueDptValue1Ucount(unittest.TestCase): + """Test class for RemoteValueDpt2ByteUnsigned objects.""" + + def setUp(self): + """Set up test class.""" + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + + def tearDown(self): + """Tear down test class.""" + self.loop.close() + + def test_to_knx(self): + """Test to_knx function with normal operation.""" + xknx = XKNX(loop=self.loop) + remote_value = RemoteValueDpt2ByteUnsigned(xknx) + self.assertEqual(remote_value.to_knx(2571), DPTArray((0x0A, 0x0B))) + + def test_from_knx(self): + """Test from_knx function with normal operation.""" + xknx = XKNX(loop=self.loop) + remote_value = RemoteValueDpt2ByteUnsigned(xknx) + self.assertEqual(remote_value.from_knx(DPTArray((0x0A, 0x0B))), 2571) + + def test_to_knx_error(self): + """Test to_knx function with wrong parametern.""" + xknx = XKNX(loop=self.loop) + remote_value = RemoteValueDpt2ByteUnsigned(xknx) + with self.assertRaises(ConversionError): + remote_value.to_knx(65536) + with self.assertRaises(ConversionError): + remote_value.to_knx("256") + + def test_set(self): + """Test setting value.""" + xknx = XKNX(loop=self.loop) + remote_value = RemoteValueDpt2ByteUnsigned( + xknx, + group_address=GroupAddress("1/2/3")) + self.loop.run_until_complete(asyncio.Task(remote_value.set(2571))) + self.assertEqual(xknx.telegrams.qsize(), 1) + telegram = xknx.telegrams.get_nowait() + self.assertEqual( + telegram, + Telegram( + GroupAddress('1/2/3'), + payload=DPTArray((0x0A, 0x0B)))) + self.loop.run_until_complete(asyncio.Task(remote_value.set(5500))) + self.assertEqual(xknx.telegrams.qsize(), 1) + telegram = xknx.telegrams.get_nowait() + self.assertEqual( + telegram, + Telegram( + GroupAddress('1/2/3'), + payload=DPTArray((0x15, 0x7C, )))) + + def test_process(self): + """Test process telegram.""" + xknx = XKNX(loop=self.loop) + remote_value = RemoteValueDpt2ByteUnsigned( + xknx, + group_address=GroupAddress("1/2/3")) + telegram = Telegram( + group_address=GroupAddress("1/2/3"), + payload=DPTArray((0x0A, 0x0B))) + self.loop.run_until_complete(asyncio.Task(remote_value.process(telegram))) + self.assertEqual(remote_value.value, 2571) + + def test_to_process_error(self): + """Test process errornous telegram.""" + xknx = XKNX(loop=self.loop) + remote_value = RemoteValueDpt2ByteUnsigned( + xknx, + group_address=GroupAddress("1/2/3")) + with self.assertRaises(CouldNotParseTelegram): + telegram = Telegram( + group_address=GroupAddress("1/2/3"), + payload=DPTBinary(1)) + self.loop.run_until_complete(asyncio.Task(remote_value.process(telegram))) + with self.assertRaises(CouldNotParseTelegram): + telegram = Telegram( + group_address=GroupAddress("1/2/3"), + payload=DPTArray((0x64, ))) + self.loop.run_until_complete(asyncio.Task(remote_value.process(telegram))) + with self.assertRaises(CouldNotParseTelegram): + telegram = Telegram( + group_address=GroupAddress("1/2/3"), + payload=DPTArray((0x64, 0x53, 0x42, ))) + self.loop.run_until_complete(asyncio.Task(remote_value.process(telegram))) diff --git a/tox.ini b/tox.ini index 2a6efb74a..8c75c97d5 100644 --- a/tox.ini +++ b/tox.ini @@ -8,6 +8,7 @@ setenv = PYTHONPATH = {toxinidir} commands = py.test --cov --cov-report= {posargs} +whitelist_externals = make deps = -rrequirements/testing.txt [testenv:flake8] diff --git a/xknx/devices/__init__.py b/xknx/devices/__init__.py index 71388b134..6e7bdfa87 100644 --- a/xknx/devices/__init__.py +++ b/xknx/devices/__init__.py @@ -28,3 +28,4 @@ from .remote_value_temp import RemoteValueTemp from .remote_value_scaling import RemoteValueScaling from. remote_value_dpt_value_1_ucount import RemoteValueDptValue1Ucount +from. remote_value_dpt_2_byte_unsigned import RemoteValueDpt2ByteUnsigned diff --git a/xknx/devices/light.py b/xknx/devices/light.py index 290b4b54a..669a61908 100644 --- a/xknx/devices/light.py +++ b/xknx/devices/light.py @@ -5,10 +5,14 @@ * switching light 'on' and 'off'. * setting the brightness. +* setting the color. +* setting the relative color temperature (tunable white). +* setting the absolute color temperature. * reading the current state from KNX bus. """ from .device import Device from .remote_value_color_rgb import RemoteValueColorRGB +from .remote_value_dpt_2_byte_unsigned import RemoteValueDpt2ByteUnsigned from .remote_value_scaling import RemoteValueScaling from .remote_value_switch import RemoteValueSwitch @@ -16,6 +20,8 @@ class Light(Device): """Class for managing a light.""" + # pylint: disable=too-many-locals + def __init__(self, xknx, name, @@ -25,6 +31,12 @@ def __init__(self, group_address_brightness_state=None, group_address_color=None, group_address_color_state=None, + group_address_tunable_white=None, + group_address_tunable_white_state=None, + group_address_color_temperature=None, + group_address_color_temperature_state=None, + min_kelvin=None, + max_kelvin=None, device_updated_cb=None): """Initialize Light class.""" # pylint: disable=too-many-arguments @@ -53,16 +65,45 @@ def __init__(self, device_name=self.name, after_update_cb=self.after_update) + self.tunable_white = RemoteValueScaling( + xknx, + group_address_tunable_white, + group_address_tunable_white_state, + device_name=self.name, + after_update_cb=self.after_update, + range_from=0, + range_to=255) + + self.color_temperature = RemoteValueDpt2ByteUnsigned( + xknx, + group_address_color_temperature, + group_address_color_temperature_state, + device_name=self.name, + after_update_cb=self.after_update) + + self.min_kelvin = min_kelvin + self.max_kelvin = max_kelvin + @property def supports_brightness(self): - """Return if cover supports direct positioning.""" + """Return if light supports brightness.""" return self.brightness.initialized @property def supports_color(self): - """Return if cover supports direct positioning.""" + """Return if light supports color.""" return self.color.initialized + @property + def supports_tunable_white(self): + """Return if light supports tunable white / relative color temperature.""" + return self.tunable_white.initialized + + @property + def supports_color_temperature(self): + """Return if light supports absolute color temperature.""" + return self.color_temperature.initialized + @classmethod def from_config(cls, xknx, name, config): """Initialize object from configuration structure.""" @@ -78,6 +119,18 @@ def from_config(cls, xknx, name, config): config.get('group_address_color') group_address_color_state = \ config.get('group_address_color_state') + group_address_tunable_white = \ + config.get('group_address_tunable_white') + group_address_tunable_white_state = \ + config.get('group_address_tunable_white_state') + group_address_color_temperature = \ + config.get('group_address_color_temperature') + group_address_color_temperature_state = \ + config.get('group_address_color_temperature_state') + min_kelvin = \ + config.get('min_kelvin') + max_kelvin = \ + config.get('max_kelvin') return cls(xknx, name, @@ -86,13 +139,21 @@ def from_config(cls, xknx, name, config): group_address_brightness=group_address_brightness, group_address_brightness_state=group_address_brightness_state, group_address_color=group_address_color, - group_address_color_state=group_address_color_state) + group_address_color_state=group_address_color_state, + group_address_tunable_white=group_address_tunable_white, + group_address_tunable_white_state=group_address_tunable_white_state, + group_address_color_temperature=group_address_color_temperature, + group_address_color_temperature_state=group_address_color_temperature_state, + min_kelvin=min_kelvin, + max_kelvin=max_kelvin) def has_group_address(self, group_address): """Test if device has given group address.""" return (self.switch.has_group_address(group_address) or self.brightness.has_group_address(group_address) or - self.color.has_group_address(group_address)) + self.color.has_group_address(group_address) or + self.tunable_white.has_group_address(group_address) or + self.color_temperature.has_group_address(group_address)) def __str__(self): """Return object as readable string.""" @@ -104,13 +165,23 @@ def __str__(self): ' color="{0}"'.format( self.color.group_addr_str()) + str_tunable_white = '' if not self.supports_tunable_white else \ + ' tunable white="{0}"'.format( + self.tunable_white.group_addr_str()) + + str_color_temperature = '' if not self.supports_color_temperature else \ + ' color temperature="{0}"'.format( + self.color_temperature.group_addr_str()) + return '' \ + 'switch="{1}"{2}{3}{4}{5} />' \ .format( self.name, self.switch.group_addr_str(), str_brightness, - str_color) + str_color, + str_tunable_white, + str_color_temperature) @property def state(self): @@ -127,7 +198,7 @@ async def set_off(self): @property def current_brightness(self): - """Return current color of light.""" + """Return current brightness of light.""" return self.brightness.value async def set_brightness(self, brightness): @@ -137,6 +208,11 @@ async def set_brightness(self, brightness): return await self.brightness.set(brightness) + @property + def current_color(self): + """Return current color of light.""" + return self.color.value + async def set_color(self, color): """Set color of light.""" if not self.supports_color: @@ -145,9 +221,28 @@ async def set_color(self, color): await self.color.set(color) @property - def current_color(self): - """Return current color of light.""" - return self.color.value + def current_tunable_white(self): + """Return current relative color temperature of light.""" + return self.tunable_white.value + + async def set_tunable_white(self, tunable_white): + """Set relative color temperature of light.""" + if not self.supports_tunable_white: + self.xknx.logger.warning("Tunable white not supported for device %s", self.get_name()) + return + await self.tunable_white.set(tunable_white) + + @property + def current_color_temperature(self): + """Return current absolute color temperature of light.""" + return self.color_temperature.value + + async def set_color_temperature(self, color_temperature): + """Set absolute color temperature of light.""" + if not self.supports_color_temperature: + self.xknx.logger.warning("Absolute Color Temperature not supported for device %s", self.get_name()) + return + await self.color_temperature.set(color_temperature) async def do(self, action): """Execute 'do' commands.""" @@ -157,6 +252,10 @@ async def do(self, action): await self.set_off() elif action.startswith("brightness:"): await self.set_brightness(int(action[11:])) + elif action.startswith("tunable_white:"): + await self.set_tunable_white(int(action[14:])) + elif action.startswith("color_temperature:"): + await self.set_color_temperature(int(action[18:])) else: self.xknx.logger.warning("Could not understand action %s for device %s", action, self.get_name()) @@ -166,6 +265,8 @@ def state_addresses(self): state_addresses.extend(self.switch.state_addresses()) state_addresses.extend(self.color.state_addresses()) state_addresses.extend(self.brightness.state_addresses()) + state_addresses.extend(self.tunable_white.state_addresses()) + state_addresses.extend(self.color_temperature.state_addresses()) return state_addresses async def process_group_write(self, telegram): @@ -173,6 +274,8 @@ async def process_group_write(self, telegram): await self.switch.process(telegram) await self.color.process(telegram) await self.brightness.process(telegram) + await self.tunable_white.process(telegram) + await self.color_temperature.process(telegram) def __eq__(self, other): """Equal operator.""" diff --git a/xknx/devices/remote_value_dpt_2_byte_unsigned.py b/xknx/devices/remote_value_dpt_2_byte_unsigned.py new file mode 100644 index 000000000..da289b303 --- /dev/null +++ b/xknx/devices/remote_value_dpt_2_byte_unsigned.py @@ -0,0 +1,37 @@ +""" +Module for managing a DTP 7001 remote value. + +DPT 7.001. +""" +from xknx.knx import DPT2ByteUnsigned, DPTArray + +from .remote_value import RemoteValue + + +class RemoteValueDpt2ByteUnsigned(RemoteValue): + """Abstraction for remote value of KNX DPT 7.001.""" + + def __init__(self, + xknx, + group_address=None, + group_address_state=None, + device_name=None, + after_update_cb=None): + """Initialize remote value of KNX DPT 7.001.""" + # pylint: disable=too-many-arguments + super(RemoteValueDpt2ByteUnsigned, self).__init__( + xknx, group_address, group_address_state, + device_name=device_name, after_update_cb=after_update_cb) + + def payload_valid(self, payload): + """Test if telegram payload may be parsed.""" + return (isinstance(payload, DPTArray) + and len(payload.value) == 2) + + def to_knx(self, value): + """Convert value to payload.""" + return DPTArray(DPT2ByteUnsigned.to_knx(value)) + + def from_knx(self, payload): + """Convert current payload to value.""" + return DPT2ByteUnsigned.from_knx(payload.value) diff --git a/xknx/devices/remote_value_sensor.py b/xknx/devices/remote_value_sensor.py index 7d40da3fe..6f9c4c3f8 100644 --- a/xknx/devices/remote_value_sensor.py +++ b/xknx/devices/remote_value_sensor.py @@ -7,11 +7,11 @@ from xknx.exceptions import ConversionError from xknx.knx import ( DPT2ByteFloat, DPT2ByteUnsigned, DPT4ByteFloat, DPT4ByteSigned, - DPT4ByteUnsigned, DPTArray, DPTBrightness, DPTElectricCurrent, - DPTElectricPotential, DPTEnergy, DPTEnthalpy, DPTFrequency, - DPTHeatFlowRate, DPTHumidity, DPTLux, DPTPartsPerMillion, DPTPhaseAngleDeg, - DPTPhaseAngleRad, DPTPower, DPTPowerFactor, DPTSpeed, DPTTemperature, - DPTUElCurrentmA, DPTVoltage, DPTWsp) + DPT4ByteUnsigned, DPTArray, DPTBrightness, DPTColorTemperature, + DPTElectricCurrent, DPTElectricPotential, DPTEnergy, DPTEnthalpy, + DPTFrequency, DPTHeatFlowRate, DPTHumidity, DPTLux, DPTPartsPerMillion, + DPTPhaseAngleDeg, DPTPhaseAngleRad, DPTPower, DPTPowerFactor, DPTSpeed, + DPTTemperature, DPTUElCurrentmA, DPTVoltage, DPTWsp) from .remote_value import RemoteValue @@ -20,12 +20,13 @@ class RemoteValueSensor(RemoteValue): """Abstraction for many different sensor DPT types.""" DPTMAP = { + 'current': DPTUElCurrentmA, + 'brightness': DPTBrightness, + 'color_temperature': DPTColorTemperature, 'temperature': DPTTemperature, - 'humidity': DPTHumidity, 'illuminance': DPTLux, - 'brightness': DPTBrightness, 'speed_ms': DPTWsp, - 'current': DPTUElCurrentmA, + 'humidity': DPTHumidity, 'voltage': DPTVoltage, 'power': DPTPower, 'electric_current': DPTElectricCurrent, diff --git a/xknx/knx/__init__.py b/xknx/knx/__init__.py index 17ce56afd..78614da43 100644 --- a/xknx/knx/__init__.py +++ b/xknx/knx/__init__.py @@ -18,7 +18,7 @@ from .dpt_hvac_mode import HVACOperationMode, DPTHVACMode, \ DPTControllerStatus from .dpt_hvac_contr_mode import DPTHVACContrMode -from .dpt_2byte import DPT2ByteUnsigned, DPTUElCurrentmA, DPT2Ucount, DPTBrightness +from .dpt_2byte import DPT2ByteUnsigned, DPTUElCurrentmA, DPT2Ucount, DPTBrightness, DPTColorTemperature from .dpt_4byte import DPT4ByteUnsigned, DPT4ByteSigned from .dpt_scene_number import DPTSceneNumber from .dpt_time import DPTTime diff --git a/xknx/knx/dpt_2byte.py b/xknx/knx/dpt_2byte.py index be00e0362..ca1cc14aa 100644 --- a/xknx/knx/dpt_2byte.py +++ b/xknx/knx/dpt_2byte.py @@ -29,6 +29,8 @@ def from_knx(cls, raw): @classmethod def to_knx(cls, value): """Serialize to KNX/IP raw data.""" + if not isinstance(value, (int)): + raise ConversionError("Cant serialize DPT2ByteUnsigned", value=value) if not cls._test_boundaries(value): raise ConversionError("Cant serialize DPT2ByteUnsigned", value=value) return value >> 8, value & 0xff @@ -55,3 +57,9 @@ class DPTBrightness(DPT2ByteUnsigned): """DPT 7.013 DPT_Brightness (lux).""" unit = "lx" + + +class DPTColorTemperature(DPT2ByteUnsigned): + """DPT 7.600 DPT_Color_Temperature (K).""" + + unit = "K"