diff --git a/README.md b/README.md index f8c0474..d65840b 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,33 @@ # wiz_light -A WiZ Light integration for Home Assistant. +A Home assistant integration for Phillips WiZ Light bulbs + +This is a fork of https://github.com/sbidy/wiz_light/ and fixes several issues with the component: + +Bug fixes: + - Fixes https://github.com/sbidy/wiz_light/issues/6: make the whole component truly async using non-blocking UDP + - Light control now works even when lights are set to a rhythm. + +Features: + - Now supports switching the light to rhythm mode! (rhythm is defined as a scene for HA) + - Implements a pattern of sending multiple command UDP datagrams until response is received + - Consolidates getPilot and setPilot calls using a PilotBuilder and PilotParser. Removes unnecessary UDP calls for each and every attribute (color, temperature, brightness, scene, etc.) and makes a combined getPilot/setPilot call + - enhanced debug logging for UDP + +This component does not need a dependency on `pywizlight` like @sbidy's component ## Next improvement: -- deterministic selection of following bulb types: Only dimmable, dimmable + white color temprature (kelvin) and full RGB support also with white color temperatur. Maybe with an auto detect feature, try and error testing or via configuration YAML. -- Prepare for hacs.xyz +- Implement hacs.xyz structure -Working features +## Working features - Brigtness - Color (RGB) - White Color Temprature - - On/Off + - On/Off, Toggle - Effects + - Setting a rhythm as a scene - Next up: - - Some improvements and bugfixes to create a more stable integration - - testing with other hardware -- **Contribution required !!** - - -## Install for testing -If you want to try the integration please clone this repo to `/custom_components/`. - -Run `git clone https://github.com/sbidy/wiz_light` within the `/custom_components/`. - -You also have to install the `pip install pywizlight` package. More infos? Check my git [pywizlight](https://github.com/sbidy/pywizlight) +## Testing +See `test.py` for how the underlying API works ## HA config To enable the platform integration add diff --git a/light.py b/light.py index 888752d..deb471b 100644 --- a/light.py +++ b/light.py @@ -10,7 +10,8 @@ import logging import voluptuous as vol -from pywizlight import wizlight +from .wizlight import wizlight, PilotBuilder, PilotParser +from .scenes import SCENES from homeassistant.exceptions import InvalidStateError from homeassistant.core import callback @@ -37,9 +38,10 @@ _LOGGER = logging.getLogger(__name__) # Validation of the user's configuration + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_NAME): cv.string }) SUPPORT_FEATURES = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT ) @@ -114,24 +116,46 @@ async def async_turn_on(self, **kwargs): """ Instruct the light to turn on. """ + # TODO: change this to set state using a single UDP call + # + + + rgb = None if ATTR_RGB_COLOR in kwargs: - self._light.rgb = kwargs[ATTR_RGB_COLOR] + rgb = kwargs[ATTR_RGB_COLOR] if ATTR_HS_COLOR in kwargs: - self._light.rgb = color_utils.color_hs_to_RGB(kwargs[ATTR_HS_COLOR][0], kwargs[ATTR_HS_COLOR][1]) + rgb = color_utils.color_hs_to_RGB(kwargs[ATTR_HS_COLOR][0], kwargs[ATTR_HS_COLOR][1]) + + brightness = None if ATTR_BRIGHTNESS in kwargs: - self._light.brightness = kwargs[ATTR_BRIGHTNESS] + brightness = kwargs[ATTR_BRIGHTNESS] + + colortemp = None if ATTR_COLOR_TEMP in kwargs: kelvin = color_utils.color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP]) - self._light.colortemp = kelvin + colortemp = kelvin + + sceneid = None if ATTR_EFFECT in kwargs: - self._light.scene = self.scene_helper(kwargs[ATTR_EFFECT]) - self._light.turn_on() + sceneid = self._light.get_id_from_scene_name(kwargs[ATTR_EFFECT]) + + if sceneid == 1000: #rhythm + pilot = PilotBuilder() + else: + pilot = PilotBuilder( + rgb = rgb, + brightness = brightness, + colortemp = colortemp, + scene = sceneid + ) + + await self._light.turn_on(pilot) async def async_turn_off(self, **kwargs): """ Instruct the light to turn off. """ - self._light.turn_off() + await self._light.turn_off() @property def color_temp(self): @@ -181,25 +205,47 @@ async def async_update(self): Fetch new state data for this light. This is the only method that should fetch new data for Home Assistant. """ - self.update_availability() - if self._available is True: - self.update_state() + await self.update_state() + + if self._state != None and self._state != False: self.update_brightness() self.update_temperature() self.update_color() self.update_effect() self.update_scene_list() -# ---- CALLBACKS ----- - @callback + async def update_state_available(self): + self._state = self._light.status + self._available = True + + async def update_state_unavailable(self): + self._state = False + self._available = False + + async def update_state(self): + """ + Update the state + """ + try: + _LOGGER.debug("[wizlight {}] updating state".format(self._light.ip)) + await self._light.updateState() + if self._light.state == None: + await self.update_state_unavailable() + else: + await self.update_state_available() + except Exception as ex: + _LOGGER.error(ex) + await self.update_state_unavailable() + _LOGGER.debug("[wizlight {}] updated state: {}".format(self._light.ip, self._state)) + def update_brightness(self): """ Update the brightness. """ - if self._light.brightness is None: + if self._light.state.get_brightness() is None: return try: - brightness = self._light.brightness + brightness = self._light.state.get_brightness() if 0 <= int(brightness) <= 255: self._brightness = int(brightness) else: @@ -210,41 +256,28 @@ def update_brightness(self): except Exception as ex: _LOGGER.error(ex) self._state = None - @callback - def update_state(self): - """ - Update the state - """ - if self._light.status is None: - return - try: - self._state = self._light.status - return - except Exception as ex: - _LOGGER.error(ex) - self._state = None - @callback + def update_temperature(self): """ Update the temperature """ - if self._light.colortemp is None: + if self._light.state.get_colortemp() is None: return try: - temperature = color_utils.color_temperature_kelvin_to_mired(self._light.colortemp) + temperature = color_utils.color_temperature_kelvin_to_mired(self._light.state.get_colortemp()) self._temperature = temperature except Exception: _LOGGER.error("Cannot evaluate temperature", exc_info=True) self._temperature = None - @callback + def update_color(self): """ Update the hs color """ - if self._light.rgb is None: + if self._light.state.get_rgb() is None: return try: - r, g, b = self._light.rgb + r, g, b = self._light.state.get_rgb() if r is None: # this is the case if the temperature was changed - no infomation was return form the lamp. # do nothing until the RGB color was changed @@ -260,30 +293,15 @@ def update_color(self): except Exception: _LOGGER.error("Cannot evaluate color", exc_info=True) self._hscolor = None - - @callback - def update_availability(self): - ''' - update the bulb availability - ''' - self._available = self._light.getConnection() - @callback def update_effect(self): ''' - update the bulb availability + update the bulb scene ''' - self._effect = self._light.scene + self._effect = self._light.state.get_scene() - # this should be improved :-) - @callback + # TODO: this should be improved :-) def update_scene_list(self): self._scenes = [] - for id in self._light.SCENES: - self._scenes.append(self._light.SCENES[id]) - - # move to pywizlight 0.2.6 - def scene_helper(self, scene): - for id in self._light.SCENES: - if self._light.SCENES[id] == scene: - return id \ No newline at end of file + for id in SCENES: + self._scenes.append(SCENES[id]) diff --git a/manifest.json b/manifest.json index 9c1e43d..98db214 100644 --- a/manifest.json +++ b/manifest.json @@ -4,5 +4,5 @@ "documentation": "https://github.com/sbidy/wiz_light", "dependencies": [], "codeowners": ["@sbidy"], - "requirements": ["pywizlight==0.2.5"] + "requirements": ["asyncio_dgram==1.0.1"] } \ No newline at end of file diff --git a/scenes.py b/scenes.py new file mode 100755 index 0000000..bd185b8 --- /dev/null +++ b/scenes.py @@ -0,0 +1,35 @@ +SCENES = { + 1:"Ocean", + 2:"Romance", + 3:"Sunset", + 4:"Party", + 5:"Fireplace", + 6:"Cozy", + 7:"Forest", + 8:"Pastel Colors", + 9:"Wake up", + 10:"Bedtime", + 11:"Warm White", + 12:"Daylight", + 13:"Cool white", + 14:"Night light", + 15:"Focus", + 16:"Relax", + 17:"True colors", + 18:"TV time", + 19:"Plantgrowth", + 20:"Spring", + 21:"Summer", + 22:"Fall", + 23:"Deepdive", + 24:"Jungle", + 25:"Mojito", + 26:"Club", + 27:"Christmas", + 28:"Halloween", + 29:"Candlelight", + 30:"Golden white", + 31:"Pulse", + 32:"Steampunk", + 1000:"Rhythm" +} \ No newline at end of file diff --git a/test.py b/test.py new file mode 100755 index 0000000..30cb191 --- /dev/null +++ b/test.py @@ -0,0 +1,72 @@ +import asyncio +import asyncio_dgram +import time +from .wizlight import wizlight, PilotBuilder +import logging +import sys + +# python3 -m wiz_light.test + +root = logging.getLogger() +root.setLevel(logging.DEBUG) + +handler = logging.StreamHandler(sys.stdout) +handler.setLevel(logging.DEBUG) +formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') +handler.setFormatter(formatter) +root.addHandler(handler) + +_LOGGER = logging.getLogger(__name__) + +async def testbulb(bulb): + wait_secs=10 + + state = await bulb.updateState() + + if state.get_state(): # check if on + await asyncio.wait_for(bulb.turn_off(), wait_secs) + await asyncio.sleep(0.5) + await asyncio.wait_for(bulb.lightSwitch(), wait_secs) + await asyncio.sleep(0.5) + await asyncio.wait_for(bulb.turn_off(), wait_secs) + await asyncio.sleep(0.5) + await asyncio.wait_for(bulb.turn_on(PilotBuilder(brightness = 255)), wait_secs) + await asyncio.sleep(0.5) + await asyncio.wait_for(bulb.turn_on(PilotBuilder(brightness = 50)), wait_secs) + await asyncio.sleep(0.5) + await asyncio.wait_for(bulb.turn_on(PilotBuilder(rgb = (50, 100, 200))), wait_secs) + await asyncio.sleep(0.5) + await asyncio.wait_for(bulb.turn_on(PilotBuilder(colortemp = 4000)), wait_secs) + await asyncio.sleep(0.5) + await asyncio.wait_for(bulb.turn_on(PilotBuilder(colortemp = 6500)), wait_secs) + await asyncio.sleep(0.5) + await asyncio.wait_for(bulb.turn_on(PilotBuilder(scene = 14)), wait_secs) + await asyncio.sleep(0.5) + await asyncio.wait_for(bulb.turn_on(PilotBuilder(scene = 24)), wait_secs) + + state = await bulb.updateState() + print(state.get_state()) + print(state.get_scene()) + print(state.get_scene()) + print(state.get_warm_white()) + print(state.get_speed()) + print(state.get_cold_white()) + print(state.get_rgb()) + print(state.get_brightness()) + print(state.get_colortemp()) + + +async def run_bulb_automation(): + loop = asyncio.get_event_loop() + bulb1 = wizlight('192.168.1.58') + bulb2 = wizlight('192.168.1.7') + # await asyncio.gather(testbulb(bulb1), testbulb(bulb2), loop = loop) + state = await bulb1.updateState() + await bulb1.turn_on(PilotBuilder(scene = 14)) + state = await bulb1.updateState() + await asyncio.sleep(0.5) + await bulb1.turn_on(PilotBuilder()) #rhythm + state = await bulb1.updateState() + +if __name__ == '__main__': + asyncio.run(run_bulb_automation()) diff --git a/wizlight.py b/wizlight.py new file mode 100755 index 0000000..2f6f703 --- /dev/null +++ b/wizlight.py @@ -0,0 +1,330 @@ +''' + MIT License + + Copyright (c) 2020 Stephan Traub + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +''' +import socket +import json +import asyncio +import asyncio_dgram +import logging +from time import time +from .scenes import SCENES + +_LOGGER = logging.getLogger(__name__) + +class PilotBuilder: + def __init__ (self, warm_white = None, + cold_white = None, + speed = None, + scene = None, + rgb = None, + brightness = None, + colortemp = None): + + self.pilot_params = {"state": True} + + if warm_white != None: + self._set_warm_white(warm_white) + if cold_white != None: + self._set_cold_white(cold_white) + if speed != None: + self._set_speed(speed) + if scene != None: + self._set_scene(scene) + if rgb != None: + self._set_rgb(rgb) + if brightness != None: + self._set_brightness(brightness) + if colortemp != None: + self._set_colortemp(colortemp) + + def get_pilot_message(self): + return json.dumps({"method": "setPilot", + "params": self.pilot_params }) + + def _set_warm_white(self, value: int): + ''' + set the value of the cold white led + ''' + if value > 0 and value < 256: + self.pilot_params['w'] = value + + + def _set_cold_white(self, value: int): + ''' + set the value of the cold white led + ''' + if value > 0 and value < 256: + self.pilot_params['c'] = value + else: + raise IndexError("Value must be between 1 and 255") + + def _set_speed(self, value: int): + ''' + set the color changing speed in precent (0-100) + This applies only to changing effects! + ''' + if value > 0 and value < 101: + self.pilot_params['speed'] = value + else: + raise IndexError("Value must be between 0 and 100") + + def _set_scene(self, scene_id: int): + ''' + set the scene by id + ''' + if scene_id in SCENES: + self.pilot_params['sceneId'] = scene_id + else: + # id not in SCENES ! + raise IndexError("Scene is not available - only 0 to 32 are supported") + + def _set_rgb(self, values): + ''' + set the rgb color state of the bulb + ''' + r, g, b = values + self.pilot_params['r'] = r + self.pilot_params['g'] = g + self.pilot_params['b'] = b + + def _set_brightness(self, value: int): + ''' + set the value of the brightness 0-255 + ''' + percent = self.hex_to_percent(value) + # lamp doesn't supports lower than 10% + if percent < 10: percent = 10 + self.pilot_params['dimming'] = percent + + def _set_colortemp(self, kelvin: int): + ''' + sets the color temperature for the white led in the bulb + ''' + # normalize the kelvin values - should be removed + if kelvin < 2500: kelvin = 2500 + if kelvin > 6500: kelvin = 6500 + + self.pilot_params['temp'] = kelvin + + def hex_to_percent(self, hex): + ''' converts hex 0-255 values to percent ''' + return round((hex/255)*100) + +class PilotParser: + def __init__ (self, pilotResult): + self.pilotResult = pilotResult + + def get_state(self) -> str: + return self.pilotResult['state'] + + def get_warm_white(self) -> int: + ''' + get the value of the warm white led + ''' + if "w" in self.pilotResult: + return self.pilotResult['w'] + else: + return None + + def get_speed(self) -> int: + ''' + get the color changing speed + ''' + if "speed" in self.pilotResult: + return self.pilotResult['speed'] + else: + return None + + def get_scene(self) -> str: + ''' + get the current scene name + ''' + if 'schdPsetId' in self.pilotResult: #rhythm + return SCENES[1000] + + id = self.pilotResult['sceneId'] + if id in SCENES: + return SCENES[id] + else: + return None + + def get_cold_white(self) -> int: + ''' + get the value of the cold white led + ''' + if "c" in self.pilotResult: + return self.pilotResult['c'] + else: + return None + + def get_rgb(self): + ''' + get the rgb color state of the bulb and turns it on + ''' + if "r" in self.pilotResult and "g" in self.pilotResult and "b" in self.pilotResult: + r = self.pilotResult['r'] + g = self.pilotResult['g'] + b = self.pilotResult['b'] + return r, g, b + else: + # no RGB color value was set + return None, None, None + + def get_brightness(self) -> int: + ''' + gets the value of the brightness 0-255 + ''' + return self.percent_to_hex(self.pilotResult['dimming']) + + def get_colortemp(self) -> int: + if "temp" in self.pilotResult: + return self.pilotResult['temp'] + else: + return None + + def percent_to_hex(self, percent): + ''' converts percent values 0-100 into hex 0-255''' + return round((percent / 100)*255) + +class wizlight: + ''' + Creates a instance of a WiZ Light Bulb + ''' + # default port for WiZ lights + + def __init__ (self, ip, port=38899): + ''' Constructor with ip of the bulb ''' + self.ip = ip + self.port = port + self.state = None + + @property + def status(self) -> bool: + ''' + returns true or false / true = on , false = off + ''' + if self.state == None: + return None + return self.state.get_state() + + ## ------------------ Non properties -------------- + + async def turn_off(self): + ''' + turns the light off + ''' + message = r'{"method":"setPilot","params":{"state":false}}' + await self.sendUDPMessage(message) + + async def turn_on(self, pilot_builder = PilotBuilder()): + ''' + turns the light on + ''' + await self.sendUDPMessage(pilot_builder.get_pilot_message(), max_send_datagrams = 10) + + def get_id_from_scene_name(self, scene: str) -> int: + ''' gets the id from a scene name ''' + for id in SCENES: + if SCENES[id] == scene: + return id + raise ValueError("Scene '%s' not in scene list." % scene) + + ### ---------- Helper Functions ------------ + async def updateState(self): + ''' + Note: Call this method before getting any other property + Also, call this method to update the current value for any property + getPilot - gets the current bulb state - no paramters need to be included + {"method": "getPilot", "id": 24} + ''' + message = r'{"method":"getPilot","params":{}}' + resp = await self.sendUDPMessage(message) + if resp != None and 'result' in resp: + self.state = PilotParser(resp['result']) + else: + self.state = None + return self.state + + async def getBulbConfig(self): + ''' + returns the configuration from the bulb + ''' + message = r'{"method":"getSystemConfig","params":{}}' + return await self.sendUDPMessage(message) + + async def lightSwitch(self): + ''' + turns the light bulb on or off like a switch + ''' + # first get the status + state = await self.updateState() + if state.get_state(): + # if the light is on - switch off + await self.turn_off() + else: + # if the light is off - turn on + await self.turn_on() + + + async def receiveUDPwithTimeout(self, stream, timeout): + data, remote_addr = await asyncio.wait_for(stream.recv(), timeout) + return data + + async def sendUDPMessage(self, message, timeout = 60, send_interval = 0.5, max_send_datagrams = 100): + ''' + send the udp message to the bulb + ''' + connid = hex(int(time()*10000000))[2:] + data = None + + try: + _LOGGER.debug("[wizlight {}, connid {}] connecting to UDP port".format(self.ip, connid)) + stream = await asyncio.wait_for(asyncio_dgram.connect((self.ip, self.port)), timeout) + _LOGGER.debug("[wizlight {}, connid {}] listening for response datagram".format(self.ip, connid)) + + receive_task = asyncio.create_task(self.receiveUDPwithTimeout(stream, timeout)) + + i = 0 + while not receive_task.done() and i < max_send_datagrams: + _LOGGER.debug("[wizlight {}, connid {}] sending command datagram {}: {}".format(self.ip, connid, i, message)) + asyncio.create_task(stream.send(bytes(message, "utf-8"))) + await asyncio.sleep(send_interval) + i += 1 + + await receive_task + data = receive_task.result() + + except asyncio.TimeoutError: + _LOGGER.exception("[wizlight {}, connid {}] Failed to do UDP call(s) to wiz light".format(self.ip, connid)) + finally: + stream.close() + + if data != None and len(data) is not None: + resp = json.loads(data.decode()) + if "error" not in resp: + _LOGGER.debug("[wizlight {}, connid {}] response received: {}".format(self.ip, connid, resp)) + return resp + else: + # exception should be created + raise ValueError("Cant read response from the bulb. Debug:",resp) +