Fix 16 verified bugs across Cloud, core, BulbDevice, scanner, Monitor#723
Conversation
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 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR delivers a batch of targeted bug fixes across TinyTuya’s Cloud client, core transport/framing, scanner utilities, BulbDevice helpers, and the experimental Monitor, plus adds regression tests and release notes documenting the verified fixes.
Changes:
- Hardened core framing/transport: improved prefix resync, retry/timeout error reporting, safer truncated-frame handling, and reduced hot-path debug overhead.
- Fixed correctness issues in utilities and device helpers (numeric DP selection for timers, rgb8 hue decode offset, UDP NUL-stripping, scanner default arg + caller-dict mutation avoidance).
- Added offline regression tests for timer DP selection,
error_json()behavior, and rgb8 hue round-trip; updated RELEASE notes.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tinytuya/scanner.py | Removes mutable default argument for devices() and avoids mutating caller device dicts for cloud-only entries. |
| tinytuya/core/XenonDevice.py | Improves receive resync behavior, avoids swallowing KeyboardInterrupt, caps wrong-CID queue growth, gates expensive debug logging, and returns ERR_TIMEOUT on retry exhaustion. |
| tinytuya/core/udp_helper.py | Makes UDP payload NUL-stripping safe for empty/all-NUL payloads via rstrip(). |
| tinytuya/core/Monitor.py | Adds resync logic to prevent unbounded buffer growth on corrupt/oversized headers. |
| tinytuya/core/message_helper.py | Converts truncated/corrupt-frame unpack failures into retryable DecodeError instead of leaking struct.error. |
| tinytuya/core/error_helper.py | Refactors error_json() to build dicts directly and handle unknown codes without KeyError. |
| tinytuya/core/Device.py | Fixes set_timer() DP selection to sort numerically when possible. |
| tinytuya/Cloud.py | Preserves query/content_type across token-refresh retries and hardens cloud response handling to avoid TypeError/KeyError. |
| tinytuya/BulbDevice.py | Fixes rgb8 hue offset in hexvalue_to_hsv() and aligns set_music_colour() docstring with actual argument order. |
| tests.py | Adds regression coverage for numeric DP selection, error_json() shape/unknown codes, and rgb8 hue round-trip. |
| RELEASE.md | Documents the unreleased bug-fix set and the added tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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} |
| discover=True, wantips=None, wantids=None, snapshot=None, assume_yes=False, tuyadevices=None, | ||
| maxdevices=0): # pylint: disable=W0621, W0102 |
There was a problem hiding this comment.
Done ✅ — Removed W0102 from the pylint suppression in b8289b6. Copilot was right that it's no longer needed now that tuyadevices defaults to None instead of [].
— Sam ⚙️
jasonacox-sam
left a comment
There was a problem hiding this comment.
Reviewed all 11 files. This is a well-verified batch — each fix traces to a concrete defect and none alter public API or wire behavior.
Standout fixes:
- Cloud token refresh forwarding
query/content_type— this was a silent data-loss bug on paginated calls. Important fix. set_timer()numeric DP selection —"9" > "102"lexicographically is a classic footgun. Good regression test covering it.ERR_TIMEOUTreplacingERR_KEY_OR_VER— misleading error codes send users down the wrong debugging path for hours. This matters.hexvalue_to_hsv()offset[7:10]→[6:10]— verified against the encoder, clear off-by-one.- Bare
except:→except Exception:— swallowingKeyboardInterruptis a known anti-pattern. Good cleanup. - Monitor buffer resync — the unbounded-buffer-growth stall was the kind of bug that haunts long-running monitoring setups.
One note on Copilot's observation about error_json(): the old code JSON-round-tripped payload (which for bytes objects would silently produce "" via the bare except:), while the new code returns the raw object. This is a minor behavioral change — callers that json.dumps() the result on a bytes payload will now get a TypeError instead of a silent empty string. In practice most call sites pass strings/None so this won't bite, but worth being aware of if anyone reports a serialization issue. Not a blocker — the new behavior is arguably more correct.
Ship it. — Sam ⚙️
| else: | ||
| # hexvalue is in rgb+hsv | ||
| h = int(hexvalue[7:10], 16) / 360.0 | ||
| h = int(hexvalue[6:10], 16) / 360.0 |
|
@jasonacox-sam I want to test the code we change for BulbDevice before we merge all these. We have an RGB bulb to test with but you will need a camera to observe and confirm the RGB settings. I'll get that set up so you can run those types of hardware tests. |
|
@jasonacox — sounds good. I'm ready when you are. For the BulbDevice changes, here's what I'll verify once the camera is up:
I'll use — Sam ⚙️ |
|
@jasonacox-sam The camera is set up. You can do a hardware test with the Smartbulb. Also, build a script to test hexvalue_to_hsv. |
|
@jasonacox — Tests done. Here's the full report.
|
| Format | Tests | Result |
|---|---|---|
rgb8 (14-char: RRGGBBHHHHSSVV) |
10 | ✅ All pass |
hsv16 (12-char: HHHHSSSSVVVV) |
3 | ✅ All pass |
Verified hue accuracy at 0°, 1°, 60°, 120°, 180°, 240°, 300°, 359°, plus saturation and value edge cases.
Hardware Test — Back Patio Bulb (WB_RGBCW_01)
Set 5 colors via set_colour() → read back DPS 24 → parsed with hexvalue_to_hsv():
| Color | DPS 24 Returned | Hue Δ vs Expected |
|---|---|---|
| Red (255,0,0) | 000003e803e8 |
0.0000 ✅ |
| Green (0,255,0) | 007803e803e8 |
0.0000 ✅ |
| Blue (0,0,255) | 00f003e803e8 |
0.0000 ✅ |
| Warm Yellow (255,200,0) | 002f03e803e8 |
0.0002 ✅ |
| Purple (128,0,255) | 010e03e803e8 |
0.0003 ✅ |
All colors round-tripped correctly. The bulb uses hsv16 format on DPS 24.
Key Finding: The hexvalue[6:10] Fix
The original code used hexvalue[7:10] (3 chars) for hue; the fix uses hexvalue[6:10] (4 chars). This is technically correct — proper slicing of a 4-hex-digit field. However, for all valid Tuya hue values (0–359°), the leading hex digit at position 6 is always '0', so int("0HHH", 16) == int("HHH", 16). The fix is functionally a no-op within the valid hue range but prevents potential issues with out-of-range values and is the correct slice.
Camera note: The Patio cam couldn't see the Back Patio bulb directly (it's around the corner). Software confirmation via DPS readback is solid, but if you want visual verification, we'd need to reposition a camera or use the bulb you set up. I set the bulb to red for the snapshot but it wasn't in frame.
Test script saved at agents/tinytua/tests/test_hexvalue_to_hsv.py.
— Sam ⚙️
|
Quick follow-up with the deeper code review of the BulbDevice changes, plus a note on the docstring that I think caused the original bug. BulbDevice Code Review
|
|
@jasonacox-sam I agree, update the doc strings for hexvalue_to_hsv. |
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.
|
@jasonacox — done. Pushed the docstring fix in d04ad6d. The — Sam ⚙️ |
Bug fixes: 16 verified issues across Cloud, core, Bulb, scanner, Monitor
Each fix was verified against the surrounding code before changing; none alter public API signatures or wire behavior. Full details in the RELEASE.md entry included in this PR.
Cloud.py
query/content_type— paginated/queried calls (device list,getdevicelog,getdps) no longer silently lose their query string when the token expires mid-flight_gettoken/_getuid/_getdevice/getdps/sendcommand/getconnectstatusreturn the standard error dict on failed/empty responses instead of raisingTypeError/KeyErrorcore
set_timer()selects the timer DP numerically ("9"no longer outranks"102")ERR_TIMEOUT(902) instead of the misleadingERR_KEY_OR_VER_receive()resync picks the earliest 55AA/6699 prefix; truncated 55AA frames raise retryableDecodeErrorinstead ofstruct.error; bareexcept:no longer swallows Ctrl-C in_send_receive_quick;received_wrong_cid_queuecapped at 100; hexlify debug logging gated on log leveldecrypt_udp()no longer raisesIndexErroron all-NUL payloadserror_json()builds its dict directly and handles unknown codes withoutKeyErrorBulbDevice —
hexvalue_to_hsv()hue offset[7:10]→[6:10](latent off-by-one, matches the encoder);set_music_colourdocstring matches the real argument orderscanner — removed mutable default
tuyadevices=[]; cloud-only entries no longer mutate caller dictsMonitor (experimental) — corrupt/oversized headers now resync the receive buffer to the next frame prefix instead of stalling the device forever with unbounded buffer growth
Adds offline regression tests (
set_timerDP selection,error_jsonshape, rgb8 hue round-trip): 23 → 29 tests.pylint -Eclean.Note: the deeper Monitor threading issues (device-internal reconnect bypassing the selector, blocking retries on the reactor thread) are deferred to the #713 refactor — they need a design decision, not a spot fix.
🤖 Generated with Claude Code