From 1f07086c02ebb2789e66febe66a3018da9e13052 Mon Sep 17 00:00:00 2001 From: uzlonewolf Date: Sat, 13 Jul 2024 17:35:12 -0700 Subject: [PATCH 1/9] Rework BulbDevice to use set_value()/set_multiple_values() instead of building the payload directly --- tinytuya/BulbDevice.py | 150 ++++++++++++++--------------------------- 1 file changed, 50 insertions(+), 100 deletions(-) diff --git a/tinytuya/BulbDevice.py b/tinytuya/BulbDevice.py index 35d3d96..6ef0c67 100644 --- a/tinytuya/BulbDevice.py +++ b/tinytuya/BulbDevice.py @@ -29,28 +29,7 @@ result = state(): Inherited - json = status() # returns json payload - set_version(version) # 3.1 [default] or 3.3 - set_socketPersistent(False/True) # False [default] or True - set_socketNODELAY(False/True) # False or True [default] - set_socketRetryLimit(integer) # retry count limit [default 5] - set_socketTimeout(timeout) # set connection timeout in seconds [default 5] - set_dpsUsed(dps_to_request) # add data points (DPS) to request - add_dps_to_request(index) # add data point (DPS) index set to None - set_retry(retry=True) # retry if response payload is truncated - set_status(on, switch=1, nowait) # Set status of switch to 'on' or 'off' (bool) - set_value(index, value, nowait) # Set int value of any index. - heartbeat(nowait) # Send heartbeat to device - updatedps(index=[1], nowait) # Send updatedps command to device - turn_on(switch=1, nowait) # Turn on device / switch # - turn_off(switch=1, nowait) # Turn off - set_timer(num_secs, nowait) # Set timer for num_secs - set_debug(toggle, color) # Activate verbose debugging output - set_sendWait(num_secs) # Time to wait after sending commands before pulling response - detect_available_dps() # Return list of DPS available from device - generate_payload(command, data) # Generate TuyaMessage payload for command with data - send(payload) # Send payload to device (do not wait for response) - receive() # Receive payload from device + Every device function from core.py """ import colorsys @@ -136,36 +115,33 @@ def _rgb_to_hexvalue(r, g, b, bulb="A"): # Bulb Type A if bulb == "A": - # h:0-360,s:0-255,v:0-255|hsv| hexvalue = "" - for value in rgb: - temp = str(hex(int(value))).replace("0x", "") - if len(temp) == 1: - temp = "0" + temp - hexvalue = hexvalue + temp - - hsvarray = [int(hsv[0] * 360), int(hsv[1] * 255), int(hsv[2] * 255)] - hexvalue_hsv = "" - for value in hsvarray: - temp = str(hex(int(value))).replace("0x", "") - if len(temp) == 1: - temp = "0" + temp - hexvalue_hsv = hexvalue_hsv + temp - if len(hexvalue_hsv) == 7: - hexvalue = hexvalue + "0" + hexvalue_hsv - else: - hexvalue = hexvalue + "00" + hexvalue_hsv + + # r:0-255,g:0-255,b:0-255|rgb| + for rgb_value in rgb: + value = int(rgb_value) + if value < 0 or value > 255: + raise ValueError(f"Bulb type {bulb} must have RGB values 0-255.") + hexvalue += '%02x' % value + + # h:0-360,s:0-255,v:0-255|hsv| + hexvalue += '%04x' % int(hsv[0] * 360) + hexvalue += '%02x' % int(hsv[1] * 255) + hexvalue += '%02x' % int(hsv[2] * 255) # Bulb Type B - if bulb == "B": + elif bulb == "B": # h:0-360,s:0-1000,v:0-1000|hsv| hexvalue = "" hsvarray = [int(hsv[0] * 360), int(hsv[1] * 1000), int(hsv[2] * 1000)] - for value in hsvarray: - temp = str(hex(int(value))).replace("0x", "") - while len(temp) < 4: - temp = "0" + temp - hexvalue = hexvalue + temp + for hsv_value in hsvarray: + value = int(hsv_value) + if value < 0 or value > 1000: + raise ValueError(f"Bulb type {bulb} must have RGB values 0-255.") + hexvalue += '%04x' % int(value) + else: + # Unsupported bulb type + raise ValueError(f"Unsupported bulb type {bulb} - unable to determine hexvalue.") return hexvalue @@ -273,11 +249,7 @@ def set_mode(self, mode="white", nowait=False): mode(string): white,colour,scene,music nowait(bool): True to send without waiting for response. """ - payload = self.generate_payload( - CONTROL, {self.DPS_INDEX_MODE[self.bulb_type]: mode} - ) - data = self._send_receive(payload, getresponse=(not nowait)) - return data + return self.set_value( self.DPS_INDEX_MODE[self.bulb_type], mode, nowait=nowait ) def set_scene(self, scene, nowait=False): """ @@ -301,11 +273,7 @@ def set_scene(self, scene, nowait=False): else: s = self.DPS_MODE_SCENE_4 - payload = self.generate_payload( - CONTROL, {self.DPS_INDEX_MODE[self.bulb_type]: s} - ) - data = self._send_receive(payload, getresponse=(not nowait)) - return data + return self.set_value( self.DPS_INDEX_MODE[self.bulb_type], s, nowait=nowait ) def set_colour(self, r, g, b, nowait=False): """ @@ -338,15 +306,12 @@ def set_colour(self, r, g, b, nowait=False): hexvalue = BulbDevice._rgb_to_hexvalue(r, g, b, self.bulb_type) - payload = self.generate_payload( - CONTROL, - { - self.DPS_INDEX_MODE[self.bulb_type]: self.DPS_MODE_COLOUR, - self.DPS_INDEX_COLOUR[self.bulb_type]: hexvalue, - }, - ) - data = self._send_receive(payload, getresponse=(not nowait)) - return data + payload = { + self.DPS_INDEX_MODE[self.bulb_type]: self.DPS_MODE_COLOUR, + self.DPS_INDEX_COLOUR[self.bulb_type]: hexvalue, + } + + return self.set_multiple_values( payload, nowait=nowait ) def set_hsv(self, h, s, v, nowait=False): """ @@ -377,19 +342,8 @@ def set_hsv(self, h, s, v, nowait=False): ) (r, g, b) = colorsys.hsv_to_rgb(h, s, v) - hexvalue = BulbDevice._rgb_to_hexvalue( - r * 255.0, g * 255.0, b * 255.0, self.bulb_type - ) - - payload = self.generate_payload( - CONTROL, - { - self.DPS_INDEX_MODE[self.bulb_type]: self.DPS_MODE_COLOUR, - self.DPS_INDEX_COLOUR[self.bulb_type]: hexvalue, - }, - ) - data = self._send_receive(payload, getresponse=(not nowait)) - return data + + return self.set_colour( r * 255.0, g * 255.0, b * 255.0, nowait=nowait ) def set_white_percentage(self, brightness=100, colourtemp=0, nowait=False): """ @@ -466,17 +420,13 @@ def set_white(self, brightness=-1, colourtemp=-1, nowait=False): "set_white: The colour temperature needs to be between 0 and 1000.", ) - payload = self.generate_payload( - CONTROL, - { - self.DPS_INDEX_MODE[self.bulb_type]: self.DPS_MODE_WHITE, - self.DPS_INDEX_BRIGHTNESS[self.bulb_type]: brightness, - self.DPS_INDEX_COLOURTEMP[self.bulb_type]: colourtemp, - }, - ) + payload = { + self.DPS_INDEX_MODE[self.bulb_type]: self.DPS_MODE_WHITE, + self.DPS_INDEX_BRIGHTNESS[self.bulb_type]: brightness, + self.DPS_INDEX_COLOURTEMP[self.bulb_type]: colourtemp, + } - data = self._send_receive(payload, getresponse=(not nowait)) - return data + return self.set_multiple_values( payload, nowait=nowait ) def set_brightness_percentage(self, brightness=100, nowait=False): """ @@ -520,6 +470,7 @@ def set_brightness(self, brightness, nowait=False): # Determine which mode bulb is in and adjust accordingly state = self.state() data = None + msg = 'set_brightness: ' if "mode" in state: if state["mode"] == "white": @@ -527,12 +478,9 @@ def set_brightness(self, brightness, nowait=False): if not self.has_brightness: log.debug("set_brightness: Device does not appear to support brightness.") # return error_json(ERR_FUNCTION, "set_brightness: Device does not support brightness.") - payload = self.generate_payload( - CONTROL, {self.DPS_INDEX_BRIGHTNESS[self.bulb_type]: brightness} - ) - data = self._send_receive(payload, getresponse=(not nowait)) - - if state["mode"] == "colour": + data = self.set_value( self.DPS_INDEX_BRIGHTNESS[self.bulb_type], brightness, nowait=nowait ) + msg += 'No repsonse from bulb.' + elif state["mode"] == "colour": # for colour mode use hsv to increase brightness if self.bulb_type == "A": value = brightness / 255.0 @@ -540,11 +488,17 @@ def set_brightness(self, brightness, nowait=False): value = brightness / 1000.0 (h, s, v) = self.colour_hsv() data = self.set_hsv(h, s, value, nowait=nowait) + msg += 'No repsonse from bulb.' + else: + msg += "Device mode is not 'white' or 'colour', cannot set brightness." + else: + msg += 'Unknown bulb state.' if data is not None or nowait is True: return data else: - return error_json(ERR_STATE, "set_brightness: Unknown bulb state.") + log.debug( msg ) + return error_json(ERR_STATE, msg) def set_colourtemp_percentage(self, colourtemp=100, nowait=False): """ @@ -588,11 +542,7 @@ def set_colourtemp(self, colourtemp, nowait=False): "set_colourtemp: The colour temperature needs to be between 0 and 1000.", ) - payload = self.generate_payload( - CONTROL, {self.DPS_INDEX_COLOURTEMP[self.bulb_type]: colourtemp} - ) - data = self._send_receive(payload, getresponse=(not nowait)) - return data + return self.set_value( self.DPS_INDEX_COLOURTEMP[self.bulb_type], colourtemp, nowait=nowait ) def brightness(self): """Return brightness value""" From 059625ceb6707644a79387706b1c5442d3d1d781 Mon Sep 17 00:00:00 2001 From: uzlonewolf Date: Sat, 13 Jul 2024 17:42:48 -0700 Subject: [PATCH 2/9] Add status caching to core --- tinytuya/core.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tinytuya/core.py b/tinytuya/core.py index 0dd98ca..58ae300 100644 --- a/tinytuya/core.py +++ b/tinytuya/core.py @@ -931,6 +931,7 @@ def _get_socket(self, renew): self.socket = None if self.socket is None: # Set up Socket + self._last_status = {} retries = 0 err = ERR_OFFLINE while retries < self.socketRetryLimit: @@ -1002,6 +1003,7 @@ def _check_socket_close(self, force=False): if (force or not self.socketPersistent) and self.socket: self.socket.close() self.socket = None + self._last_status = {} def _recv_all(self, length): tries = 2 @@ -1289,8 +1291,10 @@ def _process_message( self, msg, dev_type=None, from_child=None, minresponse=28, # if persistent, save response until the next receive() call # otherwise, trash it if found_child: + found_child._cache_response(result) result = found_child._process_response(result) else: + self._cache_response(result) result = self._process_response(result) self.received_wrong_cid_queue.append( (found_child, result) ) # events should not be coming in so fast that we will never timeout a read, so don't worry about loops @@ -1300,8 +1304,10 @@ def _process_message( self, msg, dev_type=None, from_child=None, minresponse=28, self._check_socket_close() if found_child: + found_child._cache_response(result) return found_child._process_response(result) + self._cache_response(result) return self._process_response(result) def _decode_payload(self, payload): @@ -1381,6 +1387,25 @@ def _decode_payload(self, payload): return json_payload + def _cache_response(self, response): # pylint: disable=R0201 + """ + Save (cache) the last value of every DP + """ + if (not self.socketPersistent) or (not self.socket): + return + + if response and isinstance(response, dict) and 'Error' not in response and 'Err' not in response: + for k in response: + if k == 'dps' and response[k] and isinstance(response[k], dict): + if 'dps' not in self._last_status or not isinstance(self._last_status['dps'], dict): + self._last_status['dps'] = {} + for dkey in response[k]: + self._last_status['dps'][dkey] = response[k][dkey] + #elif k == 'data' and response[k] and isinstance(response[k], dict) and 'dps' in response[k]: + # pass + else: + self._last_status[k] = response[k] + def _process_response(self, response): # pylint: disable=R0201 """ Override this function in a sub-class if you want to do some processing on the received data @@ -1565,6 +1590,17 @@ def status(self, nowait=False): return data + def last_status(self, nowait=True): + if (not self.socketPersistent) or (not self.socket) or (not self._last_status): + if not nowait: + log.debug("Last status caching not available, requesting status from device") + return self.status() + log.debug("Last status caching not available, returning None") + return None + + log.debug("Have status cache, returning it") + return self._last_status + def subdev_query( self, nowait=False ): """Query for a list of sub-devices and their status""" # final payload should look like: {"data":{"cids":[]},"reqType":"subdev_online_stat_query"} @@ -1625,6 +1661,7 @@ def set_socketPersistent(self, persist): if self.socket and not persist: self.socket.close() self.socket = None + self._last_status = {} def set_socketNODELAY(self, nodelay): self.socketNODELAY = nodelay From cacb519f92f3ea5a33a8cae9ed9e4cb79c29d2b3 Mon Sep 17 00:00:00 2001 From: uzlonewolf Date: Sat, 13 Jul 2024 17:50:28 -0700 Subject: [PATCH 3/9] Make BulbDevice prefer cached status when available --- tinytuya/BulbDevice.py | 12 ++++++------ tinytuya/core.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tinytuya/BulbDevice.py b/tinytuya/BulbDevice.py index 6ef0c67..8f99fcd 100644 --- a/tinytuya/BulbDevice.py +++ b/tinytuya/BulbDevice.py @@ -208,7 +208,7 @@ def set_version(self, version): # pylint: disable=W0621 super(BulbDevice, self).set_version(version) # Try to determine type of BulbDevice Type based on DPS indexes - status = self.status() + status = self.cached_status() if status is not None: if "dps" in status: if "1" not in status["dps"]: @@ -546,25 +546,25 @@ def set_colourtemp(self, colourtemp, nowait=False): def brightness(self): """Return brightness value""" - return self.status()[self.DPS][self.DPS_INDEX_BRIGHTNESS[self.bulb_type]] + return self.cached_status()[self.DPS][self.DPS_INDEX_BRIGHTNESS[self.bulb_type]] def colourtemp(self): """Return colour temperature""" - return self.status()[self.DPS][self.DPS_INDEX_COLOURTEMP[self.bulb_type]] + return self.cached_status()[self.DPS][self.DPS_INDEX_COLOURTEMP[self.bulb_type]] def colour_rgb(self): """Return colour as RGB value""" - hexvalue = self.status()[self.DPS][self.DPS_INDEX_COLOUR[self.bulb_type]] + hexvalue = self.cached_status()[self.DPS][self.DPS_INDEX_COLOUR[self.bulb_type]] return BulbDevice._hexvalue_to_rgb(hexvalue, self.bulb_type) def colour_hsv(self): """Return colour as HSV value""" - hexvalue = self.status()[self.DPS][self.DPS_INDEX_COLOUR[self.bulb_type]] + hexvalue = self.cached_status()[self.DPS][self.DPS_INDEX_COLOUR[self.bulb_type]] return BulbDevice._hexvalue_to_hsv(hexvalue, self.bulb_type) def state(self): """Return state of Bulb""" - status = self.status() + status = self.cached_status() state = {} if not status: return error_json(ERR_JSON, "state: empty response") diff --git a/tinytuya/core.py b/tinytuya/core.py index 58ae300..0b02127 100644 --- a/tinytuya/core.py +++ b/tinytuya/core.py @@ -1590,7 +1590,7 @@ def status(self, nowait=False): return data - def last_status(self, nowait=True): + def cached_status(self, nowait=False): if (not self.socketPersistent) or (not self.socket) or (not self._last_status): if not nowait: log.debug("Last status caching not available, requesting status from device") From f9c14fe05f48c64998c26410d6290644e41200ab Mon Sep 17 00:00:00 2001 From: uzlonewolf Date: Tue, 30 Jul 2024 06:17:32 -0700 Subject: [PATCH 4/9] Rewrite BulbDevice --- tinytuya/BulbDevice.py | 194 ++++++++++++++++++++++++++++++++--------- tinytuya/core.py | 1 + 2 files changed, 154 insertions(+), 41 deletions(-) diff --git a/tinytuya/BulbDevice.py b/tinytuya/BulbDevice.py index 8f99fcd..5d048eb 100644 --- a/tinytuya/BulbDevice.py +++ b/tinytuya/BulbDevice.py @@ -78,18 +78,24 @@ class BulbDevice(Device): "24": "colour", } - # Set Default Bulb Types - bulb_type = "A" - has_brightness = False - has_colourtemp = False - has_colour = False - def __init__(self, *args, **kwargs): + # Set Default Bulb Types + self.bulb_type = None + self.has_brightness = None + self.has_colourtemp = None + self.has_colour = None + # set the default version to None so we do not immediately connect and call status() if 'version' not in kwargs or not kwargs['version']: kwargs['version'] = None super(BulbDevice, self).__init__(*args, **kwargs) + def status(self, nowait=False): + result = super(BulbDevice, self).status(nowait=nowait) + if result and (not self.bulb_type) and (self.DPS in result): + self.detect_bulb(result) + return result + @staticmethod def _rgb_to_hexvalue(r, g, b, bulb="A"): """ @@ -197,50 +203,40 @@ def _hexvalue_to_hsv(hexvalue, bulb="A"): return (h, s, v) - def set_version(self, version): # pylint: disable=W0621 - """ - Set the Tuya device version 3.1 or 3.3 for BulbDevice - Attempt to determine BulbDevice Type: A or B based on: - Type A has keys 1-5 (default) - Type B has keys 20-29 - Type C is Feit type bulbs from costco - """ - super(BulbDevice, self).set_version(version) - - # Try to determine type of BulbDevice Type based on DPS indexes - status = self.cached_status() - if status is not None: - if "dps" in status: - if "1" not in status["dps"]: - self.bulb_type = "B" - if self.DPS_INDEX_BRIGHTNESS[self.bulb_type] in status["dps"]: - self.has_brightness = True - if self.DPS_INDEX_COLOURTEMP[self.bulb_type] in status["dps"]: - self.has_colourtemp = True - if self.DPS_INDEX_COLOUR[self.bulb_type] in status["dps"]: - self.has_colour = True - else: - self.bulb_type = "B" - else: - # response has no dps - self.bulb_type = "B" - log.debug("bulb type set to %s", self.bulb_type) - def turn_on(self, switch=0, nowait=False): """Turn the device on""" if switch == 0: + if not self.bulb_type: + self.detect_bulb() switch = self.DPS_INDEX_ON[self.bulb_type] self.set_status(True, switch, nowait=nowait) def turn_off(self, switch=0, nowait=False): """Turn the device on""" if switch == 0: + if not self.bulb_type: + self.detect_bulb() switch = self.DPS_INDEX_ON[self.bulb_type] self.set_status(False, switch, nowait=nowait) - def set_bulb_type(self, type): + def set_bulb_type(self, type, has_brightness=None, has_colourtemp=None, has_colour=None): self.bulb_type = type + if has_brightness is not None: + self.has_brightness = has_brightness + elif self.has_brightness is None: + self.has_brightness = bool(self.DPS_INDEX_BRIGHTNESS[self.bulb_type]) + + if has_colourtemp is not None: + self.has_colourtemp = has_colourtemp + elif self.has_colourtemp is None: + self.has_colourtemp = bool(self.DPS_INDEX_COLOURTEMP[self.bulb_type]) + + if has_colour is not None: + self.has_colour = has_colour + elif self.has_colour is None: + self.has_colour = bool(self.DPS_INDEX_COLOUR[self.bulb_type]) + def set_mode(self, mode="white", nowait=False): """ Set bulb mode @@ -249,6 +245,8 @@ def set_mode(self, mode="white", nowait=False): mode(string): white,colour,scene,music nowait(bool): True to send without waiting for response. """ + if not self.bulb_type: + self.detect_bulb() return self.set_value( self.DPS_INDEX_MODE[self.bulb_type], mode, nowait=nowait ) def set_scene(self, scene, nowait=False): @@ -264,6 +262,9 @@ def set_scene(self, scene, nowait=False): ERR_RANGE, "set_scene: The value for scene needs to be between 1 and 4." ) + if not self.bulb_type: + self.detect_bulb() + if scene == 1: s = self.DPS_MODE_SCENE_1 elif scene == 2: @@ -304,13 +305,32 @@ def set_colour(self, r, g, b, nowait=False): "set_colour: The value for blue needs to be between 0 and 255.", ) + if not self.bulb_type: + self.detect_bulb() + hexvalue = BulbDevice._rgb_to_hexvalue(r, g, b, self.bulb_type) payload = { - self.DPS_INDEX_MODE[self.bulb_type]: self.DPS_MODE_COLOUR, self.DPS_INDEX_COLOUR[self.bulb_type]: hexvalue, } + dp_index_mode = self.DPS_INDEX_MODE[self.bulb_type] + dp_index_on = self.DPS_INDEX_ON[self.bulb_type] + + # check to see if power and mode also need to be set + state = self.cached_status(nowait=True) + if state and self.DPS in state and state[self.DPS]: + # last state is cached, so check to see if 'mode' needs to be set + if (dp_index_mode not in state[self.DPS]) or (state[self.DPS][dp_index_mode] != self.DPS_MODE_COLOUR): + payload[dp_index_mode] = self.DPS_MODE_COLOUR + # last state is cached, so check to see if 'power' needs to be set + if (dp_index_on not in state[self.DPS]) or (not state[self.DPS][dp_index_on]): + payload[dp_index_on] = True + else: + # last state not cached so just assume they're needed + payload[dp_index_mode] = self.DPS_MODE_COLOUR + payload[dp_index_on] = True + return self.set_multiple_values( payload, nowait=nowait ) def set_hsv(self, h, s, v, nowait=False): @@ -361,10 +381,13 @@ def set_white_percentage(self, brightness=100, colourtemp=0, nowait=False): "set_white_percentage: Brightness percentage needs to be between 0 and 100.", ) - b = int(25 + (255 - 25) * brightness / 100) + if not self.bulb_type: + self.detect_bulb() if self.bulb_type == "B": b = int(10 + (1000 - 10) * brightness / 100) + else: + b = int(25 + (255 - 25) * brightness / 100) # Colourtemp if not 0 <= colourtemp <= 100: @@ -373,10 +396,10 @@ def set_white_percentage(self, brightness=100, colourtemp=0, nowait=False): "set_white_percentage: Colourtemp percentage needs to be between 0 and 100.", ) - c = int(255 * colourtemp / 100) - if self.bulb_type == "B": c = int(1000 * colourtemp / 100) + else: + c = int(255 * colourtemp / 100) data = self.set_white(b, c, nowait=nowait) return data @@ -392,6 +415,9 @@ def set_white(self, brightness=-1, colourtemp=-1, nowait=False): Default: Max Brightness and Min Colourtemp """ + if not self.bulb_type: + self.detect_bulb() + # Brightness (default Max) if brightness < 0: brightness = 255 @@ -421,11 +447,27 @@ def set_white(self, brightness=-1, colourtemp=-1, nowait=False): ) payload = { - self.DPS_INDEX_MODE[self.bulb_type]: self.DPS_MODE_WHITE, self.DPS_INDEX_BRIGHTNESS[self.bulb_type]: brightness, self.DPS_INDEX_COLOURTEMP[self.bulb_type]: colourtemp, } + dp_index_mode = self.DPS_INDEX_MODE[self.bulb_type] + dp_index_on = self.DPS_INDEX_ON[self.bulb_type] + + # check to see if power and mode also need to be set + state = self.cached_status(nowait=True) + if state and self.DPS in state and state[self.DPS]: + # last state is cached, so check to see if 'mode' needs to be set + if (dp_index_mode not in state[self.DPS]) or (state[self.DPS][dp_index_mode] != self.DPS_MODE_COLOUR): + payload[dp_index_mode] = self.DPS_MODE_WHITE + # last state is cached, so check to see if 'power' needs to be set + if (dp_index_on not in state[self.DPS]) or (not state[self.DPS][dp_index_on]): + payload[dp_index_on] = True + else: + # last state not cached so just assume they're needed + payload[dp_index_mode] = self.DPS_MODE_WHITE + payload[dp_index_on] = True + return self.set_multiple_values( payload, nowait=nowait ) def set_brightness_percentage(self, brightness=100, nowait=False): @@ -441,6 +483,10 @@ def set_brightness_percentage(self, brightness=100, nowait=False): ERR_RANGE, "set_brightness_percentage: Brightness percentage needs to be between 0 and 100.", ) + + if not self.bulb_type: + self.detect_bulb() + b = int(25 + (255 - 25) * brightness / 100) if self.bulb_type == "B": b = int(10 + (1000 - 10) * brightness / 100) @@ -456,6 +502,9 @@ def set_brightness(self, brightness, nowait=False): brightness(int): Value for the brightness (25-255). nowait(bool): True to send without waiting for response. """ + if not self.bulb_type: + self.detect_bulb() + if self.bulb_type == "A" and not 25 <= brightness <= 255: return error_json( ERR_RANGE, @@ -513,6 +562,10 @@ def set_colourtemp_percentage(self, colourtemp=100, nowait=False): ERR_RANGE, "set_colourtemp_percentage: Colourtemp percentage needs to be between 0 and 100.", ) + + if not self.bulb_type: + self.detect_bulb() + c = int(255 * colourtemp / 100) if self.bulb_type == "B": c = int(1000 * colourtemp / 100) @@ -528,6 +581,10 @@ def set_colourtemp(self, colourtemp, nowait=False): colourtemp(int): Value for the colour temperature (0-255). nowait(bool): True to send without waiting for response. """ + + if not self.bulb_type: + self.detect_bulb() + if not self.has_colourtemp: log.debug("set_colourtemp: Device does not appear to support colortemp.") # return error_json(ERR_FUNCTION, "set_colourtemp: Device does not support colortemp.") @@ -546,19 +603,23 @@ def set_colourtemp(self, colourtemp, nowait=False): def brightness(self): """Return brightness value""" + if not self.bulb_type: self.detect_bulb() return self.cached_status()[self.DPS][self.DPS_INDEX_BRIGHTNESS[self.bulb_type]] def colourtemp(self): """Return colour temperature""" + if not self.bulb_type: self.detect_bulb() return self.cached_status()[self.DPS][self.DPS_INDEX_COLOURTEMP[self.bulb_type]] def colour_rgb(self): """Return colour as RGB value""" + if not self.bulb_type: self.detect_bulb() hexvalue = self.cached_status()[self.DPS][self.DPS_INDEX_COLOUR[self.bulb_type]] return BulbDevice._hexvalue_to_rgb(hexvalue, self.bulb_type) def colour_hsv(self): """Return colour as HSV value""" + if not self.bulb_type: self.detect_bulb() hexvalue = self.cached_status()[self.DPS][self.DPS_INDEX_COLOUR[self.bulb_type]] return BulbDevice._hexvalue_to_hsv(hexvalue, self.bulb_type) @@ -580,3 +641,54 @@ def state(self): state[self.DPS_2_STATE[key]] = status[self.DPS][key] return state + + def detect_bulb(self, response=None, default='B'): + """ + Attempt to determine BulbDevice Type: A, B or C based on: + Type A has keys 1-5 + Type B has keys 20-29 + Type C is Feit type bulbs from costco + """ + if not response: + response = self.cached_status(nowait=True) + if (not response) or (self.DPS not in response): + response = self.status() + # return here as self.status() will call us again + return + if response and self.DPS in response: + # Try to determine type of BulbDevice Type based on DPS indexes + if self.bulb_type is None: + if self.DPS_INDEX_ON['B'] in response[self.DPS]: + self.bulb_type = "B" + elif self.DPS_INDEX_ON['A'] in response[self.DPS] and self.DPS_INDEX_BRIGHTNESS['A'] in response[self.DPS]: + if self.DPS_INDEX_COLOURTEMP['A'] in response[self.DPS] or self.DPS_INDEX_COLOUR['A'] in response[self.DPS]: + self.bulb_type = 'A' + else: + self.bulb_type = 'C' + + if self.bulb_type: + if self.has_brightness is None: + if self.DPS_INDEX_BRIGHTNESS[self.bulb_type] in response["dps"]: + self.has_brightness = True + if self.DPS_INDEX_COLOURTEMP[self.bulb_type] in response["dps"]: + self.has_colourtemp = True + if self.DPS_INDEX_COLOUR[self.bulb_type] in response["dps"]: + self.has_colour = True + log.debug("Bulb type set to %r. has brightness: %r, has colourtemp: %r, has colour: %r", self.bulb_type, self.has_brightness, self.has_colourtemp, self.has_colour) + else: + log.debug("No known DPs, bulb type detection failed!") + self.bulb_type = default + self.assume_bulb_attribs() + else: + # response has no dps + log.debug("No DPs in response, cannot detect bulb type!") + self.bulb_type = default + self.assume_bulb_attribs() + + def assume_bulb_attribs(self): + if self.has_brightness is None: + self.has_brightness = bool(self.DPS_INDEX_BRIGHTNESS[self.bulb_type]) + if self.has_colourtemp is None: + self.has_colourtemp = bool(self.DPS_INDEX_COLOURTEMP[self.bulb_type]) + if self.has_colour is None: + self.has_colour = bool(self.DPS_INDEX_COLOUR[self.bulb_type]) diff --git a/tinytuya/core.py b/tinytuya/core.py index 0b02127..93951dd 100644 --- a/tinytuya/core.py +++ b/tinytuya/core.py @@ -1591,6 +1591,7 @@ def status(self, nowait=False): return data def cached_status(self, nowait=False): + """Return device last status.""" if (not self.socketPersistent) or (not self.socket) or (not self._last_status): if not nowait: log.debug("Last status caching not available, requesting status from device") From edad81dcdbbbdd50b5342bc726ffc3eab523aaee Mon Sep 17 00:00:00 2001 From: uzlonewolf Date: Tue, 30 Jul 2024 07:26:37 -0700 Subject: [PATCH 5/9] Add `force` flag to set_colour() and set_white() --- tinytuya/BulbDevice.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tinytuya/BulbDevice.py b/tinytuya/BulbDevice.py index 5d048eb..e8557d4 100644 --- a/tinytuya/BulbDevice.py +++ b/tinytuya/BulbDevice.py @@ -276,7 +276,7 @@ def set_scene(self, scene, nowait=False): return self.set_value( self.DPS_INDEX_MODE[self.bulb_type], s, nowait=nowait ) - def set_colour(self, r, g, b, nowait=False): + def set_colour(self, r, g, b, nowait=False, force=False): """ Set colour of an rgb bulb. @@ -319,7 +319,7 @@ def set_colour(self, r, g, b, nowait=False): # check to see if power and mode also need to be set state = self.cached_status(nowait=True) - if state and self.DPS in state and state[self.DPS]: + if (not force) and state and self.DPS in state and state[self.DPS]: # last state is cached, so check to see if 'mode' needs to be set if (dp_index_mode not in state[self.DPS]) or (state[self.DPS][dp_index_mode] != self.DPS_MODE_COLOUR): payload[dp_index_mode] = self.DPS_MODE_COLOUR @@ -404,7 +404,7 @@ def set_white_percentage(self, brightness=100, colourtemp=0, nowait=False): data = self.set_white(b, c, nowait=nowait) return data - def set_white(self, brightness=-1, colourtemp=-1, nowait=False): + def set_white(self, brightness=-1, colourtemp=-1, nowait=False, force=False): """ Set white coloured theme of an rgb bulb. @@ -456,9 +456,9 @@ def set_white(self, brightness=-1, colourtemp=-1, nowait=False): # check to see if power and mode also need to be set state = self.cached_status(nowait=True) - if state and self.DPS in state and state[self.DPS]: + if (not force) and state and self.DPS in state and state[self.DPS]: # last state is cached, so check to see if 'mode' needs to be set - if (dp_index_mode not in state[self.DPS]) or (state[self.DPS][dp_index_mode] != self.DPS_MODE_COLOUR): + if (dp_index_mode not in state[self.DPS]) or (state[self.DPS][dp_index_mode] != self.DPS_MODE_WHITE): payload[dp_index_mode] = self.DPS_MODE_WHITE # last state is cached, so check to see if 'power' needs to be set if (dp_index_on not in state[self.DPS]) or (not state[self.DPS][dp_index_on]): From c1864f4fff0a69af8c25da348408485eb0fd53f0 Mon Sep 17 00:00:00 2001 From: uzlonewolf Date: Tue, 30 Jul 2024 07:27:33 -0700 Subject: [PATCH 6/9] Rework set_multiple_values() to try and detect devices which can only handle 1 DP at a time --- tinytuya/core.py | 84 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 67 insertions(+), 17 deletions(-) diff --git a/tinytuya/core.py b/tinytuya/core.py index 93951dd..bda7f1e 100644 --- a/tinytuya/core.py +++ b/tinytuya/core.py @@ -820,7 +820,7 @@ def assign_dp_mappings( tuyadevices, mappings ): class XenonDevice(object): def __init__( - self, dev_id, address=None, local_key="", dev_type="default", connection_timeout=5, version=3.1, persist=False, cid=None, node_id=None, parent=None, connection_retry_limit=5, connection_retry_delay=5, port=TCPPORT # pylint: disable=W0621 + self, dev_id, address=None, local_key="", dev_type="default", connection_timeout=5, version=3.1, persist=False, cid=None, node_id=None, parent=None, connection_retry_limit=5, connection_retry_delay=5, port=TCPPORT, max_simultaneous_dps=0 # pylint: disable=W0621 ): """ Represents a Tuya device. @@ -864,6 +864,7 @@ def __init__( self.local_nonce = b'0123456789abcdef' # not-so-random random key self.remote_nonce = b'' self.payload_dict = None + self.max_simultaneous_dps = max_simultaneous_dps if not local_key: local_key = "" @@ -1394,17 +1395,7 @@ def _cache_response(self, response): # pylint: disable=R0201 if (not self.socketPersistent) or (not self.socket): return - if response and isinstance(response, dict) and 'Error' not in response and 'Err' not in response: - for k in response: - if k == 'dps' and response[k] and isinstance(response[k], dict): - if 'dps' not in self._last_status or not isinstance(self._last_status['dps'], dict): - self._last_status['dps'] = {} - for dkey in response[k]: - self._last_status['dps'][dkey] = response[k][dkey] - #elif k == 'data' and response[k] and isinstance(response[k], dict) and 'dps' in response[k]: - # pass - else: - self._last_status[k] = response[k] + _merge_results(self._last_status, response) def _process_response(self, response): # pylint: disable=R0201 """ @@ -1929,9 +1920,7 @@ def set_value(self, index, value, nowait=False): index = str(index) # index and payload is a string payload = self.generate_payload(CONTROL, {index: value}) - data = self._send_receive(payload, getresponse=(not nowait)) - return data def set_multiple_values(self, data, nowait=False): @@ -1942,11 +1931,53 @@ def set_multiple_values(self, data, nowait=False): data(dict): array of index/value pairs to set nowait(bool): True to send without waiting for response. """ + # if nowait is set we can't detect failure + if nowait: + if self.max_simultaneous_dps > 0 and len(data) > self.max_simultaneous_dps: + # too many DPs, break it up into smaller chunks + ret = None + for k in data: + ret = self.set_value(k, data[k], nowait=nowait) + return ret + else: + # send them all. since nowait is set we can't detect failure + out = {} + for k in data: + out[str(k)] = data[k] + payload = self.generate_payload(CONTROL, out) + return self._send_receive(payload, getresponse=(not nowait)) + + if self.max_simultaneous_dps > 0 and len(data) > self.max_simultaneous_dps: + # too many DPs, break it up into smaller chunks + ret = {} + for k in data: + result = self.set_value(k, data[k], nowait=nowait) + _merge_results(ret, result) + time.sleep(1) + return ret + + # send them all, but try to detect devices which cannot handle multiple out = {} - for i in data: - out[str(i)] = data[i] + for k in data: + out[str(k)] = data[k] + payload = self.generate_payload(CONTROL, out) - return self._send_receive(payload, getresponse=(not nowait)) + result = self._send_receive(payload, getresponse=(not nowait)) + + if result and 'Err' in result and len(out) > 1: + # sending failed! device might only be able to handle 1 DP at a time + for k in out: + res = self.set_value(k, out[k], nowait=nowait) + del out[k] + break + if res and 'Err' not in res: + # single DP succeeded! set limit to 1 + self.max_simultaneous_dps = 1 + result = res + for k in out: + res = self.set_value(k, out[k], nowait=nowait) + _merge_results(result, res) + return result def turn_on(self, switch=1, nowait=False): """Turn the device on""" @@ -2089,3 +2120,22 @@ def deviceScan(verbose=False, maxretry=None, color=True, poll=True, forcescan=Fa """ from . import scanner return scanner.devices(verbose=verbose, scantime=maxretry, color=color, poll=poll, forcescan=forcescan, byID=byID) + +# Merge multiple results into a single dict +def _merge_results(dest, src): + if src and isinstance(src, dict) and 'Error' not in src and 'Err' not in src: + for k in src: + if k == 'dps' and src[k] and isinstance(src[k], dict): + if 'dps' not in dest or not isinstance(dest['dps'], dict): + dest['dps'] = {} + for dkey in src[k]: + dest['dps'][dkey] = src[k][dkey] + elif k == 'data' and src[k] and isinstance(src[k], dict) and 'dps' in src[k] and isinstance(src[k]['dps'], dict): + if k not in dest or not isinstance(dest[k], dict): + dest[k] = {'dps': {}} + if 'dps' not in dest[k] or not isinstance(dest[k]['dps'], dict): + dest[k]['dps'] = {} + for dkey in src[k]['dps']: + dest[k]['dps'][dkey] = src[k]['dps'][dkey] + else: + dest[k] = src[k] From 1da93224380350d8c91c5f831cd41b8ddcfdbb6d Mon Sep 17 00:00:00 2001 From: uzlonewolf Date: Mon, 5 Aug 2024 02:31:45 -0700 Subject: [PATCH 7/9] Cache tweaks and pylint cleanup --- tinytuya/BulbDevice.py | 10 +++---- tinytuya/core.py | 60 +++++++++++++++++++++++++----------------- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/tinytuya/BulbDevice.py b/tinytuya/BulbDevice.py index e8557d4..6703f96 100644 --- a/tinytuya/BulbDevice.py +++ b/tinytuya/BulbDevice.py @@ -200,7 +200,7 @@ def _hexvalue_to_hsv(hexvalue, bulb="A"): else: # Unsupported bulb type raise ValueError(f"Unsupported bulb type {bulb} - unable to determine HSV values.") - + return (h, s, v) def turn_on(self, switch=0, nowait=False): @@ -276,7 +276,7 @@ def set_scene(self, scene, nowait=False): return self.set_value( self.DPS_INDEX_MODE[self.bulb_type], s, nowait=nowait ) - def set_colour(self, r, g, b, nowait=False, force=False): + def set_colour(self, r, g, b, nowait=False): """ Set colour of an rgb bulb. @@ -319,7 +319,7 @@ def set_colour(self, r, g, b, nowait=False, force=False): # check to see if power and mode also need to be set state = self.cached_status(nowait=True) - if (not force) and state and self.DPS in state and state[self.DPS]: + if state and self.DPS in state and state[self.DPS]: # last state is cached, so check to see if 'mode' needs to be set if (dp_index_mode not in state[self.DPS]) or (state[self.DPS][dp_index_mode] != self.DPS_MODE_COLOUR): payload[dp_index_mode] = self.DPS_MODE_COLOUR @@ -404,7 +404,7 @@ def set_white_percentage(self, brightness=100, colourtemp=0, nowait=False): data = self.set_white(b, c, nowait=nowait) return data - def set_white(self, brightness=-1, colourtemp=-1, nowait=False, force=False): + def set_white(self, brightness=-1, colourtemp=-1, nowait=False): """ Set white coloured theme of an rgb bulb. @@ -456,7 +456,7 @@ def set_white(self, brightness=-1, colourtemp=-1, nowait=False, force=False): # check to see if power and mode also need to be set state = self.cached_status(nowait=True) - if (not force) and state and self.DPS in state and state[self.DPS]: + if state and self.DPS in state and state[self.DPS]: # last state is cached, so check to see if 'mode' needs to be set if (dp_index_mode not in state[self.DPS]) or (state[self.DPS][dp_index_mode] != self.DPS_MODE_WHITE): payload[dp_index_mode] = self.DPS_MODE_WHITE diff --git a/tinytuya/core.py b/tinytuya/core.py index bda7f1e..70f11f9 100644 --- a/tinytuya/core.py +++ b/tinytuya/core.py @@ -13,7 +13,7 @@ * XenonDevice(...) - Base Tuya Objects and Functions XenonDevice(dev_id, address=None, local_key="", dev_type="default", connection_timeout=5, version="3.1", persist=False, cid/node_id=None, parent=None, connection_retry_limit=5, - connection_retry_delay=5) + connection_retry_delay=5, max_simultaneous_dps=0) * Device(XenonDevice) - Tuya Class for Devices Module Functions @@ -76,7 +76,6 @@ import json import logging import socket -import select import struct import sys import time @@ -364,7 +363,7 @@ def decrypt(self, enc, use_base64=True, decode_text=True, verify_padding=False, return raw.decode("utf-8") if decode_text else raw class _AESCipher_pyaes(_AESCipher_Base): - def encrypt(self, raw, use_base64=True, pad=True, iv=False, header=None): # pylint: disable=W0621 + def encrypt(self, raw, use_base64=True, pad=True, iv=False, header=None): # pylint: disable=W0613,W0621 if iv: # GCM required for 3.5 devices raise NotImplementedError( 'pyaes does not support GCM, please install PyCryptodome' ) @@ -378,7 +377,7 @@ def encrypt(self, raw, use_base64=True, pad=True, iv=False, header=None): # pyli crypted_text += cipher.feed() # flush final block return base64.b64encode(crypted_text) if use_base64 else crypted_text - def decrypt(self, enc, use_base64=True, decode_text=True, verify_padding=False, iv=False, header=None, tag=None): + def decrypt(self, enc, use_base64=True, decode_text=True, verify_padding=False, iv=False, header=None, tag=None): # pylint: disable=W0613 if iv: # GCM required for 3.5 devices raise NotImplementedError( 'pyaes does not support GCM, please install PyCryptodome' ) @@ -446,7 +445,7 @@ def set_debug(toggle=True, color=True): log.setLevel(logging.DEBUG) log.debug("TinyTuya [%s]\n", __version__) log.debug("Python %s on %s", sys.version, sys.platform) - if AESCipher.CRYPTOLIB_HAS_GCM == False: + if not AESCipher.CRYPTOLIB_HAS_GCM: log.debug("Using %s %s for crypto", AESCipher.CRYPTOLIB, AESCipher.CRYPTOLIB_VER) log.debug("Warning: Crypto library does not support AES-GCM, v3.5 devices will not work!") else: @@ -696,7 +695,7 @@ def assign_dp_mappings( tuyadevices, mappings ): raise ValueError( '\'mappings\' must be a dict' ) if (not mappings) or (not tuyadevices): - return None + return for dev in tuyadevices: try: @@ -820,7 +819,11 @@ def assign_dp_mappings( tuyadevices, mappings ): class XenonDevice(object): def __init__( - self, dev_id, address=None, local_key="", dev_type="default", connection_timeout=5, version=3.1, persist=False, cid=None, node_id=None, parent=None, connection_retry_limit=5, connection_retry_delay=5, port=TCPPORT, max_simultaneous_dps=0 # pylint: disable=W0621 + self, dev_id, address=None, local_key="", dev_type="default", connection_timeout=5, + version=3.1, # pylint: disable=W0621 + persist=False, cid=None, node_id=None, parent=None, + connection_retry_limit=5, connection_retry_delay=5, port=TCPPORT, + max_simultaneous_dps=0 ): """ Represents a Tuya device. @@ -864,7 +867,12 @@ def __init__( self.local_nonce = b'0123456789abcdef' # not-so-random random key self.remote_nonce = b'' self.payload_dict = None - self.max_simultaneous_dps = max_simultaneous_dps + self._last_status = {} + self.max_simultaneous_dps = max_simultaneous_dps if max_simultaneous_dps else 0 + self.version = 0.0 + self.version_str = 'v0.0' + self.version_bytes = b'0.0' + self.version_header = b'' if not local_key: local_key = "" @@ -895,7 +903,7 @@ def __init__( bcast_data = find_device(dev_id) if bcast_data['ip'] is None: log.debug("Unable to find device on network (specify IP address)") - raise Exception("Unable to find device on network (specify IP address)") + raise RuntimeError("Unable to find device on network (specify IP address)") self.address = bcast_data['ip'] self.set_version(float(bcast_data['version'])) time.sleep(0.1) @@ -907,14 +915,7 @@ def __init__( XenonDevice.set_version(self, 3.1) def __del__(self): - # In case we have a lingering socket connection, close it - try: - if self.socket: - # self.socket.shutdown(socket.SHUT_RDWR) - self.socket.close() - self.socket = None - except: - pass + self.close() def __repr__(self): # FIXME can do better than this @@ -1388,16 +1389,16 @@ def _decode_payload(self, payload): return json_payload - def _cache_response(self, response): # pylint: disable=R0201 + def _cache_response(self, response): """ Save (cache) the last value of every DP """ if (not self.socketPersistent) or (not self.socket): return - _merge_results(self._last_status, response) + merge_dps_results(self._last_status, response) - def _process_response(self, response): # pylint: disable=R0201 + def _process_response(self, response): """ Override this function in a sub-class if you want to do some processing on the received data """ @@ -1593,6 +1594,9 @@ def cached_status(self, nowait=False): log.debug("Have status cache, returning it") return self._last_status + def cache_clear(self): + self._last_status = {} + def subdev_query( self, nowait=False ): """Query for a list of sub-devices and their status""" # final payload should look like: {"data":{"cids":[]},"reqType":"subdev_online_stat_query"} @@ -1684,7 +1688,15 @@ def set_sendWait(self, s): self.sendWait = s def close(self): - self.__del__() + # In case we have a lingering socket connection, close it + try: + if self.socket: + # self.socket.shutdown(socket.SHUT_RDWR) + self.socket.close() + except: + pass + + self.socket = None @staticmethod def find(did): @@ -1952,7 +1964,7 @@ def set_multiple_values(self, data, nowait=False): ret = {} for k in data: result = self.set_value(k, data[k], nowait=nowait) - _merge_results(ret, result) + merge_dps_results(ret, result) time.sleep(1) return ret @@ -1976,7 +1988,7 @@ def set_multiple_values(self, data, nowait=False): result = res for k in out: res = self.set_value(k, out[k], nowait=nowait) - _merge_results(result, res) + merge_dps_results(result, res) return result def turn_on(self, switch=1, nowait=False): @@ -2122,7 +2134,7 @@ def deviceScan(verbose=False, maxretry=None, color=True, poll=True, forcescan=Fa return scanner.devices(verbose=verbose, scantime=maxretry, color=color, poll=poll, forcescan=forcescan, byID=byID) # Merge multiple results into a single dict -def _merge_results(dest, src): +def merge_dps_results(dest, src): if src and isinstance(src, dict) and 'Error' not in src and 'Err' not in src: for k in src: if k == 'dps' and src[k] and isinstance(src[k], dict): From d78e86e627598fe9428c663f46a03005f2124273 Mon Sep 17 00:00:00 2001 From: uzlonewolf Date: Mon, 5 Aug 2024 04:15:12 -0700 Subject: [PATCH 8/9] Documentation update --- RELEASE.md | 12 ++++++++++++ tinytuya/core.py | 18 ++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 4d59017..c321835 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,17 @@ # RELEASE NOTES +## v1.15.2 - Caching and BulbDevice updates + +* When a persistent connection is open, the new function `d.cached_status()` will return a cached version of the device status. + * When called as `d.cached_status(nowait=False)` (the default) a `d.status()` call will be made if no cached status is available. + * When called as `d.cached_status(nowait=True)` then `None` will be returned immediately if no cached status is available. +* BulbDevice now uses the cached status (when available) to minimize the number of DPs sent when changing color. Call `d.cache_clear()` before changing color to force it to send all DPs. +* New device argument `max_simultaneous_dps` added to limit the number of simultaneous DP updates sent by `d.set_multiple_values()`. Some bulbs cannot handle multiple DPs set in a single command and require `max_simultaneous_dps=1`. (#504) + +## v1.15.1 - Scanner Fixes + +* Fix scanner broadcast attempting to bind to the wrong IP address, introduced in v1.15.0 + ## v1.15.0 - Scanner Fixes * Fix force-scanning bug in scanner introduced in last release and add broadcast request feature to help discover Tuya version 3.5 devices by @uzlonewolf in https://github.com/jasonacox/tinytuya/pull/511. diff --git a/tinytuya/core.py b/tinytuya/core.py index 70f11f9..edb8692 100644 --- a/tinytuya/core.py +++ b/tinytuya/core.py @@ -29,6 +29,10 @@ Device Functions json = status() # returns json payload + json = cached_status(nowait=False) # When a persistent connection is open, this will return a cached version of the device status + # if nowait=False (the default), a status() call will be made if no cached status is available. + # if nowait=True, `None` will be returned immediately if no cached status is available. + cache_clear() # Clears the cache, causing cached_status() to either call status() or return None subdev_query(nowait) # query sub-device status (only for gateway devices) set_version(version) # 3.1 [default], 3.2, 3.3 or 3.4 set_socketPersistent(False/True) # False [default] or True @@ -122,7 +126,7 @@ # Colorama terminal color capability for all platforms init() -version_tuple = (1, 15, 0) +version_tuple = (1, 15, 2) version = __version__ = "%d.%d.%d" % version_tuple __author__ = "jasonacox" @@ -1583,7 +1587,17 @@ def status(self, nowait=False): return data def cached_status(self, nowait=False): - """Return device last status.""" + """ + Return device last status if a persistent connection is open. + + Args: + nowait(bool): If cached status is is not available, either call status() (when nowait=False) or immediately return None (when nowait=True) + + Response: + json if cache is available, else + json from status() if nowait=False, or + None if nowait=True + """ if (not self.socketPersistent) or (not self.socket) or (not self._last_status): if not nowait: log.debug("Last status caching not available, requesting status from device") From 17fd66a551ebca82e41309b9d0dcbb3b16279672 Mon Sep 17 00:00:00 2001 From: uzlonewolf Date: Mon, 5 Aug 2024 04:33:42 -0700 Subject: [PATCH 9/9] Cache tweaks, documentation, and cleanup --- tinytuya/BulbDevice.py | 3 ++- tinytuya/core.py | 19 ++++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/tinytuya/BulbDevice.py b/tinytuya/BulbDevice.py index 6703f96..3abd480 100644 --- a/tinytuya/BulbDevice.py +++ b/tinytuya/BulbDevice.py @@ -34,7 +34,8 @@ import colorsys -from .core import * # pylint: disable=W0401, W0614 +from .core import Device, log +from .core import error_json, ERR_JSON, ERR_RANGE, ERR_STATE class BulbDevice(Device): """ diff --git a/tinytuya/core.py b/tinytuya/core.py index edb8692..0c2ef1f 100644 --- a/tinytuya/core.py +++ b/tinytuya/core.py @@ -26,6 +26,8 @@ device_info(dev_id) # Searches DEVICEFILE (usually devices.json) for devices with ID = dev_id and returns just that device assign_dp_mappings(tuyadevices, mappings) # Adds mappings to all the devices in the tuyadevices list decrypt_udp(msg) # Decrypts a UDP network broadcast packet + merge_dps_results(dest, src) # Merge multiple receive() responses into a single dict + # `src` will be combined with and merged into `dest` Device Functions json = status() # returns json payload @@ -872,6 +874,7 @@ def __init__( self.remote_nonce = b'' self.payload_dict = None self._last_status = {} + self._have_status = False self.max_simultaneous_dps = max_simultaneous_dps if max_simultaneous_dps else 0 self.version = 0.0 self.version_str = 'v0.0' @@ -937,7 +940,7 @@ def _get_socket(self, renew): self.socket = None if self.socket is None: # Set up Socket - self._last_status = {} + self.cache_clear() retries = 0 err = ERR_OFFLINE while retries < self.socketRetryLimit: @@ -1009,7 +1012,7 @@ def _check_socket_close(self, force=False): if (force or not self.socketPersistent) and self.socket: self.socket.close() self.socket = None - self._last_status = {} + self.cache_clear() def _recv_all(self, length): tries = 2 @@ -1598,7 +1601,7 @@ def cached_status(self, nowait=False): json from status() if nowait=False, or None if nowait=True """ - if (not self.socketPersistent) or (not self.socket) or (not self._last_status): + if (not self._have_status) or (not self.socketPersistent) or (not self.socket) or (not self._last_status): if not nowait: log.debug("Last status caching not available, requesting status from device") return self.status() @@ -1610,6 +1613,7 @@ def cached_status(self, nowait=False): def cache_clear(self): self._last_status = {} + self._have_status = False def subdev_query( self, nowait=False ): """Query for a list of sub-devices and their status""" @@ -1671,7 +1675,7 @@ def set_socketPersistent(self, persist): if self.socket and not persist: self.socket.close() self.socket = None - self._last_status = {} + self.cache_clear() def set_socketNODELAY(self, nodelay): self.socketNODELAY = nodelay @@ -1800,6 +1804,10 @@ def _merge_payload_dicts(dict1, dict2): if command_override is None: command_override = command + + if command == DP_QUERY: + self._have_status = True + if json_data is None: # I have yet to see a device complain about included but unneeded attribs, but they *will* # complain about missing attribs, so just include them all unless otherwise specified @@ -2147,7 +2155,8 @@ def deviceScan(verbose=False, maxretry=None, color=True, poll=True, forcescan=Fa from . import scanner return scanner.devices(verbose=verbose, scantime=maxretry, color=color, poll=poll, forcescan=forcescan, byID=byID) -# Merge multiple results into a single dict +# Merge multiple receive() responses into a single dict +# `src` will be combined with and merged into `dest` def merge_dps_results(dest, src): if src and isinstance(src, dict) and 'Error' not in src and 'Err' not in src: for k in src: