Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
dbff472
Intro v2.0.0 with Async Architecture and remove Python 2 support
jasonacox Sep 14, 2025
aef972d
Add base class XenonDeviceAsync
3735943886 Sep 14, 2025
aae3e57
DeviceAsync class as base for v2 async support
3735943886 Sep 14, 2025
88b1deb
Restore some deleted comments, and quirks
uzlonewolf Sep 15, 2025
a2d363e
Move payload_dict/merge_dps_results out of Device, remove duplicated …
uzlonewolf Sep 15, 2025
8c56e27
Import find_device/device_info from XenonDevice
uzlonewolf Sep 15, 2025
1ca036f
Implement callbacks for data, connect, and dataless responses
uzlonewolf Sep 15, 2025
43e7dc5
Eliminate initialize() and create()
uzlonewolf Sep 15, 2025
ca31b82
Add initial implementation of test-devices.py for DeviceAsync regress…
jasonacox Sep 15, 2025
0d832a6
Merge branch 'v2-async' of https://github.com/jasonacox/tinytuya into…
jasonacox Sep 15, 2025
94b4bec
Enhance DeviceAsync status fetching with shared discovery and error c…
jasonacox Sep 15, 2025
28296d9
Add testing framework with pytest integration, coverage reporting, an…
jasonacox Sep 15, 2025
665bead
Add pytest-asyncio to dependencies in GitHub Actions workflow
jasonacox Sep 15, 2025
a282921
Rename examples
3735943886 Sep 15, 2025
6e3e213
minor bug fix
3735943886 Sep 17, 2025
acc4bb8
Delay certain callbacks and add missing disconnect cb
uzlonewolf Sep 20, 2025
fd6815f
Demote generate_payload to private method _generate_payload
uzlonewolf Sep 20, 2025
3553e34
Remove obsolete _send_receive() argument minresponse
uzlonewolf Sep 20, 2025
a527647
Add device lock and rework callbacks again
uzlonewolf Sep 20, 2025
d503ac5
Rework "connection closed while receiving" logic and add helpers star…
uzlonewolf Sep 20, 2025
4e29108
Update 'close' to '_close' in monkey test
uzlonewolf Sep 20, 2025
2e14cd9
Split _decrypt_payload() out of _decode_payload() and into own functi…
uzlonewolf Sep 20, 2025
4ee9253
Make sure _send_receive() always returns a dict (never None), and rew…
uzlonewolf Sep 20, 2025
39a045f
Completely remove nowait - all commands send, or receive, but never b…
uzlonewolf Sep 20, 2025
628fe64
Finish removing nowait
uzlonewolf Sep 20, 2025
f31f044
Allow passing custom timeouts to _receive, and move sending code to i…
uzlonewolf Sep 21, 2025
f1f29cb
Change defaults: persist is now True, version is now 3.5, and sendWai…
uzlonewolf Sep 21, 2025
2931d46
Delay closing non-persistent sockets for 100ms to give them a chance …
uzlonewolf Sep 21, 2025
d4f2e69
minor bug fix
3735943886 Sep 23, 2025
fd80b7b
Add continuous-receive helper and a receive lock
uzlonewolf Sep 23, 2025
37dfcb5
Pylint fix
uzlonewolf Sep 23, 2025
96d8147
Start of scanner. Only scanfor( dev_id, timeout ) works at the moment
uzlonewolf Sep 23, 2025
196c6ee
Fixes for scanner and v3.5
uzlonewolf Sep 23, 2025
621c81b
Make pylint ignore the scanner for now
uzlonewolf Sep 23, 2025
9f072d8
Skip locking if sending only and device is already connected
uzlonewolf Sep 23, 2025
446bcc4
Skip locking if send-only or receive-only and device is already conne…
uzlonewolf Sep 23, 2025
adde094
Rework device locking
uzlonewolf Sep 24, 2025
4cc040f
Rework callback running and task helpers
uzlonewolf Sep 26, 2025
c218311
Make send() private as well
uzlonewolf Sep 26, 2025
923eb5e
Fix test now that _send() is private
uzlonewolf Sep 26, 2025
87a6c60
Set result to prevent UnboundLocalError
3735943886 Nov 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions ASYNC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# TinyTuya Async Roadmap (v2.x)

## Vision
Provide a first-class asyncio-native API for local Tuya LAN control that:
* Preserves rock-solid backward compatibility with the existing synchronous API (1.x style) for the large installed base.
* Enables high-concurrency, low-latency operations (parallel status polling, batched control, streaming updates) across mixed protocol versions (3.1–3.5) without blocking threads.
* Establishes a sustainable architecture so future protocol changes (e.g. 3.6+, new discovery flows) can be integrated once at the async layer and selectively backported.

## Goals
1. Async Core: Non-blocking socket connect, handshake, encrypt/decrypt, send/receive framed messages for all protocol versions.
2. High Throughput: Support dozens/hundreds of devices concurrently with graceful backpressure and timeout handling.
3. Pluggable Crypto & Parsing: Reuse existing message framing logic but allow async pipeline (reader / writer tasks) with cancellation.
4. Structured API: Mirror familiar synchronous class names with `Async` suffix (e.g. `XenonDeviceAsync`, `OutletDeviceAsync`).
5. Observability: Built-in debug / trace hooks and metrics counters (messages sent, retries, handshake duration) pluggable via callbacks.
6. Incremental Adoption: No forced migration—sync and async coexist; shared utility modules (e.g. encoding, DPS merge) remain single-source.

## Out of Scope
* Replacing synchronous classes or removing sync code paths.
* Full async Cloud API (could follow later).

## Architectural Overview (Planned)
```
+------------------------------+ +---------------------------+
| XenonDeviceAsync (base) | | MessageHelper (shared) |
| - state machine |<--calls--> | pack/unpack (sync funcs) |
| - connection supervisor | | crypto helpers |
| - protocol v3.1..v3.5 | +---------------------------+
| - send queue (asyncio.Queue)|
| - recv task (reader loop) | +---------------------------+
| - handshake coroutine |<--uses---->| Crypto (AESCipher) |
+--------------+---------------+ +---------------------------+
| derives
+----------+-----------+
| Async Device Mixins |
| (Outlet/Bulb/etc.) |
+----------------------+
```

## Milestones
| Milestone | Description | Deliverables | Target Version |
|-----------|-------------|--------------|----------------|
| M0 | Planning & Version Bump | v2.0.0, `ASYNC.md`, release notes | 2.0.0 |
| M1 | Async Core Skeleton | `xasync/connection.py`, `XenonDeviceAsync` minimal connect + status (3.1/3.3) | 2.1.0 |
| M2 | Protocol Coverage | Support 3.4/3.5 handshake & GCM in async path | 2.2.0 |
| M3 | Device Classes | `OutletDeviceAsync`, `BulbDeviceAsync`, `CoverDeviceAsync` parity subset | 2.3.0 |
| M4 | High-Perf Scanner | Async scanner refactor (parallel probes, cancellation) | 2.4.0 |
| M5 | Test & Metrics | 85%+ coverage for async modules; metrics hooks | 2.5.0 |
| M6 | Examples & Docs | Async examples, README + PROTOCOL cross-links | 2.6.0 |
| M7 | Optimization | Connection pooling, adaptive retry, rate limiting | 2.7.0 |

## Detailed Task Breakdown
### M1 – Async Core Skeleton
- [ ] Create package folder `tinytuya/asyncio/` (or `tinytuya/async_`) to avoid name collision.
- [ ] Implement `XenonDeviceAsync` with:
* `__init__(..., loop=None)` store config
* `_ensure_connection()` coroutine: open TCP, negotiate session key if needed
* `_reader_task()` coroutine: read frames, push to internal queue
* `_send_frame()` coroutine: pack + write
* `status()` -> `await get_status()` that sends DP_QUERY / CONTROL_NEW per version
* Graceful close / cancellation
- [ ] Reuse existing `pack_message` / `unpack_message` in a thread-safe way (they are CPU-bound but fast; optionally offload heavy crypto to default loop executor only if needed later).

