rustuya-local 0.1.0.dev1__tar.gz → 0.1.0.dev3__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 (99) hide show
  1. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/CHANGELOG.md +23 -0
  2. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/PKG-INFO +17 -11
  3. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/README.md +15 -9
  4. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/manifest.json +3 -3
  5. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/www/rustuya-panel.js +1 -1
  6. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/docs/MIGRATING.md +2 -2
  7. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/docs/ROADMAP.md +12 -2
  8. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/pyproject.toml +2 -2
  9. rustuya_local-0.1.0.dev3/release-notes.md +6 -0
  10. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/scripts/build_dropin.py +4 -1
  11. rustuya_local-0.1.0.dev3/src/rustuya_local/async_tools.py +24 -0
  12. rustuya_local-0.1.0.dev3/src/rustuya_local/files.py +18 -0
  13. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/devices.py +36 -13
  14. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/hub.py +44 -22
  15. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/overrides.py +41 -42
  16. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/pack.py +7 -13
  17. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/rules.py +21 -8
  18. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/runner.py +43 -22
  19. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/manager_plugin/__init__.py +1 -1
  20. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/manager_plugin/api.py +3 -15
  21. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/service.py +12 -5
  22. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/test_service.py +63 -10
  23. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_cover_settings.py +44 -0
  24. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_dropin.py +1 -1
  25. rustuya_local-0.1.0.dev3/tests/unit/test_files.py +31 -0
  26. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_host.py +107 -0
  27. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_pack.py +1 -1
  28. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_rule_files.py +101 -1
  29. rustuya_local-0.1.0.dev1/release-notes.md +0 -12
  30. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/.github/dependabot.yml +0 -0
  31. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/.github/workflows/_test.yml +0 -0
  32. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/.github/workflows/ci.yml +0 -0
  33. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/.github/workflows/hacs.yml +0 -0
  34. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/.github/workflows/hassfest.yml +0 -0
  35. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/.github/workflows/release.yml +0 -0
  36. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/.gitignore +0 -0
  37. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/LICENSE +0 -0
  38. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/__init__.py +0 -0
  39. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/brand/icon.png +0 -0
  40. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/brand/icon.svg +0 -0
  41. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/brand/icon@2x.png +0 -0
  42. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/brand/logo.png +0 -0
  43. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/brand/logo.svg +0 -0
  44. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/brand/logo@2x.png +0 -0
  45. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/bridge_supervisor.py +0 -0
  46. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/bridge_sync.py +0 -0
  47. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/broker.py +0 -0
  48. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/config_flow.py +0 -0
  49. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/const.py +0 -0
  50. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/manager_session.py +0 -0
  51. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/panel.py +0 -0
  52. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/services.yaml +0 -0
  53. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/strings.json +0 -0
  54. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/translations/en.json +0 -0
  55. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/translations/ko.json +0 -0
  56. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/docs/REDESIGN.md +0 -0
  57. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/docs/STATUS.md +0 -0
  58. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/hacs.json +0 -0
  59. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/scripts/release_notes.py +0 -0
  60. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/__init__.py +0 -0
  61. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/__main__.py +0 -0
  62. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/bridge_client.py +0 -0
  63. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/cli.py +0 -0
  64. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/config.py +0 -0
  65. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/__init__.py +0 -0
  66. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/il.py +0 -0
  67. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/memory.py +0 -0
  68. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/mqtt.py +0 -0
  69. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/transport.py +0 -0
  70. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/manager_plugin/static/index.js +0 -0
  71. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/validation.py +0 -0
  72. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/conftest.py +0 -0
  73. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/devices.py +0 -0
  74. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/test_bridge_registration.py +0 -0
  75. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/test_chain_in_process.py +0 -0
  76. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/test_chain_mqtt.py +0 -0
  77. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/test_daemon.py +0 -0
  78. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/test_full_stack.py +0 -0
  79. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/test_full_stack_custom_templates.py +0 -0
  80. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/test_full_stack_devices.py +0 -0
  81. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/test_manager_plugin.py +0 -0
  82. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/test_purge.py +0 -0
  83. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/ha/conftest.py +0 -0
  84. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/ha/fake_manager.py +0 -0
  85. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/ha/test_bridge_probe.py +0 -0
  86. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/ha/test_config_flow.py +0 -0
  87. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/ha/test_init.py +0 -0
  88. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/ha/test_manager_session.py +0 -0
  89. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/ha/test_migrate.py +0 -0
  90. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/ha/test_panel.py +0 -0
  91. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_config.py +0 -0
  92. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_hazardous.py +0 -0
  93. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_hub.py +0 -0
  94. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_il_vectors.py +0 -0
  95. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_link_watcher.py +0 -0
  96. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_panel_js.py +0 -0
  97. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_plugin_api.py +0 -0
  98. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_release_notes.py +0 -0
  99. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/tuya_samples.py +0 -0
@@ -8,6 +8,29 @@ stable release.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.1.0.dev3] — 2026-10-11
12
+
13
+ ### Changed
14
+ - Require tuya2ildevice 0.4.0.dev4 in the package and Home Assistant integration.
15
+ - Load bundled official rules from `rules/builtin/`, exclude the manifest from rule layers, and apply user files after official rules regardless of filename.
16
+ - Fetch the complete official pack into `rules/builtin/` inside the overrides directory and watch it for live reloads.
17
+ - Retire unedited files owned by the old flat override pack while preserving user edits.
18
+ - Include the official rule layers and manifest in the manager drop-in archive.
19
+
20
+ ## [0.1.0.dev2] — 2026-10-11
21
+
22
+ ### Fixed
23
+ - Serialize device setting saves and override reloads so simultaneous commands preserve every device's settings,
24
+ including when a save is cancelled during file work.
25
+ - Release a Hub's native rule pack on service shutdown, failed construction, and failed rule replacement; retain
26
+ shared packs until their last owner releases them.
27
+ - Remove Runner reconnect callbacks and subscriptions on shutdown or failed startup, and await background cleanup.
28
+ - Retry transient override-loading failures and read watched device files off the event loop.
29
+
30
+ ### Changed
31
+ - Share atomic file replacement across settings, converter files and the override pack, using unique temporary files.
32
+ - Refresh the roadmap and document the cover motion-source selector and position-source settings.
33
+
11
34
  ## [0.1.0.dev1] — 2026-10-11
12
35
 
13
36
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: rustuya-local
3
- Version: 0.1.0.dev1
3
+ Version: 0.1.0.dev3
4
4
  Summary: Local Tuya control: rustuya-bridge -> tuya2ildevice -> 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
@@ -10,7 +10,7 @@ License-File: LICENSE
10
10
  Requires-Python: >=3.13
11
11
  Requires-Dist: paho-mqtt>=2.0
12
12
  Requires-Dist: pyrustuyabridge>=0.4
13
- Requires-Dist: tuya2ildevice>=0.4.0.dev3
13
+ Requires-Dist: tuya2ildevice>=0.4.0.dev4
14
14
  Provides-Extra: fullstack
15
15
  Requires-Dist: tuyamock; extra == 'fullstack'
16
16
  Provides-Extra: manager
@@ -103,28 +103,34 @@ config; integration: `<config>/rustuya_converters`; manager plugin: its data dir
103
103
  - Converters are JSON programs under `converters.declarative`; Python code plugins are not loaded.
104
104
 
105
105
  See tuya2ildevice's README ("User overrides") for the format. A curated set ships in tuya2ildevice, and fixes published
