rustuya-local 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.
Files changed (42) hide show
  1. rustuya_local-0.0.1/.github/dependabot.yml +11 -0
  2. rustuya_local-0.0.1/.github/workflows/_test.yml +103 -0
  3. rustuya_local-0.0.1/.github/workflows/ci.yml +22 -0
  4. rustuya_local-0.0.1/.github/workflows/hacs.yml +24 -0
  5. rustuya_local-0.0.1/.github/workflows/hassfest.yml +20 -0
  6. rustuya_local-0.0.1/.github/workflows/release.yml +108 -0
  7. rustuya_local-0.0.1/.gitignore +8 -0
  8. rustuya_local-0.0.1/LICENSE +21 -0
  9. rustuya_local-0.0.1/PKG-INFO +17 -0
  10. rustuya_local-0.0.1/README.md +67 -0
  11. rustuya_local-0.0.1/THIRD_PARTY_NOTICES.md +28 -0
  12. rustuya_local-0.0.1/custom_components/rustuya/__init__.py +127 -0
  13. rustuya_local-0.0.1/custom_components/rustuya/bridge_supervisor.py +66 -0
  14. rustuya_local-0.0.1/custom_components/rustuya/config_flow.py +366 -0
  15. rustuya_local-0.0.1/custom_components/rustuya/const.py +37 -0
  16. rustuya_local-0.0.1/custom_components/rustuya/manager_session.py +33 -0
  17. rustuya_local-0.0.1/custom_components/rustuya/manifest.json +16 -0
  18. rustuya_local-0.0.1/custom_components/rustuya/strings.json +110 -0
  19. rustuya_local-0.0.1/custom_components/rustuya/translations/en.json +110 -0
  20. rustuya_local-0.0.1/docs/REDESIGN.md +161 -0
  21. rustuya_local-0.0.1/docs/STATUS.md +29 -0
  22. rustuya_local-0.0.1/hacs.json +5 -0
  23. rustuya_local-0.0.1/pyproject.toml +38 -0
  24. rustuya_local-0.0.1/src/rustuya_local/__init__.py +1 -0
  25. rustuya_local-0.0.1/src/rustuya_local/__main__.py +3 -0
  26. rustuya_local-0.0.1/src/rustuya_local/bridge_client.py +158 -0
  27. rustuya_local-0.0.1/src/rustuya_local/cli.py +67 -0
  28. rustuya_local-0.0.1/src/rustuya_local/config.py +77 -0
  29. rustuya_local-0.0.1/tests/e2e/conftest.py +75 -0
  30. rustuya_local-0.0.1/tests/e2e/devices.py +22 -0
  31. rustuya_local-0.0.1/tests/e2e/test_chain_in_process.py +111 -0
  32. rustuya_local-0.0.1/tests/e2e/test_chain_mqtt.py +82 -0
  33. rustuya_local-0.0.1/tests/e2e/test_daemon.py +85 -0
  34. rustuya_local-0.0.1/tests/e2e/test_full_stack.py +187 -0
  35. rustuya_local-0.0.1/tests/e2e/test_full_stack_custom_templates.py +72 -0
  36. rustuya_local-0.0.1/tests/e2e/test_full_stack_devices.py +152 -0
  37. rustuya_local-0.0.1/tests/ha/conftest.py +54 -0
  38. rustuya_local-0.0.1/tests/ha/fake_manager.py +126 -0
  39. rustuya_local-0.0.1/tests/ha/test_config_flow.py +228 -0
  40. rustuya_local-0.0.1/tests/ha/test_init.py +106 -0
  41. rustuya_local-0.0.1/tests/unit/test_config.py +21 -0
  42. rustuya_local-0.0.1/uv.lock +6259 -0