### M2 – Protocol v3.4 / v3.5 Handshake
- [ ] Async handshake coroutine with timeout + auto-retry
- [ ] Session key caching per open connection
- [ ] Automatic renegotiation on GCM tag failure

### M3 – Device Class Parity
- [ ] Async mixins or subclasses replicating key sync API (`set_value`, `set_multiple_values`, `turn_on/off`)
- [ ] If return values differ (e.g. coroutines), document mapping in README
- [ ] Shared DPS merge logic factored into pure functions usable by both sync/async

### M4 – Async Scanner
- [ ] Coroutine to probe IP ranges concurrently (configurable concurrency)
- [ ] Cancel outstanding probes on shutdown
- [ ] Integrate UDP discovery (v3.1–v3.5) with async sockets
- [ ] Provide `await scan_network(subnet, timeout)` returning structured device list

### M5 – Tests & QA
- [ ] Pytest-asyncio test suite for: framing, handshake, reconnection, DPS updates
- [ ] Fake device server (async) to simulate v3.1, 3.3, 3.4, 3.5 behaviors
- [ ] Performance smoke test (N devices concurrently) gating PR merges

### M6 – Documentation & Examples
- [ ] `examples/async/` directory with: basic status, bulk control, scanner usage, bulb effects
- [ ] README section: “Using the Async API” with migration notes
- [ ] Cross-link PROTOCOL.md for handshake & framing details

### M7 – Optimization & Enhancements
- [ ] Connection pooling for multiple logical child devices (gateways) sharing transport
- [ ] Adaptive retry and exponential backoff for transient network errors
- [ ] Optional structured logging adapter (JSON events)
- [ ] Metrics hook interface (`on_event(event_name, **data)`) for integrations

## Testing Strategy
| Layer | Strategy |
|-------|----------|
| Unit | Pure functions (framing, header parse) deterministic tests |
| Integration | Async fake device endpoints per protocol version |
| Performance | Timed concurrent status for N synthetic devices (assert throughput baseline) |
| Regression | Mirror critical sync tests with async equivalents |
| Fuzz (future) | Random DP payload mutation on fake server to harden parsing |

## API Sketch (Draft)
```python
import asyncio
import tinytuya
from tinytuya.asyncio import XenonDeviceAsync

async def main():
dev = XenonDeviceAsync(dev_id, address=ip, local_key=key, version=3.5, persist=True)
status = await dev.status() # coroutine
print(status)
await dev.set_value(1, True)
await dev.close()

asyncio.run(main())
```
Methods returning coroutines (awaitables): `status`, `set_value`, `set_multiple_values`, `heartbeat`, `updatedps`, `close`.

## Backward Compatibility Plan
* Sync code paths untouched; existing imports remain default.
* Async lives under `tinytuya.asyncio` (explicit opt-in) to avoid polluting top-level namespace initially.
* When async reaches parity, consider promoting selected classes to top-level import in a minor release (opt-in alias only).

## Open Questions / To Refine
- Should we introduce an event callback API for spontaneous DP updates vs polling? (Likely yes in M3/M4.)
- Provide context manager (`async with XenonDeviceAsync(...)`) for auto-connect/close? (Planned.)
- Rate limiting: global vs per-device? (Investigate after baseline performance metrics.)

## Contribution Guidelines (Async Track)
* Prefer small, reviewable PRs per milestone task.
* Include tests & docs for every new public coroutine.
* Avoid breaking sync APIs—additive only.
* Mark experimental APIs with a leading `_` or mention in docstring.

## Next Step
Implement Milestone M1 skeleton: create async package, base class, minimal status() for 3.1 & 3.3 devices.

---
Maintained with the goal of long-term stability for existing users while enabling modern async performance.
22 changes: 22 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
# RELEASE NOTES

## v2.0.0 - Async Architecture Introduction (BREAKING MAJOR VERSION)

This major release introduces the foundation for native asyncio-based device communication while fully preserving the existing synchronous API for backward compatibility.

