rustuya-local 0.1.0.dev2__tar.gz → 0.1.0.dev3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/CHANGELOG.md +9 -0
  2. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/PKG-INFO +6 -5
  3. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/README.md +4 -3
  4. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/manifest.json +3 -3
  5. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/docs/MIGRATING.md +2 -2
  6. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/pyproject.toml +2 -2
  7. rustuya_local-0.1.0.dev3/release-notes.md +6 -0
  8. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/scripts/build_dropin.py +4 -1
  9. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/overrides.py +2 -2
  10. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/pack.py +4 -4
  11. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/rules.py +21 -8
  12. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/src/rustuya_local/service.py +7 -2
  13. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/e2e/test_service.py +18 -7
  14. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/unit/test_dropin.py +1 -1
  15. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/unit/test_pack.py +1 -1
  16. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/unit/test_rule_files.py +27 -1
  17. rustuya_local-0.1.0.dev2/release-notes.md +0 -11
  18. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/.github/dependabot.yml +0 -0
  19. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/.github/workflows/_test.yml +0 -0
  20. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/.github/workflows/ci.yml +0 -0
  21. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/.github/workflows/hacs.yml +0 -0
  22. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/.github/workflows/hassfest.yml +0 -0
  23. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/.github/workflows/release.yml +0 -0
  24. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/.gitignore +0 -0
  25. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/LICENSE +0 -0
  26. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/__init__.py +0 -0
  27. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/brand/icon.png +0 -0
  28. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/brand/icon.svg +0 -0
  29. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/brand/icon@2x.png +0 -0
  30. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/brand/logo.png +0 -0
  31. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/brand/logo.svg +0 -0
  32. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/brand/logo@2x.png +0 -0
  33. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/bridge_supervisor.py +0 -0
  34. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/bridge_sync.py +0 -0
  35. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/broker.py +0 -0
  36. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/config_flow.py +0 -0
  37. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/const.py +0 -0
  38. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/manager_session.py +0 -0
  39. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/panel.py +0 -0
  40. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/services.yaml +0 -0
  41. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/strings.json +0 -0
  42. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/translations/en.json +0 -0
  43. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/translations/ko.json +0 -0
  44. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/custom_components/rustuya/www/rustuya-panel.js +0 -0
  45. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/docs/REDESIGN.md +0 -0
  46. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/docs/ROADMAP.md +0 -0
  47. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/docs/STATUS.md +0 -0
  48. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/hacs.json +0 -0
  49. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/scripts/release_notes.py +0 -0
  50. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/src/rustuya_local/__init__.py +0 -0
  51. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/src/rustuya_local/__main__.py +0 -0
  52. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/src/rustuya_local/async_tools.py +0 -0
  53. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/src/rustuya_local/bridge_client.py +0 -0
  54. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/src/rustuya_local/cli.py +0 -0
  55. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/src/rustuya_local/config.py +0 -0
  56. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/src/rustuya_local/files.py +0 -0
  57. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/__init__.py +0 -0
  58. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/devices.py +0 -0
  59. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/hub.py +0 -0
  60. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/il.py +0 -0
  61. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/memory.py +0 -0
  62. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/mqtt.py +0 -0
  63. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/runner.py +0 -0
  64. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/src/rustuya_local/host/transport.py +0 -0
  65. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/src/rustuya_local/manager_plugin/__init__.py +0 -0
  66. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/src/rustuya_local/manager_plugin/api.py +0 -0
  67. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/src/rustuya_local/manager_plugin/static/index.js +0 -0
  68. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/src/rustuya_local/validation.py +0 -0
  69. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/e2e/conftest.py +0 -0
  70. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/e2e/devices.py +0 -0
  71. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/e2e/test_bridge_registration.py +0 -0
  72. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/e2e/test_chain_in_process.py +0 -0
  73. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/e2e/test_chain_mqtt.py +0 -0
  74. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/e2e/test_daemon.py +0 -0
  75. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/e2e/test_full_stack.py +0 -0
  76. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/e2e/test_full_stack_custom_templates.py +0 -0
  77. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/e2e/test_full_stack_devices.py +0 -0
  78. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/e2e/test_manager_plugin.py +0 -0
  79. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/e2e/test_purge.py +0 -0
  80. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/ha/conftest.py +0 -0
  81. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/ha/fake_manager.py +0 -0
  82. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/ha/test_bridge_probe.py +0 -0
  83. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/ha/test_config_flow.py +0 -0
  84. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/ha/test_init.py +0 -0
  85. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/ha/test_manager_session.py +0 -0
  86. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/ha/test_migrate.py +0 -0
  87. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/ha/test_panel.py +0 -0
  88. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/unit/test_config.py +0 -0
  89. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/unit/test_cover_settings.py +0 -0
  90. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/unit/test_files.py +0 -0
  91. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/unit/test_hazardous.py +0 -0
  92. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/unit/test_host.py +0 -0
  93. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/unit/test_hub.py +0 -0
  94. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/unit/test_il_vectors.py +0 -0
  95. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/unit/test_link_watcher.py +0 -0
  96. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/unit/test_panel_js.py +0 -0
  97. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/unit/test_plugin_api.py +0 -0
  98. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/unit/test_release_notes.py +0 -0
  99. {rustuya_local-0.1.0.dev2 → rustuya_local-0.1.0.dev3}/tests/unit/tuya_samples.py +0 -0
