rustuya-local 0.1.0.dev1__tar.gz → 0.1.0.dev2__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.dev2}/CHANGELOG.md +14 -0
  2. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/PKG-INFO +12 -7
  3. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/README.md +11 -6
  4. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/manifest.json +2 -2
  5. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/www/rustuya-panel.js +1 -1
  6. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/docs/ROADMAP.md +12 -2
  7. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/pyproject.toml +1 -1
  8. rustuya_local-0.1.0.dev2/release-notes.md +11 -0
  9. rustuya_local-0.1.0.dev2/src/rustuya_local/async_tools.py +24 -0
  10. rustuya_local-0.1.0.dev2/src/rustuya_local/files.py +18 -0
  11. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/host/devices.py +36 -13
  12. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/host/hub.py +44 -22
  13. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/host/overrides.py +40 -41
  14. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/host/pack.py +3 -9
  15. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/host/runner.py +43 -22
  16. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/manager_plugin/__init__.py +1 -1
  17. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/manager_plugin/api.py +3 -15
  18. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/service.py +6 -4
  19. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/test_service.py +48 -6
  20. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_cover_settings.py +44 -0
  21. rustuya_local-0.1.0.dev2/tests/unit/test_files.py +31 -0
  22. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_host.py +107 -0
  23. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_rule_files.py +74 -0
  24. rustuya_local-0.1.0.dev1/release-notes.md +0 -12
  25. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/.github/dependabot.yml +0 -0
  26. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/.github/workflows/_test.yml +0 -0
  27. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/.github/workflows/ci.yml +0 -0
  28. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/.github/workflows/hacs.yml +0 -0
  29. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/.github/workflows/hassfest.yml +0 -0
  30. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/.github/workflows/release.yml +0 -0
  31. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/.gitignore +0 -0
  32. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/LICENSE +0 -0
  33. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/__init__.py +0 -0
  34. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/brand/icon.png +0 -0
  35. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/brand/icon.svg +0 -0
  36. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/brand/icon@2x.png +0 -0
  37. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/brand/logo.png +0 -0
  38. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/brand/logo.svg +0 -0
  39. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/brand/logo@2x.png +0 -0
  40. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/bridge_supervisor.py +0 -0
  41. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/bridge_sync.py +0 -0
  42. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/broker.py +0 -0
  43. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/config_flow.py +0 -0
  44. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/const.py +0 -0
  45. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/manager_session.py +0 -0
  46. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/panel.py +0 -0
  47. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/services.yaml +0 -0
  48. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/strings.json +0 -0
  49. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/translations/en.json +0 -0
  50. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/translations/ko.json +0 -0
  51. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/docs/MIGRATING.md +0 -0
  52. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/docs/REDESIGN.md +0 -0
  53. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/docs/STATUS.md +0 -0
  54. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/hacs.json +0 -0
  55. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/scripts/build_dropin.py +0 -0
  56. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/scripts/release_notes.py +0 -0
  57. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/__init__.py +0 -0
  58. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/__main__.py +0 -0
  59. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/bridge_client.py +0 -0
  60. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/cli.py +0 -0
  61. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/config.py +0 -0
  62. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/host/__init__.py +0 -0
  63. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/host/il.py +0 -0
  64. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/host/memory.py +0 -0
  65. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/host/mqtt.py +0 -0
  66. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/host/rules.py +0 -0
  67. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/host/transport.py +0 -0
  68. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/manager_plugin/static/index.js +0 -0
  69. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/validation.py +0 -0
  70. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/conftest.py +0 -0
  71. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/devices.py +0 -0
  72. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/test_bridge_registration.py +0 -0
  73. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/test_chain_in_process.py +0 -0
  74. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/test_chain_mqtt.py +0 -0
  75. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/test_daemon.py +0 -0
  76. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/test_full_stack.py +0 -0
  77. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/test_full_stack_custom_templates.py +0 -0
  78. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/test_full_stack_devices.py +0 -0
  79. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/test_manager_plugin.py +0 -0
  80. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/test_purge.py +0 -0
  81. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/ha/conftest.py +0 -0
  82. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/ha/fake_manager.py +0 -0
  83. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/ha/test_bridge_probe.py +0 -0
  84. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/ha/test_config_flow.py +0 -0
  85. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/ha/test_init.py +0 -0
  86. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/ha/test_manager_session.py +0 -0
  87. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/ha/test_migrate.py +0 -0
  88. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/ha/test_panel.py +0 -0
  89. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_config.py +0 -0
  90. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_dropin.py +0 -0
  91. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_hazardous.py +0 -0
  92. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_hub.py +0 -0
  93. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_il_vectors.py +0 -0
  94. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_link_watcher.py +0 -0
  95. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_pack.py +0 -0
  96. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_panel_js.py +0 -0
  97. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_plugin_api.py +0 -0
  98. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_release_notes.py +0 -0
  99. {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/tuya_samples.py +0 -0
@@ -8,6 +8,20 @@ stable release.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.1.0.dev2] — 2026-10-11
12
+
13
+ ### Fixed
14
+ - Serialize device setting saves and override reloads so simultaneous commands preserve every device's settings,
15
+ including when a save is cancelled during file work.
16
+ - Release a Hub's native rule pack on service shutdown, failed construction, and failed rule replacement; retain
17
+ shared packs until their last owner releases them.
18
+ - Remove Runner reconnect callbacks and subscriptions on shutdown or failed startup, and await background cleanup.
19
+ - Retry transient override-loading failures and read watched device files off the event loop.
20
+
21
+ ### Changed
22
+ - Share atomic file replacement across settings, converter files and the override pack, using unique temporary files.
23
+ - Refresh the roadmap and document the cover motion-source selector and position-source settings.
24
+
11
25
  ## [0.1.0.dev1] — 2026-10-11
