Skip to main content

tuya2ildevice

The one place that interprets Tuya. Sans-IO Python: takes a Tuya device as rustuya / rustuya-bridge know it and produces an ildevice: descriptor, live values and events from dps packets, and dps for ildevice commands. It opens no sockets and reads no clock. Other projects (rustuya-homeassistant, ildevice hosts, rustuya-manager) import this instead of carrying their own Tuya knowledge.

pip install tuya2ildevice              # no dependencies;  tuya2ildevice[host] adds paho-mqtt for `tuya2ildevice.host`,  [test] for the tests
from tuya2ildevice import TuyaDriver, Connected, Message, Command, descriptor_of

descriptor_of(device)                 # just the ildevice descriptor of a tuyadevices.json entry
d = TuyaDriver(device)                # the state machine (il.md section 8)
d.handle(now, Connected())            # -> [Descriptor]
d.handle(now, Message("active", {"1": True}))   # -> [Value(...), Event(...)]
d.handle(now, Command("switch_1", "off"))    # -> [SendMessage("set", {"dps": {"1": False}})]  or  [Reject(...)]

Packets and commands

  • Message(channel, json): active (device push: fires events, accumulates add_ele-style deltas) or passive / state (readback, snapshot: values only). json is a flat {dp: value} map (the host has already decoded whatever the bridge's own wire payload looked like — see Hub.on_bridge_message below).
  • Command(prop, value) is checked as il.md section 5 says before anything is sent; failures come back as Reject. Values convert as in Home Assistant core (brightness goes through 0..255; cover position is reversed unless control_back_mode is back), so a written value can differ slightly from what is read back.
  • Covers of class garage/gate are read only unless TuyaDriver(..., allow_hazardous=True) (il.md S-1).
  • A dp no platform table claims gets no property, as in Home Assistant core; TuyaDriver(..., expose_unused=True) gives each one a property chosen by its Tuya type (rustuya-homeassistant v1 did that). Integer, Enum and Boolean are writable only if the dp is in function; String, Raw, Json and Bitmap are read-only. config if writable, else diagnostic.
  • Assembled: switch, button, select, number, sensor, binary_sensor, event, light, cover, fan, siren, valve, humidifier, climate, alarm (kind alarm), vacuum. Not assembled: camera (a stream is outside the IL; driver.unsupported lists it). A doorbell alarm_message event loses its message text (an ildevice event has only a kind).

User overrides

Tuya devices are fragmented: a cloud schema can give a dp a non-standard code, leave a dp out, speak other words (on/off/pause instead of open/close/stop) or run a position the other way. Overrides fix that in Tuya's own terms, before classification, so the fixed device goes through the same tables as any other and every IL consumer (il-ha, a discovery publisher, ...) sees the fix.

TuyaDriver(device, overrides=mapping) / Hub(devices, overrides=mapping); mapping is {product_id or device id: block}, already loaded (merge_all([...]) merges several, later wins; tuya2ildevice.host.load_overrides reads a directory). Details and the block format are in overrides.py:

{ "<product_id>": {
    "dp":     {"104": {"code": "percent_control", "type": "Integer", "values": {"min": 0, "max": 100, "scale": 0, "step": 1}},
               "101": {"code": "control"}},
    "remove": ["percent_state"],
    "category": "cl",
    "remap":  {"control": {"alias": {"on": "open", "off": "close", "pause": "stop"}},
               "percent_control": {"invert": true}},
    "props":  {"countdown_1": {"label": "Timer", "category": "config", "rw": false}},
    "device": {"model": "Sliding Window Opener"},
    "converters": {"cover_motion": {"settle": 5}},
    "expose_unused": true } }
key fixes
dp with a type a dp the schema lacks or describes wrongly (defined like a quirk's DefineDp)
dp with only a code a dp with a non-standard code: renamed, its type, range and value strategy kept
remove a dp that should not be used (the tables then fall back, e.g. position read from the target)
category the Tuya category the tables are chosen by
remap.<code>.alias other words: device value -> standard value, both ways (an Enum's range is translated too)
remap.<code>.invert the other direction: a Boolean negated, an Integer mirrored in its range
props, device the finished descriptor: label, class, category, unit, read only, hidden; device label/model
converters code converters by name (below)
expose_unused this device only: every dp no table claims gets a property of its own

Unknown keys raise OverrideError. Hub.reload(mapping) applies new overrides live: republishes changed descriptors and clears removed properties.

The package ships a curated set, overrides.json (overrides.BUILTIN), for products known to need it; a user's block for the same product wins key by key, and use_quirks=False turns it off with the quirks. from_v1(custom_converters) converts rustuya-homeassistant v1 files: dp_meta, model and discovery_overrides.cover (dp roles, command words, inversion, derived state) map to the keys above; other discovery_overrides (Home Assistant payload fields) are dropped with a warning.

Code converters

The equivalent of v1's custom_converters/*.py: a per-device Converter object sees the driver's dp state (after remap) on every packet and returns derived property values (and timer requests, since it cannot read a clock). See converters.py.

class MyConverter(Converter):
    def __init__(self, config): ...
    def props(self):  return {"motion": {"type": "select", "role": "motion", "options": ["opening", "closing", "stopped"]}}
    def update(self, now, codes, changed, active):  return {"motion": "stopped"}      # or Result(values=..., timers=...)

TuyaDriver(device, converters={"<product_id or device id>": [lambda device: MyConverter({})]})
TuyaDriver(device, converter_types={"my": MyConverter}, overrides={"<product_id>": {"converters": {"my": {}}}})

Built-in ones are named in an override block: {"<product_id>": {"converters": {"cover_motion": {"settle": 5}}}}. cover_motion (ported from v1's 00_curtain.py) derives the cover's motion role (opening / closing / stopped) from the control, set-position and position dps; a snapshot never starts motion. Timers come out as SetTimer (driver) or Schedule (Hub); the host calls back with Timer / hub.on_timer(now, id, name).

Overrides as files

tuya2ildevice.host.load_overrides(path) reads a custom_converters/ directory (or one .json file) and OverrideWatcher(path, runner, base=...) follows it, reloading the Hub when a file changes:

  • *.json: override mappings, deep-merged in filename order (99_local.json refines 10_base.json); v1 files are converted with from_v1.
  • *.py: define CONVERTERS = {"name": factory}; an override block turns one on by name. The code runs in-process. A v1 plugin file (setup(api)) is reported, not loaded.
  • A bad file is reported and left out; the rest still loads. Overrides the Hub refuses leave the ones in effect.

tuya2ildevice <-> an IL host over MQTT

Hub (mqtt.py) owns one driver per device and maps il-mqtt.md on the IL side. On the bridge side it takes already-decoded input, not a raw topic/payload: on_bridge_message(now, device_id, inp, retained=False) where inp is Connected() / Disconnected() / Message(channel, dps), and its writes/reads to the bridge come out as abstract BridgeCommand(device_id, action, dps) — Hub has no idea rustuya-bridge's own MQTT topics exist, let alone that they're configurable. Rendering BridgeCommand into a real topic+payload, and turning a real bridge MQTT message into Connected/Disconnected/Message, is the host's job — correctly, that means using pyrustuyabridge's bindings (match_topic, render_template, tpl_to_wildcard, parse_seed_dps), which mirror the real bridge's own template/payload parsing, not a hand-rolled one. rustuya-local is that host for a real rustuya-bridge; its bridge_client module is the reference implementation.

tuya2ildevice.host is the IL-side host: Runner drives a Hub on one IL transport (MqttTransport over paho, or the in-process InProcessTransport), keeps the Last Will presence (M-12), reconnects, adds/removes devices while running (set_device, remove_device, sync_devices), follows rustuya-manager's tuyadevices.json (DeviceWatcher), and routes every BridgeCommand Hub produces through an injected on_bridge_command callback — supplied by whatever owns the real bridge connection — plus a matching runner.on_bridge_message(device_id, inp, retained=False) entry point for feeding decoded bridge input back in.

direction shape
bridge -> hub runner.on_bridge_message(device_id, Connected() / Disconnected() / Message(channel, {dp: value}))
hub -> bridge BridgeCommand(device_id, "set"/"get", dps) via on_bridge_command (a get after each connect)
hub -> IL host il-mqtt.md: retained il/<id> and il/<id>/<prop>; events and il/<id>/reject not retained; il/_producer/tuya presence
IL host -> hub il/<id>/<prop>/set (retained writes ignored)

Register the devices on the bridge yourself (add); the hub never touches keys.

Layout

src/tuya2ildevice/
  driver.py    TuyaDriver: packets/commands <-> outputs          io.py      inputs and outputs as data
  assemble.py  engine entity plans -> ildevice props/roles       checks.py  il.md section 5 command checks
  mqtt.py      Hub, IlTopics                                     tuya/      the DP engine (see below)
tuya/          classify + platforms, adapter (raw dps <-> values), quirks, ops, codecs, units
tuya/tables/   per-platform description tables, generated from HA core     tuya/quirks/   from tuya-device-handlers
tuya/data/     HA's allowed units per device class
scripts/       generators for those data files, and golden/ (builds the golden data; needs HA core + oracle venvs)
docs/          engine-spec.md (the engine's behaviour), analysis/ (how it was derived from HA core)
tests/         golden/ = 324 HA core fixtures + core's own snapshots; chain/ = the same through an IL host's planner

The engine reproduces Home Assistant core's tuya integration exactly, including its quirks, and the golden tests pin it: tests/golden/golden.json is core's own entity snapshots for every fixture. To follow a new HA core / tuya-device-handlers release, run the generators in scripts/ against it, then the tests; a difference is either a real change to adopt or a regression.

Tests

pip install -e .[test] && python -m pytest

tests/test_adapter.py compares the raw-dps adapter with the Tuya SDK and is skipped unless tuya-device-sharing-sdk==0.2.15 is installed. The spec repository (../ildevice, or $ILDEVICE) supplies the schema and the language-neutral vectors (commands, wire values, topics) read directly from its checkout; those tests are skipped if it is not there. tests/chain/ compares every Home Assistant core tuya fixture with core's entity snapshots through an IL host's HA-free planner (needs that host's ildevice.core on sys.path; not collected without it).

Release files for tuya2ildevice 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tuya2ildevice 0.3.0
File Size Uploaded
tuya2ildevice-0.3.0.tar.gz 419.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for tuya2ildevice 0.3.0
File Interpreter ABI Platform
tuya2ildevice-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 517.0 kB

Release files / tuya2ildevice-0.3.0.tar.gz

Download URL tuya2ildevice-0.3.0.tar.gz
Size 419.4 kB
Tags Source
SHA-256 checksum
How to use checksums
ffe662fc2fd90f683ffcd5ab9bf9007484e28479b589fe65b18437ad95568bcd
BLAKE2b-256 checksum
How to use checksums
570a8819f2dc85412e00376ed8095c5d9664bc9fb068ca50c393b0be791339df
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.

Transparency log

Release files / tuya2ildevice-0.3.0-py3-none-any.whl

Download URL tuya2ildevice-0.3.0-py3-none-any.whl
Size 97.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ffa4a10dfe0b935478735cf655db9cf1878ecad36826a81907d737c535bcf60d
BLAKE2b-256 checksum
How to use checksums
5d2698b9695f9b007dfeb12c229e939c58b71ad9b22997be2e6cafa4685f2a85
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.

Transparency log

Release history Release notifications | RSS feed

0.3.3

2 release files

0.3.2

2 release files

0.3.1

2 release files

This release

0.3.0 This release

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page