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.
- rustuya_local-0.0.1/.github/dependabot.yml +11 -0
- rustuya_local-0.0.1/.github/workflows/_test.yml +103 -0
- rustuya_local-0.0.1/.github/workflows/ci.yml +22 -0
- rustuya_local-0.0.1/.github/workflows/hacs.yml +24 -0
- rustuya_local-0.0.1/.github/workflows/hassfest.yml +20 -0
- rustuya_local-0.0.1/.github/workflows/release.yml +108 -0
- rustuya_local-0.0.1/.gitignore +8 -0
- rustuya_local-0.0.1/LICENSE +21 -0
- rustuya_local-0.0.1/PKG-INFO +17 -0
- rustuya_local-0.0.1/README.md +67 -0
- rustuya_local-0.0.1/THIRD_PARTY_NOTICES.md +28 -0
- rustuya_local-0.0.1/custom_components/rustuya/__init__.py +127 -0
- rustuya_local-0.0.1/custom_components/rustuya/bridge_supervisor.py +66 -0
- rustuya_local-0.0.1/custom_components/rustuya/config_flow.py +366 -0
- rustuya_local-0.0.1/custom_components/rustuya/const.py +37 -0
- rustuya_local-0.0.1/custom_components/rustuya/manager_session.py +33 -0
- rustuya_local-0.0.1/custom_components/rustuya/manifest.json +16 -0
- rustuya_local-0.0.1/custom_components/rustuya/strings.json +110 -0
- rustuya_local-0.0.1/custom_components/rustuya/translations/en.json +110 -0
- rustuya_local-0.0.1/docs/REDESIGN.md +161 -0
- rustuya_local-0.0.1/docs/STATUS.md +29 -0
- rustuya_local-0.0.1/hacs.json +5 -0
- rustuya_local-0.0.1/pyproject.toml +38 -0
- rustuya_local-0.0.1/src/rustuya_local/__init__.py +1 -0
- rustuya_local-0.0.1/src/rustuya_local/__main__.py +3 -0
- rustuya_local-0.0.1/src/rustuya_local/bridge_client.py +158 -0
- rustuya_local-0.0.1/src/rustuya_local/cli.py +67 -0
- rustuya_local-0.0.1/src/rustuya_local/config.py +77 -0
- rustuya_local-0.0.1/tests/e2e/conftest.py +75 -0
- rustuya_local-0.0.1/tests/e2e/devices.py +22 -0
- rustuya_local-0.0.1/tests/e2e/test_chain_in_process.py +111 -0
- rustuya_local-0.0.1/tests/e2e/test_chain_mqtt.py +82 -0
- rustuya_local-0.0.1/tests/e2e/test_daemon.py +85 -0
- rustuya_local-0.0.1/tests/e2e/test_full_stack.py +187 -0
- rustuya_local-0.0.1/tests/e2e/test_full_stack_custom_templates.py +72 -0
- rustuya_local-0.0.1/tests/e2e/test_full_stack_devices.py +152 -0
- rustuya_local-0.0.1/tests/ha/conftest.py +54 -0
- rustuya_local-0.0.1/tests/ha/fake_manager.py +126 -0
- rustuya_local-0.0.1/tests/ha/test_config_flow.py +228 -0
- rustuya_local-0.0.1/tests/ha/test_init.py +106 -0
- rustuya_local-0.0.1/tests/unit/test_config.py +21 -0
- 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,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
|
+
[](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
|