@@ -8,6 +8,15 @@ 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
+
11
20
  ## [0.1.0.dev2] — 2026-10-11
12
21
 
13
22
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: rustuya-local
3
- Version: 0.1.0.dev2
3
+ Version: 0.1.0.dev3
4
4
  Summary: Local Tuya control: rustuya-bridge -> tuya2ildevice -> IL (ildevice). HA-independent core.
5
5
  Project-URL: Homepage, https://github.com/3735943886/rustuya-local
6
6
  Project-URL: Repository, https://github.com/3735943886/rustuya-local
@@ -10,7 +10,7 @@ License-File: LICENSE
10
10
  Requires-Python: >=3.13
11
11
  Requires-Dist: paho-mqtt>=2.0
12
12
  Requires-Dist: pyrustuyabridge>=0.4
13
- Requires-Dist: tuya2ildevice>=0.4.0.dev3
13
+ Requires-Dist: tuya2ildevice>=0.4.0.dev4
14
14
  Provides-Extra: fullstack
15
15
  Requires-Dist: tuyamock; extra == 'fullstack'
16
16
  Provides-Extra: manager
@@ -103,9 +103,10 @@ config; integration: `<config>/rustuya_converters`; manager plugin: its data dir
103
103
  - Converters are JSON programs under `converters.declarative`; Python code plugins are not loaded.
104
104
 
105
105
  See tuya2ildevice's README ("User overrides") for the format. A curated set ships in tuya2ildevice, and fixes published