Highlights:
* Version bump to 2.x to signal new async subsystem (legacy sync classes unchanged).
* Planning document `ASYNC.md` added (vision, goals, milestones for XenonDeviceAsync & related classes).
* No behavioral changes to existing synchronous code paths in this initial 2.0.0 tag.
* Future minor releases (2.1.x+) will add new async classes and examples without removing sync support.

Compatibility:
* Existing imports and synchronous usage continue to work (API surface of 1.x retained).
* New async classes will live alongside current modules (no name collisions) and require explicit opt‑in.
* Officially removed Python 2.7 support.

Migration Guidance:
* You can adopt async incrementally—no action required if you stay with sync API.
* When async classes land, prefer `await device.status_async()` patterns in event loops for concurrency gains.

See `ASYNC.md` for roadmap details.


## 1.17.4 - Cloud Config

- Cloud: Add `configFile` option to the Cloud constructor, allowing users to specify the config file location (default remains 'tinytuya.json') by @blackw1ng in https://github.com/jasonacox/tinytuya/pull/640
Expand Down
1 change: 0 additions & 1 deletion server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@
"""

# Modules
from __future__ import print_function
import threading
import time
import logging
Expand Down
10 changes: 2 additions & 8 deletions tinytuya/core/XenonDevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import socket
import struct
import time
import sys

from .const import DEVICEFILE, TCPPORT
from .crypto_helper import AESCipher
Expand All @@ -20,8 +19,6 @@

log = logging.getLogger(__name__)

# Python 2 Support
IS_PY2 = sys.version_info[0] == 2

def find_device(dev_id=None, address=None):
"""Scans network for Tuya devices with either ID = dev_id or IP = address
Expand Down Expand Up @@ -904,11 +901,8 @@ def _negotiate_session_key_generate_step_3( self, rkey ):
return MessagePayload(CT.SESS_KEY_NEG_FINISH, rkey_hmac)

def _negotiate_session_key_generate_finalize( self ):
if IS_PY2:
k = [ chr(ord(a)^ord(b)) for (a,b) in zip(self.local_nonce,self.remote_nonce) ]
self.local_key = ''.join(k)
else:
self.local_key = bytes( [ a^b for (a,b) in zip(self.local_nonce,self.remote_nonce) ] )
# Python 3: nonce XOR produces bytes object directly
self.local_key = bytes(a ^ b for (a, b) in zip(self.local_nonce, self.remote_nonce))
log.debug("Session nonce XOR'd: %r", self.local_key)

cipher = AESCipher(self.real_local_key)
Expand Down
33 changes: 6 additions & 27 deletions tinytuya/core/core.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# TinyTuya Module
# -*- coding: utf-8 -*-
"""
Python module to interface with Tuya WiFi smart devices
Python module to interface with Tuya WiFi smart devices.
(Python 3 only as of v2.0.0 – legacy Python 2 support removed.)

Author: Jason A. Cox
For more information see https://github.com/jasonacox/tinytuya
Expand Down Expand Up @@ -76,7 +77,6 @@
"""

# Modules
from __future__ import print_function # python 2.7 support
import logging
import sys

Expand All @@ -90,45 +90,24 @@

from .crypto_helper import AESCipher

# Backward compatibility for python2
try:
input = raw_input
except NameError:
pass


# Colorama terminal color capability for all platforms
if HAVE_COLORAMA:
init()

version_tuple = (1, 17, 4) # Major, Minor, Patch
version_tuple = (2, 0, 0) # Major, Minor, Patch
version = __version__ = "%d.%d.%d" % version_tuple
__author__ = "jasonacox"

log = logging.getLogger(__name__)


# Python 2 Support
IS_PY2 = sys.version_info[0] == 2


# Misc Helpers
def bin2hex(x, pretty=False):
if pretty:
space = " "
else:
space = ""
if IS_PY2:
result = "".join("%02X%s" % (ord(y), space) for y in x)
else:
result = "".join("%02X%s" % (y, space) for y in x)
return result
space = " " if pretty else ""
return "".join("%02X%s" % (b, space) for b in x)

