rustuya-local 0.1.0.dev7__tar.gz → 0.1.0.dev10__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.1.0.dev7 → rustuya_local-0.1.0.dev10}/CHANGELOG.md +29 -2
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/PKG-INFO +47 -14
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/README.md +45 -12
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/__init__.py +10 -4
- rustuya_local-0.1.0.dev10/custom_components/rustuya/bridge_supervisor.py +104 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/config_flow.py +7 -13
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/manager_session.py +7 -4
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/manifest.json +3 -3
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/panel.py +71 -66
- rustuya_local-0.1.0.dev10/docs/ARCHITECTURE.md +79 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/docs/MIGRATING.md +14 -7
- rustuya_local-0.1.0.dev10/docs/REDESIGN.md +385 -0
- rustuya_local-0.1.0.dev10/docs/REVIEW.md +108 -0
- rustuya_local-0.1.0.dev10/docs/ROADMAP.md +39 -0
- rustuya_local-0.1.0.dev10/docs/STATUS.md +41 -0
- rustuya_local-0.1.0.dev10/docs/history/ROADMAP-2026-10-12.md +111 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/pyproject.toml +2 -2
- rustuya_local-0.1.0.dev10/release-notes.md +9 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/bridge_client.py +38 -5
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/mqtt.py +49 -3
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/pack.py +27 -5
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/runner.py +14 -5
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/transport.py +10 -1
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/test_bridge_registration.py +52 -2
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/test_daemon.py +16 -11
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/ha/fake_manager.py +13 -3
- rustuya_local-0.1.0.dev10/tests/ha/test_bridge_supervisor.py +95 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/ha/test_manager_session.py +1 -1
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/ha/test_panel.py +91 -6
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_host.py +4 -2
- rustuya_local-0.1.0.dev10/tests/unit/test_mqtt_lifecycle.py +110 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_pack.py +50 -0
- rustuya_local-0.1.0.dev7/custom_components/rustuya/bridge_supervisor.py +0 -66
- rustuya_local-0.1.0.dev7/custom_components/rustuya/manager_registry.py +0 -71
- rustuya_local-0.1.0.dev7/docs/REDESIGN.md +0 -327
- rustuya_local-0.1.0.dev7/docs/ROADMAP.md +0 -102
- rustuya_local-0.1.0.dev7/docs/STATUS.md +0 -6
- rustuya_local-0.1.0.dev7/release-notes.md +0 -3
- rustuya_local-0.1.0.dev7/tests/ha/test_manager_registry.py +0 -69
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/.github/dependabot.yml +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/.github/workflows/_test.yml +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/.github/workflows/ci.yml +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/.github/workflows/hacs.yml +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/.github/workflows/hassfest.yml +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/.github/workflows/release.yml +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/.gitignore +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/LICENSE +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/icon.png +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/icon.svg +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/icon@2x.png +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/logo.png +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/logo.svg +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/logo@2x.png +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/bridge_sync.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/broker.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/const.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/panel_bridge.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/services.yaml +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/strings.json +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/translations/en.json +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/translations/ko.json +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/www/rustuya-panel.js +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/hacs.json +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/scripts/build_dropin.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/scripts/release_notes.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/__init__.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/__main__.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/async_tools.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/cli.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/config.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/files.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/__init__.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/devices.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/hub.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/il.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/memory.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/overrides.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/rules.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/manager_plugin/__init__.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/manager_plugin/api.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/manager_plugin/static/index.js +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/service.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/validation.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/conftest.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/devices.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/test_chain_in_process.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/test_chain_mqtt.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/test_full_stack.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/test_full_stack_custom_templates.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/test_full_stack_devices.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/test_manager_plugin.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/test_purge.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/test_service.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/ha/conftest.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/ha/test_bridge_probe.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/ha/test_config_flow.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/ha/test_init.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/ha/test_migrate.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_config.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_cover_settings.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_dropin.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_files.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_hazardous.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_hub.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_il_vectors.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_link_watcher.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_panel_js.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_plugin_api.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_release_notes.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_rule_files.py +0 -0
- {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/tuya_samples.py +0 -0
|
@@ -3,11 +3,38 @@
|
|
|
3
3
|
All notable changes to this project are documented in this file. A release's GitHub Release notes are its section
|
|
4
4
|
here (the release workflow reads it, and refuses to publish a version without one).
|
|
5
5
|
|
|
6
|
-
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
7
|
-
|
|
6
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The current development series uses
|
|
7
|
+
`0.1.0.devN`; the earlier `0.0.x` releases remain below as history.
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.1.0.dev10] — 2026-10-18
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- Wait for live bridge acknowledgements and the panel's registry refresh instead of sleeping after device commands.
|
|
15
|
+
- Verify embedded bridge startup through a live MQTT config publication, supervise its native async task, and
|
|
16
|
+
retain ownership when shutdown times out.
|
|
17
|
+
- Wait for producer presence with an event and an explicit timeout instead of repeated short sleeps.
|
|
18
|
+
- Use manager state-change notifications for wizard progress and cloud-fetch completion; remove status polling.
|
|
19
|
+
- Use manager registry readiness instead of patching its private methods; require rustuya-manager 0.2.3.
|
|
20
|
+
- Create session directories with a regular loop instead of a discarded list comprehension.
|
|
21
|
+
- Wait for daemon readiness events in the subprocess test, allowing both startup timeout stages to finish.
|
|
22
|
+
|
|
23
|
+
## [0.1.0.dev8] — 2026-10-13
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- Always stop the embedded bridge during Home Assistant unload, including service-stop failure or cancellation.
|
|
27
|
+
- Close MQTT callback tasks, ignore queued dispatch after shutdown, retrieve asynchronous handler errors and stop
|
|
28
|
+
the paho thread outside the event loop, including when a connection attempt fails.
|
|
29
|
+
- Preserve concurrent partial Home Assistant settings/options saves by locking reads, merges and restart together.
|
|
30
|
+
- Reject unsafe pack ownership paths, malformed hashes and bounds; preserve host settings and symlink targets.
|
|
31
|
+
|
|
32
|
+
### Documentation
|
|
33
|
+
- Separate current status, architecture and outstanding work from the historical redesign record.
|
|
34
|
+
- Document the 2026-10-13 code review, verification results and remaining reliability issues.
|
|
35
|
+
- Correct daemon setup, test prerequisites and saved cover settings precedence.
|
|
36
|
+
- Translate all Korean Markdown documents into English, including archived design and roadmap history.
|
|
37
|
+
|
|
11
38
|
## [0.1.0.dev7] — 2026-10-12
|
|
12
39
|
|
|
13
40
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: rustuya-local
|
|
3
|
-
Version: 0.1.0.
|
|
3
|
+
Version: 0.1.0.dev10
|
|
4
4
|
Summary: Local Tuya control: rustuya-bridge -> tuya-interpreter -> IL (ildevice). HA-independent core.
|
|
5
5
|
Project-URL: Homepage, https://github.com/3735943886/rustuya-local
|
|
6
6
|
Project-URL: Repository, https://github.com/3735943886/rustuya-local
|
|
@@ -14,7 +14,7 @@ Requires-Dist: tuya-interpreter>=0.1.0a2
|
|
|
14
14
|
Provides-Extra: fullstack
|
|
15
15
|
Requires-Dist: tuyamock; extra == 'fullstack'
|
|
16
16
|
Provides-Extra: manager
|
|
17
|
-
Requires-Dist: rustuya-manager[web]>=0.2.
|
|
17
|
+
Requires-Dist: rustuya-manager[web]>=0.2.3; extra == 'manager'
|
|
18
18
|
Provides-Extra: test
|
|
19
19
|
Requires-Dist: httpx2; extra == 'test'
|
|
20
20
|
Requires-Dist: ildevice-homeassistant; extra == 'test'
|
|
@@ -46,11 +46,29 @@ rustuya-bridge ──MQTT──► rustuya-local ──MQTT (il/…)──► il
|
|
|
46
46
|
|
|
47
47
|
## Daemon
|
|
48
48
|
|
|
49
|
+
```bash
|
|
50
|
+
uv venv --python 3.13
|
|
51
|
+
uv pip install -e .
|
|
52
|
+
.venv/bin/rustuya-local run --config config.json
|
|
49
53
|
```
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
|
|
55
|
+
Create `config.json` beside the device file before running:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"bridge": {"host": "localhost", "port": 1883, "root": "rustuya"},
|
|
60
|
+
"il": {"host": "localhost", "port": 1883, "prefix": "il", "source": "tuya"},
|
|
61
|
+
"devices": "tuyadevices.json",
|
|
62
|
+
"custom_converters": "custom_converters",
|
|
63
|
+
"pack": true,
|
|
64
|
+
"watch_interval": 5,
|
|
65
|
+
"options": {"allow_hazardous": false, "expose_unused": false}
|
|
66
|
+
}
|
|
52
67
|
```
|
|
53
68
|
|
|
69
|
+
The package requires Python 3.13 or newer. Device and converter paths are relative to the configuration file.
|
|
70
|
+
`watch_interval: 0` disables polling; use a positive interval to apply file edits and downloaded rules while running.
|
|
71
|
+
|
|
54
72
|
`config.json` (see `src/rustuya_local/config.py`): the bridge and IL brokers, the IL prefix, the device list, a
|
|
55
73
|
`custom_converters` directory, and options for the Hub (`allow_hazardous`, `expose_unused`, `overrides`).
|
|
56
74
|
The device list is the `tuyadevices.json` that [rustuya-manager](https://github.com/3735943886/rustuya-manager) keeps (its QR-login wizard writes
|
|
@@ -134,7 +152,9 @@ device settings are enabled, unless an override selects another source. A settin
|
|
|
134
152
|
the setting is saved as the device's block in `zz_settings.json` in the converters directory and applied at once, so it
|
|
135
153
|
survives a restart and can be edited there too. Without a converters directory (a daemon with no
|
|
136
154
|
`custom_converters`) the switches are not offered; `"options": {"device_settings": false}` turns them off.
|
|
137
|
-
A block with the same `cover` keys in your own file or the builtin rules sets a model's starting point
|
|
155
|
+
A block with the same `cover` keys in your own file or the builtin rules sets a model's starting point.
|
|
156
|
+
User files, including `zz_settings.json`, are applied in filename order; a later file can override saved settings.
|
|
157
|
+
The daemon's inline `options.overrides` is applied after file layers.
|
|
138
158
|
Older `cover.infer_motion` and `converters.cover_motion` blocks are upgraded on loading to `cover.state_source`
|
|
139
159
|
(`inferred`, or `none` for `infer_motion: false`) and `cover.settle`; see [migration notes](docs/MIGRATING.md).
|
|
140
160
|
|
|
@@ -172,15 +192,22 @@ rustuya-local release (bump the patch version and tag; the manager's catalog fol
|
|
|
172
192
|
|
|
173
193
|
## Tests
|
|
174
194
|
|
|
175
|
-
```
|
|
176
|
-
|
|
195
|
+
```bash
|
|
196
|
+
uv venv --python 3.14
|
|
197
|
+
uv pip install -e ../ildevice-homeassistant -e ".[test,test-ha,fullstack,manager]"
|
|
198
|
+
.venv/bin/python -m pytest -q
|
|
199
|
+
ruff check src tests custom_components scripts
|
|
177
200
|
```
|
|
178
201
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
202
|
+
The complete suite uses Python 3.14 because the consumer and Home Assistant test dependencies require it.
|
|
203
|
+
Install `mosquitto` for broker tests and Node.js for panel JavaScript tests. The command above installs the consumer
|
|
204
|
+
from a sibling checkout and other dependencies from the package index; CI follows the same approach.
|
|
205
|
+
Missing base or test dependencies can fail collection; optional broker, emulator and fixture tests skip when their
|
|
206
|
+
prerequisites are unavailable. The full-stack device comparison uses `../tuya-interpreter/tests/golden`; IL wire and
|
|
207
|
+
topic vectors use `../ildevice/vectors` (or the `ILDEVICE` environment variable). Keep those checkouts for these checks.
|
|
182
208
|
|
|
183
|
-
- `tests/unit/`:
|
|
209
|
+
- `tests/unit/`: configuration validation, host lifecycle, IL vectors, rule loading, settings persistence,
|
|
210
|
+
pack ownership, converter APIs, panel JavaScript and release packaging.
|
|
184
211
|
- `tests/e2e/test_service.py`: the service on in-memory brokers (overrides directory, a failed start).
|
|
185
212
|
- `tests/e2e/test_chain_*.py`, `test_daemon.py`: the runner with ildevice-homeassistant's consumer model on the other side, in one process
|
|
186
213
|
and over a real broker; the daemon as a subprocess.
|
|
@@ -190,8 +217,14 @@ Everything here needs the sibling repos and, for the last two groups, `mosquitto
|
|
|
190
217
|
(`tuyamock`) around the daemon: state, commands, rejections, a device dropping off and returning, a daemon restart, and a
|
|
191
218
|
differential check that 38 real device shapes come out of the whole chain exactly as `tuya-interpreter` computes them.
|
|
192
219
|
|
|
193
|
-
##
|
|
220
|
+
## Documentation
|
|
194
221
|
|
|
195
|
-
|
|
222
|
+
- [Current status](docs/STATUS.md): version, document index and verification boundaries.
|
|
223
|
+
- [Architecture](docs/ARCHITECTURE.md): current components, lifecycle and rule precedence.
|
|
224
|
+
- [Migration](docs/MIGRATING.md): upgrading old converters and rustuya-homeassistant installations.
|
|
225
|
+
- [Code review](docs/REVIEW.md): findings and local verification from 2026-10-13.
|
|
226
|
+
- [Roadmap](docs/ROADMAP.md): outstanding work and real-device checks.
|
|
227
|
+
- [Design history](docs/REDESIGN.md): the September redesign record; historical APIs and versions.
|
|
228
|
+
- [Changelog](CHANGELOG.md): changes grouped by release.
|
|
196
229
|
|
|
197
|
-
Licence:
|
|
230
|
+
Licence: [MIT](LICENSE).
|
|
@@ -20,11 +20,29 @@ rustuya-bridge ──MQTT──► rustuya-local ──MQTT (il/…)──► il
|
|
|
20
20
|
|
|
21
21
|
## Daemon
|
|
22
22
|
|
|
23
|
+
```bash
|
|
24
|
+
uv venv --python 3.13
|
|
25
|
+
uv pip install -e .
|
|
26
|
+
.venv/bin/rustuya-local run --config config.json
|
|
23
27
|
```
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
|
|
29
|
+
Create `config.json` beside the device file before running:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"bridge": {"host": "localhost", "port": 1883, "root": "rustuya"},
|
|
34
|
+
"il": {"host": "localhost", "port": 1883, "prefix": "il", "source": "tuya"},
|
|
35
|
+
"devices": "tuyadevices.json",
|
|
36
|
+
"custom_converters": "custom_converters",
|
|
37
|
+
"pack": true,
|
|
38
|
+
"watch_interval": 5,
|
|
39
|
+
"options": {"allow_hazardous": false, "expose_unused": false}
|
|
40
|
+
}
|
|
26
41
|
```
|
|
27
42
|
|
|
43
|
+
The package requires Python 3.13 or newer. Device and converter paths are relative to the configuration file.
|
|
44
|
+
`watch_interval: 0` disables polling; use a positive interval to apply file edits and downloaded rules while running.
|
|
45
|
+
|
|
28
46
|
`config.json` (see `src/rustuya_local/config.py`): the bridge and IL brokers, the IL prefix, the device list, a
|
|
29
47
|
`custom_converters` directory, and options for the Hub (`allow_hazardous`, `expose_unused`, `overrides`).
|
|
30
48
|
The device list is the `tuyadevices.json` that [rustuya-manager](https://github.com/3735943886/rustuya-manager) keeps (its QR-login wizard writes
|
|
@@ -108,7 +126,9 @@ device settings are enabled, unless an override selects another source. A settin
|
|
|
108
126
|
the setting is saved as the device's block in `zz_settings.json` in the converters directory and applied at once, so it
|
|
109
127
|
survives a restart and can be edited there too. Without a converters directory (a daemon with no
|
|
110
128
|
`custom_converters`) the switches are not offered; `"options": {"device_settings": false}` turns them off.
|
|
111
|
-
A block with the same `cover` keys in your own file or the builtin rules sets a model's starting point
|
|
129
|
+
A block with the same `cover` keys in your own file or the builtin rules sets a model's starting point.
|
|
130
|
+
User files, including `zz_settings.json`, are applied in filename order; a later file can override saved settings.
|
|
131
|
+
The daemon's inline `options.overrides` is applied after file layers.
|
|
112
132
|
Older `cover.infer_motion` and `converters.cover_motion` blocks are upgraded on loading to `cover.state_source`
|
|
113
133
|
(`inferred`, or `none` for `infer_motion: false`) and `cover.settle`; see [migration notes](docs/MIGRATING.md).
|
|
114
134
|
|
|
@@ -146,15 +166,22 @@ rustuya-local release (bump the patch version and tag; the manager's catalog fol
|
|
|
146
166
|
|
|
147
167
|
## Tests
|
|
148
168
|
|
|
149
|
-
```
|
|
150
|
-
|
|
169
|
+
```bash
|
|
170
|
+
uv venv --python 3.14
|
|
171
|
+
uv pip install -e ../ildevice-homeassistant -e ".[test,test-ha,fullstack,manager]"
|
|
172
|
+
.venv/bin/python -m pytest -q
|
|
173
|
+
ruff check src tests custom_components scripts
|
|
151
174
|
```
|
|
152
175
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
176
|
+
The complete suite uses Python 3.14 because the consumer and Home Assistant test dependencies require it.
|
|
177
|
+
Install `mosquitto` for broker tests and Node.js for panel JavaScript tests. The command above installs the consumer
|
|
178
|
+
from a sibling checkout and other dependencies from the package index; CI follows the same approach.
|
|
179
|
+
Missing base or test dependencies can fail collection; optional broker, emulator and fixture tests skip when their
|
|
180
|
+
prerequisites are unavailable. The full-stack device comparison uses `../tuya-interpreter/tests/golden`; IL wire and
|
|
181
|
+
topic vectors use `../ildevice/vectors` (or the `ILDEVICE` environment variable). Keep those checkouts for these checks.
|
|
156
182
|
|
|
157
|
-
- `tests/unit/`:
|
|
183
|
+
- `tests/unit/`: configuration validation, host lifecycle, IL vectors, rule loading, settings persistence,
|
|
184
|
+
pack ownership, converter APIs, panel JavaScript and release packaging.
|
|
158
185
|
- `tests/e2e/test_service.py`: the service on in-memory brokers (overrides directory, a failed start).
|
|
159
186
|
- `tests/e2e/test_chain_*.py`, `test_daemon.py`: the runner with ildevice-homeassistant's consumer model on the other side, in one process
|
|
160
187
|
and over a real broker; the daemon as a subprocess.
|
|
@@ -164,8 +191,14 @@ Everything here needs the sibling repos and, for the last two groups, `mosquitto
|
|
|
164
191
|
(`tuyamock`) around the daemon: state, commands, rejections, a device dropping off and returning, a daemon restart, and a
|
|
165
192
|
differential check that 38 real device shapes come out of the whole chain exactly as `tuya-interpreter` computes them.
|
|
166
193
|
|
|
167
|
-
##
|
|
194
|
+
## Documentation
|
|
168
195
|
|
|
169
|
-
|
|
196
|
+
- [Current status](docs/STATUS.md): version, document index and verification boundaries.
|
|
197
|
+
- [Architecture](docs/ARCHITECTURE.md): current components, lifecycle and rule precedence.
|
|
198
|
+
- [Migration](docs/MIGRATING.md): upgrading old converters and rustuya-homeassistant installations.
|
|
199
|
+
- [Code review](docs/REVIEW.md): findings and local verification from 2026-10-13.
|
|
200
|
+
- [Roadmap](docs/ROADMAP.md): outstanding work and real-device checks.
|
|
201
|
+
- [Design history](docs/REDESIGN.md): the September redesign record; historical APIs and versions.
|
|
202
|
+
- [Changelog](CHANGELOG.md): changes grouped by release.
|
|
170
203
|
|
|
171
|
-
Licence:
|
|
204
|
+
Licence: [MIT](LICENSE).
|
{rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/__init__.py
RENAMED
|
@@ -252,13 +252,19 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
|
252
252
|
# setup that follows puts it back anyway; only disabling the entry (or deleting it, below) removes it
|
|
253
253
|
if entry.disabled_by is not None:
|
|
254
254
|
panel.async_remove(hass)
|
|
255
|
-
runtime: RuntimeData = hass.data[DOMAIN]
|
|
255
|
+
runtime: RuntimeData = hass.data[DOMAIN][entry.entry_id]
|
|
256
256
|
restarting = getattr(runtime, "restarting", False) and entry.disabled_by is None
|
|
257
|
-
|
|
257
|
+
try:
|
|
258
|
+
await runtime.service.stop(offline=not restarting)
|
|
259
|
+
finally:
|
|
260
|
+
try:
|
|
261
|
+
if runtime.embedded_bridge:
|
|
262
|
+
await runtime.embedded_bridge.stop()
|
|
263
|
+
finally:
|
|
264
|
+
# A stopped service cannot be reused, even when draining its commands failed.
|
|
265
|
+
hass.data[DOMAIN].pop(entry.entry_id, None)
|
|
258
266
|
if restarting:
|
|
259
267
|
hass.data[RESUME] = True
|
|
260
|
-
if runtime.embedded_bridge:
|
|
261
|
-
await runtime.embedded_bridge.stop()
|
|
262
268
|
return True
|
|
263
269
|
|
|
264
270
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Own the embedded bridge's native async task and verify its MQTT startup signal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import secrets
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
_LOGGER = logging.getLogger(__name__)
|
|
12
|
+
START_TIMEOUT = 15.0
|
|
13
|
+
STOP_TIMEOUT = 10.0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EmbeddedBridge:
|
|
17
|
+
def __init__(self, broker: str, root: str, state_file: str, log_level: str = "warn",
|
|
18
|
+
username: str | None = None, password: str | None = None) -> None:
|
|
19
|
+
self.broker = broker
|
|
20
|
+
self.root = root
|
|
21
|
+
self.state_file = state_file
|
|
22
|
+
self.log_level = log_level
|
|
23
|
+
self.username = username
|
|
24
|
+
self.password = password
|
|
25
|
+
self._server = None
|
|
26
|
+
self._task: asyncio.Future | None = None
|
|
27
|
+
self._stopping = False
|
|
28
|
+
|
|
29
|
+
async def start(self) -> None:
|
|
30
|
+
import pyrustuyabridge as pb
|
|
31
|
+
|
|
32
|
+
from rustuya_local.host.mqtt import MqttTransport
|
|
33
|
+
|
|
34
|
+
if self._task is not None:
|
|
35
|
+
raise RuntimeError("the embedded bridge has already been started")
|
|
36
|
+
address = urlsplit(self.broker)
|
|
37
|
+
observer = MqttTransport(address.hostname, address.port or 1883,
|
|
38
|
+
client_id=f"rustuya-startup-{secrets.token_hex(8)}",
|
|
39
|
+
username=self.username, password=self.password,
|
|
40
|
+
tls=address.scheme == "mqtts")
|
|
41
|
+
ready = asyncio.Event()
|
|
42
|
+
|
|
43
|
+
def on_config(message) -> None:
|
|
44
|
+
# A retained config may belong to an earlier, dead bridge instance.
|
|
45
|
+
if message.retain or not message.payload:
|
|
46
|
+
return
|
|
47
|
+
try:
|
|
48
|
+
config = json.loads(message.payload)
|
|
49
|
+
except (ValueError, UnicodeDecodeError):
|
|
50
|
+
return
|
|
51
|
+
if isinstance(config, dict) and config:
|
|
52
|
+
ready.set()
|
|
53
|
+
|
|
54
|
+
waiter = None
|
|
55
|
+
try:
|
|
56
|
+
async with asyncio.timeout(START_TIMEOUT):
|
|
57
|
+
await observer.connect()
|
|
58
|
+
await observer.subscribe(f"{self.root}/bridge/config", on_config)
|
|
59
|
+
kwargs = {"mqtt_broker": self.broker, "mqtt_root_topic": self.root, "mqtt_retain": True,
|
|
60
|
+
"state_file": self.state_file, "no_signals": True, "log_level": self.log_level}
|
|
61
|
+
if self.username:
|
|
62
|
+
kwargs["mqtt_username"] = self.username
|
|
63
|
+
if self.password:
|
|
64
|
+
kwargs["mqtt_password"] = self.password
|
|
65
|
+
self._server = pb.PyBridgeServer(**kwargs)
|
|
66
|
+
self._stopping = False
|
|
67
|
+
self._task = asyncio.ensure_future(self._server.start_async())
|
|
68
|
+
self._task.add_done_callback(self._exited)
|
|
69
|
+
waiter = asyncio.create_task(ready.wait())
|
|
70
|
+
await asyncio.wait((waiter, self._task), return_when=asyncio.FIRST_COMPLETED)
|
|
71
|
+
if self._task.done():
|
|
72
|
+
await self._task # preserve the native startup exception
|
|
73
|
+
raise RuntimeError("the embedded bridge exited during startup")
|
|
74
|
+
except BaseException:
|
|
75
|
+
await self.stop()
|
|
76
|
+
raise
|
|
77
|
+
finally:
|
|
78
|
+
if waiter is not None:
|
|
79
|
+
waiter.cancel()
|
|
80
|
+
await asyncio.gather(waiter, return_exceptions=True)
|
|
81
|
+
try:
|
|
82
|
+
await observer.close()
|
|
83
|
+
except BaseException:
|
|
84
|
+
# Startup has not returned ownership to the caller yet.
|
|
85
|
+
await self.stop()
|
|
86
|
+
raise
|
|
87
|
+
|
|
88
|
+
def _exited(self, task: asyncio.Future) -> None:
|
|
89
|
+
if task.cancelled():
|
|
90
|
+
return
|
|
91
|
+
error = task.exception()
|
|
92
|
+
if not self._stopping:
|
|
93
|
+
_LOGGER.error("the embedded bridge stopped unexpectedly: %s", error or "server exited")
|
|
94
|
+
|
|
95
|
+
async def stop(self) -> None:
|
|
96
|
+
if self._task is None:
|
|
97
|
+
return
|
|
98
|
+
self._stopping = True
|
|
99
|
+
self._server.stop()
|
|
100
|
+
# Shield the native task: a timed-out shutdown must retain ownership so
|
|
101
|
+
# another stop can still wait for cleanup, rather than losing the server.
|
|
102
|
+
await asyncio.wait_for(asyncio.shield(asyncio.gather(self._task, return_exceptions=True)), STOP_TIMEOUT)
|
|
103
|
+
self._task = None
|
|
104
|
+
self._server = None
|
{rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/config_flow.py
RENAMED
|
@@ -54,8 +54,6 @@ from .const import (
|
|
|
54
54
|
|
|
55
55
|
_LOGGER = logging.getLogger(__name__)
|
|
56
56
|
PROBE_TIMEOUT = 5.0 # seconds each for the broker to accept us and the bridge's retained config to arrive
|
|
57
|
-
TICK = 0.1 # seconds between looks at the login's state while its progress shows
|
|
58
|
-
TICKS = 50 # looks before the progress step is redrawn anyway
|
|
59
57
|
|
|
60
58
|
|
|
61
59
|
def _qr_schema(qr_url: str) -> vol.Schema:
|
|
@@ -164,7 +162,7 @@ class _ManagerSteps:
|
|
|
164
162
|
return self.async_show_progress_done(next_step_id="cloud_wizard_scan")
|
|
165
163
|
return self.async_show_progress(step_id="cloud_wizard_progress", progress_action="cloud_wizard_working",
|
|
166
164
|
description_placeholders={"message": session.message},
|
|
167
|
-
progress_task=self.hass.async_create_task(self.
|
|
165
|
+
progress_task=self.hass.async_create_task(self._async_wait_wizard_state()))
|
|
168
166
|
|
|
169
167
|
async def async_step_cloud_wizard_scan(self, user_input: dict[str, Any] | None = None) -> FlowResult:
|
|
170
168
|
"""`async_show_progress` only takes plain description text -- no selector, so a QR needs an actual form
|
|
@@ -176,18 +174,14 @@ class _ManagerSteps:
|
|
|
176
174
|
return self.async_show_form(step_id="cloud_wizard_scan",
|
|
177
175
|
data_schema=_qr_schema(self._manager.wizard.session.qr_url))
|
|
178
176
|
|
|
179
|
-
async def
|
|
180
|
-
"""
|
|
181
|
-
(`WizardManager._run`), so this just waits for its state to change (or a short cap) so the
|
|
182
|
-
frontend's re-poll of `cloud_wizard_progress` promptly redraws instead of only on its own timer."""
|
|
177
|
+
async def _async_wait_wizard_state(self) -> None:
|
|
178
|
+
"""Wake HA progress when the manager reports a wizard state change."""
|
|
183
179
|
from rustuya_manager.wizard import WizardState
|
|
184
180
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
return
|
|
190
|
-
await asyncio.sleep(TICK)
|
|
181
|
+
wizard = self._manager.wizard
|
|
182
|
+
state = wizard.session.state
|
|
183
|
+
if state not in (WizardState.DONE, WizardState.ERROR):
|
|
184
|
+
await wizard.wait_state_change(state)
|
|
191
185
|
|
|
192
186
|
def _sync_form(self, step_id: str, diff: Any, error: str | None = None) -> FlowResult:
|
|
193
187
|
self._sync_shown = True
|
{rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/manager_session.py
RENAMED
|
@@ -68,9 +68,7 @@ async def open_manager(*, broker: str, root: str, devices_path: str, username: s
|
|
|
68
68
|
# `sync()`'s diff is only meaningful once the bridge's device list has arrived; without this every cloud
|
|
69
69
|
# device looks "missing" (an empty bridge side), which is how a whole device list ended up offered for
|
|
70
70
|
# registration.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
await ready_registry(manager, WAIT)
|
|
71
|
+
await manager.wait_ready(bootstrap_timeout=WAIT, bridge_timeout=WAIT)
|
|
74
72
|
except BaseException:
|
|
75
73
|
await close_manager(manager)
|
|
76
74
|
raise
|
|
@@ -103,7 +101,12 @@ async def open_for_entry(hass: Any, data: dict[str, Any], *, run_bridge: bool =
|
|
|
103
101
|
dirs.append(os.path.dirname(state_file))
|
|
104
102
|
bridge = {"bridge_state": state_file,
|
|
105
103
|
"bridge_log_level": data.get(CONF_BRIDGE_LOG_LEVEL, DEFAULT_BRIDGE_LOG_LEVEL)}
|
|
106
|
-
|
|
104
|
+
def ensure_directories() -> None:
|
|
105
|
+
for directory in dirs:
|
|
106
|
+
if directory:
|
|
107
|
+
os.makedirs(directory, exist_ok=True)
|
|
108
|
+
|
|
109
|
+
await hass.async_add_executor_job(ensure_directories)
|
|
107
110
|
username, password = broker.credentials(data)
|
|
108
111
|
return await open_manager(broker=broker.url(data), root=data[CONF_BRIDGE_ROOT],
|
|
109
112
|
devices_path=data[CONF_DEVICES_PATH], username=username, password=password, **bridge)
|
{rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/manifest.json
RENAMED
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
"requirements": [
|
|
12
12
|
"tuya-interpreter>=0.1.0a2",
|
|
13
13
|
"paho-mqtt>=2.0",
|
|
14
|
-
"rustuya-manager>=0.2.
|
|
14
|
+
"rustuya-manager>=0.2.3",
|
|
15
15
|
"pyrustuyabridge>=0.4",
|
|
16
|
-
"rustuya-local>=0.1.0.
|
|
16
|
+
"rustuya-local>=0.1.0.dev10"
|
|
17
17
|
],
|
|
18
18
|
"single_config_entry": true,
|
|
19
|
-
"version": "0.1.0.
|
|
19
|
+
"version": "0.1.0.dev10"
|
|
20
20
|
}
|