12
26
 
13
27
  ### 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.dev2
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
@@ -110,21 +110,26 @@ put there, and leaves a file alone once you edit it.
110
110
 
111
111
  ### Cover settings
112
112
 
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:
113
+ A curtain or blind whose position runs the other way, or whose *opening* / *closing* state is wrong, can be fixed
114
+ from its own configuration switches and motion-source selector. Each appears only where it changes something:
115
115
 
116
- | switch | shown when the cover has | turn it on when |
116
+ | setting | shown when the cover has | use it when |
117
117
  |---|---|---|
118
118
  | *Invert current position* | a position it reports | fully opened, it shows 0% |
119
119
  | *Invert target position* | a target position, separate from the reported one | *Open* closes it |
120
120
  | *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 |
121
+ | *Use target as current position* | distinct reported and target position sources | the target DP should supply the displayed position |
122
+ | *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 |
123
+ | *Invert reported motion* | the `control` motion source | the reported *opening* and *closing* meanings are reversed |
122
124
 
123
- With one position data point for both, *Invert current position* turns both. A switch changes nothing on the device:
125
+ With one position data point for both, *Invert current position* turns both. Motion defaults to `inferred` when
126
+ device settings are enabled, unless an override selects another source. A setting changes nothing on the device:
124
127
  the setting is saved as the device's block in `zz_settings.json` in the converters directory and applied at once, so it
