rustuya-local 0.1.0.dev7__tar.gz → 0.1.0.dev8__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 (109) hide show
  1. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/CHANGELOG.md +17 -2
  2. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/PKG-INFO +46 -13
  3. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/README.md +45 -12
  4. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/custom_components/rustuya/__init__.py +10 -4
  5. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/custom_components/rustuya/manifest.json +2 -2
  6. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/custom_components/rustuya/panel.py +56 -50
  7. rustuya_local-0.1.0.dev8/docs/ARCHITECTURE.md +78 -0
  8. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/docs/MIGRATING.md +14 -7
  9. rustuya_local-0.1.0.dev8/docs/REDESIGN.md +385 -0
  10. rustuya_local-0.1.0.dev8/docs/REVIEW.md +108 -0
  11. rustuya_local-0.1.0.dev8/docs/ROADMAP.md +38 -0
  12. rustuya_local-0.1.0.dev8/docs/STATUS.md +41 -0
  13. rustuya_local-0.1.0.dev8/docs/history/ROADMAP-2026-10-12.md +111 -0
  14. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/pyproject.toml +1 -1
  15. rustuya_local-0.1.0.dev8/release-notes.md +12 -0
  16. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/src/rustuya_local/host/mqtt.py +49 -3
  17. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/src/rustuya_local/host/pack.py +27 -5
  18. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/src/rustuya_local/host/transport.py +10 -1
  19. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/ha/test_panel.py +72 -0
  20. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/unit/test_host.py +4 -2
  21. rustuya_local-0.1.0.dev8/tests/unit/test_mqtt_lifecycle.py +110 -0
  22. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/unit/test_pack.py +50 -0
  23. rustuya_local-0.1.0.dev7/docs/REDESIGN.md +0 -327
  24. rustuya_local-0.1.0.dev7/docs/ROADMAP.md +0 -102
  25. rustuya_local-0.1.0.dev7/docs/STATUS.md +0 -6
  26. rustuya_local-0.1.0.dev7/release-notes.md +0 -3
  27. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/.github/dependabot.yml +0 -0
  28. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/.github/workflows/_test.yml +0 -0
  29. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/.github/workflows/ci.yml +0 -0
  30. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/.github/workflows/hacs.yml +0 -0
  31. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/.github/workflows/hassfest.yml +0 -0
  32. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/.github/workflows/release.yml +0 -0
  33. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/.gitignore +0 -0
  34. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/LICENSE +0 -0
  35. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/custom_components/rustuya/brand/icon.png +0 -0
  36. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/custom_components/rustuya/brand/icon.svg +0 -0
  37. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/custom_components/rustuya/brand/icon@2x.png +0 -0
  38. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/custom_components/rustuya/brand/logo.png +0 -0
  39. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/custom_components/rustuya/brand/logo.svg +0 -0
  40. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/custom_components/rustuya/brand/logo@2x.png +0 -0
  41. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/custom_components/rustuya/bridge_supervisor.py +0 -0
  42. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/custom_components/rustuya/bridge_sync.py +0 -0
  43. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/custom_components/rustuya/broker.py +0 -0
  44. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/custom_components/rustuya/config_flow.py +0 -0
  45. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/custom_components/rustuya/const.py +0 -0
  46. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/custom_components/rustuya/manager_registry.py +0 -0
  47. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/custom_components/rustuya/manager_session.py +0 -0
  48. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/custom_components/rustuya/panel_bridge.py +0 -0
  49. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/custom_components/rustuya/services.yaml +0 -0
  50. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/custom_components/rustuya/strings.json +0 -0
  51. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/custom_components/rustuya/translations/en.json +0 -0
  52. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/custom_components/rustuya/translations/ko.json +0 -0
  53. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/custom_components/rustuya/www/rustuya-panel.js +0 -0
  54. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/hacs.json +0 -0
  55. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/scripts/build_dropin.py +0 -0
  56. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/scripts/release_notes.py +0 -0
  57. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/src/rustuya_local/__init__.py +0 -0
  58. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/src/rustuya_local/__main__.py +0 -0
  59. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/src/rustuya_local/async_tools.py +0 -0
  60. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/src/rustuya_local/bridge_client.py +0 -0
  61. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/src/rustuya_local/cli.py +0 -0
  62. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/src/rustuya_local/config.py +0 -0
  63. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/src/rustuya_local/files.py +0 -0
  64. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/src/rustuya_local/host/__init__.py +0 -0
  65. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/src/rustuya_local/host/devices.py +0 -0
  66. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/src/rustuya_local/host/hub.py +0 -0
  67. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/src/rustuya_local/host/il.py +0 -0
  68. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/src/rustuya_local/host/memory.py +0 -0
  69. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/src/rustuya_local/host/overrides.py +0 -0
  70. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/src/rustuya_local/host/rules.py +0 -0
  71. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/src/rustuya_local/host/runner.py +0 -0
  72. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/src/rustuya_local/manager_plugin/__init__.py +0 -0
  73. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/src/rustuya_local/manager_plugin/api.py +0 -0
  74. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/src/rustuya_local/manager_plugin/static/index.js +0 -0
  75. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/src/rustuya_local/service.py +0 -0
  76. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/src/rustuya_local/validation.py +0 -0
  77. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/e2e/conftest.py +0 -0
  78. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/e2e/devices.py +0 -0
  79. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/e2e/test_bridge_registration.py +0 -0
  80. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/e2e/test_chain_in_process.py +0 -0
  81. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/e2e/test_chain_mqtt.py +0 -0
  82. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/e2e/test_daemon.py +0 -0
  83. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/e2e/test_full_stack.py +0 -0
  84. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/e2e/test_full_stack_custom_templates.py +0 -0
  85. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/e2e/test_full_stack_devices.py +0 -0
  86. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/e2e/test_manager_plugin.py +0 -0
  87. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/e2e/test_purge.py +0 -0
  88. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/e2e/test_service.py +0 -0
  89. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/ha/conftest.py +0 -0
  90. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/ha/fake_manager.py +0 -0
  91. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/ha/test_bridge_probe.py +0 -0
  92. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/ha/test_config_flow.py +0 -0
  93. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/ha/test_init.py +0 -0
  94. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/ha/test_manager_registry.py +0 -0
  95. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/ha/test_manager_session.py +0 -0
  96. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/ha/test_migrate.py +0 -0
  97. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/unit/test_config.py +0 -0
  98. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/unit/test_cover_settings.py +0 -0
  99. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/unit/test_dropin.py +0 -0
  100. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/unit/test_files.py +0 -0
  101. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/unit/test_hazardous.py +0 -0
  102. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/unit/test_hub.py +0 -0
  103. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/unit/test_il_vectors.py +0 -0
  104. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/unit/test_link_watcher.py +0 -0
  105. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/unit/test_panel_js.py +0 -0
  106. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/unit/test_plugin_api.py +0 -0
  107. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/unit/test_release_notes.py +0 -0
  108. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/unit/test_rule_files.py +0 -0
  109. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev8}/tests/unit/tuya_samples.py +0 -0