@@ -0,0 +1,11 @@
1
+ # To get started with Dependabot version updates, you'll need to specify which
2
+ # package ecosystems to update and where the package manifests are located.
3
+ # Please see the documentation for all configuration options:
4
+ # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5
+
6
+ version: 2
7
+ updates:
8
+ - package-ecosystem: "github-actions" # See documentation for possible values
9
+ directory: "/" # Location of package manifests
10
+ schedule:
11
+ interval: "weekly"
@@ -0,0 +1,103 @@
1
+ name: Test (reusable)
2
+
3
+ # Single source of truth for rustuya-local's pre-merge / pre-publish checks. Called from ci.yml
4
+ # on push/PR and as a gate from release.yml, so a commit that fails these can't reach PyPI.
5
+ #
6
+ # rustuya-local depends on sibling repos that are not on PyPI yet (il-ha, rustuya-manager,
7
+ # tuyamock -- see pyproject.toml's [tool.uv.sources]), so this checks them out as siblings under
8
+ # $GITHUB_WORKSPACE and installs with `uv`: `uv` is what resolves [tool.uv.sources]'s local-path
9
+ # overrides, plain `pip install -e ".[test]"` cannot see them and would try (and fail) to fetch
10
+ # "il-ha" from PyPI.
11
+
12
+ on:
13
+ workflow_call:
14
+
15
+ permissions:
16
+ contents: read
17
+
18
+ jobs:
19
+ test:
20
+ name: test (py${{ matrix.python-version }})
21
+ runs-on: ubuntu-latest
22
+ strategy:
23
+ fail-fast: false
24
+ matrix:
25
+ # il-ha (the test/fullstack sibling dep) requires Python>=3.14; rustuya-local's own
26
+ # requires-python floor (3.13, in pyproject.toml) stays lower since only the test extras
27
+ # need 3.14, not the base package.
28
+ python-version: ["3.14"]
29
+
30
+ steps:
31
+ - name: Check out rustuya-local
32
+ uses: actions/checkout@v7
33
+ with:
34
+ path: rustuya-local
35
+
36
+ # Siblings for the path dependencies in rustuya-local's [tool.uv.sources]. None of the
37
+ # three is on PyPI yet -- if a checkout here 404s, that sibling repo hasn't been pushed to
38
+ # GitHub (or renamed/moved) and this needs updating to match.
39
+ - name: Check out il-ha
40
+ uses: actions/checkout@v7
41
+ with:
42
+ repository: 3735943886/ildevice-homeassistant
43
+ path: il-ha
44
+
45
+ - name: Check out rustuya-manager
46
+ uses: actions/checkout@v7
47
+ with:
48
+ repository: 3735943886/rustuya-manager
49
+ path: rustuya-manager
50
+
51
+ - name: Check out tuyamock
52
+ uses: actions/checkout@v7
53
+ with:
54
+ repository: 3735943886/tuyamock
55
+ path: tuyamock
56
+
57
+ # tuya2ildevice is on PyPI (installed below via uv), so this isn't for dependency
58
+ # resolution -- it's so tests/e2e/test_full_stack_devices.py can reach its sibling
59
+ # checkout's tests/golden fixture data, which isn't shipped in the published package.
60
+ - name: Check out tuya2ildevice
61
+ uses: actions/checkout@v7
62
+ with:
63
+ repository: 3735943886/tuya2ildevice
64
+ path: tuya2ildevice
65
+
66
+ - name: Set up Python ${{ matrix.python-version }}
67
+ uses: actions/setup-python@v7
68
+ with:
69
+ python-version: ${{ matrix.python-version }}
70
+
71
+ - name: Install uv
72
+ uses: astral-sh/setup-uv@v7
73
+
74
+ - name: Install + start mosquitto
75
+ # tests/ha/ and tests/e2e/ each skip gracefully (pytest.skip / importorskip) when
76
+ # mosquitto, pyrustuyabridge or tuyamock is missing (see their conftest.py) -- installed
77
+ # here so CI actually exercises those tests instead of silently skipping them.
78
+ run: |
79
+ sudo apt-get update
80
+ sudo apt-get install -y --no-install-recommends mosquitto mosquitto-clients
81
+ sudo tee /etc/mosquitto/conf.d/ci.conf > /dev/null <<'EOF'
82
+ listener 1883
83
+ allow_anonymous true
84
+ EOF
85
+ sudo systemctl restart mosquitto
86
+ for _ in $(seq 1 20); do
87
+ if mosquitto_sub -h localhost -t '$SYS/broker/version' -C 1 -W 2 >/dev/null 2>&1; then
88
+ echo "mosquitto reachable"
89
+ exit 0
90
+ fi
91
+ sleep 0.5
92
+ done
93
+ echo "mosquitto did not become reachable"
94
+ sudo journalctl -u mosquitto --no-pager | tail -50
95
+ exit 1
96
+
97
+ - name: Install rustuya-local + test extras
98
+ working-directory: rustuya-local
99
+ run: uv pip install --system -e ".[test,test-ha,fullstack]"
100
+
101
+ - name: pytest
102
+ working-directory: rustuya-local
103
+ run: pytest -q
@@ -0,0 +1,22 @@
1
+ name: CI
2
+
3
+ # Runs on PRs targeting main and on direct pushes to main.
4
+ # The actual pytest matrix lives in _test.yml so the same checks also run as a
5
+ # pre-flight gate inside release.yml.
6
+
7
+ on:
8
+ push:
9
+ branches: [main]
10
+ pull_request:
11
+ branches: [main]
12
+
13
+ concurrency:
14
+ group: ci-${{ github.ref }}
15
+ cancel-in-progress: true
16
+
17
+ permissions:
18
+ contents: read
19
+
20
+ jobs:
21
+ test:
22
+ uses: ./.github/workflows/_test.yml
@@ -0,0 +1,24 @@
1
+ name: HACS Validation
2
+
3
+ # Validates that custom_components/rustuya can be installed through HACS (hacs.json, manifest.json
4
+ # shape, repo metadata). Runs on every push/PR plus a daily schedule so a change on GitHub's side
5
+ # (e.g. a new HACS check) surfaces without needing a code change here to trigger it.
6
+
7
+ on:
8
+ push:
9
+ branches: [main, master]
10
+ pull_request:
11
+ schedule:
12
+ - cron: "0 0 * * *"
13
+ workflow_dispatch:
14
+
15
+ jobs:
16
+ validate:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v7
20
+
21
+ - name: HACS validation
22
+ uses: hacs/action@main
23
+ with:
24
+ category: integration
@@ -0,0 +1,20 @@
1
+ name: Hassfest
2
+
3
+ # Home Assistant's own manifest.json / translations / config-flow schema validator for
4
+ # custom_components/rustuya. HACS runs this same check before listing an integration, so it's
5
+ # worth catching here first.
6
+
7
+ on:
8
+ push:
9
+ branches: [main, master]
10
+ pull_request:
11
+ schedule:
12
+ - cron: "0 0 * * *"
13
+ workflow_dispatch:
14
+
15
+ jobs:
16
+ validate:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v7
20
+ - uses: home-assistant/actions/hassfest@master
@@ -0,0 +1,108 @@
1
+ name: Release
2
+
3
+ # Builds + publishes rustuya-local when a `v*` tag is pushed. Routes based on the tag shape:
4
+ # v0.1.0 -> PyPI (plain MAJOR.MINOR.PATCH = final release)
5
+ # v0.1.0rc1 -> TestPyPI (any PEP 440 pre-release/dev/post suffix)
6
+ # v0.0.1.dev0 -> TestPyPI
7
+ #
8
+ # PyPI refuses re-uploads of the same version forever, so the pre-release cycle on TestPyPI is
9
+ # the safety net: tag a pre-release, verify the wheel installs, fix any issues, then tag the
10
+ # plain version for the real index.
11
+ #
12
+ # OIDC trusted publishing -- no API tokens stored in repo secrets. One-time setup needed on BOTH
13
+ # registries before the first tag (the trust DBs are separate):
14
+ # https://pypi.org/manage/account/publishing/
15
+ # https://test.pypi.org/manage/account/publishing/
16
+ # Same entry on each:
17
+ # Project name: rustuya-local
18
+ # Owner: 3735943886
19
+ # Repository: rustuya-local
20
+ # Workflow: release.yml
21
+ # Environment: pypi
22
+
23
+ on:
24
+ push:
25
+ tags:
26
+ - "v*"
27
+
28
+ # OIDC needs id-token: write. The publish job additionally needs contents: write to create the
29
+ # GitHub Release; that's granted at the job level below so the reusable test job inherits only read.
30
+ permissions:
31
+ contents: read
32
+ id-token: write
33
+
34
+ jobs:
35
+ # Same checks as CI -- gates the publish so a broken tag can't reach PyPI.
36
+ test:
37
+ uses: ./.github/workflows/_test.yml
38
+
39
+ publish:
40
+ name: Build + publish
41
+ needs: test
42
+ runs-on: ubuntu-latest
43
+ environment:
44
+ name: pypi
45
+ url: https://pypi.org/p/rustuya-local
46
+ permissions:
47
+ contents: write
48
+ id-token: write
49
+
50
+ steps:
51
+ - uses: actions/checkout@v7
52
+
53
+ - name: Set up Python
54
+ uses: actions/setup-python@v7
55
+ with:
56
+ python-version: "3.13"
57
+
58
+ - name: Sanity-check that the tag matches the package version
59
+ # rustuya-local's version is single-sourced as a static field in pyproject.toml (no
60
+ # dynamic __version__.py to read instead) -- catches tagging without bumping it.
61
+ run: |
62
+ TAG="${GITHUB_REF_NAME#v}"
63
+ PKG_VER=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
64
+ if [ "$TAG" != "$PKG_VER" ]; then
65
+ echo "Tag v$TAG does not match pyproject.toml version $PKG_VER" >&2
66
+ exit 1
67
+ fi
68
+
69
+ - name: Decide TestPyPI vs PyPI from the tag shape
70
+ id: target
71
+ run: |
72
+ TAG="${GITHUB_REF_NAME#v}"
73
+ if [[ "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
74
+ echo "url=https://upload.pypi.org/legacy/" >> "$GITHUB_OUTPUT"
75
+ echo "name=PyPI" >> "$GITHUB_OUTPUT"
76
+ echo "prerelease=false" >> "$GITHUB_OUTPUT"
77
+ else
78
+ echo "url=https://test.pypi.org/legacy/" >> "$GITHUB_OUTPUT"
79
+ echo "name=TestPyPI" >> "$GITHUB_OUTPUT"
80
+ echo "prerelease=true" >> "$GITHUB_OUTPUT"
81
+ fi
82
+ echo "Target: $TAG -> $(grep '^name=' "$GITHUB_OUTPUT" | tail -1)"
83
+
84
+ - name: Install build tooling
85
+ run: python -m pip install --upgrade pip build twine
86
+
87
+ - name: Build sdist + wheel
88
+ # Building only needs rustuya-local's own checkout: [tool.uv.sources]'s sibling paths are
89
+ # a uv dev-install convenience, not something `python -m build` resolves or needs.
90
+ run: python -m build --sdist --wheel
91
+
92
+ - name: Check distribution metadata
93
+ run: twine check dist/*
94
+
95
+ - name: Publish to ${{ steps.target.outputs.name }}
96
+ uses: pypa/gh-action-pypi-publish@release/v1
97
+ with:
98
+ repository-url: ${{ steps.target.outputs.url }}
99
+
100
+ - name: Create or update the GitHub Release
101
+ uses: softprops/action-gh-release@v3
102
+ with:
103
+ files: dist/*
104
+ draft: false
105
+ prerelease: ${{ steps.target.outputs.prerelease }}
106
+ generate_release_notes: true
107
+ env:
108
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,8 @@
1
+ .claude/
2
+ __pycache__/
3
+ *.pyc
4
+ .venv/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ *.egg-info/
8
+ legacy/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 3735943886
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.
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.5
2
+ Name: rustuya-local
3
+ Version: 0.0.1
4
+ Summary: Local Tuya control: rustuya-bridge -> tuya2ildevice -> IL (ildevice). HA-independent core.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.13
8
+ Requires-Dist: pyrustuyabridge<0.4,>=0.3.0
9
+ Requires-Dist: tuya2ildevice[host]<0.3,>=0.2.0
10
+ Provides-Extra: fullstack
11
+ Requires-Dist: tuyamock; extra == 'fullstack'
12
+ Provides-Extra: test
13
+ Requires-Dist: il-ha; extra == 'test'
14
+ Requires-Dist: pytest; extra == 'test'
15
+ Requires-Dist: pytest-asyncio; extra == 'test'
16
+ Provides-Extra: test-ha
17
+ Requires-Dist: pytest-homeassistant-custom-component; extra == 'test-ha'
@@ -0,0 +1,67 @@
1
+ # rustuya-local
2
+
3
+ [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration)
4
+
5
+ Local control of Tuya devices through [rustuya-bridge](https://github.com/3735943886/rustuya-bridge), as IL
6
+ ([ildevice](../ildevice)) devices. It runs without Home Assistant as the standalone `rustuya-local` daemon
7
+ (this package); `custom_components/rustuya` is a thin Home Assistant integration on top of the same core, installable
8
+ through HACS.
9
+
10
+ ```
11
+ rustuya-bridge ──MQTT──► rustuya-local ──MQTT (il/…)──► il-ha / any IL consumer
12
+ raw LAN dps Runner + tuya2ildevice.Hub descriptors, values, commands (il-mqtt.md)
13
+ ```
14
+
15
+ - **tuya2ildevice** (sibling repo) is the only place that interprets Tuya *and* the host around it: `tuya2ildevice.host`
16
+ runs the Hub on MQTT (Last Will presence, reconnects, runtime add/remove, following `tuyadevices.json`, the bridge's
17
+ own topic templates).
18
+ - **rustuya-local** is only the deployment: a configuration file and the `rustuya-local run` daemon on top of that host
19
+ (about 150 lines). It does not depend on il-ha.
20
+ - **il-ha** (sibling repo) turns IL descriptors into Home Assistant entities; it never sees Tuya.
21
+
22
+ ## Run
23
+
24
+ ```
25
+ uv venv && uv pip install -e ".[test]" # sibling checkouts ../tuya2ildevice and ../il-ha are path dependencies
26
+ rustuya-local run --config config.json
27
+ ```
28
+
29
+ `config.json` (see `src/rustuya_local/config.py`): the bridge and IL brokers, the IL prefix, the device list and options
30
+ for the Hub (`allow_hazardous`, `expose_unused`, `overrides`). The device list is the `tuyadevices.json` that
31
+ [rustuya-manager](../rustuya-manager) keeps (its QR-login wizard writes it, with each device's cloud
32
+ `category`/`function`/`status_range`/`local_strategy`); rustuya-local follows it as it changes and never writes it. The
33
+ bridge's own topic templates are read from its retained `{root}/bridge/config`.
34
+
35
+ ## Home Assistant (HACS)
36
+
37
+ `custom_components/rustuya` runs the same `Hub`/`Runner` as the standalone daemon, as a background task tied to a
38
+ config entry, with an optional embedded rustuya-bridge. It creates no entities itself — install
39
+ [il-ha](../il-ha) (or any IL consumer) separately to turn its devices into entities.
40
+
41
+ 1. HACS → the three-dot menu → **Custom repositories** → add this repository URL with category **Integration**.
42
+ 2. Install **Rustuya**, restart Home Assistant.
43
+ 3. Settings → Devices & Services → **Add Integration** → **Rustuya**, and follow the config flow.
44
+
45
+ ## Tests
46
+
47
+ ```
48
+ .venv/bin/python -m pytest
49
+ ```
50
+
51
+ Everything here needs the sibling repos and, for the last two groups, `mosquitto`, `pyrustuya-bridge` and `tuyamock`
52
+ (`pip install -e ".[test,fullstack]"`); those are skipped when missing. Parity with Home Assistant core's fixtures lives in
53
+ `tuya2ildevice/tests/chain`, the wire and topic vectors in `ildevice/vectors`.
54
+
55
+ - `tests/unit/`: the configuration.
56
+ - `tests/e2e/test_chain_*.py`, `test_daemon.py`: the runner with il-ha's consumer model on the other side, in one process
57
+ and over a real broker; the daemon as a subprocess.
58
+ - `tests/e2e/test_full_stack*.py`: the real rustuya-bridge (`pyrustuyabridge`, in process) and Tuya device emulators
59
+ (`tuyamock`) around the daemon: state, commands, rejections, a device dropping off and returning, a daemon restart, and a
60
+ differential check that 38 real device shapes come out of the whole chain exactly as `tuya2ildevice` computes them.
61
+
62
+ ## Status
63
+
64
+ Planning, progress and open decisions: `docs/REDESIGN.md`. The earlier Home Assistant-only implementation is kept in
65
+ `legacy/` (not in git) until the new one is verified; `docs/STATUS.md` describes it.
66
+
67
+ Licence: `LICENSE` (MIT). Third-party notices: `THIRD_PARTY_NOTICES.md`.
@@ -0,0 +1,28 @@
1
+ # Third-party notices
2
+
3
+ This repository is config and a daemon/HA integration only: it contains no vendored or reproduced third-party
4
+ code. The Apache-2.0-derived reproduction of Home Assistant core's `tuya` integration, tuya-device-handlers and
5
+ tuya-device-sharing-sdk that this project used to carry directly (`entity_engine.py`, `bridge_device.py`,
6
+ `rustuya_ha.tuya2ha.v2`) moved out during the redesign to `tuya2ildevice` — see
7
+ [that repository's THIRD_PARTY_NOTICES.md](https://github.com/3735943886/tuya2ildevice/blob/main/THIRD_PARTY_NOTICES.md)
8
+ for those notices; nothing of it remains here.
9
+
10
+ ## rustuya / rustuya-bridge / rustuya-manager / tuya2ildevice
11
+
12
+ This project is a client, over MQTT, of [rustuya-bridge](https://github.com/3735943886/rustuya-bridge) (itself
13
+ built on [rustuya](https://github.com/3735943886/rustuya)), converts what it publishes with
14
+ [tuya2ildevice](https://github.com/3735943886/tuya2ildevice), and depends on
15
+ [rustuya-manager](https://github.com/3735943886/rustuya-manager)'s `Manager` facade for device
16
+ registration/removal and the Tuya Cloud QR-login wizard, used only by `custom_components/rustuya_local`'s config
17
+ and options flows. No code from any of these is vendored. `custom_components/rustuya_local/bridge_supervisor.py`
18
+ optionally depends at runtime on the `pyrustuyabridge` PyO3 bindings package, only when the user opts into
19
+ embedded-bridge mode; in external-bridge mode this integration never imports it (the topic layer it talks over is
20
+ `tuya2ildevice`'s own, not `pyrustuyabridge`).
21
+
22
+ ## History
23
+
24
+ Earlier versions of this repository generated Home Assistant entities directly (`tuya2ha.v2`, later moved here as
25
+ `rustuya_ha.tuya2ha.v2`); see git history for that design. The current architecture produces IL
26
+ (`ildevice`/`il-ha`'s Instance Layer) instead: this repository has no Home Assistant entity code and creates no
27
+ entities itself. All Apache-2.0-derived attribution now lives with the code it actually describes, in
28
+ `tuya2ildevice`.
@@ -0,0 +1,127 @@
1
+ """Rustuya as a Home Assistant integration: runs the same `tuya2ildevice.host.Runner` the standalone
2
+ `rustuya-local` daemon does, as a background task tied to this config entry, with an optional embedded
3
+ rustuya-bridge (`pyrustuyabridge`). It creates no entities and does not depend on il-ha: it is only an IL
4
+ *producer* — install il-ha (or any IL consumer) separately to turn its devices into entities.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import logging
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ from homeassistant.config_entries import ConfigEntry
15
+ from homeassistant.core import HomeAssistant
16
+
17
+ from .const import (
18
+ BRIDGE_EMBEDDED,
19
+ CONF_ALLOW_HAZARDOUS,
20
+ CONF_BRIDGE_LOG_LEVEL,
21
+ CONF_BRIDGE_MODE,
22
+ CONF_BRIDGE_ROOT,
23
+ CONF_BRIDGE_STATE_FILE,
24
+ CONF_BROKER_HOST,
25
+ CONF_BROKER_PASSWORD,
26
+ CONF_BROKER_PORT,
27
+ CONF_BROKER_USERNAME,
28
+ CONF_DEVICES_PATH,
29
+ CONF_EXPOSE_UNUSED,
30
+ CONF_IL_PREFIX,
31
+ CONF_IL_SOURCE,
32
+ CONF_WATCH_INTERVAL,
33
+ DEFAULT_WATCH_INTERVAL,
34
+ DOMAIN,
35
+ )
36
+
37
+ _LOGGER = logging.getLogger(__name__)
38
+
39
+
40
+ @dataclass
41
+ class RuntimeData:
42
+ runner: Any
43
+ watcher: Any | None
44
+ bridge_transport: Any
45
+ il_transport: Any
46
+ embedded_bridge: Any | None
47
+
48
+
49
+ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
50
+ from rustuya_local.bridge_client import BridgeClient
51
+ from tuya2ildevice import Hub, IlTopics
52
+ from tuya2ildevice.host import DeviceWatcher, MqttTransport, Runner, load_devices
53
+
54
+ from .bridge_supervisor import EmbeddedBridge
55
+
56
+ data, options = entry.data, entry.options
57
+ broker = f"mqtt://{data[CONF_BROKER_HOST]}:{data[CONF_BROKER_PORT]}"
58
+ username, password = data.get(CONF_BROKER_USERNAME) or None, data.get(CONF_BROKER_PASSWORD) or None
59
+
60
+ # a failure partway through (a later transport, the device file, Runner.start) must not leak what already
61
+ # connected — each `push_async_callback` only runs if everything after it is unwound because of an exception,
62
+ # or immediately by `stack.pop_all()` never being reached
63
+ async with contextlib.AsyncExitStack() as stack:
64
+ embedded_bridge = None
65
+ if data[CONF_BRIDGE_MODE] == BRIDGE_EMBEDDED:
66
+ embedded_bridge = EmbeddedBridge(broker, data[CONF_BRIDGE_ROOT], data[CONF_BRIDGE_STATE_FILE],
67
+ data.get(CONF_BRIDGE_LOG_LEVEL, "warn"), username, password)
68
+ await embedded_bridge.start()
69
+ stack.push_async_callback(embedded_bridge.stop)
70
+
71
+ bridge_transport = MqttTransport(data[CONF_BROKER_HOST], data[CONF_BROKER_PORT],
72
+ client_id=f"rustuya-bridge-{entry.entry_id[:8]}",
73
+ username=username, password=password)
74
+ await bridge_transport.connect()
75
+ stack.push_async_callback(bridge_transport.close)
76
+ bridge_client = BridgeClient(bridge_transport, data[CONF_BRIDGE_ROOT])
77
+
78
+ devices = await hass.async_add_executor_job(load_devices, data[CONF_DEVICES_PATH])
79
+ hub = Hub(devices, il=IlTopics(data.get(CONF_IL_PREFIX, "il"), data.get(CONF_IL_SOURCE, "tuya")),
80
+ allow_hazardous=options.get(CONF_ALLOW_HAZARDOUS, False),
81
+ expose_unused=options.get(CONF_EXPOSE_UNUSED, False))
82
+ will = hub.presence(False)
83
+ il_transport = MqttTransport(data[CONF_BROKER_HOST], data[CONF_BROKER_PORT],
84
+ client_id=f"rustuya-il-{entry.entry_id[:8]}", username=username,
85
+ password=password, will=(will.topic, will.payload, will.qos, will.retain))
86
+ await il_transport.connect()
87
+ stack.push_async_callback(il_transport.close)
88
+
89
+ runner = Runner(hub, il_transport, on_bridge_command=bridge_client.send_command)
90
+ bridge_client.runner = runner
91
+ await runner.start()
92
+ stack.push_async_callback(runner.stop)
93
+ await bridge_client.start()
94
+
95
+ watcher = None
96
+ interval = options.get(CONF_WATCH_INTERVAL, DEFAULT_WATCH_INTERVAL)
97
+ if interval > 0:
98
+ watcher = DeviceWatcher(data[CONF_DEVICES_PATH], runner, interval)
99
+ watcher.start()
100
+ stack.push_async_callback(watcher.stop)
101
+
102
+ hass.data.setdefault(DOMAIN, {})[entry.entry_id] = RuntimeData(
103
+ runner=runner, watcher=watcher, bridge_transport=bridge_transport, il_transport=il_transport,
104
+ embedded_bridge=embedded_bridge,
105
+ )
106
+ entry.async_on_unload(entry.add_update_listener(_async_reload))
107
+ _LOGGER.info("driving %d device(s) via %s (bridge: %s)", len(devices), data[CONF_BRIDGE_ROOT], data[CONF_BRIDGE_MODE])
108
+
109
+ # everything above succeeded: async_unload_entry (RuntimeData) owns closing it now, not this stack
110
+ stack.pop_all()
111
+ return True
112
+
113
+
114
+ async def _async_reload(hass: HomeAssistant, entry: ConfigEntry) -> None:
115
+ await hass.config_entries.async_reload(entry.entry_id)
116
+
117
+
118
+ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
119
+ runtime: RuntimeData = hass.data[DOMAIN].pop(entry.entry_id)
120
+ if runtime.watcher:
121
+ await runtime.watcher.stop()
122
+ await runtime.runner.stop()
123
+ await runtime.il_transport.close()
124
+ await runtime.bridge_transport.close()
125
+ if runtime.embedded_bridge:
126
+ await runtime.embedded_bridge.stop()
127
+ return True
@@ -0,0 +1,66 @@
1
+ """An embedded rustuya-bridge: `pyrustuyabridge.PyBridgeServer` run in a background thread, tied to this config
2
+ entry's lifecycle instead of a separate rustuya-bridge process. Same class our own end-to-end tests drive
3
+ (tuya2ildevice/rustuya-local tests/e2e/test_full_stack.py) — proven against a real broker and real Tuya-protocol
4
+ devices, just supervised here instead of by a test fixture.
5
+
6
+ The embedded bridge still needs a real MQTT broker to talk through (it manages its own native MQTT client); it does
7
+ not remove that requirement, only who launches and owns the bridge process.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ import threading
15
+
16
+ _LOGGER = logging.getLogger(__name__)
17
+
18
+
19
+ class EmbeddedBridge:
20
+ def __init__(self, broker: str, root: str, state_file: str, log_level: str = "warn",
21
+ username: str | None = None, password: str | None = None) -> None:
22
+ self.broker = broker
23
+ self.root = root
24
+ self.state_file = state_file
25
+ self.log_level = log_level
26
+ self.username = username
27
+ self.password = password
28
+ self._server = None
29
+ self._thread: threading.Thread | None = None
30
+
31
+ async def start(self) -> None:
32
+ import pyrustuyabridge as pb
33
+
34
+ kwargs = dict(mqtt_broker=self.broker, mqtt_root_topic=self.root, mqtt_retain=True,
35
+ state_file=self.state_file, no_signals=True, log_level=self.log_level)
36
+ if self.username:
37
+ kwargs["mqtt_username"] = self.username
38
+ if self.password:
39
+ kwargs["mqtt_password"] = self.password
40
+ self._server = pb.PyBridgeServer(**kwargs)
41
+ loop = asyncio.get_running_loop()
42
+ started = loop.create_future()
43
+
44
+ def run() -> None:
45
+ try:
46
+ self._server.start()
47
+ except Exception:
48
+ _LOGGER.exception("the embedded bridge stopped unexpectedly")
49
+ finally:
50
+ if not started.done():
51
+ loop.call_soon_threadsafe(started.set_result, None)
52
+
53
+ self._thread = threading.Thread(target=run, name="rustuya-embedded-bridge", daemon=True)
54
+ self._thread.start()
55
+ # give the bridge a moment to fail fast (a bad broker URL, a locked state file) before reporting success;
56
+ # `run()` resolves `started` on exit, so a quick crash surfaces as this future finishing early too
57
+ await asyncio.wait([asyncio.ensure_future(asyncio.sleep(0.2)), started], return_when=asyncio.FIRST_COMPLETED)
58
+ if self._thread is not None and not self._thread.is_alive():
59
+ raise RuntimeError("the embedded bridge exited during startup; check the log")
60
+
61
+ async def stop(self) -> None:
62
+ if self._server is not None:
63
+ self._server.stop()
64
+ if self._thread is not None:
65
+ await asyncio.get_running_loop().run_in_executor(None, self._thread.join, 10)
66
+ self._thread = None