diff --git a/RELEASE.md b/RELEASE.md index 01ca573..c321835 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,13 @@ # 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 diff --git a/tinytuya/BulbDevice.py b/tinytuya/BulbDevice.py index 35d3d96..3abd480 100644 --- a/tinytuya/BulbDevice.py +++ b/tinytuya/BulbDevice.py @@ -29,33 +29,13 @@ 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 -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): """ @@ -99,18 +79,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"): """ @@ -136,36 +122,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 @@ -218,53 +201,43 @@ 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 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.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) + return (h, s, v) 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 @@ -273,11 +246,9 @@ 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 + 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): """ @@ -292,6 +263,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: @@ -301,11 +275,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): """ @@ -336,17 +306,33 @@ 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.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_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): """ @@ -377,19 +363,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): """ @@ -407,10 +382,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: @@ -419,10 +397,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 @@ -438,6 +416,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 @@ -466,17 +447,29 @@ 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_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_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]): + 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 - 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): """ @@ -491,6 +484,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) @@ -506,6 +503,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, @@ -520,6 +520,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 +528,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 +538,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): """ @@ -559,6 +563,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) @@ -574,6 +582,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.") @@ -588,33 +600,33 @@ 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""" - return self.status()[self.DPS][self.DPS_INDEX_BRIGHTNESS[self.bulb_type]] + 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""" - return self.status()[self.DPS][self.DPS_INDEX_COLOURTEMP[self.bulb_type]] + 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""" - hexvalue = self.status()[self.DPS][self.DPS_INDEX_COLOUR[self.bulb_type]] + 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""" - hexvalue = self.status()[self.DPS][self.DPS_INDEX_COLOUR[self.bulb_type]] + 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) 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") @@ -630,3 +642,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 d03dc23..0c2ef1f 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 @@ -26,9 +26,15 @@ 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 + 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 @@ -76,7 +82,6 @@ import json import logging import socket -import select import struct import sys import time @@ -123,7 +128,7 @@ # Colorama terminal color capability for all platforms init() -version_tuple = (1, 15, 1) +version_tuple = (1, 15, 2) version = __version__ = "%d.%d.%d" % version_tuple __author__ = "jasonacox" @@ -364,7 +369,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 +383,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 +451,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 +701,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 +825,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 # 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,6 +873,13 @@ def __init__( self.local_nonce = b'0123456789abcdef' # not-so-random random key 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' + self.version_bytes = b'0.0' + self.version_header = b'' if not local_key: local_key = "" @@ -894,7 +910,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) @@ -906,14 +922,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 @@ -931,6 +940,7 @@ def _get_socket(self, renew): self.socket = None if self.socket is None: # Set up Socket + self.cache_clear() retries = 0 err = ERR_OFFLINE while retries < self.socketRetryLimit: @@ -1002,6 +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.cache_clear() def _recv_all(self, length): tries = 2 @@ -1289,8 +1300,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 +1313,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,7 +1396,16 @@ def _decode_payload(self, payload): return json_payload - def _process_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_dps_results(self._last_status, response) + + def _process_response(self, response): """ Override this function in a sub-class if you want to do some processing on the received data """ @@ -1565,6 +1589,32 @@ def status(self, nowait=False): return data + def cached_status(self, nowait=False): + """ + 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._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() + log.debug("Last status caching not available, returning None") + return None + + log.debug("Have status cache, returning it") + return self._last_status + + 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""" # final payload should look like: {"data":{"cids":[]},"reqType":"subdev_online_stat_query"} @@ -1625,6 +1675,7 @@ def set_socketPersistent(self, persist): if self.socket and not persist: self.socket.close() self.socket = None + self.cache_clear() def set_socketNODELAY(self, nodelay): self.socketNODELAY = nodelay @@ -1655,7 +1706,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): @@ -1745,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 @@ -1891,9 +1954,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): @@ -1904,11 +1965,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_dps_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_dps_results(result, res) + return result def turn_on(self, switch=1, nowait=False): """Turn the device on""" @@ -2051,3 +2154,23 @@ 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 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: + 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]