diff --git a/README.md b/README.md index 26214d1..c24088c 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,61 @@ # pywizlight A python connector for WiZ light bulbs. -Work in progress an only tested with the [SLV Play RGB bulb](https://www.amazon.de/dp/B07PNCDJLW). + +Tested with the following lights: +[Original Phillips Wiz WiFi LEDs](https://www.lighting.philips.co.in/consumer/smart-wifi-led) +[SLV Play RGB bulb](https://www.amazon.de/dp/B07PNCDJLW) ## Example ```python - from pywizlight import wizlight + from pywizlight.bulb import wizlight, PilotBuilder + # create/get the current thread's asyncio loop + loop = asyncio.get_event_loop() # setup a standard light light = wizlight("") + bulb2 = wizlight("") + await asyncio.gather(bulb1.turn_on(PilotBuilder(brightness = 255), + bulb2.turn_on(PilotBuilder(warm_white = 255), loop = loop) ``` @@ -39,46 +63,54 @@ Work in progress an only tested with the [SLV Play RGB bulb](https://www.amazon. - **sceneId** - calls one of thr predefined scenes (int from 0 to 32) [Wiki](https://github.com/sbidy/pywizlight/wiki/Light-Scenes) - **speed** - sets the color changing speed in precent - **dimming** - sets the dimmer of the bulb in precent +- **temp** - sets color temperature in kelvins - **r** - red color range 0-255 - **g** - green color range 0-255 - **b** - blue color range 0-255 - **c** - cold white range 0-255 - **w** - warm white range 0-255 - **id** - the bulb id +- **state** - when it's on or off +- **schdPsetId** - rhythm id of the room + +## Async I/O +For async I/O this component uses https://github.com/jsbronder/asyncio-dgram, which internally uses asyncio DatagramTransport, which allows completely non-blocking UDP transport ## Classes `wizlight(ip)` Creates a instance of a WiZ Light Bulb. Constructor with ip of the bulb ### Instance variables -`brightness`gets the value of the brightness 0-255 -`color` get the rgbW color state of the bulb and turns it on +You need to first fetch the state by calling `light.updateState()` +After that all state can be fetched from `light.state`, which is a `PilotParser` object -`colortemp` get the color temperature ot the bulb +`PilotParser.get_brightness()`gets the value of the brightness 0-255 -`rgb` get the rgb color state of the bulb and turns it on +`PilotParser.get_rgb()` get the rgbW color state of the bulb -`status` returns true or false / true = on , false = off +`PilotPerson.get_colortemp()` get the color temperature ot the bulb -### Methods -`getBulbConfig(self)` returns the configuration from the bulb +`PilotPerson.get_warm_white/get_cold_white()` get the current warm/cold setting (not supported by original Phillips Wiz bulbs) + +`PilotPerson.get_scene()` gets the current scene name -`getState(self)` gets the current bulb state - no paramters need to be included +`PilotPerson.get_state()` returns true or false / true = on , false = off -`hex_to_percent(self, hex)` helper for convertring 0-255 to 0-100 +### Methods +`getBulbConfig(self)` returns the hardware configuration of the bulb -`percent_to_hex(self, percent)` helper for converting 0-100 into 0-255 +`updateState(self)` gets the current bulb state from the light using `sendUDPMessage` and sets it to `self.state` `lightSwitch(self)` turns the light bulb on or off like a switch -`sendUDPMessage(self, message)` send the udp message to the bulb +`sendUDPMessage(self, message, timeout = 60, send_interval = 0.5, max_send_datagrams = 100):` sends the udp message to the bulb. Since UDP can loose packets, and your light might be a long distance away from the router, we continuously keep sending the UDP command datagram until there is a response from the light. This has in tests worked way better than just sending once and just waiting for a timeout. You can set the async operation timeout using `timeout`, the time interval to sleep between continuous UDP sends using `send_interval` and the maximum number of continuous pings to send using `max_send_datagrams`. It is already hard coded to a lower value for `setPilot` (set light state) vs `getPilot` (fetch light state) so as to avoid flickering the light. `turn_off(self)` turns the light off -`turn_on(self)` turns the light on +`turn_on(PilotBuilder)` turns the light on. This take a `PilotBuilder` object, which can be used to set all the parameters programmtically - rgb, color temperature, brightness, etc. To set the light to rhythm mode, create an empty `PilotBuilder`. -## Bulb methodes (UDP nativ): +## Bulb methodes (UDP native): - **getSystemConfig** - gets the current system configuration - no paramters need - **syncPilot** - sent by the bulb as heart-beats - **getPilot** - gets the current bulb state - no paramters need to be included @@ -87,11 +119,28 @@ Work in progress an only tested with the [SLV Play RGB bulb](https://www.amazon. - **Registration** - used to "register" with the bulb: This notifies the built that it you want it to send you heartbeat sync packets. -## Example requests +## Example UDP requests Send message to the bulb: `{"method":"setPilot","params":{"r":255,"g":255,"b":255,"dimming":50}}` Response: `{"method":"setPilot","env":"pro","result":{"success":true}}` Get state of the bulb: `{"method":"getPilot","params":{}}` -Response: `{"method":"getPilot","env":"pro","result":{"mac":"0000000000","rssi":-65,"src":"","state":false,"sceneId":0,"temp":6500,"dimming":100}}` +Responses: + +custom color mode: + +`{'method': 'getPilot', 'env': 'pro', 'result': {'mac': 'a8bb50a4f94d', 'rssi': -60, 'src': '', 'state': True, 'sceneId': 0, 'temp': 5075, 'dimming': 47}}` + +scene mode: + +`{'method': 'getPilot', 'env': 'pro', 'result': {'mac': 'a8bb50a4f94d', 'rssi': -65, 'src': '', 'state': True, 'sceneId': 12, 'speed': 100, 'temp': 4200, 'dimming': 47}}` + +rhythm mode: + +`{'method': 'getPilot', 'env': 'pro', 'result': {'mac': 'a8bb50a4f94d', 'rssi': -63, 'src': '', 'state': True, 'sceneId': 14, 'speed': 100, 'dimming': 100, 'schdPsetId': 9}}` + +## Contributors + +@sbidy for the entire python library from scratch with complete light control +@angadsingh for implementing asyncio and non-blocking UDP, rhythm support, performance optimizations (https://github.com/angadsingh/wiz_light/commit/caa90cebd9f8ccb2d588c900e36fbf19277eda9c) diff --git a/pywizlight/bulb.py b/pywizlight/bulb.py old mode 100644 new mode 100755 index 38d556a..2f6f703 --- a/pywizlight/bulb.py +++ b/pywizlight/bulb.py @@ -23,299 +23,308 @@ ''' import socket import json +import asyncio +import asyncio_dgram +import logging +from time import time +from .scenes import SCENES -class wizlight: - ''' - Creates a instance of a WiZ Light Bulb - ''' - # default port for WiZ lights - 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" - } +_LOGGER = logging.getLogger(__name__) - def __init__ (self, ip, port=38899): - ''' Constructor with ip of the bulb ''' - self.ip = ip - self.port = port - +class PilotBuilder: + def __init__ (self, warm_white = None, + cold_white = None, + speed = None, + scene = None, + rgb = None, + brightness = None, + colortemp = None): - @property - def warm_white(self) -> int: - ''' - get the value of the warm white led - ''' - resp = self.getState() - if "w" in resp['result']: - return resp['result']['w'] - else: - return None + self.pilot_params = {"state": True} - @warm_white.setter - def warm_white(self, value: int): + 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: - message = r'{"method":"setPilot","params":{"w":%i}}' % (value) - self.sendUDPMessage(message) + self.pilot_params['w'] = value - @property - def speed(self) -> int: - ''' - get the color changing speed + + def _set_cold_white(self, value: int): ''' - resp = self.getState() - if "speed" in resp['result']: - return resp['result']['speed'] + set the value of the cold white led + ''' + if value > 0 and value < 256: + self.pilot_params['c'] = value else: - return None + raise IndexError("Value must be between 1 and 255") - @speed.setter - def speed(self, value: int): + 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: - message = r'{"method":"setPilot","params":{"speed":%i}}' % (value) - self.sendUDPMessage(message) + self.pilot_params['speed'] = value else: raise IndexError("Value must be between 0 and 100") - @property - def scene(self) -> str: + def _set_scene(self, scene_id: int): ''' - get the current scene name + set the scene by id ''' - id = self.getState()['result']['sceneId'] - if id in self.SCENES: - return self.SCENES[id] + if scene_id in SCENES: + self.pilot_params['sceneId'] = scene_id else: - return None + # id not in SCENES ! + raise IndexError("Scene is not available - only 0 to 32 are supported") - @scene.setter - def scene(self, scene_id: int): + def _set_rgb(self, values): ''' - set the scene by id + set the rgb color state of the bulb ''' - if scene_id in self.SCENES: - message = '{"method":"setPilot","params":{"sceneId":%i}}' % scene_id - self.sendUDPMessage(message) + 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: - # id not in SCENES ! - raise IndexError("Scene is not available - only 0 to 32 are supported") + return None - @property - def cold_white(self) -> int: + def get_speed(self) -> int: + ''' + get the color changing speed ''' - get the value of the cold white led + if "speed" in self.pilotResult: + return self.pilotResult['speed'] + else: + return None + + def get_scene(self) -> str: ''' - resp = self.getState() - if "c" in resp['result']: - return resp['result']['c'] + 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 - @cold_white.setter - def cold_white(self, value: int): + def get_cold_white(self) -> int: ''' - set the value of the cold white led + get the value of the cold white led ''' - if value > 0 and value < 256: - message = r'{"method":"setPilot","params":{"c":%i}}' % (value) - self.sendUDPMessage(message) + if "c" in self.pilotResult: + return self.pilotResult['c'] else: - raise IndexError("Value must be between 1 and 255") + return None - @property - def rgb(self): + def get_rgb(self): ''' get the rgb color state of the bulb and turns it on ''' - resp = self.getState() - if "r" in resp['result'] and "g" in resp['result'] and "b" in resp['result']: - r = resp['result']['r'] - g = resp['result']['g'] - b = resp['result']['b'] + 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 - @rgb.setter - def rgb(self, values): - ''' - set the rgb color state of the bulb - ''' - r, g, b = values - message = r'{"method":"setPilot","params":{"r":%i,"g":%i,"b":%i}}' % (r,g,b) - self.sendUDPMessage(message) - - @property - def brightness(self) -> int: + def get_brightness(self) -> int: ''' gets the value of the brightness 0-255 ''' - return self.percent_to_hex(self.getState()['result']['dimming']) + return self.percent_to_hex(self.pilotResult['dimming']) - @brightness.setter - def 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 - message = r'{"method":"setPilot","params":{"dimming":%i}}' % percent - self.sendUDPMessage(message) - - @property - def colortemp(self) -> int: - resp = self.getState() - if "temp" in resp['result']: - return resp['result']['temp'] + def get_colortemp(self) -> int: + if "temp" in self.pilotResult: + return self.pilotResult['temp'] else: return None - @colortemp.setter - def 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 - - message = r'{"method":"setPilot","params":{"temp":%i}}' % kelvin - self.sendUDPMessage(message) + 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 ''' - resp = self.getState() - if "state" in resp['result']: - return resp['result']['state'] - else: - raise ValueError("Cant read response for 'state' from the bulb. Debug:", resp) + if self.state == None: + return None + return self.state.get_state() ## ------------------ Non properties -------------- - def turn_off(self): + async def turn_off(self): ''' turns the light off ''' message = r'{"method":"setPilot","params":{"state":false}}' - self.sendUDPMessage(message) + await self.sendUDPMessage(message) - def turn_on(self): + async def turn_on(self, pilot_builder = PilotBuilder()): ''' turns the light on ''' - message = r'{"method":"setPilot","params":{"state":true}}' - self.sendUDPMessage(message) + 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 self.SCENES: - if self.SCENES[id] == scene: + for id in SCENES: + if SCENES[id] == scene: return id raise ValueError("Scene '%s' not in scene list." % scene) ### ---------- Helper Functions ------------ - def getState(self): + 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":{}}' - return self.sendUDPMessage(message) + resp = await self.sendUDPMessage(message) + if resp != None and 'result' in resp: + self.state = PilotParser(resp['result']) + else: + self.state = None + return self.state - def getBulbConfig(self): + async def getBulbConfig(self): ''' returns the configuration from the bulb ''' message = r'{"method":"getSystemConfig","params":{}}' - return self.sendUDPMessage(message) + return await self.sendUDPMessage(message) - def lightSwitch(self): + async def lightSwitch(self): ''' turns the light bulb on or off like a switch ''' # first get the status - result = self.getState() - if result['result']['state']: + state = await self.updateState() + if state.get_state(): # if the light is on - switch off - self.turn_off() + await self.turn_off() else: # if the light is off - turn on - self.turn_on() - - def getConnection(self) -> bool: - ''' - returns true or false and indicates the connection state - ''' - try: - self.getState() - return True - except OSError: - return False + await self.turn_on() + + async def receiveUDPwithTimeout(self, stream, timeout): + data, remote_addr = await asyncio.wait_for(stream.recv(), timeout) + return data - def sendUDPMessage(self, message): + async def sendUDPMessage(self, message, timeout = 60, send_interval = 0.5, max_send_datagrams = 100): ''' send the udp message to the bulb ''' - # fix port for Wiz Lights - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # UDP - sock.sendto(bytes(message, "utf-8"), (self.ip, self.port)) - sock.settimeout(20.0) - data, addr = sock.recvfrom(1024) - if len(data) is not None: + 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: - return 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) - - def hex_to_percent(self, hex): - ''' converts hex 0-255 values to percent ''' - return round((hex/255)*100) - - def percent_to_hex(self, percent): - ''' converts percent values 0-100 into hex 0-255''' - return round((percent / 100)*255) diff --git a/pywizlight/scenes.py b/pywizlight/scenes.py new file mode 100755 index 0000000..bd185b8 --- /dev/null +++ b/pywizlight/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