125
128
  survives a restart and can be edited there too. Without a converters directory (a daemon with no
126
129
  `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.
130
+ A block with the same `cover` keys in your own file or the pack sets a model's starting point; the saved settings win.
131
+ Older `cover.infer_motion` and `converters.cover_motion` blocks are upgraded on loading to `cover.state_source`
132
+ (`inferred`, or `none` for `infer_motion: false`) and `cover.settle`; see [migration notes](docs/MIGRATING.md).
128
133
 
129
134
  In Home Assistant the files can be edited from a sidebar panel: the integration's *Configure* → *Add the Rustuya
130
135
  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
@@ -84,21 +84,26 @@ put there, and leaves a file alone once you edit it.
84
84
 
85
85
  ### Cover settings
86
86
 
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:
87
+ A curtain or blind whose position runs the other way, or whose *opening* / *closing* state is wrong, can be fixed
88
+ from its own configuration switches and motion-source selector. Each appears only where it changes something:
89
89
 
90
- | switch | shown when the cover has | turn it on when |
90
+ | setting | shown when the cover has | use it when |
91
91
  |---|---|---|
92
92
  | *Invert current position* | a position it reports | fully opened, it shows 0% |
93
93
  | *Invert target position* | a target position, separate from the reported one | *Open* closes it |
94
94
  | *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 |
95
+ | *Use target as current position* | distinct reported and target position sources | the target DP should supply the displayed position |
96
+ | *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 |
97
+ | *Invert reported motion* | the `control` motion source | the reported *opening* and *closing* meanings are reversed |
96
98
 
97
- With one position data point for both, *Invert current position* turns both. A switch changes nothing on the device:
99
+ With one position data point for both, *Invert current position* turns both. Motion defaults to `inferred` when
100
+ device settings are enabled, unless an override selects another source. A setting changes nothing on the device:
98
101
  the setting is saved as the device's block in `zz_settings.json` in the converters directory and applied at once, so it
99
102
  survives a restart and can be edited there too. Without a converters directory (a daemon with no
100
103
  `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.
104
+ A block with the same `cover` keys in your own file or the pack sets a model's starting point; the saved settings win.
105
+ Older `cover.infer_motion` and `converters.cover_motion` blocks are upgraded on loading to `cover.state_source`
106
+ (`inferred`, or `none` for `infer_motion: false`) and `cover.settle`; see [migration notes](docs/MIGRATING.md).
102
107
 
103
108
  In Home Assistant the files can be edited from a sidebar panel: the integration's *Configure* → *Add the Rustuya
104
109
  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
@@ -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.dev1"
16
+ "rustuya-local>=0.1.0.dev2"
17
17
  ],
18
18
  "single_config_entry": true,
19
- "version": "0.1.0.dev1"
19
+ "version": "0.1.0.dev2"
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
@@ -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,7 +4,7 @@ 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.dev2"
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"
@@ -0,0 +1,11 @@
1
+ ### Fixed
2
+ - Serialize device setting saves and override reloads so simultaneous commands preserve every device's settings,
3
+ including when a save is cancelled during file work.
4
+ - Release a Hub's native rule pack on service shutdown, failed construction, and failed rule replacement; retain
5
+ shared packs until their last owner releases them.
6
+ - Remove Runner reconnect callbacks and subscriptions on shutdown or failed startup, and await background cleanup.
7
+ - Retry transient override-loading failures and read watched device files off the event loop.
8
+
9
+ ### Changed
10
+ - Share atomic file replacement across settings, converter files and the override pack, using unique temporary files.
11
+ - Refresh the roadmap and document the cover motion-source selector and position-source settings.
@@ -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():
@@ -20,8 +20,10 @@ from typing import Any
20
20
 
21
21
  from tuya2ildevice import OverrideError, merge_all
22
22
 
23
- from rustuya_local.host.rules import load_rules_tolerant, upgrade_overrides
23
+ from rustuya_local.host.rules import load_rules_tolerant, rule_files, upgrade_overrides
24
24
 
25
+ from ..async_tools import cancel_task, run_in_thread
26
+ from ..files import atomic_write
25
27
  from .runner import Runner
26
28
 
27
29
  _LOGGER = logging.getLogger(__name__)
@@ -35,15 +37,6 @@ class OverrideSet:
35
37
  warnings: list[str] = field(default_factory=list)
36
38
 
37
39
 
38
- def _files(path: Path) -> list[Path]:
39
- if path.is_file():
40
- return [path]
41
- if not path.is_dir():
42
- return []
43
- return sorted(p for p in path.iterdir()
44
- if p.is_file() and p.suffix == ".json" and p.name[0] not in "._")
45
-
46
-
47
40
  def load_overrides(path: str | Path) -> OverrideSet:
48
41
  """Read a directory (or one `.json` file) of rule files over `TUYA_ENGINE_RULES`. Never raises for a bad file."""
