Metadata-Version: 2.5
Name: tuya2ildevice
Version: 0.3.9
Summary: Sans-IO converter: Tuya (rustuya / rustuya-bridge) device + DPS packets <-> ildevice descriptor, values and commands
Project-URL: Repository, https://github.com/3735943886/tuya2ildevice
License-Expression: MIT
License-File: LICENSE
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Home Automation
Requires-Python: >=3.10
Provides-Extra: host
Requires-Dist: paho-mqtt>=2.0; extra == 'host'
Provides-Extra: test
Requires-Dist: jsonschema; extra == 'test'
Requires-Dist: paho-mqtt>=2.0; extra == 'test'
Requires-Dist: pytest; extra == 'test'
Requires-Dist: pytest-asyncio; extra == 'test'
Requires-Dist: tuya-device-sharing-sdk==0.2.15; extra == 'test'
Description-Content-Type: text/markdown

# tuya2ildevice

The one place that interprets Tuya. Sans-IO Python: takes a Tuya device as [rustuya](https://github.com/3735943886/rustuya) /
[rustuya-bridge](https://github.com/3735943886/rustuya-bridge) know it and produces an
[ildevice](https://github.com/3735943886/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](https://github.com/3735943886/rustuya-local), ildevice hosts,
[rustuya-manager](https://github.com/3735943886/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
```

```python
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), so a written value can differ slightly
  from what is read back. A cover's position is the device's own number, never mirrored (core mirrors it); a device
  that counts the other way gets `remap.invert`.
- A `passive` report (a live one: the host drops retained ones) that changes a value is the device's own push, like
  `active`: converters see it as live and events fire. An increment (`report_type: sum`) is added only from `active`.
- 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](src/tuya2ildevice/overrides.py):

```json
{ "<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):

```json
{ "<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](pack/README.md), 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](src/tuya2ildevice/converters.py).

```python
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/](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](src/tuya2ildevice/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](https://github.com/3735943886/rustuya-bridge)'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](https://github.com/3735943886/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).

A second one: a cover's position is not mirrored. The bridge shows the device's number and so does the IL; an
installation inverts a device that counts the other way itself (`remap.invert`). The golden tests mirror core's
expected position and position writes where core mirrored them (`core_reverses` in `tests/test_golden.py`).

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