@@ -3,11 +3,26 @@
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/). Versions are `0.0.x` until the first
7
- stable release.
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.dev8] — 2026-10-13
12
+
13
+ ### Fixed
14
+ - Always stop the embedded bridge during Home Assistant unload, including service-stop failure or cancellation.
15
+ - Close MQTT callback tasks, ignore queued dispatch after shutdown, retrieve asynchronous handler errors and stop
16
+ the paho thread outside the event loop, including when a connection attempt fails.
17
+ - Preserve concurrent partial Home Assistant settings/options saves by locking reads, merges and restart together.
18
+ - Reject unsafe pack ownership paths, malformed hashes and bounds; preserve host settings and symlink targets.
19
+
20
+ ### Documentation
21
+ - Separate current status, architecture and outstanding work from the historical redesign record.
22
+ - Document the 2026-10-13 code review, verification results and remaining reliability issues.
23
+ - Correct daemon setup, test prerequisites and saved cover settings precedence.
24
+ - Translate all Korean Markdown documents into English, including archived design and roadmap history.
25
+
11
26
  ## [0.1.0.dev7] — 2026-10-12
12
27
 
13
28
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: rustuya-local
3
- Version: 0.1.0.dev7
3
+ Version: 0.1.0.dev8
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
@@ -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
- uv venv && uv pip install -e .
51
- rustuya-local run --config config.json
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; the saved settings win.
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
- .venv/bin/python -m pytest
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
- Everything here needs the sibling repos and, for the last two groups, `mosquitto`, `pyrustuya-bridge` and `tuyamock`
180
- (`pip install -e ".[test,fullstack]"`); those are skipped when missing. Parity with Home Assistant core's fixtures lives in
181
- `tuya-interpreter/tests/chain`, the wire and topic vectors in `ildevice/vectors`.
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/`: the configuration.
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
- ## Status
220
+ ## Documentation
194
221
 
195
- Planning, progress and open decisions: `docs/REDESIGN.md`.
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: `LICENSE` (MIT).
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
- uv venv && uv pip install -e .
25
- rustuya-local run --config config.json
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; the saved settings win.
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
- .venv/bin/python -m pytest
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
- Everything here needs the sibling repos and, for the last two groups, `mosquitto`, `pyrustuya-bridge` and `tuyamock`
154
- (`pip install -e ".[test,fullstack]"`); those are skipped when missing. Parity with Home Assistant core's fixtures lives in
155
- `tuya-interpreter/tests/chain`, the wire and topic vectors in `ildevice/vectors`.
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/`: the configuration.
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
- ## Status
194
+ ## Documentation
168
195
 