106
- between its releases (its [override pack](https://github.com/3735943886/tuya2ildevice/tree/master/rules/pack)) are copied
107
- into the directory at start and daily, as `00_pack_*` files that your own files refine. Turn that off with `"pack":
108
- false` (daemon), the Rustuya panel's *Options* (integration), or the plugin tab. The pack only replaces or removes the files it
106
+ between its releases (its [override pack](https://github.com/3735943886/tuya2ildevice/tree/master/rules/builtin)) are copied
107
+ into `rules/builtin/` inside the overrides directory at start and daily. These include `00-default.json`; your own
108
+ files are applied after all official rules. The bundled defaults update with tuya2ildevice, even when pack sync is off.
109
+ Turn online updates off with `"pack": false` (daemon), the Rustuya panel's *Options* (integration), or the plugin tab. The pack only replaces or removes the files it
109
110
  put there, and leaves a file alone once you edit it.
110
111
 
111
112
  ### Cover settings
112
113
 
113
- A curtain or blind whose position runs the other way, or that never shows *opening* / *closing*, is fixed from its
114
- own configuration switches, not a file. Each appears only where it changes something:
114
+ A curtain or blind whose position runs the other way, or whose *opening* / *closing* state is wrong, can be fixed
115
+ from its own configuration switches and motion-source selector. Each appears only where it changes something:
115
116
 
116
- | switch | shown when the cover has | turn it on when |
117
+ | setting | shown when the cover has | use it when |
117
118
  |---|---|---|
118
119
  | *Invert current position* | a position it reports | fully opened, it shows 0% |
119
120
  | *Invert target position* | a target position, separate from the reported one | *Open* closes it |
120
121
  | *Invert control* | an open / close command and no target position | *Open* closes it |
121
- | *Infer motion* (on by default) | a reported position and a target or a command | off: when *opening* / *closing* is wrong |
122
+ | *Use target as current position* | distinct reported and target position sources | the target DP should supply the displayed position |
123
+ | *Motion state source* | a position or control DP, with no custom converter owning the motion state | choose `inferred` for motion inference, `control` for control reports, or `none` to disable motion generation; `control` requires a control DP |
124
+ | *Invert reported motion* | the `control` motion source | the reported *opening* and *closing* meanings are reversed |
122
125
 
123
- With one position data point for both, *Invert current position* turns both. A switch changes nothing on the device:
126
+ With one position data point for both, *Invert current position* turns both. Motion defaults to `inferred` when
127
+ device settings are enabled, unless an override selects another source. A setting changes nothing on the device:
124
128
  the setting is saved as the device's block in `zz_settings.json` in the converters directory and applied at once, so it
125
129
  survives a restart and can be edited there too. Without a converters directory (a daemon with no
126
130
  `custom_converters`) the switches are not offered; `"options": {"device_settings": false}` turns them off.
127
- A block with the same `cover` keys in your own file or the pack sets a model's starting point; the switches win.
131
+ A block with the same `cover` keys in your own file or the pack sets a model's starting point; the saved settings win.
132
+ Older `cover.infer_motion` and `converters.cover_motion` blocks are upgraded on loading to `cover.state_source`
133
+ (`inferred`, or `none` for `infer_motion: false`) and `cover.settle`; see [migration notes](docs/MIGRATING.md).
128
134
 
129
135
  In Home Assistant the files can be edited from a sidebar panel: the integration's *Configure* → *Add the Rustuya
130
136
  panel to the sidebar* adds it and links to it. The panel is for administrators. It fetches the device list from Tuya Cloud, shows the
@@ -77,28 +77,34 @@ config; integration: `<config>/rustuya_converters`; manager plugin: its data dir
77
77
  - Converters are JSON programs under `converters.declarative`; Python code plugins are not loaded.
78
78
 
79
79
  See tuya2ildevice's README ("User overrides") for the format. A curated set ships in tuya2ildevice, and fixes published
80
- between its releases (its [override pack](https://github.com/3735943886/tuya2ildevice/tree/master/rules/pack)) are copied
81
- into the directory at start and daily, as `00_pack_*` files that your own files refine. Turn that off with `"pack":
82
- false` (daemon), the Rustuya panel's *Options* (integration), or the plugin tab. The pack only replaces or removes the files it
80
+ between its releases (its [override pack](https://github.com/3735943886/tuya2ildevice/tree/master/rules/builtin)) are copied
81
+ into `rules/builtin/` inside the overrides directory at start and daily. These include `00-default.json`; your own
82
+ files are applied after all official rules. The bundled defaults update with tuya2ildevice, even when pack sync is off.
83
+ Turn online updates off with `"pack": false` (daemon), the Rustuya panel's *Options* (integration), or the plugin tab. The pack only replaces or removes the files it
83
84
  put there, and leaves a file alone once you edit it.
84
85
 
85
86
  ### Cover settings
86
87
 
87
- A curtain or blind whose position runs the other way, or that never shows *opening* / *closing*, is fixed from its
88
- own configuration switches, not a file. Each appears only where it changes something:
88
+ A curtain or blind whose position runs the other way, or whose *opening* / *closing* state is wrong, can be fixed
89
+ from its own configuration switches and motion-source selector. Each appears only where it changes something:
89
90
 
90
- | switch | shown when the cover has | turn it on when |
91
+ | setting | shown when the cover has | use it when |
91
92
  |---|---|---|
92
93
  | *Invert current position* | a position it reports | fully opened, it shows 0% |
93
94
  | *Invert target position* | a target position, separate from the reported one | *Open* closes it |
94
95
  | *Invert control* | an open / close command and no target position | *Open* closes it |
95
- | *Infer motion* (on by default) | a reported position and a target or a command | off: when *opening* / *closing* is wrong |
96
+ | *Use target as current position* | distinct reported and target position sources | the target DP should supply the displayed position |
97
+ | *Motion state source* | a position or control DP, with no custom converter owning the motion state | choose `inferred` for motion inference, `control` for control reports, or `none` to disable motion generation; `control` requires a control DP |
98
+ | *Invert reported motion* | the `control` motion source | the reported *opening* and *closing* meanings are reversed |
96
99
 
97
- With one position data point for both, *Invert current position* turns both. A switch changes nothing on the device:
100
+ With one position data point for both, *Invert current position* turns both. Motion defaults to `inferred` when
101
+ device settings are enabled, unless an override selects another source. A setting changes nothing on the device:
98
102
  the setting is saved as the device's block in `zz_settings.json` in the converters directory and applied at once, so it
99
103
  survives a restart and can be edited there too. Without a converters directory (a daemon with no
100
104
  `custom_converters`) the switches are not offered; `"options": {"device_settings": false}` turns them off.
101
- A block with the same `cover` keys in your own file or the pack sets a model's starting point; the switches win.
105
+ A block with the same `cover` keys in your own file or the pack sets a model's starting point; the saved settings win.
106
+ Older `cover.infer_motion` and `converters.cover_motion` blocks are upgraded on loading to `cover.state_source`
107
+ (`inferred`, or `none` for `infer_motion: false`) and `cover.settle`; see [migration notes](docs/MIGRATING.md).
102
108
 
103
109
  In Home Assistant the files can be edited from a sidebar panel: the integration's *Configure* → *Add the Rustuya
104
110
  panel to the sidebar* adds it and links to it. The panel is for administrators. It fetches the device list from Tuya Cloud, shows the
@@ -9,12 +9,12 @@
9
9
  "iot_class": "local_push",
10
10
  "issue_tracker": "https://github.com/3735943886/rustuya-local/issues",
11
11
  "requirements": [
12
- "tuya2ildevice>=0.4.0.dev3",
12
+ "tuya2ildevice>=0.4.0.dev4",
13
13
  "paho-mqtt>=2.0",
14
14
  "rustuya-manager>=0.2.2",
15
15
  "pyrustuyabridge>=0.4",
16
- "rustuya-local>=0.1.0.dev1"
16
+ "rustuya-local>=0.1.0.dev3"
17
17
  ],
18
18
  "single_config_entry": true,
19
- "version": "0.1.0.dev1"
19
+ "version": "0.1.0.dev3"
20
20
  }
@@ -3,7 +3,7 @@
3
3
  // <config>/rustuya_converters and the override pack (the rustuya-manager plugin tab's file API).
4
4
  //
5
5
  // Home Assistant sets `hass`, `narrow` and `panel` on the element; every call goes through hass.callApi, which carries
6
- // the user's token. The views are for administrators only (converters run code in Home Assistant's process).
6
+ // the user's token. The views are for administrators only (rule files change how devices are driven).
7
7
 
8
8
  const ORIGIN = { pack: "origin_pack", pack_edited: "origin_pack_edited" }; // I18N keys
9
9
  const PACK_CHANGES = ["added", "updated", "removed", "kept"]; // a pack sync's lists, in this order
@@ -69,8 +69,8 @@ rustuya-manager plugin. It is retired. Its replacement is two pieces:
69
69
  - `converters.cover_motion` and a cover's `infer_motion` are read as the cover's `state_source` (`inferred`, or
70
70
  `none` for `infer_motion: false`), and a `cover_motion` `settle` as the cover's `settle`; its other keys are
71
71
  dropped with a warning. `zz_settings.json` is rewritten in the new form the next time a setting is saved.
72
- - Leave the old pack (`00_default.json`, `00_curtain.py`, `.rustuya_pack.json`) out. Fixes arrive as `00_pack_*`
73
- files by themselves.
72
+ - Leave the old pack (`00_default.json`, `00_curtain.py`, `.rustuya_pack.json`) out. Official rules ship with tuya2ildevice; online updates arrive under
73
+ `rules/builtin/` automatically. User files are applied after all official rules.
74
74
 
75
75
  Changes to the directory are picked up while running, no restart needed.
76
76
 
@@ -1,6 +1,16 @@
1
- # 남은 일 (2026-09-28 기준)
1
+ # 남은 일 (2026-10-11 갱신)
2
2
 
3
- 재설계(REDESIGN.md 9장)는 끝났다. 현재 배포: tuya2ildevice 0.3.16, rustuya-local 0.0.45(PyPI, HACS, 매니저 카탈로그 drop-in), rustuya-manager 0.2.1, pyrustuyabridge 0.4.0(PyPI).
3
+ 현재 작업 트리는 `rustuya-local 0.1.0.dev2`이며 `tuya2ildevice>=0.4.0.dev3`, `pyrustuyabridge>=0.4`를 요구한다.
4
+ HA 통합과 매니저 플러그인 테스트는 `rustuya-manager>=0.2.2`를 사용한다. 변환은 Sans-I/O Rust 드라이버가 맡고,
5
+ 기기 레지스트리·IL MQTT 어댑터·전송·타이머·규칙 파일 로딩은 `rustuya_local.host`로 옮겼다.
6
+ 버전별 변경은 [CHANGELOG.md](../CHANGELOG.md)에 기록한다. 이 문서는 실제 배포 서버의 최신 상태를 확인하는 자료가 아니다.
7
+
8
+ 2026-10-11 코드 검토 후 동시 설정 저장을 직렬화하고, Hub 규칙 팩과 Runner 재연결 콜백의 종료 처리를 보강했다.
9
+ 파일 원자적 교체와 비동기 작업 종료 처리를 공통화했고, 기기 목록 감시의 파일 읽기를 이벤트 루프 밖으로 옮겼다.
10
+ 실기기 검증은 아래 M2에 기록된 범위이며, 이번 코드·자동 테스트 검증으로 완료 처리하지 않는다.
11
+
12
+ 아래 마일스톤의 완료 항목은 2026-09-28까지의 이력이다. 당시 배포 기준은 tuya2ildevice 0.3.16,
13
+ rustuya-local 0.0.45, rustuya-manager 0.2.1, pyrustuyabridge 0.4.0이었다.
4
14
  rustuya-homeassistant 는 폐기 안내만 푸시했다(아카이브 안 함). 2026-09-27 에 저장소 이름을 rustuya-homeassistant 로 바꿨다가 같은 날
5
15
  되돌렸다(HA 전용이 아니고 이름이 여러 개면 헷갈림): 저장소·PyPI·import·CLI·플러그인 id 모두 `rustuya-local`.
6
16
  아래는 그 뒤에 남은 것을 마일스톤 순서로 정리한 것이다. 저장소 표기: **T** tuya2ildevice, **L** rustuya-local, **H** il-ha,
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "rustuya-local"
7
- version = "0.1.0.dev1"
7
+ version = "0.1.0.dev3"
8
8
  description = "Local Tuya control: rustuya-bridge -> tuya2ildevice -> IL (ildevice). HA-independent core."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
11
11
  license = "MIT"
12
- dependencies = ["tuya2ildevice>=0.4.0.dev3", "pyrustuyabridge>=0.4", "paho-mqtt>=2.0"]
12
+ dependencies = ["tuya2ildevice>=0.4.0.dev4", "pyrustuyabridge>=0.4", "paho-mqtt>=2.0"]
13
13
  # rustuya-manager (custom_components/rustuya's config flow) is optional at the package level (the HA manifest
14
14
  # pulls it in); declared here too so the venv used for this repo's tests has it. pyrustuyabridge is a base dependency
15
15
  # now (not just for the embedded bridge): rustuya-local's own bridge_client uses it to interpret rustuya-bridge's
@@ -0,0 +1,6 @@
1
+ ### Changed
2
+ - Require tuya2ildevice 0.4.0.dev4 in the package and Home Assistant integration.
3
+ - Load bundled official rules from `rules/builtin/`, exclude the manifest from rule layers, and apply user files after official rules regardless of filename.
4
+ - Fetch the complete official pack into `rules/builtin/` inside the overrides directory and watch it for live reloads.
5
+ - Retire unedited files owned by the old flat override pack while preserving user edits.
6
+ - Include the official rule layers and manifest in the manager drop-in archive.
@@ -58,7 +58,10 @@ def _members() -> list[tuple[str, Path]]:
58
58
  members = dict(out)
59
59
  members.setdefault(f"{prefix}/{native.name}", native)
60
60
  for layer in rule_files(rule_paths()[:1]): # the rule layers a wheel bundles, nothing else
61
- members.setdefault(f"{prefix}/rules/{layer.name}", layer)
61
+ members.setdefault(f"{prefix}/rules/builtin/{layer.name}", layer)
62
+ manifest = rule_paths()[0] / 'manifest.json'
63
+ if manifest.is_file():
64
+ members.setdefault(f"{prefix}/rules/builtin/manifest.json", manifest)
62
65
  out = list(members.items())
63
66
  return sorted(out)
64
67
 
@@ -0,0 +1,24 @@
1
+ """Small lifecycle helpers for background tasks and file work."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ from collections.abc import Callable
6
+
7
+
8
+ async def cancel_task(task: asyncio.Task | None) -> None:
9
+ """Cancel an owned task and wait until its cleanup has finished."""
10
+ if task is not None:
11
+ task.cancel()
12
+ with contextlib.suppress(asyncio.CancelledError):
13
+ await task
14
+
15
+
16
+ async def run_in_thread[**P, T](function: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
17
+ """Finish file work before propagating cancellation; cancelling cannot stop its thread."""
18
+ task = asyncio.create_task(asyncio.to_thread(function, *args, **kwargs))
19
+ try:
20
+ return await asyncio.shield(task)
21
+ except asyncio.CancelledError:
22
+ # Do not release a caller's lock or close its resources while the worker still uses them.
23
+ await asyncio.gather(task, return_exceptions=True)
24
+ raise
@@ -0,0 +1,18 @@
1
+ """Atomic replacement shared by settings, converter files and the override pack."""
2
+
3
+ import os
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+
8
+ def atomic_write(path: Path, data: str | bytes) -> None:
9
+ """Replace a file using a unique sibling temporary file; preserve it if writing fails."""
10
+ payload = data.encode("utf-8") if isinstance(data, str) else data
11
+ fd, name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
12
+ temporary = Path(name)
13
+ try:
14
+ with os.fdopen(fd, "wb") as stream:
15
+ stream.write(payload)
16
+ os.replace(temporary, path)
17
+ finally:
18
+ temporary.unlink(missing_ok=True)
@@ -7,13 +7,25 @@ from __future__ import annotations
7
7
  import asyncio
8
8
  import json
9
9
  import logging
10
+ from dataclasses import dataclass
10
11
  from pathlib import Path
12
+ from typing import Protocol
11
13
 
12
- from .runner import Runner
14
+ from ..async_tools import cancel_task, run_in_thread
13
15
 
14
16
  _LOGGER = logging.getLogger(__name__)
15
17
 
16
18
 
19
+ class DeviceRegistry(Protocol):
20
+ def sync_devices(self, records: list[dict]) -> object: ...
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class _DeviceUpdate:
25
+ stamp: tuple[int, int] | None
26
+ records: list[dict] | None
27
+
28
+
17
29
  def parse_devices(raw: str | bytes) -> tuple[list[dict], list[str]]:
18
30
  """(usable records, ids skipped). The file is a list of records or a dict of them; a record without an `id` or a
19
31
  `category` cannot be turned into an IL device (there is nothing to classify it by)."""
@@ -43,7 +55,7 @@ class DeviceWatcher:
43
55
  """Follow the device file: a new record is added to the runner, a changed one replaced, a removed one removed.
44
56
  A file that cannot be read or parsed right now (half written by something else) leaves things as they are."""
45
57
 
46
- def __init__(self, path: str | Path, runner: Runner, interval: float = 5.0) -> None:
58
+ def __init__(self, path: str | Path, runner: DeviceRegistry, interval: float = 5.0) -> None:
47
59
  self.path = Path(path)
48
60
  self.runner = runner
49
61
  self.interval = interval
@@ -51,13 +63,13 @@ class DeviceWatcher:
51
63
  self._task: asyncio.Task | None = None
52
64
 
53
65
  def start(self) -> None:
54
- self._stamp = self._stat()
55
- self._task = asyncio.ensure_future(self._loop())
66
+ if self._task is None:
67
+ self._stamp = self._stat()
68
+ self._task = asyncio.create_task(self._loop())
56
69
 
57
70
  async def stop(self) -> None:
58
- if self._task:
59
- self._task.cancel()
60
- self._task = None
71
+ task, self._task = self._task, None
72
+ await cancel_task(task)
61
73
 
62
74
  def _stat(self) -> tuple[int, int] | None:
63
75
  try:
@@ -68,20 +80,31 @@ class DeviceWatcher:
68
80
 
69
81
  def check(self) -> bool:
70
82
  """Sync once if the file changed since the last look; True if it did."""
83
+ return self._apply(self._read_changes())
84
+
85
+ def _read_changes(self) -> _DeviceUpdate | None:
71
86
  stamp = self._stat()
72
87
  if stamp is None or stamp == self._stamp:
73
- return False
74
- self._stamp = stamp
88
+ return None
75
89
  try:
76
90
  records = load_devices(self.path)
77
91
  except (OSError, ValueError):
78
92
  _LOGGER.warning("could not read %s; keeping the devices as they are", self.path)
79
- self._stamp = None # look again next time
80
- return True
81
- self.runner.sync_devices(records)
93
+ return _DeviceUpdate(None, None) # look again next time
94
+ return _DeviceUpdate(stamp, records)
95
+
96
+ def _apply(self, update: _DeviceUpdate | None) -> bool:
97
+ if update is None:
98
+ return False
99
+ if update.records is not None:
100
+ self.runner.sync_devices(update.records)
101
+ self._stamp = update.stamp
82
102
  return True
83
103
 
84
104
  async def _loop(self) -> None:
85
105
  while True:
86
106
  await asyncio.sleep(self.interval)
87
- self.check()
107
+ try:
108
+ self._apply(await run_in_thread(self._read_changes))
109
+ except Exception:
110
+ _LOGGER.exception("could not sync devices from %s; will retry", self.path)
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import time
5
+ import weakref
5
6
  from collections import Counter
6
7
  from collections.abc import Sequence
7
8
  from typing import Any
@@ -27,6 +28,12 @@ from .rules import upgrade_overrides
27
28
  _pack_users: Counter[str] = Counter()
28
29
 
29
30
 
31
+ def _acquire(rules: dict) -> str:
32
+ rules_id = register_rules(rules)
33
+ _pack_users[rules_id] += 1
34
+ return rules_id
35
+
36
+
30
37
  def _release(rules_id: str) -> None:
31
38
  _pack_users[rules_id] -= 1
32
39
  if _pack_users[rules_id] <= 0:
@@ -42,16 +49,27 @@ class Hub:
42
49
  self.il = il or IlTopics()
43
50
  self._kw = {**driver_kwargs, 'rules': rules if rules is not None else default_rules()}
44
51
  self._kw['overrides'] = upgrade_overrides(self._kw.get('overrides'))
45
- self._rules_id = register_rules(self._kw['rules'])
46
- _pack_users[self._rules_id] += 1
47
52
  for device in devices:
48
53
  self._check_id(device['id'])
49
54
  self._devices = {d['id']: d for d in devices}
50
- self.drivers = {i: self._create(d, self._kw) for i, d in self._devices.items()}
55
+ self.drivers: dict[str, TuyaDriver] = {}
56
+ self._rules_id = _acquire(self._kw['rules'])
57
+ self._release_rules = weakref.finalize(self, _release, self._rules_id)
58
+ try:
59
+ self.drivers = {i: TuyaDriver(d, **self._kw) for i, d in self._devices.items()}
60
+ except BaseException:
61
+ self.close()
62
+ raise
51
63
 
52
- @staticmethod
53
- def _create(device, options):
54
- return TuyaDriver(device, **options)
64
+ def close(self) -> None:
65
+ """Release this Hub's drivers and rule pack once; stop() only publishes presence."""
66
+ self.drivers.clear()
67
+ self._devices.clear()
68
+ self._release_rules()
69
+
70
+ def _check_open(self) -> None:
71
+ if not self._release_rules.alive:
72
+ raise RuntimeError('Hub is closed')
55
73
 
56
74
  @property
57
75
  def records(self) -> dict[str, dict]:
@@ -87,8 +105,9 @@ class Hub:
87
105
 
88
106
  def set_device(self, device: dict, seed: Sequence[Connected | Disconnected | Message] = (),
89
107
  now: float | None = None) -> list:
108
+ self._check_open()
90
109
  self._check_id(device['id'])
91
- new = self._create(device, self._kw)
110
+ new = TuyaDriver(device, **self._kw)
92
111
  old = self.drivers.get(device['id'])
93
112
  if not seed and old is not None and old.linked:
94
113
  seed = [Connected()]
@@ -110,19 +129,27 @@ class Hub:
110
129
  *self.il.clear(device_id, old.descriptor['props'], descriptor=True)]
111
130
 
112
131
  def reload(self, overrides: dict | None, *, rules: dict | None = None, now: float | None = None) -> list:
132
+ self._check_open()
113
133
  options = {**self._kw, 'overrides': upgrade_overrides(overrides)}
114
- rules_id = self._rules_id
115
134
  if rules is not None:
116
135
  options['rules'] = rules
117
- rules_id = register_rules(rules)
136
+ rules_id = _acquire(options['rules'])
118
137
  try:
119
- fresh = {i: self._create(d, options) for i, d in self._devices.items()}
120
- except Exception:
121
- if not _pack_users[rules_id]: # refused: a pack nobody took is not kept
122
- unregister_rules(rules_id)
138
+ fresh = {i: TuyaDriver(d, **options) for i, d in self._devices.items()}
139
+ actions, replacements = self._replacements(fresh, options['rules'], time.time() if now is None else now)
140
+ except BaseException:
141
+ _release(rules_id)
123
142
  raise
143
+ self.drivers.update(replacements)
144
+ self._kw = options
145
+ self._release_rules()
146
+ self._rules_id = rules_id
147
+ self._release_rules = weakref.finalize(self, _release, rules_id)
148
+ return actions
149
+
150
+ def _replacements(self, fresh: dict[str, TuyaDriver], rules: dict,
151
+ now: float) -> tuple[list, dict[str, TuyaDriver]]:
124
152
  actions = []
125
- now = time.time() if now is None else now
126
153
  replacements = {}
127
154
  for device_id, new in fresh.items():
128
155
  old = self.drivers[device_id]
@@ -131,17 +158,11 @@ class Hub:
131
158
  seed = [Connected()] if old.linked else []
132
159
  if old.synced:
133
160
  seed.append(Message('state', old.dps))
134
- actions.extend(self._adopt(old, new, seed, now, rules=options['rules']))
161
+ actions.extend(self._adopt(old, new, seed, now, rules=rules))
135
162
  if old.linked and not old.synced:
136
163
  actions.append(BridgeCommand(device_id, 'get'))
137
164
  replacements[device_id] = new
138
- self.drivers.update(replacements)
139
- self._kw = options
140
- if rules_id != self._rules_id: # every driver was replaced: none uses the old pack now
141
- _pack_users[rules_id] += 1
142
- _release(self._rules_id)
143
- self._rules_id = rules_id
144
- return actions
165
+ return actions, replacements
145
166
 
146
167
  def subscriptions(self) -> list[Subscribe]:
147
168
  return [Subscribe(IL, self.il.set_subscription())]
@@ -150,6 +171,7 @@ class Hub:
150
171
  return Publish(IL, self.il.presence, 'online' if online else 'offline', True)
151
172
 
152
173
  def start(self, now: float | None = None) -> list:
174
+ self._check_open()
153
175
  now = time.time() if now is None else now
154
176
  actions = [self.presence(True)]
155
177
  for device_id, driver in self.drivers.items():