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-local, 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. 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, role, read only, hidden; device kind/class/label/model
props.<name> with a src a property defined from a dp (below), replacing one of that name
converters code converters by name (below)
expose_unused this device only: every dp no table claims gets a property of its own
auto: false no property from the tables at all: only what props defines and the converters give

A property defined from a dp builds what the tables cannot, such as a cover from a position dp alone (a window opener whose category has no cover table):

{ "<product_id>": {
    "dp":     {"104": {"code": "percent_control", "type": "Integer", "mode": "RW",
                       "values": {"unit": "%", "min": 0, "max": 100, "scale": 0, "step": 1}}},
    "device": {"kind": "cover", "class": "window"},
    "props":  {"position": {"src": "percent_control", "role": "position"},
               "open":     {"src": "percent_control", "type": "trigger", "role": "open", "send": 100},
               "close":    {"src": "percent_control", "type": "trigger", "role": "close", "send": 0},
               "battery":  {"src": "residual_electricity", "role": "battery", "category": "diagnostic"}} } }

Its type follows the dp's (Boolean binary, Integer number, Enum select, others text) unless given; min, max, step, unit and options come from the dp unless given; it is writable when the dp is in function (rw: false makes it read only); a trigger writes its send value. Values go through remap like any other.

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

The package carries no block for any product or device. A fix for a model goes in a converters file: the user's own, or the override pack, which hosts copy into that directory.

Code converters

A per-device Converter object sees the driver's dp state (after remap) on every packet and returns property values (and timer requests, since it cannot read a clock). Its properties may replace the tables' ones of the same name, and one with rw: true (or a trigger) is written through its write(prop, value, codes), which returns the dps to send as {code: value}. See converters.py.

class MyConverter(Converter):
    def __init__(self, config): ...
    def props(self):  return {"filter_low": {"type": "binary", "class": "problem"}}
    def update(self, now, codes, changed, active):  return {"filter_low": codes.get("filter_life", 100) < 10}  # or Result(...)
    def write(self, prop, value, codes):  return {"<code>": value}        # only for rw / trigger properties

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 derives the cover's cover_state role (open / closed / 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).
  • *.py: define CONVERTERS = {"name": factory}; an override block turns one on by name. The code runs in-process.
  • A bad file is reported and left out; the rest still loads. Overrides the Hub refuses leave the ones in effect.

The override pack

Fixes for non-standard devices can reach users before the next release: pack/ on master holds override files and tuya2ildevice.host.pack.sync(directory) copies them into a host's custom_converters/ directory, where the watcher loads them like the user's own (rustuya-local does this at start and daily, unless turned off). Every file is checked against the manifest's SHA-256, and sync only writes or removes the files it put there, as recorded in .tuya2ildevice_pack.json. A user's file with the same name, or a pack file the user has edited, is left alone. A manifest entry can be limited to a range of tuya2ildevice versions (pack.LEVEL). .py pack files run in the host's process, like the user's own files.

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.

One producer per IL prefix and source: await producer_running(il, hub.il.presence) before starting says whether another one is serving it. It returns True when a running Runner answers a probe on <presence>/probe (the answer comes on <presence>/alive; neither is retained, and IL consumers, subscribed to _producer/+, do not see them). It returns False when presence is not online. It returns None when presence is online but nothing answers: a stale presence whose Last Will never reached the broker, or a producer on tuya2ildevice before 0.3.5.

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.

One deliberate exception: where Tuya's own category list (tuya/tables/_tuya_categories.json, generated by scripts/gen_tuya_categories.py from Tuya's standard instruction set page) disagrees with core, Tuya's list wins. The decisions are data too (_tuya_standard_rules.json, applied by tuya/standard.py): a kg ("Switch") category's switches are switch, not core's outlet plug icon; cz ("Socket") and pc ("Power strip") stay outlet. The golden tests apply the same file to core's expectations, and each such difference is a tagged deviate row in docs/engine-spec.md (P-28).

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).

Metadata

Release files for tuya2ildevice 0.3.8

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.8
File Size Uploaded
tuya2ildevice-0.3.8.tar.gz 436.6 kB Details

Built distribution (wheel)

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

Total release size: 545.6 kB

Release files / tuya2ildevice-0.3.8.tar.gz

Download URL tuya2ildevice-0.3.8.tar.gz
Size 436.6 kB
Tags Source
SHA-256 checksum
How to use checksums
4b60b779260ccc8e513d621b06354e35db4bf3786ad9aed3fa7c52b12b09f450
BLAKE2b-256 checksum
How to use checksums
30ba7e2c861e0e7f5d13fb6c32e4d46e05d165824bc278288f782ba86c67f290
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 27, 2026.

Transparency log

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

Download URL tuya2ildevice-0.3.8-py3-none-any.whl
Size 108.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e5c3b3fe1e01131f59347f57ae16af1dd548dc6acfe96cbe0d07a291f534148a
BLAKE2b-256 checksum
How to use checksums
4a92458cdb0ff04926e69849b41f831f8b9dda18301843c3661f99f265cc115c
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 27, 2026.

Transparency log
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