106
- between its releases (its [override pack](https://github.com/3735943886/tuya2ildevice/tree/master/rules/pack)) are copied
107
- into the directory at start and daily, as `00_pack_*` files that your own files refine. Turn that off with `"pack":
108
- false` (daemon), the Rustuya panel's *Options* (integration), or the plugin tab. The pack only replaces or removes the files it
106
+ between its releases (its [override pack](https://github.com/3735943886/tuya2ildevice/tree/master/rules/builtin)) are copied
107
+ into `rules/builtin/` inside the overrides directory at start and daily. These include `00-default.json`; your own
108
+ files are applied after all official rules. The bundled defaults update with tuya2ildevice, even when pack sync is off.
109
+ Turn online updates off with `"pack": false` (daemon), the Rustuya panel's *Options* (integration), or the plugin tab. The pack only replaces or removes the files it
109
110
  put there, and leaves a file alone once you edit it.
110
111
 
111
112
  ### Cover settings
@@ -77,9 +77,10 @@ config; integration: `<config>/rustuya_converters`; manager plugin: its data dir
77
77
  - Converters are JSON programs under `converters.declarative`; Python code plugins are not loaded.
78
78
 
79
79
  See tuya2ildevice's README ("User overrides") for the format. A curated set ships in tuya2ildevice, and fixes published
80
- between its releases (its [override pack](https://github.com/3735943886/tuya2ildevice/tree/master/rules/pack)) are copied
81
- into the directory at start and daily, as `00_pack_*` files that your own files refine. Turn that off with `"pack":
82
- false` (daemon), the Rustuya panel's *Options* (integration), or the plugin tab. The pack only replaces or removes the files it
80
+ between its releases (its [override pack](https://github.com/3735943886/tuya2ildevice/tree/master/rules/builtin)) are copied
81
+ into `rules/builtin/` inside the overrides directory at start and daily. These include `00-default.json`; your own
82
+ files are applied after all official rules. The bundled defaults update with tuya2ildevice, even when pack sync is off.
83
+ Turn online updates off with `"pack": false` (daemon), the Rustuya panel's *Options* (integration), or the plugin tab. The pack only replaces or removes the files it
83
84
  put there, and leaves a file alone once you edit it.
84
85
 
85
86
  ### Cover settings
@@ -9,12 +9,12 @@
9
9
  "iot_class": "local_push",
10
10
  "issue_tracker": "https://github.com/3735943886/rustuya-local/issues",
11
11
  "requirements": [
12
- "tuya2ildevice>=0.4.0.dev3",
12
+ "tuya2ildevice>=0.4.0.dev4",
13
13
  "paho-mqtt>=2.0",
14
14
  "rustuya-manager>=0.2.2",
15
15
  "pyrustuyabridge>=0.4",
16
- "rustuya-local>=0.1.0.dev2"
16
+ "rustuya-local>=0.1.0.dev3"
17
17
  ],
18
18
  "single_config_entry": true,
19
- "version": "0.1.0.dev2"
19
+ "version": "0.1.0.dev3"
20
20
  }
@@ -69,8 +69,8 @@ rustuya-manager plugin. It is retired. Its replacement is two pieces:
69
69
  - `converters.cover_motion` and a cover's `infer_motion` are read as the cover's `state_source` (`inferred`, or
70
70
  `none` for `infer_motion: false`), and a `cover_motion` `settle` as the cover's `settle`; its other keys are
71
71
  dropped with a warning. `zz_settings.json` is rewritten in the new form the next time a setting is saved.
72
- - Leave the old pack (`00_default.json`, `00_curtain.py`, `.rustuya_pack.json`) out. Fixes arrive as `00_pack_*`
73
- files by themselves.
72
+ - Leave the old pack (`00_default.json`, `00_curtain.py`, `.rustuya_pack.json`) out. Official rules ship with tuya2ildevice; online updates arrive under
73
+ `rules/builtin/` automatically. User files are applied after all official rules.
74
74
 
75
75
  Changes to the directory are picked up while running, no restart needed.
76
76
 
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "rustuya-local"
7
- version = "0.1.0.dev2"
7
+ version = "0.1.0.dev3"
8
8
  description = "Local Tuya control: rustuya-bridge -> tuya2ildevice -> IL (ildevice). HA-independent core."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
11
11
  license = "MIT"
12
- dependencies = ["tuya2ildevice>=0.4.0.dev3", "pyrustuyabridge>=0.4", "paho-mqtt>=2.0"]
12
+ dependencies = ["tuya2ildevice>=0.4.0.dev4", "pyrustuyabridge>=0.4", "paho-mqtt>=2.0"]
13
13
  # rustuya-manager (custom_components/rustuya's config flow) is optional at the package level (the HA manifest
14
14
  # pulls it in); declared here too so the venv used for this repo's tests has it. pyrustuyabridge is a base dependency
15
15
  # now (not just for the embedded bridge): rustuya-local's own bridge_client uses it to interpret rustuya-bridge's
@@ -0,0 +1,6 @@
1
+ ### Changed
2
+ - Require tuya2ildevice 0.4.0.dev4 in the package and Home Assistant integration.
3
+ - Load bundled official rules from `rules/builtin/`, exclude the manifest from rule layers, and apply user files after official rules regardless of filename.
4
+ - Fetch the complete official pack into `rules/builtin/` inside the overrides directory and watch it for live reloads.
5
+ - Retire unedited files owned by the old flat override pack while preserving user edits.
6
+ - Include the official rule layers and manifest in the manager drop-in archive.
@@ -58,7 +58,10 @@ def _members() -> list[tuple[str, Path]]:
58
58
  members = dict(out)
59
59
  members.setdefault(f"{prefix}/{native.name}", native)
60
60
  for layer in rule_files(rule_paths()[:1]): # the rule layers a wheel bundles, nothing else
61
- members.setdefault(f"{prefix}/rules/{layer.name}", layer)
61
+ members.setdefault(f"{prefix}/rules/builtin/{layer.name}", layer)
62
+ manifest = rule_paths()[0] / 'manifest.json'
63
+ if manifest.is_file():
64
+ members.setdefault(f"{prefix}/rules/builtin/manifest.json", manifest)
62
65
  out = list(members.items())
63
66
  return sorted(out)
64
67
 
@@ -95,12 +95,12 @@ class OverrideWatcher:
95
95
 
96
96
  def _stat(self) -> Any:
97
97
  stamp = []
98
- for p in rule_files([self.path]):
98
+ for p in rule_files([self.path, self.path / 'rules' / 'builtin']):
99
99
  try:
100
100
  st = p.stat()
101
101
  except OSError:
102
102
  continue
103
- stamp.append((p.name, st.st_mtime_ns, st.st_size))
103
+ stamp.append((str(p), st.st_mtime_ns, st.st_size))
104
104
  return tuple(stamp)
105
105
 
106
106
  def check(self) -> bool:
@@ -1,8 +1,8 @@
1
- """The override pack: fixes for non-standard devices published on tuya2ildevice's `master` (`rules/pack/`) and copied into
1
+ """The override pack: fixes for non-standard devices published on tuya2ildevice's `master` (`rules/builtin/`) and copied into
2
2
  the host's rule directory, so a fix reaches users without a release. `OverrideWatcher` then loads the copied files
3
3
  like the user's own. The files are rule layers (JSON); fetching them is the host's, the engine only merges them.
4
4
 
5
- `pack/manifest.json` lists the files with their SHA-256 (built by `scripts/build_pack.py`):
5
+ `builtin/manifest.json` lists the files with their SHA-256 (built by `scripts/build_pack.py`):
6
6
 
7
7
  {"version": 1, "files": [{"name": "00_pack_abc.json", "sha256": "...", "requires": 1, "until": 3}]}
8
8
 
@@ -38,7 +38,7 @@ from .overrides import SETTINGS_FILE
38
38
  _LOGGER = logging.getLogger(__name__)
39
39
 
40
40
  LEVEL = 4 # 2: `cover` settings; 3: `delta`; 4: rule-layer files (tuya2ildevice 0.4)
41
- BASE_URL = "https://raw.githubusercontent.com/3735943886/tuya2ildevice/master/rules/pack/"
41
+ BASE_URL = "https://raw.githubusercontent.com/3735943886/tuya2ildevice/master/rules/builtin/"
42
42
  MANIFEST = "manifest.json"
43
43
  LEDGER = ".tuya2ildevice_pack.json"
44
44
  MAX_BYTES = 1024 * 1024
@@ -129,7 +129,7 @@ def _wanted(doc: dict[str, Any]) -> dict[str, str]:
129
129
  continue
130
130
  if entry.get("requires", 0) > LEVEL or entry.get("until", LEVEL + 1) <= LEVEL:
131
131
  continue
132
- if name == SETTINGS_FILE: # the host's own settings: never the pack's
132
+ if name in (SETTINGS_FILE, MANIFEST): # the host's own settings: never the pack's
133
133
  _LOGGER.warning("pack: %s is the host's settings file, not taken from the pack", name)
134
134
  continue
135
135
  out[name] = sha.lower()
@@ -64,8 +64,8 @@ def _upgrade_layer(data, where):
64
64
 
65
65
  def rule_paths(paths=(), *, bundled_path=None):
66
66
  package = Path(tuya2ildevice.__file__).resolve().parent
67
- bundled = package / 'rules'
68
- checkout = package.parents[1] / 'rules'
67
+ bundled = package / 'rules' / 'builtin'
68
+ checkout = package.parents[1] / 'rules' / 'builtin'
69
69
  location = bundled_path or (bundled if bundled.is_dir() else checkout)
70
70
  return [Path(location), *map(Path, paths)]
71
71
 
@@ -73,14 +73,16 @@ def rule_paths(paths=(), *, bundled_path=None):
73
73
  def rule_files(locations):
74
74
  files = []
75
75
  for location in map(Path, locations):
76
- if location.is_file():
76
+ if location.is_file() and location.name != 'manifest.json':
77
77
  files.append(location)
78
78
  elif location.is_dir():
79
- files.extend(p for p in location.iterdir() if p.is_file() and p.suffix == '.json' and p.name[0] not in '._')
79
+ files.extend(p for p in location.iterdir()
80
+ if p.is_file() and p.suffix == '.json' and p.name[0] not in '._'
81
+ and p.name != 'manifest.json')
80
82
  return sorted(files, key=lambda p: os.fsencode(p.name))
81
83
 
82
84
 
83
- def merge_rule_files(locations, *, tolerant=False):
85
+ def merge_rule_files(locations, *, tolerant=False, base=None):
84
86
  layers, warnings = [], []
85
87
  files = rule_files(locations)
86
88
  for path in files:
@@ -91,7 +93,7 @@ def merge_rule_files(locations, *, tolerant=False):
91
93
  if not tolerant:
92
94
  raise ValueError(f'{path}: {error}') from error
93
95
  warnings.append(f'{path.name}: {error}')
94
- result = merge_rules(layers, tolerant=tolerant)
96
+ result = merge_rules(layers, base=base, tolerant=tolerant)
95
97
  names = {str(p): p.name for p in files}
96
98
  for warning in result['warnings']:
97
99
  source, _, message = warning.partition(': ')
@@ -99,12 +101,23 @@ def merge_rule_files(locations, *, tolerant=False):
99
101
  return {**result, 'warnings': warnings}
100
102
 
101
103
 
104
+ def _load_rules(paths=(), *, bundled_path=None, tolerant=False):
105
+ locations = rule_paths(paths, bundled_path=bundled_path)
106
+ official = merge_rule_files(locations[:1])
107
+ # Downloaded official rules refine the bundled pack; user files always run last.
108
+ downloaded = [Path(p) / 'rules' / 'builtin' for p in paths if Path(p).is_dir()]
109
+ official = merge_rule_files(downloaded, base=official['rules'], tolerant=tolerant)
110
+ result = merge_rule_files(locations[1:], base=official['rules'], tolerant=tolerant)
111
+ result['warnings'] = official['warnings'] + result['warnings']
112
+ return result
113
+
114
+
102
115
  def load_rules(paths=(), *, bundled_path=None):
103
- return merge_rule_files(rule_paths(paths, bundled_path=bundled_path))['rules']
116
+ return _load_rules(paths, bundled_path=bundled_path)['rules']
104
117
 
105
118
 
106
119
  def load_rules_tolerant(paths=(), *, bundled_path=None):
107
- return merge_rule_files(rule_paths(paths, bundled_path=bundled_path), tolerant=True)
120
+ return _load_rules(paths, bundled_path=bundled_path, tolerant=True)
108
121
 
109
122
 
110
123
  @functools.cache
@@ -16,7 +16,7 @@ integration, the rustuya-manager plugin or a daemon) serves them, since every de
16
16
  `online` that nobody answers for (a Last Will lost with the broker, or a producer on tuya2ildevice < 0.3.5) does not
17
17
  stop the start.
18
18
 
19
- With `pack` on, the override pack (tuya2ildevice's `rules/pack/`) is copied into the overrides directory in the background at
19
+ With `pack` on, the override pack (tuya2ildevice's `rules/builtin/`) is copied into the overrides directory in the background at
20
20
  start and every `pack_interval` seconds; the directory watcher loads what it brings.
21
21
 
22
22
  A device's settings (a cover's direction toggles, tuya2ildevice's `device_settings`) are IL properties like any other;
@@ -249,7 +249,12 @@ class Service:
249
249
  while True:
250
250
  self._pack_now.clear()
251
251
  try:
252
- result = await run_in_thread(pack.sync, directory, base_url=url)
252
+ # Retire unedited files owned by the old flat pack, preserving user files.
253
+ retired = await run_in_thread(pack.sync, directory, manifest={'files': []})
254
+ result = await run_in_thread(pack.sync, directory / 'rules' / 'builtin', base_url=url)
255
+ result.removed.extend(retired.removed)
256
+ result.kept.extend(retired.kept)
257
+ result.failed.extend(retired.failed)
253
258
  self.pack_status = {"at": time.time(), **result.as_dict()}
254
259
  except pack.PackError as e:
255
260
  _LOGGER.warning("override pack: %s", e)
@@ -201,7 +201,14 @@ async def test_the_override_pack_is_synced_in_the_background(tmp_path, monkeypat
201
201
  monkeypatch.setattr(bc.BridgeClient, "start", lambda self, timeout=0.05: orig(self, timeout))
202
202
  served, conv = tmp_path / "served", tmp_path / "conv"
203
203
  served.mkdir()
204
- fix = json.dumps({"lamp1": {"label": "Packed"}})
204
+ conv.mkdir()
205
+ (conv / '00_pack_old.json').write_text('{}')
206
+ (conv / '00_pack_edited.json').write_text('{"overrides": {}}')
207
+ (conv / '.tuya2ildevice_pack.json').write_text(json.dumps({'files': {
208
+ '00_pack_old.json': hashlib.sha256(b'{}').hexdigest(),
209
+ '00_pack_edited.json': hashlib.sha256(b'{}').hexdigest(),
210
+ }}))
211
+ fix = json.dumps({"overrides": {"lamp1": {"device": {"label": "Packed"}}}})
205
212
  (served / "00_pack_lamp.json").write_text(fix)
206
213
  (served / "manifest.json").write_text(json.dumps({"version": 1, "files": [
207
214
  {"name": "00_pack_lamp.json", "sha256": hashlib.sha256(fix.encode()).hexdigest()}]}))
@@ -212,8 +219,10 @@ async def test_the_override_pack_is_synced_in_the_background(tmp_path, monkeypat
212
219
  if service.pack_status:
213
220
  break
214
221
  await asyncio.sleep(0.02)
215
- assert service.pack_status["added"] == ["00_pack_lamp.json"] and (conv / "00_pack_lamp.json").is_file()
216
- (conv / "00_pack_lamp.json").unlink()
222
+ assert service.pack_status["added"] == ["00_pack_lamp.json"] and (conv / "rules" / "builtin" / "00_pack_lamp.json").is_file()
223
+ assert not (conv / '00_pack_old.json').exists()
224
+ assert (conv / '00_pack_edited.json').read_text() == '{"overrides": {}}'
225
+ (conv / "rules" / "builtin" / "00_pack_lamp.json").unlink()
217
226
  first, service.pack_status = service.pack_status, None
218
227
  assert service.sync_pack_now() # not a day later: now
219
228
  for _ in range(100):
@@ -230,7 +239,7 @@ async def test_the_override_pack_is_synced_in_the_background(tmp_path, monkeypat
230
239
  if service.pack_status:
231
240
  break
232
241
  await asyncio.sleep(0.02)
233
- assert "error" in service.pack_status and (conv / "00_pack_lamp.json").is_file()
242
+ assert "error" in service.pack_status and (conv / "rules" / "builtin" / "00_pack_lamp.json").is_file()
234
243
  await service.stop()
235
244
 
236
245
 
@@ -333,9 +342,11 @@ async def test_stop_waits_for_an_override_pack_write_in_progress(tmp_path, monke
333
342
  entered = asyncio.Event()
334
343
  release = threading.Event()
335
344
  loop = asyncio.get_running_loop()
336
- path = tmp_path / 'conv' / '00_pack_test.json'
345
+ path = tmp_path / 'conv' / 'rules' / 'builtin' / '00_pack_test.json'
337
346
 
338
- def blocked_sync(directory, *, base_url):
347
+ def blocked_sync(directory, *, base_url=pack.BASE_URL, manifest=None):
348
+ if manifest is not None:
349
+ return pack.SyncResult()
339
350
  loop.call_soon_threadsafe(entered.set)
340
351
  if not release.wait(5):
341
352
  raise TimeoutError('test did not release pack sync')
@@ -346,7 +357,7 @@ async def test_stop_waits_for_an_override_pack_write_in_progress(tmp_path, monke
346
357
  monkeypatch.setattr(pack, 'sync', blocked_sync)
347
358
  t = Transports()
348
359
  await t.bridge.publish('rustuya/bridge/config', '{}', 1, True)
349
- service = _service(t, tmp_path, overrides_path=path.parent, pack=True)
360
+ service = _service(t, tmp_path, overrides_path=tmp_path / 'conv', pack=True)
350
361
  await service.start()
351
362
  stopping = None
352
363
  try:
@@ -21,7 +21,7 @@ def test_one_package_with_tuya2ildevice_inside(tmp_path):
21
21
  for needed in ("rustuya_local/__init__.py", "rustuya_local/manager_plugin/static/index.js",
22
22
  "rustuya_local/_vendor/tuya2ildevice/__init__.py", "rustuya_local/_vendor/tuya2ildevice/native.py"):
23
23
  assert needed in names
24
- assert "rustuya_local/_vendor/tuya2ildevice/rules/00-default.json" in names
24
+ assert "rustuya_local/_vendor/tuya2ildevice/rules/builtin/00-default.json" in names
25
25
  assert any(n.startswith("rustuya_local/_vendor/tuya2ildevice/") and n.endswith((".so", ".dll", ".dylib"))
26
26
  for n in names)
27
27
  assert not any("__pycache__" in n or n.startswith("rustuya_local/_vendor/paho") for n in names)
@@ -10,7 +10,7 @@ import pytest
10
10
  from rustuya_local.host import load_overrides, pack
11
11
  from rustuya_local.host.pack import LEDGER, PackError, read_ledger, sync
12
12
 
13
- PUBLISHED = Path(__file__).resolve().parents[3] / "tuya2ildevice" / "rules" / "pack" # the sibling checkout
13
+ PUBLISHED = Path(__file__).resolve().parents[3] / "tuya2ildevice" / "rules" / "builtin" # the sibling checkout
14
14
 
15
15
 
16
16
  def sha(text: str) -> str:
@@ -53,7 +53,7 @@ def test_watcher_reloads_global_rules_and_restores_defaults_after_removal(tmp_pa
53
53
 
54
54
 
55
55
 
56
- def test_host_uses_global_filename_order_for_rule_layers(tmp_path):
56
+ def test_host_applies_user_layers_after_bundled_rules(tmp_path):
57
57
  write(tmp_path / '000_before_base.json', {'maps': {'switch_on': {'probe': True}}})
58
58
  write(tmp_path / '10_after_base.json', {'overrides': {'p': {'device': {'label': 'user'}}}})
59
59
  loaded = load_overrides(tmp_path)
@@ -168,3 +168,29 @@ def test_failed_adoption_releases_the_candidate_and_keeps_the_existing_drivers(m
168
168
  assert candidate not in _pack_users and not unregister_rules(candidate)
169
169
  finally:
170
170
  hub.close()
171
+
172
+
173
+ def test_official_manifest_is_not_a_rule_and_user_files_run_last(tmp_path):
174
+ builtin = tmp_path / 'rules' / 'builtin'
175
+ builtin.mkdir(parents=True)
176
+ write(builtin / 'manifest.json', {'version': 1, 'files': []})
177
+ write(builtin / '99_official.json', {'overrides': {'p': {'device': {'label': 'official'}}}})
178
+ write(tmp_path / '000_user.json', {'overrides': {'p': {'device': {'label': 'user'}}}})
179
+ loaded = load_overrides(tmp_path)
180
+ assert not loaded.warnings
181
+ assert TuyaDriver(DEVICE, rules=loaded.rules).descriptor['label'] == 'user'
182
+
183
+
184
+ def test_watcher_follows_downloaded_official_rules(tmp_path):
185
+ builtin = tmp_path / 'rules' / 'builtin'
186
+ builtin.mkdir(parents=True)
187
+ watcher = OverrideWatcher(tmp_path, None)
188
+ assert watcher.check()
189
+ path = builtin / '10_official.json'
190
+ write(path, {'overrides': {'p': {'device': {'label': 'downloaded'}}}})
191
+ assert watcher.check()
192
+ assert watcher.load().overrides['p']['device']['label'] == 'downloaded'
193
+ assert not watcher.check()
194
+ path.unlink()
195
+ assert watcher.check()
196
+ assert watcher.load().overrides.get('p', {}).get('device', {}).get('label') != 'downloaded'
@@ -1,11 +0,0 @@
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.