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.
Files changed (111) hide show
  1. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/CHANGELOG.md +29 -2
  2. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/PKG-INFO +47 -14
  3. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/README.md +45 -12
  4. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/__init__.py +10 -4
  5. rustuya_local-0.1.0.dev10/custom_components/rustuya/bridge_supervisor.py +104 -0
  6. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/config_flow.py +7 -13
  7. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/manager_session.py +7 -4
  8. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/manifest.json +3 -3
  9. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/panel.py +71 -66
  10. rustuya_local-0.1.0.dev10/docs/ARCHITECTURE.md +79 -0
  11. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/docs/MIGRATING.md +14 -7
  12. rustuya_local-0.1.0.dev10/docs/REDESIGN.md +385 -0
  13. rustuya_local-0.1.0.dev10/docs/REVIEW.md +108 -0
  14. rustuya_local-0.1.0.dev10/docs/ROADMAP.md +39 -0
  15. rustuya_local-0.1.0.dev10/docs/STATUS.md +41 -0
  16. rustuya_local-0.1.0.dev10/docs/history/ROADMAP-2026-10-12.md +111 -0
  17. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/pyproject.toml +2 -2
  18. rustuya_local-0.1.0.dev10/release-notes.md +9 -0
  19. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/bridge_client.py +38 -5
  20. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/mqtt.py +49 -3
  21. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/pack.py +27 -5
  22. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/runner.py +14 -5
  23. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/transport.py +10 -1
  24. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/test_bridge_registration.py +52 -2
  25. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/test_daemon.py +16 -11
  26. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/ha/fake_manager.py +13 -3
  27. rustuya_local-0.1.0.dev10/tests/ha/test_bridge_supervisor.py +95 -0
  28. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/ha/test_manager_session.py +1 -1
  29. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/ha/test_panel.py +91 -6
  30. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_host.py +4 -2
  31. rustuya_local-0.1.0.dev10/tests/unit/test_mqtt_lifecycle.py +110 -0
  32. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_pack.py +50 -0
  33. rustuya_local-0.1.0.dev7/custom_components/rustuya/bridge_supervisor.py +0 -66
  34. rustuya_local-0.1.0.dev7/custom_components/rustuya/manager_registry.py +0 -71
  35. rustuya_local-0.1.0.dev7/docs/REDESIGN.md +0 -327
  36. rustuya_local-0.1.0.dev7/docs/ROADMAP.md +0 -102
  37. rustuya_local-0.1.0.dev7/docs/STATUS.md +0 -6
  38. rustuya_local-0.1.0.dev7/release-notes.md +0 -3
  39. rustuya_local-0.1.0.dev7/tests/ha/test_manager_registry.py +0 -69
  40. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/.github/dependabot.yml +0 -0
  41. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/.github/workflows/_test.yml +0 -0
  42. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/.github/workflows/ci.yml +0 -0
  43. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/.github/workflows/hacs.yml +0 -0
  44. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/.github/workflows/hassfest.yml +0 -0
  45. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/.github/workflows/release.yml +0 -0
  46. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/.gitignore +0 -0
  47. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/LICENSE +0 -0
  48. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/icon.png +0 -0
  49. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/icon.svg +0 -0
  50. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/icon@2x.png +0 -0
  51. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/logo.png +0 -0
  52. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/logo.svg +0 -0
  53. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/logo@2x.png +0 -0
  54. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/bridge_sync.py +0 -0
  55. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/broker.py +0 -0
  56. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/const.py +0 -0
  57. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/panel_bridge.py +0 -0
  58. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/services.yaml +0 -0
  59. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/strings.json +0 -0
  60. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/translations/en.json +0 -0
  61. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/translations/ko.json +0 -0
  62. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/www/rustuya-panel.js +0 -0
  63. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/hacs.json +0 -0
  64. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/scripts/build_dropin.py +0 -0
  65. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/scripts/release_notes.py +0 -0
  66. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/__init__.py +0 -0
  67. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/__main__.py +0 -0
  68. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/async_tools.py +0 -0
  69. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/cli.py +0 -0
  70. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/config.py +0 -0
  71. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/files.py +0 -0
  72. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/__init__.py +0 -0
  73. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/devices.py +0 -0
  74. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/hub.py +0 -0
  75. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/il.py +0 -0
  76. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/memory.py +0 -0
  77. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/overrides.py +0 -0
  78. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/rules.py +0 -0
  79. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/manager_plugin/__init__.py +0 -0
  80. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/manager_plugin/api.py +0 -0
  81. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/manager_plugin/static/index.js +0 -0
  82. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/service.py +0 -0
  83. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/src/rustuya_local/validation.py +0 -0
  84. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/conftest.py +0 -0
  85. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/devices.py +0 -0
  86. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/test_chain_in_process.py +0 -0
  87. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/test_chain_mqtt.py +0 -0
  88. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/test_full_stack.py +0 -0
  89. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/test_full_stack_custom_templates.py +0 -0
  90. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/test_full_stack_devices.py +0 -0
  91. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/test_manager_plugin.py +0 -0
  92. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/test_purge.py +0 -0
  93. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/e2e/test_service.py +0 -0
  94. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/ha/conftest.py +0 -0
  95. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/ha/test_bridge_probe.py +0 -0
  96. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/ha/test_config_flow.py +0 -0
  97. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/ha/test_init.py +0 -0
  98. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/ha/test_migrate.py +0 -0
  99. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_config.py +0 -0
  100. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_cover_settings.py +0 -0
  101. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_dropin.py +0 -0
  102. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_files.py +0 -0
  103. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_hazardous.py +0 -0
  104. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_hub.py +0 -0
  105. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_il_vectors.py +0 -0
  106. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_link_watcher.py +0 -0
  107. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_panel_js.py +0 -0
  108. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_plugin_api.py +0 -0
  109. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_release_notes.py +0 -0
  110. {rustuya_local-0.1.0.dev7 → rustuya_local-0.1.0.dev10}/tests/unit/test_rule_files.py +0 -0
  111. {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/). 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.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.dev7
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.2; extra == 'manager'
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
- 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
 
@@ -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
@@ -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._async_wizard_tick()))
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 _async_wizard_tick(self) -> None:
180
- """`async_show_progress` needs a task to await; the wizard already runs in the background
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
- session = self._manager.wizard.session
186
- start_state = session.state
187
- for _ in range(TICKS):
188
- if session.state != start_state or session.state in (WizardState.DONE, WizardState.ERROR):
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
@@ -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
- from .manager_registry import ready_registry
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
- await hass.async_add_executor_job(lambda: [os.makedirs(d, exist_ok=True) for d in dirs if d])
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)
@@ -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.2",
14
+ "rustuya-manager>=0.2.3",
15
15
  "pyrustuyabridge>=0.4",
16
- "rustuya-local>=0.1.0.dev7"
16
+ "rustuya-local>=0.1.0.dev10"
17
17
  ],
18
18
  "single_config_entry": true,
19
- "version": "0.1.0.dev7"
19
+ "version": "0.1.0.dev10"
20
20
  }