49
42
  locations = [p for p in os.environ.get("TUYA_ENGINE_RULES", "").split(os.pathsep) if p]
@@ -59,13 +52,15 @@ class OverrideWatcher:
59
52
  engine refuses is left out and reported (`load_overrides`); overrides the Hub refuses as a whole (`OverrideError`,
60
53
  a bad `base`) are reported and the ones in effect stay."""
61
54
 
62
- def __init__(self, path: str | Path, runner: Runner, interval: float = 5.0, base: dict | None = None) -> None:
55
+ def __init__(self, path: str | Path, runner: Runner | None, interval: float = 5.0,
56
+ base: dict | None = None) -> None:
63
57
  self.path = Path(path)
64
58
  self.runner = runner
65
59
  self.interval = interval
66
60
  self.base = base or {}
67
61
  self._stamp: Any = None
68
62
  self._task: asyncio.Task | None = None
63
+ self._lock = asyncio.Lock()
69
64
 
70
65
  def load(self) -> OverrideSet:
71
66
  loaded = load_overrides(self.path)
@@ -82,24 +77,25 @@ class OverrideWatcher:
82
77
  async def load_initial(self) -> OverrideSet:
83
78
  """Load what is there now, off the event loop, and remember it as applied: pass the result to the Hub you
84
79
  create, then `watch()`."""
85
- def work() -> OverrideSet:
86
- self._stamp = self._stat()
87
- return self.load()
88
- return await asyncio.to_thread(work)
80
+ async with self._lock:
81
+ self._stamp, loaded = await run_in_thread(self._snapshot)
82
+ return loaded
83
+
84
+ def _snapshot(self) -> tuple[Any, OverrideSet]:
85
+ return self._stat(), self.load()
89
86
 
90
87
  def watch(self) -> None:
91
88
  """Start following the directory from the state last loaded."""
92
89
  if self._task is None:
93
- self._task = asyncio.ensure_future(self._loop())
90
+ self._task = asyncio.create_task(self._loop())
94
91
 
95
92
  async def stop(self) -> None:
96
- if self._task:
97
- self._task.cancel()
98
- self._task = None
93
+ task, self._task = self._task, None
94
+ await cancel_task(task)
99
95
 
100
96
  def _stat(self) -> Any:
101
97
  stamp = []
102
- for p in _files(self.path):
98
+ for p in rule_files([self.path]):
103
99
  try:
104
100
  st = p.stat()
105
101
  except OSError:
@@ -112,8 +108,8 @@ class OverrideWatcher:
112
108
  stamp = self._stat()
113
109
  if stamp == self._stamp:
114
110
  return False
115
- self._stamp = stamp
116
111
  self._apply(self.load())
112
+ self._stamp = stamp
117
113
  return True
118
114
 
119
115
  def _apply(self, loaded: OverrideSet) -> None:
@@ -131,18 +127,18 @@ class OverrideWatcher:
131
127
  if self.path.is_file() or (self.path.suffix == ".json" and not self.path.is_dir()):
132
128
  _LOGGER.warning("overrides are the one file %s: the settings of %s are not saved", self.path, device_id)
133
129
  return
134
- try:
135
- await asyncio.to_thread(self._write_settings, device_id, block)
136
- except Exception:
137
- _LOGGER.exception("could not save the settings of %s in %s", device_id, self.path / SETTINGS_FILE)
138
- return
139
- try:
140
- def work() -> tuple[Any, OverrideSet]:
141
- return self._stat(), self.load()
142
- self._stamp, loaded = await asyncio.to_thread(work)
143
- self._apply(loaded)
144
- except Exception:
145
- _LOGGER.exception("could not apply the settings of %s", device_id)
130
+ async with self._lock:
131
+ try:
132
+ await run_in_thread(self._write_settings, device_id, block)
133
+ except Exception:
134
+ _LOGGER.exception("could not save the settings of %s in %s", device_id, self.path / SETTINGS_FILE)
135
+ return
136
+ try:
137
+ stamp, loaded = await run_in_thread(self._snapshot)
138
+ self._apply(loaded)
139
+ self._stamp = stamp
140
+ except Exception:
141
+ _LOGGER.exception("could not apply the settings of %s", device_id)
146
142
 
147
143
  def _write_settings(self, device_id: str, block: dict) -> None:
148
144
  path = self.path / SETTINGS_FILE
@@ -160,17 +156,20 @@ class OverrideWatcher:
160
156
  entries[device_id] = block
161
157
  else:
162
158
  entries.pop(device_id, None)
163
- tmp = path.with_name(f".{path.name}.tmp") # a dot file: never loaded
164
- tmp.write_text(json.dumps(data, indent=2, sort_keys=True, ensure_ascii=False) + "\n", "utf-8")
165
- os.replace(tmp, path)
159
+ atomic_write(path, json.dumps(data, indent=2, sort_keys=True, ensure_ascii=False) + "\n")
166
160
 
167
161
  async def _loop(self) -> None:
168
162
  # the file work (stat, read) runs in a worker thread: a host such as Home Assistant
169
163
  # must not block its event loop on it; only the reload itself runs on the loop
170
164
  while True:
171
165
  await asyncio.sleep(self.interval)
172
- stamp = await asyncio.to_thread(self._stat)
173
- if stamp == self._stamp:
174
- continue
175
- self._stamp = stamp
176
- self._apply(await asyncio.to_thread(self.load))
166
+ async with self._lock:
167
+ try:
168
+ stamp = await run_in_thread(self._stat)
169
+ if stamp == self._stamp:
170
+ continue
171
+ stamp, loaded = await run_in_thread(self._snapshot)
172
+ self._apply(loaded)
173
+ self._stamp = stamp
174
+ except Exception:
175
+ _LOGGER.exception("could not reload overrides in %s; will retry", self.path)
@@ -26,13 +26,13 @@ from __future__ import annotations
26
26
  import hashlib
27
27
  import json
28
28
  import logging
29
- import os
30
29
  import re
31
30
  import urllib.request
32
31
  from dataclasses import dataclass, field
33
32
  from pathlib import Path
34
33
  from typing import Any
35
34
 
35
+ from ..files import atomic_write
36
36
  from .overrides import SETTINGS_FILE
37
37
 
38
38
  _LOGGER = logging.getLogger(__name__)
@@ -87,12 +87,6 @@ def _sha(data: bytes) -> str:
87
87
  return hashlib.sha256(data).hexdigest()
88
88
 
89
89
 
90
- def _write(path: Path, data: bytes) -> None:
91
- tmp = path.with_name(f".{path.name}.tmp")
92
- tmp.write_bytes(data)
93
- os.replace(tmp, path)
94
-
95
-
96
90
  def fetch_manifest(base_url: str = BASE_URL) -> dict[str, Any]:
97
91
  raw = _download(base_url + MANIFEST)
98
92
  try:
@@ -167,7 +161,7 @@ def sync(directory: str | Path, *, base_url: str = BASE_URL, manifest: dict[str,
167
161
  if _sha(data) != sha:
168
162
  raise PackError(f"{name}: does not match the manifest's hash")
169
163
  _check(name, data)
170
- _write(path, data)
164
+ atomic_write(path, data)
171
165
  except (PackError, OSError) as e:
172
166
  out.failed.append(str(e) if str(e).startswith(name) else f"{name}: {e}")
173
167
  if name in before and current is not None:
@@ -194,7 +188,7 @@ def sync(directory: str | Path, *, base_url: str = BASE_URL, manifest: dict[str,
194
188
 
195
189
  ledger = json.dumps({"version": 1, "files": dict(sorted(owned.items()))}, indent=2) + "\n"
196
190
  if owned or before:
197
- _write(directory / LEDGER, ledger.encode())
191
+ atomic_write(directory / LEDGER, ledger)
198
192
  for kind, names in (("added", out.added), ("updated", out.updated), ("removed", out.removed)):
199
193
  if names:
200
194
  _LOGGER.info("pack: %s %s", kind, ", ".join(names))