Protocol-faithful mock of a Tuya local-protocol device, built on tinytuya primitives
Project description
tuyamock
A protocol-faithful mock of a Tuya local-protocol device, built on tinytuya's own message/crypto primitives.
It plays the device side of the Tuya LAN protocol so you can test Tuya
clients end-to-end without real hardware. Because the device side is
implemented with tinytuya's pack_message / unpack_message / AESCipher, the
mock is an independent oracle: a client that re-implements the protocol (in
any language) can be validated against it without the validation being circular.
Why "independent oracle"
The intended workflow is a two-step bootstrap:
- mock ⟷ tinytuya client — prove the mock is protocol-correct using
tinytuya as the reference client. Once these tests pass, the mock is treated
as ground truth. (This is exactly what
tests/test_with_tinytuya.pydoes.) - mock ⟷ client-under-test — point the other client at the validated mock. Any failure is then isolated to that client, not the harness.
If the mock spoke a re-implemented dialect of the protocol, step 1 would be circular and bugs could hide on both sides. Reusing tinytuya's primitives avoids that.
Dependence on tinytuya (read this before upgrading tinytuya)
The mock does not re-implement the Tuya protocol — it imports tinytuya at runtime and calls tinytuya's own functions for all crypto/framing. This is deliberate (it's what makes the mock an independent oracle: the device and the tinytuya reference client run the same byte-level code, so they cannot silently disagree). But it means the mock's correctness is coupled to the installed tinytuya at three different layers, only one of which auto-adapts. The failure modes are different per layer — know which is which before bumping the dependency.
Layer 1 — imported symbols (the linkage surface)
Pulled straight from tinytuya and called, never copied. All from internal
modules (tinytuya.core.*), not a documented/stable public API:
| imported symbol | import path | used in | role |
|---|---|---|---|
pack_message |
tinytuya.core.message_helper |
protocol.py (VersionProfile.pack_response), server.py (discovery beacon) |
assemble 55AA / 6699 frames |
unpack_message |
tinytuya.core.message_helper |
protocol.py (VersionProfile.unpack_request) |
disassemble one frame → TuyaMessage |
TuyaMessage |
tinytuya.core.message_helper |
protocol.py (pack_response), server.py (_maybe_broadcast) |
8-field namedtuple passed to pack_message |
parse_header |
tinytuya.core.message_helper |
device.py (take_frames) |
read total_length for stream framing |
AESCipher |
tinytuya.core.crypto_helper |
protocol.py (the crypto-utility wrappers) |
AES-ECB / AES-GCM + PKCS#7 padding |
command_types (CT) |
tinytuya.core |
protocol/device/server | command opcode values |
header (H) |
tinytuya.core |
protocol/server | prefix/header/version-byte constants |
DecodeError |
tinytuya.core.exceptions |
device/server | raised on short/garbled frames |
tinytuya.udpkey |
tinytuya (top level) |
server.py (_maybe_broadcast) |
HMAC key for the UDP discovery beacon |
Constants we read out of H: PREFIX_55AA_VALUE, PREFIX_6699_VALUE,
PROTOCOL_3x_HEADER, PROTOCOL_VERSION_BYTES_31. Opcodes we read out of CT:
SESS_KEY_NEG_START/RESP/FINISH, DP_QUERY, DP_QUERY_NEW, CONTROL,
CONTROL_NEW, UPDATEDPS, HEART_BEAT, UDP_NEW. If tinytuya renames/moves any
of these, the mock fails to import — loud, immediate, trivial to diagnose.
Layer 2 — behavioral contracts (the dangerous, implicit surface)
Beyond mere symbol existence, the mock hard-codes assumptions about how these functions behave and what their arguments mean. These are not enforced by any type and will not raise on a signature change — they produce wrong bytes. Enumerated so a reviewer knows exactly what to re-verify against tinytuya source:
TuyaMessagefield order/semantics. We construct it positionally asTuyaMessage(seqno, cmd, retcode, payload, crc, crc_good, prefix, iv)inprotocol.pypack_response(andserver.py_maybe_broadcast). The last field doubles as the GCM-iv/flag:Truefor 6699,Nonefor 55AA. If tinytuya reorders or repurposes any field, frames serialize wrong with no error.pack_message(msg, hmac_key=…)— we rely onhmac_key=None⇒ CRC32 framing andhmac_key=<bytes>⇒ HMAC-SHA256 framing (VersionProfile.framing_key()returns the session key on v3.4/v3.5,Noneon v3.1–3.3). And thatpack_messagereads the prefix frommsg.prefixto pick 55AA vs 6699.unpack_message(frame, hmac_key=…, no_retcode=True)— we passno_retcode=Trueon requests (client→device frames have no retcode) and instead prepend the 4-byte retcode ourselves on 55AA responses via theRETCODEconstant (inpack_response). The retcode convention living outsidepack_messagefor 55AA but inside it for 6699 is a tinytuya-specific asymmetry we mirror by hand.AESCipherkeyword contract. We depend on the exact kwargsencrypt(data, use_base64=, pad=, iv=)anddecrypt(data, use_base64=, decode_text=)(see the crypto-utility wrappers at the top ofprotocol.py), e.g.use_base64=Falsefor raw-bytes ECB on the wire,pad=Falsefor session-key derivation, andiv=selecting GCM mode and being prepended to the ciphertext. The v3.5 session key is specificallyGCM(nonce, iv=client_nonce[:12])[12:28](derive_session_key_gcm) — a 16-byte slice out of the GCM output whose offset is a tinytuya implementation detail.parse_header(...).total_lengthis howtake_frames(indevice.py) re-frames the TCP byte stream (multiple frames per read / frame split across reads). A change to that attribute name or its length accounting silently corrupts framing.NO_PROTOCOL_HEADER_CMDS—protocol.NEGOTIATION_CMDSis a hand-copied mirror ofXenonDevice.NO_PROTOCOL_HEADER_CMDS. If tinytuya adds a command that skips the version header, our copy goes stale.
Layer 3 — client protocol policy (hand-mirrored, never auto-adapts)
tinytuya exposes primitives but not "how a device is supposed to respond." All
of that is replicated by hand from XenonDevice into our VersionProfiles (in
protocol.py) and command dispatch (Session in device.py). If tinytuya changes
client behaviour (new handshake, moved header, a v3.6), the mock will not
notice:
- version-header placement —
version_bytes + PROTOCOL_3x_HEADERis prepended after ECB on v3.2/3.3 (plaintext on the wire, stripped before decrypt — baseVersionProfile._decrypt_payload/encrypt_payload), encrypted with the payload on v3.4 (V34Profile), and carried inside the GCM payload on v3.5 (V35Profile). Real-device data-plane responses must include it or the client'sdevice22len & 0x0Fstrip heuristic chops the JSON. - device22 dialect — reject the standard query with
json obj data unvalid(theDATA_UNVALIDconstant, used inSession.handle) so the client detects device22 and retries viaCONTROL_NEW; v3.2 is always device22; device22 returns only the requested dps. Only valid on v3.2–3.4 (rejected at config inDeviceConfigfor 3.1/3.5). - standard query opcode by version —
DP_QUERY(v3.1–3.3) vsDP_QUERY_NEW(v3.4+);--dev22must reject whichever applies (keying only onDP_QUERYsilently no-op'd device22 on v3.4). See theDP_QUERY/DP_QUERY_NEWbranch inSession.handle. - session-key handshake — START→RESP(nonce+HMAC)→FINISH(verify HMAC, install
key), key swapped only after FINISH; v3.4 key =
ECB(session_nonce), v3.5 key = the GCM slice above (Session._handle_neg_start/_handle_neg_finishplus each profile'sderive_session_key). - v3.1 payload scheme —
b"3.1" + md5hex[8:24] + base64(ECB(data))(encrypt_31/decrypt_31inprotocol.py). - device→client seqno is a device-side incrementing counter, not a request echo
(
Session.__init__/pack_response).
Summary: does the mock auto-adapt to a tinytuya algorithm change?
| change in tinytuya | mock reaction |
|---|---|
internals of pack_message/unpack_message/AESCipher/HMAC/GCM |
auto-adapts (same code path runs on both ends) |
value of an opcode or magic constant in CT/H |
auto-adapts (we read the symbol, not a literal) |
| rename/move of an imported internal symbol | breaks loudly at import (Layer 1) |
TuyaMessage field order, kwarg contract, total_length semantics |
breaks silently → wrong bytes (Layer 2) |
| client policy: header timing, device22 flow, handshake, new version | does not adapt — must hand-edit protocol.py/device.py (Layer 3) |
So: low-level crypto/framing is delegated and tracks tinytuya for free; the per-version protocol policy is replicated and must be maintained by hand. Layer 2 is the trap — it neither auto-adapts nor fails loudly.
Every Layer-1/2/3 site above is tagged in the source with a
TINYTUYA-COUPLINGcomment (noting its layer).grep -rn TINYTUYA-COUPLING src/enumerates exactly what to re-verify against tinytuya on an upgrade.
Pinned version & upgrade procedure
The dev .venv pins tinytuya via an editable install (pip install -e ../tinytuya,
currently tinytuya 1.18.1). Treat any tinytuya bump as a deliberate event, not
a transparent one:
- Re-run
pytest tests/test_with_tinytuya.py— the stage-1 bootstrap is the only thing that catches Layer-2/Layer-3 silent drift. It exercises every version + the device22 quirk against the real tinytuya client. - If it fails, diff
tinytuya/core/message_helper.pyandcrypto_helper.pyagainst the Layer-2 contracts above, then re-check theXenonDevicepolicy points (Layer 3). - Only then re-point your client-under-test (stage 2) at the mock.
Install
pip install tuyamock # pulls tinytuya from PyPI
For development (run the test suite, editable install):
python -m venv .venv && . .venv/bin/activate
pip install -e ".[test]"
Usage
# v3.5 bulb on the default port (6668)
python -m tuyamock --version 3.5 --local-key thisisarealkey00
# OS-assigned port (printed as the first stdout line) for parallel tests
python -m tuyamock --port 0 --version 3.4 --local-key thisisarealkey00
# inject data points; emulate the device22 status quirk (v3.3/v3.4)
python -m tuyamock --version 3.3 --dev22 --dps '{"1": true, "20": "white"}'
The first line on stdout is always the bound TCP port (handy with
--port 0); all logging goes to stderr (-v = info, -vv = debug).
Key options
| flag | meaning |
|---|---|
--version {3.1,3.2,3.3,3.4,3.5} |
protocol version to emulate |
--local-key |
16-byte device key |
--port |
TCP port (0 = OS-assigned, printed to stdout) |
--dps |
canned data points as a JSON object |
--dev22 |
emulate device22 (only valid on v3.2–v3.4; see below) |
--discovery |
periodically emit the UDP discovery beacon |
--max-connections N |
exit cleanly after N client connections (test isolation) |
In-process (Python API)
For tests you can run the mock in a background thread and drive it with tinytuya from a single file — no subprocess, no port wrangling:
import tinytuya
import tuyamock
with tuyamock.MockDevice(local_key="thisisarealkey00", version="3.5",
dps={"1": True, "20": "white"}) as mock:
d = tinytuya.Device("eb0123456789abcdefghij", "127.0.0.1",
"thisisarealkey00", version=3.5, port=mock.port)
print(d.status()["dps"]) # {'1': True, '20': 'white'}
d.set_value("20", "red")
print(mock.dps) # {'1': True, '20': 'red'} (live device state)
MockDevice(...) takes the same options as the CLI (version, dps, dev22,
port=0 for an OS-assigned port, …). mock.port is the bound port, mock.dps
is the live device state, and mock.server.connections counts accepted
connections. See examples/inprocess_demo.py.
The device22 reject→reconnect handshake works against the mock: a single
status() on a device22 device uses two connections (the mock rejects the
standard query, tinytuya detects device22, reconnects, and retries via
CONTROL_NEW).
Stateful device
The mock keeps live dps state and responds to the full tinytuya client command
surface, so a set is reflected by a later status():
| tinytuya client call | wire command | mock behaviour |
|---|---|---|
status() |
DP_QUERY / DP_QUERY_NEW |
returns current dps |
set_value() / set_status() / turn_on() / turn_off() |
CONTROL (CONTROL_NEW on v3.4+) |
merges dps into state, reports the changed dps |
set_multiple_values() |
CONTROL |
merges all, reports changed |
updatedps() |
UPDATEDPS |
reports the requested dpIds |
heartbeat() |
HEART_BEAT |
empty-payload ack |
State is shared across connections (tinytuya opens a fresh connection per command by default), so set-then-query works end-to-end.
import tinytuya
d = tinytuya.Device("eb0123456789abcdefghij", "127.0.0.1",
"thisisarealkey00", version=3.5, port=PORT)
d.set_value("1", True) # -> {"dps": {"1": True}}
d.status()["dps"]["1"] # -> True (persisted)
Supported protocol versions
| version | framing | payload crypto | session key |
|---|---|---|---|
| 3.1 | 55AA + CRC32 | 3.1+md5+base64(AES-ECB) |
— |
| 3.2 | 55AA + CRC32 | AES-ECB(local_key) | — (client uses device22 dialect) |
| 3.3 | 55AA + CRC32 | AES-ECB(local_key) | — |
| 3.4 | 55AA + HMAC-SHA256 | AES-ECB(session_key) | ECB-derived |
| 3.5 | 6699 + AES-GCM | (GCM is the framing) | GCM-derived |
device22
A device22 device returns only the data points it is explicitly asked for and
rejects the standard status query with json obj data unvalid, forcing the
client to retry via CONTROL_NEW. Enable it with --dev22.
It is only meaningful where the tinytuya reference client supports it:
| version | device22 |
|---|---|
| 3.1 | not supported — --dev22 is rejected at startup |
| 3.2 | always device22 (the v3.2 client forces the dialect; --dev22 optional) |
| 3.3 | opt-in via --dev22 (client auto-detects from the rejected query) |
| 3.4 | opt-in via --dev22 (rejects DP_QUERY_NEW, not DP_QUERY) |
| 3.5 | not supported — --dev22 is rejected at startup |
The standard status query differs by version (DP_QUERY on v3.1–3.3,
DP_QUERY_NEW on v3.4+), and --dev22 rejects whichever one applies.
Architecture
protocol.py— per-versionVersionProfiles plus shared crypto/framing utilities. Each profile captures framing, the payload codec, and session negotiation, so the rest of the code is version-agnostic.device.py—DeviceConfig(static config),Session(per-connection nonces/keys), and command dispatch.server.py— single-client IPv4 TCP loop (avoids the original example'sAF_INET6dual-stack trap),--port 0support, clean SIGTERM/SIGINT shutdown, optional UDP discovery beacon. Serving one connection at a time is protocol-faithful, not a limitation: a real Tuya device handles a single local TCP connection and does not support concurrent local connections, so clients talk to it serially (a fresh connection per command). Adding multi-client support would make the mock behave unlike real hardware — don't.cli.py— thepython -m tuyamockentry point.
Tests
pytest -v
tests/test_with_tinytuya.py runs the stage-1 bootstrap across every supported
version plus the device22 quirk, custom-dps injection, --port 0, wrong-key
rejection, parallel isolation, and clean shutdown.
tests/test_inprocess.py adds the MockDevice API tests and a single-CPU-pinned
reconnect stress test.
CI (.github/workflows/ci.yml) runs the suite on Python 3.8–3.12 for every push
and pull request.
Releasing (PyPI)
Publishing is automated by .github/workflows/publish.yml via PyPI
Trusted Publishing (OIDC — no API
token stored). To cut a release:
- Bump the version — single source:
__version__insrc/tuyamock/__init__.py(pyproject reads it viadynamic = ["version"]) — and commit. - Tag and push, e.g.
git tag v0.0.1 && git push origin v0.0.1. - Create a GitHub Release for that tag. Publishing the release triggers the
workflow: it runs the tests, builds the sdist + wheel,
twine checks them, and uploads to PyPI.
One-time setup: on PyPI add a trusted publisher for the project (tuyamock)
pointing at this repo, publish.yml, environment pypi. (Prefer a token? Replace
the OIDC step with with: password: ${{ secrets.PYPI_API_TOKEN }}.)
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tuyamock-0.0.1.tar.gz.
File metadata
- Download URL: tuyamock-0.0.1.tar.gz
- Upload date:
- Size: 32.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f7c45d0e8ae15587bf5f2c3e10ebc1cb1ea8c7267b6b349acaa754391c2b58be
|
|
| MD5 |
6ef61d8e1b0f78b1e2b2bb70e7b98a4e
|
|
| BLAKE2b-256 |
9b9c737701c801b84064e0494b13c5d24b312b45936daca7a184bc50f677c427
|
Provenance
The following attestation bundles were made for tuyamock-0.0.1.tar.gz:
Publisher:
publish.yml on 3735943886/tuyamock
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tuyamock-0.0.1.tar.gz -
Subject digest:
f7c45d0e8ae15587bf5f2c3e10ebc1cb1ea8c7267b6b349acaa754391c2b58be - Sigstore transparency entry: 1755448765
- Sigstore integration time:
-
Permalink:
3735943886/tuyamock@7ff1493cf27695a3f599b6c54b228251797b866d -
Branch / Tag:
refs/tags/v0.0.1 - Owner: https://github.com/3735943886
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@7ff1493cf27695a3f599b6c54b228251797b866d -
Trigger Event:
release
-
Statement type:
File details
Details for the file tuyamock-0.0.1-py3-none-any.whl.
File metadata
- Download URL: tuyamock-0.0.1-py3-none-any.whl
- Upload date:
- Size: 24.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a9552d32f21e3ba34ea4dd1659b0b2682f8b4e6cdc85053a2e3b13f4be6361d7
|
|
| MD5 |
78f3dc78d684494b7a11f69f22edc324
|
|
| BLAKE2b-256 |
15eb5196178d433e09b3bc6bffbac1ad4c35048f04489352e1a409bdd8446db2
|
Provenance
The following attestation bundles were made for tuyamock-0.0.1-py3-none-any.whl:
Publisher:
publish.yml on 3735943886/tuyamock
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tuyamock-0.0.1-py3-none-any.whl -
Subject digest:
a9552d32f21e3ba34ea4dd1659b0b2682f8b4e6cdc85053a2e3b13f4be6361d7 - Sigstore transparency entry: 1755448805
- Sigstore integration time:
-
Permalink:
3735943886/tuyamock@7ff1493cf27695a3f599b6c54b228251797b866d -
Branch / Tag:
refs/tags/v0.0.1 - Owner: https://github.com/3735943886
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@7ff1493cf27695a3f599b6c54b228251797b866d -
Trigger Event:
release
-
Statement type: