tuyamock 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tuyamock-0.0.1/LICENSE +21 -0
- tuyamock-0.0.1/PKG-INFO +365 -0
- tuyamock-0.0.1/README.md +335 -0
- tuyamock-0.0.1/pyproject.toml +53 -0
- tuyamock-0.0.1/setup.cfg +4 -0
- tuyamock-0.0.1/src/tuyamock/__init__.py +19 -0
- tuyamock-0.0.1/src/tuyamock/__main__.py +6 -0
- tuyamock-0.0.1/src/tuyamock/cli.py +137 -0
- tuyamock-0.0.1/src/tuyamock/device.py +278 -0
- tuyamock-0.0.1/src/tuyamock/protocol.py +255 -0
- tuyamock-0.0.1/src/tuyamock/server.py +269 -0
- tuyamock-0.0.1/src/tuyamock.egg-info/PKG-INFO +365 -0
- tuyamock-0.0.1/src/tuyamock.egg-info/SOURCES.txt +17 -0
- tuyamock-0.0.1/src/tuyamock.egg-info/dependency_links.txt +1 -0
- tuyamock-0.0.1/src/tuyamock.egg-info/entry_points.txt +2 -0
- tuyamock-0.0.1/src/tuyamock.egg-info/requires.txt +4 -0
- tuyamock-0.0.1/src/tuyamock.egg-info/top_level.txt +1 -0
- tuyamock-0.0.1/tests/test_inprocess.py +114 -0
- tuyamock-0.0.1/tests/test_with_tinytuya.py +237 -0
tuyamock-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tuyamock contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tuyamock-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tuyamock
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Protocol-faithful mock of a Tuya local-protocol device, built on tinytuya primitives
|
|
5
|
+
Author: tuyamock contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/3735943886/tuyamock
|
|
8
|
+
Project-URL: Repository, https://github.com/3735943886/tuyamock
|
|
9
|
+
Project-URL: Issues, https://github.com/3735943886/tuyamock/issues
|
|
10
|
+
Keywords: tuya,tinytuya,mock,testing,iot,smart-home,local-protocol
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Software Development :: Testing :: Mocking
|
|
22
|
+
Classifier: Topic :: Home Automation
|
|
23
|
+
Requires-Python: >=3.8
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: tinytuya>=1.13
|
|
27
|
+
Provides-Extra: test
|
|
28
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# tuyamock
|
|
32
|
+
|
|
33
|
+
[](https://github.com/3735943886/tuyamock/actions/workflows/ci.yml)
|
|
34
|
+
[](https://pypi.org/project/tuyamock/)
|
|
35
|
+
[](https://pypi.org/project/tuyamock/)
|
|
36
|
+
[](LICENSE)
|
|
37
|
+
|
|
38
|
+
A protocol-faithful **mock of a Tuya local-protocol device**, built on
|
|
39
|
+
[tinytuya](https://github.com/jasonacox/tinytuya)'s own message/crypto
|
|
40
|
+
primitives.
|
|
41
|
+
|
|
42
|
+
It plays the *device* side of the Tuya LAN protocol so you can test Tuya
|
|
43
|
+
*clients* end-to-end without real hardware. Because the device side is
|
|
44
|
+
implemented with tinytuya's `pack_message` / `unpack_message` / `AESCipher`, the
|
|
45
|
+
mock is an **independent oracle**: a client that re-implements the protocol (in
|
|
46
|
+
any language) can be validated against it without the validation being circular.
|
|
47
|
+
|
|
48
|
+
## Why "independent oracle"
|
|
49
|
+
|
|
50
|
+
The intended workflow is a two-step bootstrap:
|
|
51
|
+
|
|
52
|
+
1. **mock ⟷ tinytuya client** — prove the mock is protocol-correct using
|
|
53
|
+
tinytuya as the reference client. Once these tests pass, the mock is treated
|
|
54
|
+
as ground truth. *(This is exactly what `tests/test_with_tinytuya.py` does.)*
|
|
55
|
+
2. **mock ⟷ client-under-test** — point the other client at the validated mock.
|
|
56
|
+
Any failure is then isolated to that client, not the harness.
|
|
57
|
+
|
|
58
|
+
If the mock spoke a re-implemented dialect of the protocol, step 1 would be
|
|
59
|
+
circular and bugs could hide on both sides. Reusing tinytuya's primitives avoids
|
|
60
|
+
that.
|
|
61
|
+
|
|
62
|
+
## Dependence on tinytuya (read this before upgrading tinytuya)
|
|
63
|
+
|
|
64
|
+
The mock does **not** re-implement the Tuya protocol — it **imports tinytuya at
|
|
65
|
+
runtime and calls tinytuya's own functions** for all crypto/framing. This is
|
|
66
|
+
deliberate (it's what makes the mock an independent oracle: the device and the
|
|
67
|
+
tinytuya reference client run the *same* byte-level code, so they cannot silently
|
|
68
|
+
disagree). But it means the mock's correctness is **coupled to the installed
|
|
69
|
+
tinytuya** at three different layers, only one of which auto-adapts. The failure
|
|
70
|
+
modes are different per layer — know which is which before bumping the dependency.
|
|
71
|
+
|
|
72
|
+
### Layer 1 — imported symbols (the linkage surface)
|
|
73
|
+
|
|
74
|
+
Pulled straight from tinytuya and *called*, never copied. All from **internal**
|
|
75
|
+
modules (`tinytuya.core.*`), not a documented/stable public API:
|
|
76
|
+
|
|
77
|
+
| imported symbol | import path | used in | role |
|
|
78
|
+
|-----------------|-------------|---------|------|
|
|
79
|
+
| `pack_message` | `tinytuya.core.message_helper` | `protocol.py` (`VersionProfile.pack_response`), `server.py` (discovery beacon) | assemble 55AA / 6699 frames |
|
|
80
|
+
| `unpack_message` | `tinytuya.core.message_helper` | `protocol.py` (`VersionProfile.unpack_request`) | disassemble one frame → `TuyaMessage` |
|
|
81
|
+
| `TuyaMessage` | `tinytuya.core.message_helper` | `protocol.py` (`pack_response`), `server.py` (`_maybe_broadcast`) | 8-field namedtuple passed to `pack_message` |
|
|
82
|
+
| `parse_header` | `tinytuya.core.message_helper` | `device.py` (`take_frames`) | read `total_length` for stream framing |
|
|
83
|
+
| `AESCipher` | `tinytuya.core.crypto_helper` | `protocol.py` (the crypto-utility wrappers) | AES-ECB / AES-GCM + PKCS#7 padding |
|
|
84
|
+
| `command_types` (`CT`) | `tinytuya.core` | protocol/device/server | command opcode *values* |
|
|
85
|
+
| `header` (`H`) | `tinytuya.core` | protocol/server | prefix/header/version-byte constants |
|
|
86
|
+
| `DecodeError` | `tinytuya.core.exceptions` | device/server | raised on short/garbled frames |
|
|
87
|
+
| `tinytuya.udpkey` | `tinytuya` (top level) | `server.py` (`_maybe_broadcast`) | HMAC key for the UDP discovery beacon |
|
|
88
|
+
|
|
89
|
+
Constants we read out of `H`: `PREFIX_55AA_VALUE`, `PREFIX_6699_VALUE`,
|
|
90
|
+
`PROTOCOL_3x_HEADER`, `PROTOCOL_VERSION_BYTES_31`. Opcodes we read out of `CT`:
|
|
91
|
+
`SESS_KEY_NEG_START/RESP/FINISH`, `DP_QUERY`, `DP_QUERY_NEW`, `CONTROL`,
|
|
92
|
+
`CONTROL_NEW`, `UPDATEDPS`, `HEART_BEAT`, `UDP_NEW`. If tinytuya renames/moves any
|
|
93
|
+
of these, the mock fails to **import** — loud, immediate, trivial to diagnose.
|
|
94
|
+
|
|
95
|
+
### Layer 2 — behavioral contracts (the dangerous, implicit surface)
|
|
96
|
+
|
|
97
|
+
Beyond mere symbol existence, the mock hard-codes assumptions about *how these
|
|
98
|
+
functions behave and what their arguments mean*. These are not enforced by any
|
|
99
|
+
type and will not raise on a signature change — they produce **wrong bytes**.
|
|
100
|
+
Enumerated so a reviewer knows exactly what to re-verify against tinytuya source:
|
|
101
|
+
|
|
102
|
+
* **`TuyaMessage` field order/semantics.** We construct it positionally as
|
|
103
|
+
`TuyaMessage(seqno, cmd, retcode, payload, crc, crc_good, prefix, iv)` in
|
|
104
|
+
`protocol.py` `pack_response` (and `server.py` `_maybe_broadcast`). The last field
|
|
105
|
+
doubles as the GCM-iv/flag: `True` for 6699, `None` for 55AA. If tinytuya
|
|
106
|
+
reorders or repurposes any field, frames serialize wrong with no error.
|
|
107
|
+
* **`pack_message(msg, hmac_key=…)`** — we rely on `hmac_key=None` ⇒ CRC32 framing
|
|
108
|
+
and `hmac_key=<bytes>` ⇒ HMAC-SHA256 framing (`VersionProfile.framing_key()`
|
|
109
|
+
returns the session key on v3.4/v3.5, `None` on v3.1–3.3). And that `pack_message`
|
|
110
|
+
reads the prefix from `msg.prefix` to pick 55AA vs 6699.
|
|
111
|
+
* **`unpack_message(frame, hmac_key=…, no_retcode=True)`** — we pass
|
|
112
|
+
`no_retcode=True` on requests (client→device frames have no retcode) and instead
|
|
113
|
+
prepend the 4-byte retcode **ourselves** on 55AA responses via the `RETCODE`
|
|
114
|
+
constant (in `pack_response`). The retcode convention living *outside*
|
|
115
|
+
`pack_message` for 55AA but *inside* it for 6699 is a tinytuya-specific asymmetry
|
|
116
|
+
we mirror by hand.
|
|
117
|
+
* **`AESCipher` keyword contract.** We depend on the exact kwargs
|
|
118
|
+
`encrypt(data, use_base64=, pad=, iv=)` and
|
|
119
|
+
`decrypt(data, use_base64=, decode_text=)` (see the crypto-utility wrappers at the
|
|
120
|
+
top of `protocol.py`), e.g. `use_base64=False` for raw-bytes ECB on the wire,
|
|
121
|
+
`pad=False` for session-key derivation, and `iv=` selecting GCM mode and being
|
|
122
|
+
prepended to the ciphertext. The v3.5 session key is specifically
|
|
123
|
+
`GCM(nonce, iv=client_nonce[:12])[12:28]` (`derive_session_key_gcm`) — a 16-byte
|
|
124
|
+
slice out of the GCM output whose offset is a tinytuya implementation detail.
|
|
125
|
+
* **`parse_header(...).total_length`** is how `take_frames` (in `device.py`)
|
|
126
|
+
re-frames the TCP byte stream (multiple frames per read / frame split across
|
|
127
|
+
reads). A change to that attribute name or its length accounting silently
|
|
128
|
+
corrupts framing.
|
|
129
|
+
* **`NO_PROTOCOL_HEADER_CMDS`** — `protocol.NEGOTIATION_CMDS` is a hand-copied
|
|
130
|
+
mirror of `XenonDevice.NO_PROTOCOL_HEADER_CMDS`. If tinytuya adds a command that
|
|
131
|
+
skips the version header, our copy goes stale.
|
|
132
|
+
|
|
133
|
+
### Layer 3 — client protocol *policy* (hand-mirrored, never auto-adapts)
|
|
134
|
+
|
|
135
|
+
tinytuya exposes primitives but **not** "how a device is supposed to respond." All
|
|
136
|
+
of that is replicated by hand from `XenonDevice` into our `VersionProfile`s (in
|
|
137
|
+
`protocol.py`) and command dispatch (`Session` in `device.py`). If tinytuya changes
|
|
138
|
+
*client* behaviour (new handshake, moved header, a v3.6), the mock **will not
|
|
139
|
+
notice**:
|
|
140
|
+
|
|
141
|
+
* **version-header placement** — `version_bytes + PROTOCOL_3x_HEADER` is prepended
|
|
142
|
+
*after* ECB on v3.2/3.3 (plaintext on the wire, stripped before decrypt — base
|
|
143
|
+
`VersionProfile._decrypt_payload`/`encrypt_payload`), encrypted *with* the payload
|
|
144
|
+
on v3.4 (`V34Profile`), and carried *inside* the GCM payload on v3.5 (`V35Profile`).
|
|
145
|
+
Real-device data-plane responses must include it or the client's `device22`
|
|
146
|
+
`len & 0x0F` strip heuristic chops the JSON.
|
|
147
|
+
* **device22 dialect** — reject the standard query with `json obj data unvalid` (the
|
|
148
|
+
`DATA_UNVALID` constant, used in `Session.handle`) so the client detects device22
|
|
149
|
+
and retries via `CONTROL_NEW`; v3.2 is *always* device22; device22 returns only the
|
|
150
|
+
requested dps. Only valid on v3.2–3.4 (rejected at config in `DeviceConfig` for
|
|
151
|
+
3.1/3.5).
|
|
152
|
+
* **standard query opcode by version** — `DP_QUERY` (v3.1–3.3) vs `DP_QUERY_NEW`
|
|
153
|
+
(v3.4+); `--dev22` must reject *whichever* applies (keying only on `DP_QUERY`
|
|
154
|
+
silently no-op'd device22 on v3.4). See the `DP_QUERY`/`DP_QUERY_NEW` branch in
|
|
155
|
+
`Session.handle`.
|
|
156
|
+
* **session-key handshake** — START→RESP(nonce+HMAC)→FINISH(verify HMAC, install
|
|
157
|
+
key), key swapped only *after* FINISH; v3.4 key = `ECB(session_nonce)`, v3.5 key =
|
|
158
|
+
the GCM slice above (`Session._handle_neg_start`/`_handle_neg_finish` plus each
|
|
159
|
+
profile's `derive_session_key`).
|
|
160
|
+
* **v3.1 payload scheme** — `b"3.1" + md5hex[8:24] + base64(ECB(data))`
|
|
161
|
+
(`encrypt_31`/`decrypt_31` in `protocol.py`).
|
|
162
|
+
* **device→client seqno** is a device-side incrementing counter, not a request echo
|
|
163
|
+
(`Session.__init__` / `pack_response`).
|
|
164
|
+
|
|
165
|
+
### Summary: does the mock auto-adapt to a tinytuya algorithm change?
|
|
166
|
+
|
|
167
|
+
| change in tinytuya | mock reaction |
|
|
168
|
+
|--------------------|---------------|
|
|
169
|
+
| internals of `pack_message`/`unpack_message`/`AESCipher`/HMAC/GCM | **auto-adapts** (same code path runs on both ends) |
|
|
170
|
+
| value of an opcode or magic constant in `CT`/`H` | **auto-adapts** (we read the symbol, not a literal) |
|
|
171
|
+
| rename/move of an imported internal symbol | **breaks loudly** at import (Layer 1) |
|
|
172
|
+
| `TuyaMessage` field order, kwarg contract, `total_length` semantics | **breaks silently → wrong bytes** (Layer 2) |
|
|
173
|
+
| client *policy*: header timing, device22 flow, handshake, new version | **does not adapt** — must hand-edit `protocol.py`/`device.py` (Layer 3) |
|
|
174
|
+
|
|
175
|
+
So: low-level crypto/framing is *delegated* and tracks tinytuya for free; the
|
|
176
|
+
per-version protocol *policy* is *replicated* and must be maintained by hand. Layer 2
|
|
177
|
+
is the trap — it neither auto-adapts nor fails loudly.
|
|
178
|
+
|
|
179
|
+
> Every Layer-1/2/3 site above is tagged in the source with a `TINYTUYA-COUPLING`
|
|
180
|
+
> comment (noting its layer). `grep -rn TINYTUYA-COUPLING src/` enumerates exactly
|
|
181
|
+
> what to re-verify against tinytuya on an upgrade.
|
|
182
|
+
|
|
183
|
+
### Pinned version & upgrade procedure
|
|
184
|
+
|
|
185
|
+
The dev `.venv` pins tinytuya via an editable install (`pip install -e ../tinytuya`,
|
|
186
|
+
currently **tinytuya 1.18.1**). Treat any tinytuya bump as a deliberate event, not
|
|
187
|
+
a transparent one:
|
|
188
|
+
|
|
189
|
+
1. Re-run `pytest tests/test_with_tinytuya.py` — the stage-1 bootstrap is the **only**
|
|
190
|
+
thing that catches Layer-2/Layer-3 silent drift. It exercises every version + the
|
|
191
|
+
device22 quirk against the real tinytuya client.
|
|
192
|
+
2. If it fails, diff `tinytuya/core/message_helper.py` and `crypto_helper.py` against
|
|
193
|
+
the Layer-2 contracts above, then re-check the `XenonDevice` policy points (Layer 3).
|
|
194
|
+
3. Only then re-point your client-under-test (stage 2) at the mock.
|
|
195
|
+
|
|
196
|
+
## Install
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
pip install tuyamock # pulls tinytuya from PyPI
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
For development (run the test suite, editable install):
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
python -m venv .venv && . .venv/bin/activate
|
|
206
|
+
pip install -e ".[test]"
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Usage
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
# v3.5 bulb on the default port (6668)
|
|
213
|
+
python -m tuyamock --version 3.5 --local-key thisisarealkey00
|
|
214
|
+
|
|
215
|
+
# OS-assigned port (printed as the first stdout line) for parallel tests
|
|
216
|
+
python -m tuyamock --port 0 --version 3.4 --local-key thisisarealkey00
|
|
217
|
+
|
|
218
|
+
# inject data points; emulate the device22 status quirk (v3.3/v3.4)
|
|
219
|
+
python -m tuyamock --version 3.3 --dev22 --dps '{"1": true, "20": "white"}'
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The first line on **stdout** is always the bound TCP port (handy with
|
|
223
|
+
`--port 0`); all logging goes to **stderr** (`-v` = info, `-vv` = debug).
|
|
224
|
+
|
|
225
|
+
### Key options
|
|
226
|
+
|
|
227
|
+
| flag | meaning |
|
|
228
|
+
|------|---------|
|
|
229
|
+
| `--version {3.1,3.2,3.3,3.4,3.5}` | protocol version to emulate |
|
|
230
|
+
| `--local-key` | 16-byte device key |
|
|
231
|
+
| `--port` | TCP port (`0` = OS-assigned, printed to stdout) |
|
|
232
|
+
| `--dps` | canned data points as a JSON object |
|
|
233
|
+
| `--dev22` | emulate device22 (only valid on v3.2–v3.4; see below) |
|
|
234
|
+
| `--discovery` | periodically emit the UDP discovery beacon |
|
|
235
|
+
| `--max-connections N` | exit cleanly after N client connections (test isolation) |
|
|
236
|
+
|
|
237
|
+
## In-process (Python API)
|
|
238
|
+
|
|
239
|
+
For tests you can run the mock in a background thread and drive it with tinytuya
|
|
240
|
+
from a single file — no subprocess, no port wrangling:
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
import tinytuya
|
|
244
|
+
import tuyamock
|
|
245
|
+
|
|
246
|
+
with tuyamock.MockDevice(local_key="thisisarealkey00", version="3.5",
|
|
247
|
+
dps={"1": True, "20": "white"}) as mock:
|
|
248
|
+
d = tinytuya.Device("eb0123456789abcdefghij", "127.0.0.1",
|
|
249
|
+
"thisisarealkey00", version=3.5, port=mock.port)
|
|
250
|
+
print(d.status()["dps"]) # {'1': True, '20': 'white'}
|
|
251
|
+
d.set_value("20", "red")
|
|
252
|
+
print(mock.dps) # {'1': True, '20': 'red'} (live device state)
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
`MockDevice(...)` takes the same options as the CLI (`version`, `dps`, `dev22`,
|
|
256
|
+
`port=0` for an OS-assigned port, …). `mock.port` is the bound port, `mock.dps`
|
|
257
|
+
is the live device state, and `mock.server.connections` counts accepted
|
|
258
|
+
connections. See [`examples/inprocess_demo.py`](examples/inprocess_demo.py).
|
|
259
|
+
|
|
260
|
+
The device22 reject→reconnect handshake works against the mock: a single
|
|
261
|
+
`status()` on a device22 device uses two connections (the mock rejects the
|
|
262
|
+
standard query, tinytuya detects device22, reconnects, and retries via
|
|
263
|
+
`CONTROL_NEW`).
|
|
264
|
+
|
|
265
|
+
## Stateful device
|
|
266
|
+
|
|
267
|
+
The mock keeps live dps state and responds to the full tinytuya client command
|
|
268
|
+
surface, so a `set` is reflected by a later `status()`:
|
|
269
|
+
|
|
270
|
+
| tinytuya client call | wire command | mock behaviour |
|
|
271
|
+
|----------------------|--------------|----------------|
|
|
272
|
+
| `status()` | `DP_QUERY` / `DP_QUERY_NEW` | returns current dps |
|
|
273
|
+
| `set_value()` / `set_status()` / `turn_on()` / `turn_off()` | `CONTROL` (`CONTROL_NEW` on v3.4+) | merges dps into state, reports the changed dps |
|
|
274
|
+
| `set_multiple_values()` | `CONTROL` | merges all, reports changed |
|
|
275
|
+
| `updatedps()` | `UPDATEDPS` | reports the requested dpIds |
|
|
276
|
+
| `heartbeat()` | `HEART_BEAT` | empty-payload ack |
|
|
277
|
+
|
|
278
|
+
State is shared across connections (tinytuya opens a fresh connection per
|
|
279
|
+
command by default), so set-then-query works end-to-end.
|
|
280
|
+
|
|
281
|
+
```python
|
|
282
|
+
import tinytuya
|
|
283
|
+
d = tinytuya.Device("eb0123456789abcdefghij", "127.0.0.1",
|
|
284
|
+
"thisisarealkey00", version=3.5, port=PORT)
|
|
285
|
+
d.set_value("1", True) # -> {"dps": {"1": True}}
|
|
286
|
+
d.status()["dps"]["1"] # -> True (persisted)
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
## Supported protocol versions
|
|
290
|
+
|
|
291
|
+
| version | framing | payload crypto | session key |
|
|
292
|
+
|---------|---------|----------------|-------------|
|
|
293
|
+
| 3.1 | 55AA + CRC32 | `3.1`+md5+base64(AES-ECB) | — |
|
|
294
|
+
| 3.2 | 55AA + CRC32 | AES-ECB(local_key) | — *(client uses device22 dialect)* |
|
|
295
|
+
| 3.3 | 55AA + CRC32 | AES-ECB(local_key) | — |
|
|
296
|
+
| 3.4 | 55AA + HMAC-SHA256 | AES-ECB(session_key) | ECB-derived |
|
|
297
|
+
| 3.5 | 6699 + AES-GCM | (GCM is the framing) | GCM-derived |
|
|
298
|
+
|
|
299
|
+
### device22
|
|
300
|
+
|
|
301
|
+
A `device22` device returns only the data points it is explicitly asked for and
|
|
302
|
+
rejects the standard status query with `json obj data unvalid`, forcing the
|
|
303
|
+
client to retry via `CONTROL_NEW`. Enable it with `--dev22`.
|
|
304
|
+
|
|
305
|
+
It is only meaningful where the tinytuya reference client supports it:
|
|
306
|
+
|
|
307
|
+
| version | device22 |
|
|
308
|
+
|---------|----------|
|
|
309
|
+
| 3.1 | not supported — `--dev22` is **rejected at startup** |
|
|
310
|
+
| 3.2 | **always** device22 (the v3.2 client forces the dialect; `--dev22` optional) |
|
|
311
|
+
| 3.3 | opt-in via `--dev22` (client auto-detects from the rejected query) |
|
|
312
|
+
| 3.4 | opt-in via `--dev22` (rejects `DP_QUERY_NEW`, not `DP_QUERY`) |
|
|
313
|
+
| 3.5 | not supported — `--dev22` is **rejected at startup** |
|
|
314
|
+
|
|
315
|
+
The standard status query differs by version (`DP_QUERY` on v3.1–3.3,
|
|
316
|
+
`DP_QUERY_NEW` on v3.4+), and `--dev22` rejects whichever one applies.
|
|
317
|
+
|
|
318
|
+
## Architecture
|
|
319
|
+
|
|
320
|
+
* **`protocol.py`** — per-version `VersionProfile`s plus shared crypto/framing
|
|
321
|
+
utilities. Each profile captures framing, the payload codec, and session
|
|
322
|
+
negotiation, so the rest of the code is version-agnostic.
|
|
323
|
+
* **`device.py`** — `DeviceConfig` (static config), `Session` (per-connection
|
|
324
|
+
nonces/keys), and command dispatch.
|
|
325
|
+
* **`server.py`** — single-client IPv4 TCP loop (avoids the original example's
|
|
326
|
+
`AF_INET6` dual-stack trap), `--port 0` support, clean SIGTERM/SIGINT
|
|
327
|
+
shutdown, optional UDP discovery beacon. Serving **one connection at a time is
|
|
328
|
+
protocol-faithful**, not a limitation: a real Tuya device handles a single local
|
|
329
|
+
TCP connection and does not support concurrent local connections, so clients talk
|
|
330
|
+
to it serially (a fresh connection per command). Adding multi-client support
|
|
331
|
+
would make the mock behave *unlike* real hardware — don't.
|
|
332
|
+
* **`cli.py`** — the `python -m tuyamock` entry point.
|
|
333
|
+
|
|
334
|
+
## Tests
|
|
335
|
+
|
|
336
|
+
```bash
|
|
337
|
+
pytest -v
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
`tests/test_with_tinytuya.py` runs the stage-1 bootstrap across every supported
|
|
341
|
+
version plus the device22 quirk, custom-dps injection, `--port 0`, wrong-key
|
|
342
|
+
rejection, parallel isolation, and clean shutdown.
|
|
343
|
+
`tests/test_inprocess.py` adds the `MockDevice` API tests and a single-CPU-pinned
|
|
344
|
+
reconnect stress test.
|
|
345
|
+
|
|
346
|
+
CI (`.github/workflows/ci.yml`) runs the suite on Python 3.8–3.12 for every push
|
|
347
|
+
and pull request.
|
|
348
|
+
|
|
349
|
+
## Releasing (PyPI)
|
|
350
|
+
|
|
351
|
+
Publishing is automated by `.github/workflows/publish.yml` via PyPI
|
|
352
|
+
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC — no API
|
|
353
|
+
token stored). To cut a release:
|
|
354
|
+
|
|
355
|
+
1. Bump the version — single source: `__version__` in
|
|
356
|
+
`src/tuyamock/__init__.py` (pyproject reads it via `dynamic = ["version"]`) —
|
|
357
|
+
and commit.
|
|
358
|
+
2. Tag and push, e.g. `git tag v0.0.1 && git push origin v0.0.1`.
|
|
359
|
+
3. Create a GitHub Release for that tag. Publishing the release triggers the
|
|
360
|
+
workflow: it runs the tests, builds the sdist + wheel, `twine check`s them, and
|
|
361
|
+
uploads to PyPI.
|
|
362
|
+
|
|
363
|
+
One-time setup: on PyPI add a trusted publisher for the project (`tuyamock`)
|
|
364
|
+
pointing at this repo, `publish.yml`, environment `pypi`. (Prefer a token? Replace
|
|
365
|
+
the OIDC step with `with: password: ${{ secrets.PYPI_API_TOKEN }}`.)
|