def hex2bin(x):
if IS_PY2:
return x.decode("hex")
else:
return bytes.fromhex(x)
return bytes.fromhex(x)

def set_debug(toggle=True, color=True):
"""Enable tinytuya verbose logging"""
Expand Down
1 change: 0 additions & 1 deletion tinytuya/core/crypto_helper.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# TinyTuya Module
# -*- coding: utf-8 -*-

from __future__ import print_function # python 2.7 support
import base64
import logging
import time
Expand Down
12 changes: 2 additions & 10 deletions tinytuya/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

"""
# Modules
from __future__ import print_function
from collections import namedtuple
import ipaddress
import json
Expand Down Expand Up @@ -42,11 +41,6 @@
#except:
# SCANLIBS = False

# Backward compatibility for python2
try:
input = raw_input
except NameError:
pass

try:
import netifaces # pylint: disable=E0401
Expand Down Expand Up @@ -379,8 +373,8 @@ def get_peer(self):
r = self.sock.recv( 5000 )
if self.debug:
print('Debug sock', self.ip, 'closed but received data?? Received:', r)
# ugh, ConnectionResetError and ConnectionRefusedError are not available on python 2.7
#except ConnectionResetError:
# Connection retry logic
#except ConnectionResetError: # Python 3 specific
except OSError as e:
if self.initial_connect_retries and e.errno == errno.ECONNRESET:
# connected, but then closed
Expand Down Expand Up @@ -1060,8 +1054,6 @@ def scan(scantime=None, color=True, forcescan=False, discover=True, assume_yes=F

def _generate_ip(networks, verbose, term):
for netblock in networks:
if tinytuya.IS_PY2 and type(netblock) == str:
netblock = netblock.decode('latin1')
try:
network = ipaddress.ip_network(netblock, strict=False)
log.debug("Starting brute force network scan %s", network)
Expand Down
6 changes: 0 additions & 6 deletions tinytuya/wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
The TuyAPI/CLI wizard inspired and informed this python version.
"""
# Modules
from __future__ import print_function
import json
from datetime import datetime
import tinytuya
Expand All @@ -36,11 +35,6 @@

HAVE_COLOR = HAVE_COLORAMA or not sys.platform.startswith('win')

# Backward compatibility for python2
try:
input = raw_input
except NameError:
pass

# Colorama terminal color capability for all platforms
if HAVE_COLORAMA:
Expand Down
19 changes: 3 additions & 16 deletions tools/ttcorefunc.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
# -*- coding: utf-8 -*-

# Modules
from __future__ import print_function # python 2.7 support
import binascii
from collections import namedtuple
import base64
Expand Down Expand Up @@ -96,8 +95,6 @@

NO_PROTOCOL_HEADER_CMDS = [DP_QUERY, DP_QUERY_NEW, UPDATEDPS, HEART_BEAT, SESS_KEY_NEG_START, SESS_KEY_NEG_RESP, SESS_KEY_NEG_FINISH, LAN_EXT_STREAM ]

# Python 2 Support
IS_PY2 = sys.version_info[0] == 2

# Tuya Packet Format
TuyaHeader = namedtuple('TuyaHeader', 'prefix seqno cmd length total_length')
Expand Down Expand Up @@ -241,21 +238,11 @@ def _unpad(s, verify_padding=False):

# Misc Helpers
def bin2hex(x, pretty=False):
if pretty:
space = " "
else:
space = ""
if IS_PY2:
result = "".join("%02X%s" % (ord(y), space) for y in x)
else:
result = "".join("%02X%s" % (y, space) for y in x)
return result
space = " " if pretty else ""
return "".join("%02X%s" % (y, space) for y in x)

def hex2bin(x):
if IS_PY2:
return x.decode("hex")
else:
return bytes.fromhex(x)
return bytes.fromhex(x)

def set_debug(toggle=True, color=True):
"""Enable tinytuya verbose logging"""
Expand Down
Loading