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.
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/CHANGELOG.md +23 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/PKG-INFO +17 -11
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/README.md +15 -9
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/manifest.json +3 -3
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/www/rustuya-panel.js +1 -1
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/docs/MIGRATING.md +2 -2
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/docs/ROADMAP.md +12 -2
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/pyproject.toml +2 -2
- rustuya_local-0.1.0.dev3/release-notes.md +6 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/scripts/build_dropin.py +4 -1
- rustuya_local-0.1.0.dev3/src/rustuya_local/async_tools.py +24 -0
- rustuya_local-0.1.0.dev3/src/rustuya_local/files.py +18 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/devices.py +36 -13
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/hub.py +44 -22
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/overrides.py +41 -42
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/pack.py +7 -13
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/rules.py +21 -8
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/runner.py +43 -22
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/manager_plugin/__init__.py +1 -1
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/manager_plugin/api.py +3 -15
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/service.py +12 -5
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/test_service.py +63 -10
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_cover_settings.py +44 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_dropin.py +1 -1
- rustuya_local-0.1.0.dev3/tests/unit/test_files.py +31 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_host.py +107 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_pack.py +1 -1
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_rule_files.py +101 -1
- rustuya_local-0.1.0.dev1/release-notes.md +0 -12
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/.github/dependabot.yml +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/.github/workflows/_test.yml +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/.github/workflows/ci.yml +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/.github/workflows/hacs.yml +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/.github/workflows/hassfest.yml +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/.github/workflows/release.yml +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/.gitignore +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/LICENSE +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/__init__.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/brand/icon.png +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/brand/icon.svg +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/brand/icon@2x.png +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/brand/logo.png +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/brand/logo.svg +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/brand/logo@2x.png +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/bridge_supervisor.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/bridge_sync.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/broker.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/config_flow.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/const.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/manager_session.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/panel.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/services.yaml +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/strings.json +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/translations/en.json +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/translations/ko.json +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/docs/REDESIGN.md +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/docs/STATUS.md +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/hacs.json +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/scripts/release_notes.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/__init__.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/__main__.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/bridge_client.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/cli.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/config.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/__init__.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/il.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/memory.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/mqtt.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/transport.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/manager_plugin/static/index.js +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/src/rustuya_local/validation.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/conftest.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/devices.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/test_bridge_registration.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/test_chain_in_process.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/test_chain_mqtt.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/test_daemon.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/test_full_stack.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/test_full_stack_custom_templates.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/test_full_stack_devices.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/test_manager_plugin.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/e2e/test_purge.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/ha/conftest.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/ha/fake_manager.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/ha/test_bridge_probe.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/ha/test_config_flow.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/ha/test_init.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/ha/test_manager_session.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/ha/test_migrate.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/ha/test_panel.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_config.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_hazardous.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_hub.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_il_vectors.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_link_watcher.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_panel_js.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_plugin_api.py +0 -0
- {rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/tests/unit/test_release_notes.py +0 -0
- {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.
|
|
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.
|
|
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/
|
|
107
|
-
into the directory at start and daily
|
|
108
|
-
|
|
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
|
|
114
|
-
own configuration switches
|
|
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
|
-
|
|
|
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
|
-
| *
|
|
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.
|
|
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
|
|
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/
|
|
81
|
-
into the directory at start and daily
|
|
82
|
-
|
|
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
|
|
88
|
-
own configuration switches
|
|
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
|
-
|
|
|
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
|
-
| *
|
|
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.
|
|
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
|
|
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
|
{rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/manifest.json
RENAMED
|
@@ -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.
|
|
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.
|
|
16
|
+
"rustuya-local>=0.1.0.dev3"
|
|
17
17
|
],
|
|
18
18
|
"single_config_entry": true,
|
|
19
|
-
"version": "0.1.0.
|
|
19
|
+
"version": "0.1.0.dev3"
|
|
20
20
|
}
|
{rustuya_local-0.1.0.dev1 → rustuya_local-0.1.0.dev3}/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
|
|
@@ -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.
|
|
73
|
-
files
|
|
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-
|
|
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,12 +4,12 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "rustuya-local"
|
|
7
|
-
version = "0.1.0.
|
|
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.
|
|
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
|
|
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():
|