169
- Planning, progress and open decisions: `docs/REDESIGN.md`.
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: `LICENSE` (MIT).
204
+ Licence: [MIT](LICENSE).
@@ -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].pop(entry.entry_id)
255
+ runtime: RuntimeData = hass.data[DOMAIN][entry.entry_id]
256
256
  restarting = getattr(runtime, "restarting", False) and entry.disabled_by is None
257
- await runtime.service.stop(offline=not restarting)
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
 
@@ -13,8 +13,8 @@
13
13
  "paho-mqtt>=2.0",
14
14
  "rustuya-manager>=0.2.2",
15
15
  "pyrustuyabridge>=0.4",
16
- "rustuya-local>=0.1.0.dev7"
16
+ "rustuya-local>=0.1.0.dev8"
17
17
  ],
18
18
  "single_config_entry": true,
19
- "version": "0.1.0.dev7"
19
+ "version": "0.1.0.dev8"
20
20
  }
@@ -221,11 +221,12 @@ class OptionsView(_View):
221
221
  or not all(isinstance(v, bool) for v in body.values())):
222
222
  return self.json_message(f"the options are {sorted(DEFAULT_OPTIONS)}, each true or false",
223
223
  HTTPStatus.BAD_REQUEST)
224
- options = {**entry.options, **body}
225
- if options == dict(entry.options):
226
- return self.json({"restarting": False})
227
- await _restart(request.app["hass"], entry, options=options) # answered once it has restarted
228
- return self.json({"restarting": True})
224
+ async with _settings_lock(request.app["hass"]):
225
+ options = {**entry.options, **body}
226
+ if options == dict(entry.options):
227
+ return self.json({"restarting": False})
228
+ await _restart(request.app["hass"], entry, options=options) # answered once it has restarted
229
+ return self.json({"restarting": True})
229
230
 
230
231
 
231
232
  def settings_of(data: Any) -> dict[str, Any]:
@@ -275,24 +276,30 @@ class SettingsView(_View):
275
276
  @_entry_view
276
277
  async def put(self, request: web.Request, entry: Any, runtime: Any) -> web.Response:
277
278
  hass = request.app["hass"]
278
- try:
279
- new = validate_settings(hass, entry.data, await request.json())
280
- except ValueError as e:
281
- return self.json_message(str(e), HTTPStatus.BAD_REQUEST)
282
- old = dict(entry.data)
283
- if new == settings_of(old):
284
- return self.json({"restarting": False})
285
- external = old.get(CONF_BRIDGE_MODE) != BRIDGE_EMBEDDED
286
- if external and new[CONF_BRIDGE_ROOT] != old[CONF_BRIDGE_ROOT]:
287
- from .config_flow import _probe_bridge
288
-
289
- error = await _probe_bridge(hass, {**old, **new})
290
- if error is not None:
291
- return self.json_message(
292
- f"no rustuya-bridge answers on {new[CONF_BRIDGE_ROOT]}" if error == "bridge_not_found"
293
- else "cannot connect to the broker", HTTPStatus.BAD_REQUEST)
294
- await _apply(hass, entry, old, {**old, **new}) # answered once it has restarted
295
- return self.json({"restarting": True})
279
+ body = await _json_body(request)
280
+ async with _settings_lock(hass):
281
+ try:
282
+ new = validate_settings(hass, entry.data, body)
283
+ except ValueError as e:
284
+ return self.json_message(str(e), HTTPStatus.BAD_REQUEST)
285
+ old = dict(entry.data)
286
+ if new == settings_of(old):
287
+ return self.json({"restarting": False})
288
+ external = old.get(CONF_BRIDGE_MODE) != BRIDGE_EMBEDDED
289
+ if external and new[CONF_BRIDGE_ROOT] != old[CONF_BRIDGE_ROOT]:
290
+ from .config_flow import _probe_bridge
291
+
292
+ error = await _probe_bridge(hass, {**old, **new})
293
+ if error is not None:
294
+ return self.json_message(
295
+ f"no rustuya-bridge answers on {new[CONF_BRIDGE_ROOT]}" if error == "bridge_not_found"
296
+ else "cannot connect to the broker", HTTPStatus.BAD_REQUEST)
297
+ await _apply(hass, entry, old, {**old, **new}) # answered once it has restarted
298
+ return self.json({"restarting": True})
299
+
300
+
301
+ def _settings_lock(hass: HomeAssistant) -> asyncio.Lock:
302
+ return hass.data.setdefault(f"{DOMAIN}_settings_lock", asyncio.Lock())
296
303
 
297
304
 
298
305
  async def _apply(hass: HomeAssistant, entry: Any, old: dict[str, Any], data: dict[str, Any]) -> None:
