From 27aa6986ed527f817a433933e00582a0ac0c66f4 Mon Sep 17 00:00:00 2001 From: Jason Cox Date: Thu, 2 Jul 2026 21:09:14 -0700 Subject: [PATCH 1/3] fix: correct 16 verified bugs across Cloud, core, Bulb, scanner, Monitor Cloud.py - _tuyaplatform token-refresh retry now forwards query/content_type so queried endpoints don't silently lose their query string on token expiry - _gettoken/_getuid/_getdevice/getdps/sendcommand/getconnectstatus return the standard error dict on failed/empty responses instead of raising TypeError/KeyError core/Device.py - set_timer() selects the timer DP numerically (sort key=int); previously lexicographic sort made "9" outrank "102" core/XenonDevice.py - exhausted-retry socket timeouts return ERR_TIMEOUT instead of the misleading ERR_KEY_OR_VER - _receive() resync picks the earliest of the 55AA/6699 prefixes - bare except: -> except Exception: in _send_receive_quick (3 sites) so KeyboardInterrupt propagates during session handshakes - received_wrong_cid_queue capped at 100 entries (drop-oldest) - hexlify debug logging gated on isEnabledFor(DEBUG) on hot paths core/message_helper.py - truncated 55AA frames raise DecodeError (retryable) instead of leaking struct.error to the generic network-failure handler core/udp_helper.py - decrypt_udp() strips trailing NULs safely (no IndexError on empty/all-NUL) core/error_helper.py - error_json() builds the dict directly and falls back to "Unknown Error" for unrecognized codes instead of raising KeyError BulbDevice.py - hexvalue_to_hsv() reads hue from [6:10] matching the encoder (latent off-by-one); docstring for set_music_colour() matches real signature scanner.py - devices() mutable default tuyadevices=[] -> None; cloud-only entries are added from a shallow copy so caller dicts are not mutated core/Monitor.py - corrupt/oversized headers resync the receive buffer to the next frame prefix instead of buffering forever (permanent stall + unbounded growth) Adds offline regression tests (set_timer DP selection, error_json shape, rgb8 hue round-trip): 23 -> 29 tests. pylint -E clean. Co-Authored-By: Claude Fable 5 --- RELEASE.md | 15 ++++++++ tests.py | 68 +++++++++++++++++++++++++++++++++ tinytuya/BulbDevice.py | 4 +- tinytuya/Cloud.py | 53 ++++++++++++++++--------- tinytuya/core/Device.py | 5 ++- tinytuya/core/Monitor.py | 17 +++++++++ tinytuya/core/XenonDevice.py | 31 ++++++++++----- tinytuya/core/error_helper.py | 13 ++----- tinytuya/core/message_helper.py | 4 ++ tinytuya/core/udp_helper.py | 5 +-- tinytuya/scanner.py | 7 +++- 11 files changed, 178 insertions(+), 44 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 7fa12cd5..e1f37f3e 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,20 @@ # RELEASE NOTES +## Unreleased - Bug Fixes + +* **Cloud**: Token-refresh retry in `_tuyaplatform()` now forwards `query` and `content_type`, so paginated/queried calls (device list, `getdevicelog`, `getdps`) no longer silently lose their query string when the access token expires mid-flight. +* **Cloud**: `_gettoken()`, `_getuid()`, `_getdevice()`, `getdps()`, `sendcommand()`, and `getconnectstatus()` now return the standard error dict on a failed or empty Cloud response instead of raising `TypeError`/`KeyError`. +* **Device**: `set_timer()` now selects the timer DP numerically instead of lexicographically (previously `"9"` sorted above `"102"`, targeting the wrong DP on devices with DP indices spanning a power of ten). +* **XenonDevice**: Exhausted-retry socket timeouts now return `ERR_TIMEOUT` (902, "Timeout Waiting for Device") instead of the misleading `ERR_KEY_OR_VER` ("Check device key or version"). +* **XenonDevice**: Frame resync in `_receive()` now picks the earliest prefix when both 55AA and 6699 markers appear in buffered garbage; bare `except:` clauses in `_send_receive_quick()` no longer swallow `KeyboardInterrupt`; `received_wrong_cid_queue` is capped at 100 entries; hot-path `binascii.hexlify()` debug logging is now gated on the log level. +* **BulbDevice**: `hexvalue_to_hsv()` reads Hue from the correct hex offset `[6:10]` in the rgb8 format (latent off-by-one); corrected the `set_music_colour()` docstring to match the actual argument order (`transition` first). +* **Monitor**: A corrupt or desynced stream no longer stalls a device permanently — oversized/garbage headers now trigger a buffer resync to the next frame prefix instead of buffering forever. +* **message_helper**: Truncated 55AA frames raise `DecodeError` (retryable) instead of leaking `struct.error`. +* **udp_helper**: `decrypt_udp()` no longer raises `IndexError` on an empty or all-NUL 6699 broadcast payload. +* **error_helper**: `error_json()` builds its dict directly (no JSON string round-trip) and returns "Unknown Error" for unrecognized codes instead of raising `KeyError`. +* **scanner**: `devices()` no longer uses a shared mutable default for `tuyadevices` and no longer mutates the caller's device dicts when adding cloud-only entries to scan results. +* Added offline regression tests for `set_timer` DP selection, `error_json` shape/unknown codes, and rgb8 hue round-trip (tests 23 → 29). + ## v1.19.0 - Monitor Class, IPv6, and Community Fixes * **New Feature: `Monitor` class** — Single-thread, multi-device status monitoring using `selectors` (`select`/`poll`/`epoll`). Watch any number of Tuya devices on one OS thread with callback-driven updates (`on_status`, `on_connect`, `on_disconnect`), automatic heartbeats, gateway/cid routing, thread-safe command queue, and optional `auto_reconnect`. No `asyncio`, no per-device threads, no new dependencies. See `examples/monitor_example.py` and `examples/monitor_poll_example.py`. Implements the [proposal by @3735943886](https://github.com/jasonacox/tinytuya/pull/649#issuecomment-4628381086) via [#712](https://github.com/jasonacox/tinytuya/pull/712) by @jasonacox-sam. **Note:** `Monitor` is an experimental class. See [#713](https://github.com/jasonacox/tinytuya/issues/713) for feedback and future refactoring plans. diff --git a/tests.py b/tests.py index 28693eab..6acd941b 100755 --- a/tests.py +++ b/tests.py @@ -76,6 +76,25 @@ def test_set_timer(self): self.assertEqual(result_cmd, expected_cmd) self.assertDictEqual(result_payload, expected_payload) + def test_set_timer_picks_numeric_max_dp(self): + # regression: DP keys must be selected numerically, not lexicographically + # ("9" > "102" as strings, but 102 > 9 as ints) + d = tinytuya.OutletDevice('DEVICE_ID_HERE', 'IP_ADDRESS_HERE', LOCAL_KEY) + d.set_version(3.1) + # status() supplies the DPS the timer selection scans + d.status = lambda nowait=False: {"devId":"DEVICE_ID","dps":{"1":False,"9":0,"102":0}} + d._send_receive = MagicMock(return_value={"devId":"DEVICE_ID","dps":{"1":False,"9":0,"102":0}}) + + # act + d.set_timer(6666) + + # gather results + result_cmd, result_payload = get_results_from_mock(d) + + # assert the timer targeted DP "102", not "9" + self.assertEqual(result_cmd, tinytuya.CONTROL) + self.assertDictEqual(result_payload, {"uid":"DEVICE_ID_HERE","devId":"DEVICE_ID_HERE","t":"","dps":{"102":6666}}) + def test_set_status(self): # arrange d = tinytuya.OutletDevice('DEVICE_ID_HERE', 'IP_ADDRESS_HERE', LOCAL_KEY) @@ -423,5 +442,54 @@ def test_oversized_payload_rejected(self): mh.parse_header(oversized + b'\x00' * 40) +class TestErrorJson(unittest.TestCase): + """error_json must return the expected dict shape and never raise, even + for an unknown error code.""" + + def test_known_code_shape(self): + from tinytuya.core.error_helper import error_json, ERR_TIMEOUT + result = error_json(ERR_TIMEOUT) + self.assertEqual(result["Error"], "Timeout Waiting for Device") + self.assertEqual(result["Err"], "902") + self.assertIsNone(result["Payload"]) + self.assertEqual(set(result.keys()), {"Error", "Err", "Payload"}) + + def test_payload_preserved(self): + from tinytuya.core.error_helper import error_json, ERR_CLOUD + result = error_json(ERR_CLOUD, "some detail") + self.assertEqual(result["Payload"], "some detail") + self.assertEqual(result["Err"], "913") + + def test_unknown_code_does_not_raise(self): + from tinytuya.core.error_helper import error_json + result = error_json(99999) + self.assertEqual(result["Error"], "Unknown Error") + self.assertEqual(result["Err"], "99999") + + def test_default_none_code(self): + from tinytuya.core.error_helper import error_json + result = error_json() + self.assertEqual(result["Error"], "Unknown Error") + self.assertEqual(result["Err"], "None") + self.assertIsNone(result["Payload"]) + + +class TestHexvalueRoundTrip(unittest.TestCase): + """hexvalue_to_hsv must decode the hue written by rgb_to_hexvalue at the + correct offset (regression for the [7:10] vs [6:10] off-by-one).""" + + def test_rgb8_round_trip(self): + import colorsys + for rgb in ((255, 128, 0), (0, 255, 64), (30, 60, 200)): + hexvalue = tinytuya.BulbDevice.rgb_to_hexvalue(*rgb, 'rgb8') + self.assertEqual(len(hexvalue), 14) + h, s, v = tinytuya.BulbDevice.hexvalue_to_hsv(hexvalue, 'rgb8') + eh, es, ev = colorsys.rgb_to_hsv(rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0) + # allow small quantization error from the 8-bit hex encoding + self.assertAlmostEqual(h, eh, delta=0.01) + self.assertAlmostEqual(s, es, delta=0.01) + self.assertAlmostEqual(v, ev, delta=0.01) + + if __name__ == '__main__': unittest.main() diff --git a/tinytuya/BulbDevice.py b/tinytuya/BulbDevice.py index 32c79509..d2ee2ce8 100644 --- a/tinytuya/BulbDevice.py +++ b/tinytuya/BulbDevice.py @@ -23,7 +23,7 @@ set_timer(self, num_secs, nowait=False): set_musicmode(self, transition, modify_settings=True, nowait=False): unset_musicmode( self ): - set_music_colour( self, red, green, blue, brightness=None, colourtemp=None, transition=None, nowait=False ): + set_music_colour( self, transition, red, green, blue, brightness=None, colourtemp=None, nowait=False ): set_colour(r, g, b, nowait): set_hsv(h, s, v, nowait): set_white_percentage(brightness=100, colourtemp=0, nowait): @@ -363,7 +363,7 @@ def hexvalue_to_hsv(hexvalue, hexformat=None): h, s, v = colorsys.rgb_to_hsv(rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0) else: # hexvalue is in rgb+hsv - h = int(hexvalue[7:10], 16) / 360.0 + h = int(hexvalue[6:10], 16) / 360.0 s = int(hexvalue[10:12], 16) / 255.0 v = int(hexvalue[12:14], 16) / 255.0 elif hexformat == 'hsv16': diff --git a/tinytuya/Cloud.py b/tinytuya/Cloud.py index 6c635dc2..ad0a45b3 100644 --- a/tinytuya/Cloud.py +++ b/tinytuya/Cloud.py @@ -251,7 +251,7 @@ def _tuyaplatform(self, uri, action='GET', post=None, ver='v1.0', recursive=Fals log.debug("Failed to renew token") return None else: - return self._tuyaplatform(uri, action, post, ver, True) + return self._tuyaplatform(uri, action, post, ver, True, query, content_type) try: response_dict = json.loads(response.content.decode()) @@ -274,9 +274,10 @@ def _gettoken(self): response_dict = self._tuyaplatform('token?grant_type=1') if not response_dict or 'success' not in response_dict or not response_dict['success']: + msg = response_dict.get('msg') if response_dict else 'no response' self.error = error_json( ERR_CLOUDTOKEN, - "Cloud _gettoken() failed: %r" % response_dict['msg'], + "Cloud _gettoken() failed: %r" % msg, ) return self.error @@ -301,17 +302,15 @@ def _getuid(self, deviceid=None): uri = 'devices/%s' % deviceid response_dict = self._tuyaplatform(uri) - if not response_dict['success']: - if 'code' not in response_dict: - response_dict['code'] = -1 - if 'msg' not in response_dict: - response_dict['msg'] = 'Unknown Error' + if not response_dict or not response_dict.get('success'): + code = response_dict.get('code', -1) if response_dict else -1 + msg = response_dict.get('msg', 'Unknown Error') if response_dict else 'no response' log.debug( - "Error from Tuya Cloud: %r", response_dict['msg'], + "Error from Tuya Cloud: %r", msg, ) return error_json( ERR_CLOUD, - "Error from Tuya Cloud: Code %r: %r" % (response_dict['code'], response_dict['msg']) + "Error from Tuya Cloud: Code %r: %r" % (code, msg) ) uid = response_dict['result']['uid'] @@ -613,9 +612,14 @@ def _getdevice(self, param='status', deviceid=None): uri = 'iot-03/devices/%s/%s' % (deviceid, param) response_dict = self._tuyaplatform(uri) - if not response_dict['success']: + if not response_dict or not response_dict.get('success'): + msg = response_dict.get('msg', 'Unknown Error') if response_dict else 'no response' log.debug( - "Error from Tuya Cloud: %r", response_dict['msg'], + "Error from Tuya Cloud: %r", msg, + ) + return error_json( + ERR_CLOUD, + "Error from Tuya Cloud: %r" % msg ) return response_dict @@ -651,9 +655,14 @@ def getdps(self, deviceid=None): uri = 'devices/%s/specifications' % (deviceid) response_dict = self._tuyaplatform(uri, ver='v1.1') - if not response_dict['success']: + if not response_dict or not response_dict.get('success'): + msg = response_dict.get('msg', 'Unknown Error') if response_dict else 'no response' log.debug( - "Error from Tuya Cloud: %r", response_dict['msg'], + "Error from Tuya Cloud: %r", msg, + ) + return error_json( + ERR_CLOUD, + "Error from Tuya Cloud: %r" % msg ) return response_dict @@ -671,9 +680,14 @@ def sendcommand(self, deviceid=None, commands=None, uri='iot-03/devices/'): uri += '%s/commands' % (deviceid) response_dict = self._tuyaplatform(uri,action='POST',post=commands) - if not response_dict['success']: + if not response_dict or not response_dict.get('success'): + msg = response_dict.get('msg', 'Unknown Error') if response_dict else 'no response' log.debug( - "Error from Tuya Cloud: %r", response_dict['msg'], + "Error from Tuya Cloud: %r", msg, + ) + return error_json( + ERR_CLOUD, + "Error from Tuya Cloud: %r" % msg ) return response_dict @@ -691,8 +705,13 @@ def getconnectstatus(self, deviceid=None): uri = 'devices/%s' % (deviceid) response_dict = self._tuyaplatform(uri, ver='v1.0') - if not response_dict['success']: - log.debug("Error from Tuya Cloud: %r", response_dict['msg']) + if not response_dict or not response_dict.get('success'): + msg = response_dict.get('msg', 'Unknown Error') if response_dict else 'no response' + log.debug("Error from Tuya Cloud: %r", msg) + return error_json( + ERR_CLOUD, + "Error from Tuya Cloud: %r" % msg + ) return(response_dict["result"]["online"]) def getdevicelog(self, deviceid=None, start=None, end=None, evtype=None, size=0, max_fetches=50, start_row_key=None, params=None): diff --git a/tinytuya/core/Device.py b/tinytuya/core/Device.py index 0f7a6369..d568056e 100644 --- a/tinytuya/core/Device.py +++ b/tinytuya/core/Device.py @@ -176,7 +176,10 @@ def set_timer(self, num_secs, dps_id=0, nowait=False): if "dps" in status: devices = status["dps"] devices_numbers = list(devices.keys()) - devices_numbers.sort() + try: + devices_numbers.sort(key=int) + except (TypeError, ValueError): + devices_numbers.sort() dps_id = devices_numbers[-1] else: log.debug("set_timer received error=%r", status) diff --git a/tinytuya/core/Monitor.py b/tinytuya/core/Monitor.py index fc46f317..d2082381 100644 --- a/tinytuya/core/Monitor.py +++ b/tinytuya/core/Monitor.py @@ -66,6 +66,7 @@ def on_status(device, result): import time from . import header as H +from .const import MAX_PAYLOAD_LENGTH from .message_helper import ( parse_header, unpack_message, @@ -475,6 +476,22 @@ def _process_buffer(self, state): try: header = parse_header(buf) except Exception: + # Normally this means the header is incomplete, so wait for more + # data. But if the buffer is already larger than any legal frame, + # "not enough data" cannot be the real cause — the stream is + # desynced/corrupt. Resync by discarding up to the next prefix. + if len(buf) > MAX_PAYLOAD_LENGTH: + next_55aa = buf.find(H.PREFIX_55AA_BIN, 1) + next_6699 = buf.find(H.PREFIX_6699_BIN, 1) + candidates = [o for o in (next_55aa, next_6699) if o >= 1] + if candidates: + next_offset = min(candidates) + log.debug("Corrupt/oversized header, resyncing buffer to next prefix at offset %d", next_offset) + state.recv_buffer = buf[next_offset:] + continue + log.debug("Corrupt/oversized header and no further prefix found, dropping buffer") + state.recv_buffer = buf[-3:] + return # Incomplete header — wait for more data return diff --git a/tinytuya/core/XenonDevice.py b/tinytuya/core/XenonDevice.py index ad2748ed..6dbd1f84 100644 --- a/tinytuya/core/XenonDevice.py +++ b/tinytuya/core/XenonDevice.py @@ -13,7 +13,7 @@ from .const import DEVICEFILE, TCPPORT from .crypto_helper import AESCipher -from .error_helper import ERR_CONNECT, ERR_DEVTYPE, ERR_JSON, ERR_KEY_OR_VER, ERR_OFFLINE, ERR_PAYLOAD, error_json +from .error_helper import ERR_CONNECT, ERR_DEVTYPE, ERR_JSON, ERR_KEY_OR_VER, ERR_OFFLINE, ERR_PAYLOAD, ERR_TIMEOUT, error_json from .exceptions import DecodeError from .message_helper import MessagePayload, TuyaMessage, pack_message, unpack_message, parse_header from . import command_types as CT, header as H @@ -478,7 +478,13 @@ def _receive(self): if prefix_offset_55AA < 0 and prefix_offset_6699 < 0: data = data[1-prefix_len:] else: - prefix_offset = prefix_offset_6699 if prefix_offset_55AA < 0 else prefix_offset_55AA + # pick the earliest (minimum non-negative) prefix offset + if prefix_offset_55AA < 0: + prefix_offset = prefix_offset_6699 + elif prefix_offset_6699 < 0: + prefix_offset = prefix_offset_55AA + else: + prefix_offset = min(prefix_offset_55AA, prefix_offset_6699) data = data[prefix_offset:] data += self._recv_all( min_len - len(data) ) @@ -490,7 +496,8 @@ def _receive(self): if remaining > 0: data += self._recv_all( remaining ) - log.debug("received data=%r", binascii.hexlify(data)) + if log.isEnabledFor(logging.DEBUG): + log.debug("received data=%r", binascii.hexlify(data)) hmac_key = self.local_key if self.version >= 3.4 else None no_retcode = False #None if self.version >= 3.5 else False return unpack_message(data, header=header, hmac_key=hmac_key, no_retcode=no_retcode) @@ -509,12 +516,12 @@ def _send_receive_quick(self, payload, recv_retries, from_child=None): # pylint: enc_payload = self._encode_message(payload) if type(payload) == MessagePayload else payload try: self.socket.sendall(enc_payload) - except: + except Exception: self._check_socket_close(True) return None try: self.raw_sent = parse_header(enc_payload) - except: + except Exception: self.raw_sent = None if not recv_retries: return True @@ -522,7 +529,7 @@ def _send_receive_quick(self, payload, recv_retries, from_child=None): # pylint: try: msg = self._receive() self.raw_recv.append(msg) - except: + except Exception: msg = None if msg: self._get_retcode(self.raw_sent, msg) # set self.cmd_retcode @@ -638,7 +645,7 @@ def _send_receive(self, payload, minresponse=28, getresponse=True, decode_respon self.socketRetryLimit ) # timeout reached - return error - return error_json(ERR_KEY_OR_VER) + return error_json(ERR_TIMEOUT) # wait a bit before retrying time.sleep(0.1) except DecodeError as err: @@ -744,6 +751,10 @@ def _process_message( self, msg, dev_type=None, from_child=None, minresponse=28, self._cache_response(result) result = self._process_response(result) self.received_wrong_cid_queue.append( (found_child, result) ) + # cap the queue so a stream of wrong-CID updates cannot grow it unbounded + if len(self.received_wrong_cid_queue) > 100: + log.debug( 'received_wrong_cid_queue exceeded 100 entries, dropping oldest' ) + self.received_wrong_cid_queue.pop(0) # events should not be coming in so fast that we will never timeout a read, so don't worry about loops return self._send_receive( None, minresponse, True, decode_response, from_child=from_child) @@ -969,7 +980,8 @@ def _encode_message( self, msg ): msg = TuyaMessage(self.seqno, msg.cmd, None, payload, 0, True, H.PREFIX_6699_VALUE, True) self.seqno += 1 # increase message sequence number data = pack_message(msg,hmac_key=self.local_key) - log.debug("payload [%d] encrypted=%r",self.seqno, binascii.hexlify(data) ) + if log.isEnabledFor(logging.DEBUG): + log.debug("payload [%d] encrypted=%r",self.seqno, binascii.hexlify(data) ) return data payload = self.cipher.encrypt(payload, False) @@ -1004,7 +1016,8 @@ def _encode_message( self, msg ): msg = TuyaMessage(self.seqno, msg.cmd, 0, payload, 0, True, H.PREFIX_55AA_VALUE, False) self.seqno += 1 # increase message sequence number buffer = pack_message(msg,hmac_key=hmac_key) - log.debug("payload encrypted=%r",binascii.hexlify(buffer)) + if log.isEnabledFor(logging.DEBUG): + log.debug("payload encrypted=%r",binascii.hexlify(buffer)) return buffer def _get_retcode(self, sent, msg): diff --git a/tinytuya/core/error_helper.py b/tinytuya/core/error_helper.py index 6d7f42b4..702008e8 100644 --- a/tinytuya/core/error_helper.py +++ b/tinytuya/core/error_helper.py @@ -1,7 +1,6 @@ # TinyTuya Module # -*- coding: utf-8 -*- -import json import logging log = logging.getLogger(__name__) @@ -46,13 +45,7 @@ def error_json(number=None, payload=None): """Return error details in JSON""" - try: - spayload = json.dumps(payload) - # spayload = payload.replace('\"','').replace('\'','') - except: - spayload = '""' + error = error_codes.get(number, error_codes[None]) + log.debug("ERROR %s - %s - payload: %s", error, str(number), payload) - vals = (error_codes[number], str(number), spayload) - log.debug("ERROR %s - %s - payload: %s", *vals) - - return json.loads('{ "Error":"%s", "Err":"%s", "Payload":%s }' % vals) + return {"Error": error, "Err": str(number), "Payload": payload} diff --git a/tinytuya/core/message_helper.py b/tinytuya/core/message_helper.py index 22021f2f..4e1791a0 100644 --- a/tinytuya/core/message_helper.py +++ b/tinytuya/core/message_helper.py @@ -125,6 +125,10 @@ def unpack_message(data, hmac_key=None, header=None, no_retcode=False): raise DecodeError('Not enough data to unpack payload') end_len = struct.calcsize(end_fmt) + # a truncated/corrupt frame can declare a length smaller than retcode+crc+suffix; + # bail out cleanly instead of letting struct.unpack() raise a raw struct.error + if header.length < (retcode_len + end_len) or (msg_len - (header_len + retcode_len)) < end_len: + raise DecodeError('Not enough data to unpack payload') # the retcode is technically part of the payload, but strip it as we do not want it here retcode = 0 if not retcode_len else struct.unpack(H.MESSAGE_RETCODE_FMT, data[header_len:header_len+retcode_len])[0] payload = data[header_len+retcode_len:msg_len] diff --git a/tinytuya/core/udp_helper.py b/tinytuya/core/udp_helper.py index 21142b47..152f8db7 100644 --- a/tinytuya/core/udp_helper.py +++ b/tinytuya/core/udp_helper.py @@ -38,8 +38,7 @@ def decrypt_udp(msg): if header.prefix == H.PREFIX_6699_VALUE: unpacked = unpack_message(msg, hmac_key=udpkey, no_retcode=None) payload = unpacked.payload.decode() - # app sometimes has extra bytes at the end - while payload[-1] == chr(0): - payload = payload[:-1] + # app sometimes has extra NUL bytes at the end + payload = payload.rstrip('\x00') return payload return decrypt(msg, udpkey) diff --git a/tinytuya/scanner.py b/tinytuya/scanner.py index 91ef2440..a9e52864 100644 --- a/tinytuya/scanner.py +++ b/tinytuya/scanner.py @@ -1129,7 +1129,7 @@ def _print_device_info( result, note, term, extra_message=None, verbose=True ): # Scan function def devices(verbose=False, scantime=None, color=True, poll=True, forcescan=False, byID=False, show_timer=None, - discover=True, wantips=None, wantids=None, snapshot=None, assume_yes=False, tuyadevices=[], + discover=True, wantips=None, wantids=None, snapshot=None, assume_yes=False, tuyadevices=None, maxdevices=0): # pylint: disable=W0621, W0102 """Scans your network for Tuya devices and returns dictionary of devices discovered devices = tinytuya.deviceScan(verbose) @@ -1163,6 +1163,8 @@ def devices(verbose=False, scantime=None, color=True, poll=True, forcescan=False dps = devices[ip]['dps'] """ + tuyadevices = [] if tuyadevices is None else tuyadevices + # Terminal formatting color = color and HAVE_COLOR termcolors = tinytuya.termcolor(color) @@ -1799,7 +1801,8 @@ def tuyaLookup(deviceid): for item in tuyadevices: k = item["id"] if not any(d['gwId'] == k for d in devicesarray): - tmp = item + # operate on a shallow copy so we do not mutate the caller's dict + tmp = dict(item) tmp["gwId"] = item["id"] tmp["ip"] = '' tmp['origin'] = 'cloud' From b8289b682dcb5f1c2a1a6fcdf21e48cd2a131864 Mon Sep 17 00:00:00 2001 From: Sam Cox Date: Thu, 2 Jul 2026 22:46:24 -0700 Subject: [PATCH 2/3] =?UTF-8?q?Remove=20W0102=20pylint=20suppression=20?= =?UTF-8?q?=E2=80=94=20mutable=20default=20arg=20is=20gone=20(tuyadevices?= =?UTF-8?q?=3DNone)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tinytuya/scanner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinytuya/scanner.py b/tinytuya/scanner.py index a9e52864..acd603c0 100644 --- a/tinytuya/scanner.py +++ b/tinytuya/scanner.py @@ -1130,7 +1130,7 @@ def _print_device_info( result, note, term, extra_message=None, verbose=True ): # Scan function def devices(verbose=False, scantime=None, color=True, poll=True, forcescan=False, byID=False, show_timer=None, discover=True, wantips=None, wantids=None, snapshot=None, assume_yes=False, tuyadevices=None, - maxdevices=0): # pylint: disable=W0621, W0102 + maxdevices=0): # pylint: disable=W0621 """Scans your network for Tuya devices and returns dictionary of devices discovered devices = tinytuya.deviceScan(verbose) From d04ad6d6a95544871979e2b023f41e6f5f8f19e2 Mon Sep 17 00:00:00 2001 From: Sam Cox Date: Sun, 5 Jul 2026 14:25:14 -0700 Subject: [PATCH 3/3] =?UTF-8?q?Fix=20rgb8=20docstring=20format:=20rrggbb0h?= =?UTF-8?q?hhssvv=20=E2=86=92=20rrggbbhhhhssvv?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The separator '0' in the format description was misleading — it's actually the leading hex digit of the 4-char hue field (HHHH), not padding. This notation is what led to the original [7:10] off-by-one bug in hexvalue_to_hsv(). Updated all 4 docstrings in BulbDevice for consistency: rgb_to_hexvalue, hsv_to_hexvalue, hexvalue_to_rgb, hexvalue_to_hsv. --- tinytuya/BulbDevice.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tinytuya/BulbDevice.py b/tinytuya/BulbDevice.py index d2ee2ce8..3650750a 100644 --- a/tinytuya/BulbDevice.py +++ b/tinytuya/BulbDevice.py @@ -199,7 +199,7 @@ def rgb_to_hexvalue(r, g, b, hexformat): g(int): Value for the colour green as int from 0-255. b(int): Value for the colour blue as int from 0-255. hexformat(str): Selects the return format - "rgb8": rrggbb0hhhssvv + "rgb8": rrggbbhhhhssvv "hsv16": hhhhssssvvvv """ err = '' @@ -250,7 +250,7 @@ def hsv_to_hexvalue(h, s, v, hexformat): s(float): colour Saturation as float from 0-1 v(float): colour Value as float from 0-1 hexformat(str): Selects the return format - "rgb8": rrggbb0hhhssvv + "rgb8": rrggbbhhhhssvv "hsv16": hhhhssssvvvv """ err = '' @@ -283,7 +283,7 @@ def hexvalue_to_rgb(hexvalue, hexformat=None): Args: hexvalue(string): The hex representation generated by BulbDevice.rgb_to_hexvalue() hexformat(str or None): - "rgb8": The hex is in rrggbb0hhhssvv format + "rgb8": The hex is in rrggbbhhhhssvv format "hsv16": The hex is in hhhhssssvvvv format None: Try to auto-detect the format """ @@ -340,7 +340,7 @@ def hexvalue_to_hsv(hexvalue, hexformat=None): Args: hexvalue(string): The hex representation generated by BulbDevice.rgb_to_hexvalue() hexformat(str or None): - "rgb8": The hex is in rrggbb0hhhssvv format + "rgb8": The hex is in rrggbbhhhhssvv format "hsv16": The hex is in hhhhssssvvvv format None: Try to auto-detect the format """