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.
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/CHANGELOG.md +14 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/PKG-INFO +12 -7
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/README.md +11 -6
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/manifest.json +2 -2
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/www/rustuya-panel.js +1 -1
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/docs/ROADMAP.md +12 -2
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/pyproject.toml +1 -1
- rustuya_local-0.1.0.dev2/release-notes.md +11 -0
- rustuya_local-0.1.0.dev2/src/rustuya_local/async_tools.py +24 -0
- rustuya_local-0.1.0.dev2/src/rustuya_local/files.py +18 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/host/devices.py +36 -13
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/host/hub.py +44 -22
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/host/overrides.py +40 -41
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/host/pack.py +3 -9
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/host/runner.py +43 -22
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/manager_plugin/__init__.py +1 -1
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/manager_plugin/api.py +3 -15
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/service.py +6 -4
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/test_service.py +48 -6
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_cover_settings.py +44 -0
- rustuya_local-0.1.0.dev2/tests/unit/test_files.py +31 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_host.py +107 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_rule_files.py +74 -0
- rustuya_local-0.1.0.dev1/release-notes.md +0 -12
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/.github/dependabot.yml +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/.github/workflows/_test.yml +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/.github/workflows/ci.yml +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/.github/workflows/hacs.yml +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/.github/workflows/hassfest.yml +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/.github/workflows/release.yml +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/.gitignore +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/LICENSE +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/__init__.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/brand/icon.png +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/brand/icon.svg +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/brand/icon@2x.png +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/brand/logo.png +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/brand/logo.svg +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/brand/logo@2x.png +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/bridge_supervisor.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/bridge_sync.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/broker.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/config_flow.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/const.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/manager_session.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/panel.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/services.yaml +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/strings.json +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/translations/en.json +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/translations/ko.json +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/docs/MIGRATING.md +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/docs/REDESIGN.md +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/docs/STATUS.md +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/hacs.json +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/scripts/build_dropin.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/scripts/release_notes.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/__init__.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/__main__.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/bridge_client.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/cli.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/config.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/host/__init__.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/host/il.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/host/memory.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/host/mqtt.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/host/rules.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/host/transport.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/manager_plugin/static/index.js +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/src/rustuya_local/validation.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/conftest.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/devices.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/test_bridge_registration.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/test_chain_in_process.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/test_chain_mqtt.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/test_daemon.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/test_full_stack.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/test_full_stack_custom_templates.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/test_full_stack_devices.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/test_manager_plugin.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/e2e/test_purge.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/ha/conftest.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/ha/fake_manager.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/ha/test_bridge_probe.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/ha/test_config_flow.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/ha/test_init.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/ha/test_manager_session.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/ha/test_migrate.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/ha/test_panel.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_config.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_dropin.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_hazardous.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_hub.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_il_vectors.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_link_watcher.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_pack.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_panel_js.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_plugin_api.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/tests/unit/test_release_notes.py +0 -0
- {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.
|
|
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
|
|
114
|
-
own configuration switches
|
|
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
|
-
|
|
|
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
|
-
| *
|
|
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.
|
|
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
|
|
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
|
|
88
|
-
own configuration switches
|
|
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
|
-
|
|
|
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
|
-
| *
|
|
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.
|
|
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
|
|
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
|
{rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev2}/custom_components/rustuya/www/rustuya-panel.js
RENAMED
|
@@ -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 (
|
|
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-
|
|
1
|
+
# 남은 일 (2026-10-11 갱신)
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
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
|
|
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:
|
|
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.
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 =
|
|
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
|
-
|
|
136
|
+
rules_id = _acquire(options['rules'])
|
|
118
137
|
try:
|
|
119
|
-
fresh = {i:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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=
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
86
|
-
self._stamp = self.
|
|
87
|
-
return
|
|
88
|
-
|
|
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.
|
|
90
|
+
self._task = asyncio.create_task(self._loop())
|
|
94
91
|
|
|
95
92
|
async def stop(self) -> None:
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|