@@ -318,34 +325,33 @@ async def _restart(hass: HomeAssistant, entry: Any, *, data: dict[str, Any] | No
318
325
  """Stop the entry, run `between`, store the new data / options, start it again — all awaited, so the panel's save
319
326
  answers once the restart is over (the page then reloads onto the restarted entry, not onto one about to go).
320
327
  Stopped, the entry has no update listener, so storing does not start a second reload. An options-only restart
321
- keeps the IL presence online across it (`mark_restart`); new data may move the broker or the IL topics."""
322
- lock = hass.data.setdefault(f"{DOMAIN}_settings_lock", asyncio.Lock())
323
- async with lock:
324
- old_data, old_options = dict(entry.data), dict(entry.options)
325
- if data is None:
326
- from . import mark_restart
327
-
328
- mark_restart(hass, entry)
329
- if not await hass.config_entries.async_unload(entry.entry_id):
330
- raise RestartFailed("Could not stop the integration; settings were not changed")
328
+ keeps the IL presence online across it (`mark_restart`); new data may move the broker or the IL topics.
329
+ The request handler holds `_settings_lock` from reading the current entry through this restart."""
330
+ old_data, old_options = dict(entry.data), dict(entry.options)
331
+ if data is None:
332
+ from . import mark_restart
333
+
334
+ mark_restart(hass, entry)
335
+ if not await hass.config_entries.async_unload(entry.entry_id):
336
+ raise RestartFailed("Could not stop the integration; settings were not changed")
337
+ try:
338
+ if between is not None:
339
+ await between()
340
+ changes = {k: v for k, v in (("data", data), ("options", options)) if v is not None}
341
+ hass.config_entries.async_update_entry(entry, **changes)
342
+ if not await hass.config_entries.async_setup(entry.entry_id):
343
+ raise RestartFailed("The integration could not start with these settings")
344
+ except Exception as e:
345
+ # Failed setup leaves SETUP_ERROR/SETUP_RETRY, which cannot be set up
346
+ # directly. Unload also cancels any scheduled retry with the bad data.
347
+ await hass.config_entries.async_unload(entry.entry_id)
348
+ hass.config_entries.async_update_entry(entry, data=old_data, options=old_options)
331
349
  try:
332
- if between is not None:
333
- await between()
334
- changes = {k: v for k, v in (("data", data), ("options", options)) if v is not None}
335
- hass.config_entries.async_update_entry(entry, **changes)
336
- if not await hass.config_entries.async_setup(entry.entry_id):
337
- raise RestartFailed("The integration could not start with these settings")
338
- except Exception as e:
339
- # Failed setup leaves SETUP_ERROR/SETUP_RETRY, which cannot be set up
340
- # directly. Unload also cancels any scheduled retry with the bad data.
341
- await hass.config_entries.async_unload(entry.entry_id)
342
- hass.config_entries.async_update_entry(entry, data=old_data, options=old_options)
343
- try:
344
- restored = await hass.config_entries.async_setup(entry.entry_id)
345
- except Exception: # noqa: BLE001 -- keep the recovery API usable even if rollback startup fails
346
- restored = False
347
- suffix = "Previous settings restored" if restored else "Previous settings restored, but startup still fails"
348
- raise RestartFailed(f"{e}. {suffix}; settings remain editable") from e
350
+ restored = await hass.config_entries.async_setup(entry.entry_id)
351
+ except Exception: # noqa: BLE001 -- keep the recovery API usable even if rollback startup fails
352
+ restored = False
353
+ suffix = "Previous settings restored" if restored else "Previous settings restored, but startup still fails"
354
+ raise RestartFailed(f"{e}. {suffix}; settings remain editable") from e
349
355
 
350
356
 
351
357
  class PackView(_View):
@@ -0,0 +1,78 @@
1
+ # Current architecture
2
+
3
+ Baseline: 2026-10-13, `rustuya-local 0.1.0.dev8`.
4
+ See [README](../README.md) for installation and [REDESIGN](REDESIGN.md) for earlier designs.
5
+
6
+ ```text
7
+ Tuya LAN devices
8
+ ↕
9
+ rustuya-bridge / embedded pyrustuyabridge
10
+ ↕ MQTT (topic and payload templates announced by the bridge)
11
+ BridgeClient ↔ Runner ↔ Hub ↔ tuya-interpreter TuyaDriver
12
+ ↕
13
+ IL MQTT transport
14
+ ↕
15
+ ildevice-homeassistant / other IL consumers
16
+ ```
17
+
18
+ ## Components
19
+
20
+ | Location | Responsibility |
21
+ |---|---|
22
+ | `src/rustuya_local/service.py` | Shared assembly, startup/shutdown, pack updates, retained cleanup, raw DP commands |
23
+ | `bridge_client.py` | Bridge templates, paginated registry snapshots, intersection of registered and configured devices |
24
+ | `host/hub.py` | Driver registry, replacement/reload, native rule-pack reference management |
25
+ | `host/runner.py` | IL subscriptions and publish queue, timers, presence/probes, settings callbacks |
26
+ | `host/il.py` | Mapping driver outputs to IL MQTT topics and values |
27
+ | `host/rules.py`, `overrides.py` | Rule discovery/merging, legacy cover keys, file watching and settings persistence |
28
+ | `host/pack.py` | Official rule downloads, hash verification and ownership ledger |
29
+ | `host/mqtt.py`, `memory.py` | paho MQTT and in-process test transports |
30
+ | `cli.py`, `config.py` | Standalone daemon and configuration validation |
31
+ | `manager_plugin/` | Supervision by the manager, settings/file APIs and web tab |
32
+ | `custom_components/rustuya/` | HA config entry, embedded bridge, login/registration and administrator panel |
33
+ | External `tuya-interpreter` | Single-device Tuya DP interpretation, command validation and rule composition |
34
+
35
+ HA entity creation belongs to the IL consumer. The base package does not import Home Assistant.
36
+
37
+ ## Startup and shutdown
38
+
39
+ `Service.start()` prepares the native library and rules in worker threads, then connects the bridge and IL transports
40
+ and creates the Hub. It refuses startup if a producer with the same IL prefix/source answers a probe. This is a
41
+ preflight check, not an atomic distributed lock. After a complete bridge registry snapshot, it advertises devices
42
+ present in both the device file and registry and clears stale IL topics left by the previous run. Pages are checked
43
+ for order, counts and duplicates, but the bridge has no request ID for complete correlation with other requests.
44
+
45
+ Normal shutdown marks presence offline and leaves descriptors/state retained. Restarting within the same namespace
46
+ preserves presence. Moving namespaces or running `purge` clears the previous retained topics. `AsyncExitStack` manages
47
+ service resources. HA unload always attempts embedded-bridge shutdown, even if service shutdown fails or is cancelled,
48
+ and then removes the unusable runtime. The resume marker is set only after successful cleanup.
49
+
50
+ MQTT close blocks new dispatch, cancels and awaits asynchronous handlers, and stops the paho thread outside the event
51
+ loop. Cancelling close still waits for its cleanup before propagating cancellation. Asynchronous handler errors are
52
+ retrieved and logged. Closed transports cannot be connected, subscribed or published through again.
53
+
54
+ ## Rule and settings precedence
55
+
56
+ 1. Bundled `rules/builtin/*.json` from `tuya-interpreter`.
57
+ 2. Downloaded `rules/builtin/*.json` below the configured rule directories.
58
+ 3. JSON files from `TUYA_ENGINE_RULES` and user directories, merged by filename byte order.
59
+ 4. Inline service `hub_options.overrides`; the daemon calls this `options.overrides`.
60
+
61
+ Later values override earlier ones. `manifest.json` is excluded from rule layers, and directory files starting with
62
+ `.` or `_` are skipped. `zz_settings.json` is a normal user layer: later filenames or inline overrides can replace
63
+ its settings. Invalid user layers are skipped with warnings. A rejected Hub reload preserves the existing drivers.
64
+
65
+ Automatic pack updates run at startup and daily; manual updates also work when automatic updates are disabled.
66
+ Only files with matching ownership hashes are replaced or removed; user edits are preserved. Manifest and ledger
67
+ entries must have safe filenames and SHA-256 hashes. Host settings, manifests and symlink targets cannot be owned
68
+ by the pack. Each managed file is checked against the directory boundary.
69
+
70
+ The watcher must run to apply downloaded files to an active Hub. The daemon's `watch_interval: 0` disables polling.
71
+ IL settings saves use a separate lock and atomic replacement, then reload immediately. HA settings/options API
72
+ requests share a lock covering the current-entry read, patch merge, validation, probing and restart.
73
+
74
+ ## Verification and coupling
75
+
76
+ Unit tests, in-process services, real MQTT brokers, Tuya emulators and HA integration tests verify different
77
+ boundaries. They do not replace real LAN-device tests. Driver state internals and the manager's private registry API
78
+ remain coupling points that need checking when dependencies change. See [REVIEW](REVIEW.md) for fixes and limits.
@@ -1,4 +1,9 @@
1
- # Moving to tuya-interpreter
1
+ # Migration guide
2
+
3
+ For the current component boundaries and file precedence, see [Architecture](ARCHITECTURE.md).
4
+ Use [README](../README.md) for installation and [Changelog](../CHANGELOG.md) for version-specific changes.
5
+
6
+ ## Moving to rustuya-local 0.1 / tuya-interpreter
2
7
 
3
8
  The device file, MQTT configuration and IL topics stay the same. Custom files in the converters directory now use
4
9
  the rule-layer format. Wrap an existing product/device override mapping in `{"overrides": ...}`. For example:
@@ -7,13 +12,15 @@ the rule-layer format. Wrap an existing product/device override mapping in `{"ov
7
12
  {"overrides": {"product_id": {"cover": {"invert_position": true}}}}
8
13
  ```
9
14
 
10
- Apply the same conversion to `zz_settings.json` to preserve cover settings saved by an older version. Leave
15
+ Back up the converters directory before editing. Apply the same conversion to `zz_settings.json` to preserve cover settings saved by an older version. Leave
11
16
  `options.overrides` in the daemon configuration as a product/device mapping; the host still accepts that format.
12
17
  Rewrite Python converters as JSON programs under `converters.declarative`. Invalid JSON rule layers are reported
13
18
  and skipped, so check the logs after upgrading. The managed override pack updates automatically when enabled;
14
- files you edited remain yours to convert.
19
+ files you edited remain yours to convert. JSON files use filename order, including `zz_settings.json`; check for later
20
+ files or inline overrides that replace saved settings. See the cover compatibility details below for `infer_motion`
21
+ and `converters.cover_motion`.
15
22
 
16
- # Moving from rustuya-homeassistant
23
+ ## Moving from rustuya-homeassistant
17
24
 
18
25
  rustuya-homeassistant (`rustuya-ha`) published Home Assistant MQTT discovery for your Tuya devices, as a CLI or a
19
26
  rustuya-manager plugin. It is retired. Its replacement is two pieces:
@@ -24,7 +31,7 @@ rustuya-manager plugin. It is retired. Its replacement is two pieces:
24
31
  - **[ildevice-homeassistant](https://github.com/3735943886/ildevice-homeassistant)** (HACS) turns IL devices
25
32
  into Home Assistant entities. MQTT discovery is no longer used.
26
33
 
27
- ## What changes for you
34
+ ### What changes for you
28
35
 
29
36
  - **Entity ids change.** The old entities belonged to Home Assistant's `mqtt` integration; the new ones belong to
30
37
  `ildevice`, and Home Assistant keeps the two apart. Automations, scripts and dashboards that name the old entities
@@ -34,7 +41,7 @@ rustuya-manager plugin. It is retired. Its replacement is two pieces:
34
41
  with more, fewer or differently split entities than v1 made.
35
42
  - **Availability** comes from rustuya-local's presence: when it stops, its devices show unavailable.
36
43
 
37
- ## Steps
44
+ ### Steps
38
45
 
39
46
  1. **Remove the old discovery** while `rustuya-ha` still runs, so Home Assistant drops those entities:
40
47
 
@@ -65,7 +72,7 @@ rustuya-manager plugin. It is retired. Its replacement is two pieces:
65
72
  A property the tables do not build is defined from its dp in `props`.
66
73
  - A curtain's `invert_position` / `invert_set_position`, reversed command words and derived state are not files any
67
74
  more: each cover has configuration switches for them (README, *Cover settings*).
68
- - Rewrite Python converters as JSON programs under `converters.declarative`; Python plugins are not loaded in 0.4.
75
+ - Rewrite Python converters as JSON programs under `converters.declarative`; Python plugins are not loaded by rustuya-local 0.1.
69
76
  - `converters.cover_motion` and a cover's `infer_motion` are read as the cover's `state_source` (`inferred`, or
70
77
  `none` for `infer_motion: false`), and a `cover_motion` `settle` as the cover's `settle`; its other keys are
71
78
  dropped with a warning. `zz_settings.json` is rewritten in the new form the next time a setting is saved.