rustuya-local 0.1.0.dev10__tar.gz → 0.1.0.dev12__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 (106) hide show
  1. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/CHANGELOG.md +13 -0
  2. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/PKG-INFO +2 -2
  3. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/custom_components/rustuya/manifest.json +3 -3
  4. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/docs/ARCHITECTURE.md +9 -0
  5. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/docs/REVIEW.md +2 -1
  6. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/docs/ROADMAP.md +1 -1
  7. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/docs/STATUS.md +3 -3
  8. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/pyproject.toml +2 -2
  9. rustuya_local-0.1.0.dev12/release-notes.md +4 -0
  10. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/src/rustuya_local/host/hub.py +1 -10
  11. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/src/rustuya_local/host/il.py +4 -1
  12. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/src/rustuya_local/service.py +10 -6
  13. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/e2e/test_bridge_registration.py +1 -1
  14. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/e2e/test_chain_in_process.py +1 -1
  15. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/e2e/test_chain_mqtt.py +1 -1
  16. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/e2e/test_daemon.py +6 -6
  17. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/e2e/test_full_stack.py +2 -2
  18. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/e2e/test_full_stack_custom_templates.py +1 -1
  19. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/e2e/test_manager_plugin.py +6 -6
  20. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/e2e/test_purge.py +6 -6
  21. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/e2e/test_service.py +12 -8
  22. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/ha/test_init.py +10 -10
  23. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/unit/test_host.py +10 -10
  24. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/unit/test_hub.py +7 -7
  25. rustuya_local-0.1.0.dev12/tests/unit/test_il_topics.py +27 -0
  26. rustuya_local-0.1.0.dev10/release-notes.md +0 -9
  27. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/.github/dependabot.yml +0 -0
  28. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/.github/workflows/_test.yml +0 -0
  29. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/.github/workflows/ci.yml +0 -0
  30. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/.github/workflows/hacs.yml +0 -0
  31. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/.github/workflows/hassfest.yml +0 -0
  32. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/.github/workflows/release.yml +0 -0
  33. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/.gitignore +0 -0
  34. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/LICENSE +0 -0
  35. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/README.md +0 -0
  36. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/custom_components/rustuya/__init__.py +0 -0
  37. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/custom_components/rustuya/brand/icon.png +0 -0
  38. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/custom_components/rustuya/brand/icon.svg +0 -0
  39. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/custom_components/rustuya/brand/icon@2x.png +0 -0
  40. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/custom_components/rustuya/brand/logo.png +0 -0
  41. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/custom_components/rustuya/brand/logo.svg +0 -0
  42. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/custom_components/rustuya/brand/logo@2x.png +0 -0
  43. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/custom_components/rustuya/bridge_supervisor.py +0 -0
  44. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/custom_components/rustuya/bridge_sync.py +0 -0
  45. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/custom_components/rustuya/broker.py +0 -0
  46. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/custom_components/rustuya/config_flow.py +0 -0
  47. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/custom_components/rustuya/const.py +0 -0
  48. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/custom_components/rustuya/manager_session.py +0 -0
  49. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/custom_components/rustuya/panel.py +0 -0
  50. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/custom_components/rustuya/panel_bridge.py +0 -0
  51. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/custom_components/rustuya/services.yaml +0 -0
  52. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/custom_components/rustuya/strings.json +0 -0
  53. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/custom_components/rustuya/translations/en.json +0 -0
  54. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/custom_components/rustuya/translations/ko.json +0 -0
  55. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/custom_components/rustuya/www/rustuya-panel.js +0 -0
  56. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/docs/MIGRATING.md +0 -0
  57. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/docs/REDESIGN.md +0 -0
  58. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/docs/history/ROADMAP-2026-10-12.md +0 -0
  59. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/hacs.json +0 -0
  60. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/scripts/build_dropin.py +0 -0
  61. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/scripts/release_notes.py +0 -0
  62. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/src/rustuya_local/__init__.py +0 -0
  63. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/src/rustuya_local/__main__.py +0 -0
  64. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/src/rustuya_local/async_tools.py +0 -0
  65. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/src/rustuya_local/bridge_client.py +0 -0
  66. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/src/rustuya_local/cli.py +0 -0
  67. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/src/rustuya_local/config.py +0 -0
  68. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/src/rustuya_local/files.py +0 -0
  69. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/src/rustuya_local/host/__init__.py +0 -0
  70. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/src/rustuya_local/host/devices.py +0 -0
  71. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/src/rustuya_local/host/memory.py +0 -0
  72. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/src/rustuya_local/host/mqtt.py +0 -0
  73. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/src/rustuya_local/host/overrides.py +0 -0
  74. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/src/rustuya_local/host/pack.py +0 -0
  75. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/src/rustuya_local/host/rules.py +0 -0
  76. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/src/rustuya_local/host/runner.py +0 -0
  77. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/src/rustuya_local/host/transport.py +0 -0
  78. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/src/rustuya_local/manager_plugin/__init__.py +0 -0
  79. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/src/rustuya_local/manager_plugin/api.py +0 -0
  80. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/src/rustuya_local/manager_plugin/static/index.js +0 -0
  81. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/src/rustuya_local/validation.py +0 -0
  82. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/e2e/conftest.py +0 -0
  83. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/e2e/devices.py +0 -0
  84. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/e2e/test_full_stack_devices.py +0 -0
  85. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/ha/conftest.py +0 -0
  86. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/ha/fake_manager.py +0 -0
  87. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/ha/test_bridge_probe.py +0 -0
  88. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/ha/test_bridge_supervisor.py +0 -0
  89. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/ha/test_config_flow.py +0 -0
  90. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/ha/test_manager_session.py +0 -0
  91. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/ha/test_migrate.py +0 -0
  92. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/ha/test_panel.py +0 -0
  93. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/unit/test_config.py +0 -0
  94. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/unit/test_cover_settings.py +0 -0
  95. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/unit/test_dropin.py +0 -0
  96. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/unit/test_files.py +0 -0
  97. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/unit/test_hazardous.py +0 -0
  98. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/unit/test_il_vectors.py +0 -0
  99. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/unit/test_link_watcher.py +0 -0
  100. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/unit/test_mqtt_lifecycle.py +0 -0
  101. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/unit/test_pack.py +0 -0
  102. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/unit/test_panel_js.py +0 -0
  103. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/unit/test_plugin_api.py +0 -0
  104. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/unit/test_release_notes.py +0 -0
  105. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/unit/test_rule_files.py +0 -0
  106. {rustuya_local-0.1.0.dev10 → rustuya_local-0.1.0.dev12}/tests/unit/tuya_samples.py +0 -0
@@ -8,6 +8,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.1.0.dev12] — 2026-10-18
12
+
13
+ ### Changed
14
+ - Promote the 0.1 development line to master; preserve the previous 0.0 line on `legacy/0.0.x`.
15
+ - Publish retained IL descriptors at `<prefix>/<id>/descriptor`. Reserve `descriptor` and `reject` as property names.
16
+ Reconciliation and purge use the new descriptor path; old root topics are left untouched.
17
+
18
+ ## [0.1.0.dev11] — 2026-10-18
19
+
20
+ ### Changed
21
+ - Decide which drivers a reload replaces with tuya-interpreter's public `TuyaDriver.same_config` instead of
22
+ comparing its version-specific state snapshot; require tuya-interpreter 0.1.0rc2.
23
+
11
24
  ## [0.1.0.dev10] — 2026-10-18
12
25
 
13
26
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: rustuya-local
3
- Version: 0.1.0.dev10
3
+ Version: 0.1.0.dev12
4
4
  Summary: Local Tuya control: rustuya-bridge -> tuya-interpreter -> 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: tuya-interpreter>=0.1.0a2
13
+ Requires-Dist: tuya-interpreter>=0.1.0rc2
14
14
  Provides-Extra: fullstack
15
15
  Requires-Dist: tuyamock; extra == 'fullstack'
16
16
  Provides-Extra: manager
@@ -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
- "tuya-interpreter>=0.1.0a2",
12
+ "tuya-interpreter>=0.1.0rc2",
13
13
  "paho-mqtt>=2.0",
14
14
  "rustuya-manager>=0.2.3",
15
15
  "pyrustuyabridge>=0.4",
16
- "rustuya-local>=0.1.0.dev10"
16
+ "rustuya-local>=0.1.0.dev12"
17
17
  ],
18
18
  "single_config_entry": true,
19
- "version": "0.1.0.dev10"
19
+ "version": "0.1.0.dev12"
20
20
  }
@@ -34,6 +34,15 @@ ildevice-homeassistant / other IL consumers
34
34
 
35
35
  HA entity creation belongs to the IL consumer. The base package does not import Home Assistant.
36
36
 
37
+ ## IL topics
38
+
39
+ Descriptors are retained at `<prefix>/<id>/descriptor`; property values stay at
40
+ `<prefix>/<id>/<prop>` and commands at `<prefix>/<id>/<prop>/set`.
41
+ `descriptor` and `reject` are reserved property names. Consumers can discover devices by
42
+ subscribing to `<prefix>/+/descriptor`. The consumer must support this descriptor layout.
43
+ Reconciliation and purge identify owned devices only through this descriptor path.
44
+ Other topics, including `<prefix>/<id>`, are ignored and left untouched.
45
+
37
46
  ## Startup and shutdown
38
47
 
39
48
  `Service.start()` prepares the native library and rules in worker threads, then connects the bridge and IL transports
@@ -71,7 +71,8 @@ reproduction. README, architecture and migration notes now explain actual preced
71
71
 
72
72
  ## Remaining maintenance considerations
73
73
 
74
- - `Hub._same` depends on driver state keys `block`, `delta` and `covers`; check compatibility when dependencies change.
74
+ - ~~`Hub._same` depends on driver state keys `block`, `delta` and `covers`.~~ Resolved: the reload comparison uses
75
+ tuya-interpreter's public `TuyaDriver.same_config`.
75
76
  - `manager_registry.ready_registry` patches private manager methods/fields; a public registry contract would reduce coupling.
76
77
  - Producer probing is a startup check, not atomic exclusion of two simultaneously starting producers.
77
78
  - Bridge status has no request ID; contiguous-page validation cannot perfectly distinguish all other-client responses.
@@ -15,7 +15,7 @@ Completed milestones and earlier deployment records are preserved in the [previo
15
15
  ## Maintenance
16
16
 
17
17
  - [x] Replace private manager registry patches and wizard state polling with public async contracts.
18
- - [ ] Reduce dependence on driver state internals in `Hub._same` through a public contract.
18
+ - [x] Reduce dependence on driver state internals in `Hub._same` through a public contract (`TuyaDriver.same_config`).
19
19
  - [ ] Verify Python 3.13 with minimum supported dependency versions. The full CI suite uses 3.14; wheel import/CLI smoke tests pass on 3.13.
20
20
  - [ ] Define the supported drop-in OS/architectures and provide platform assets as needed. Each archive includes its build host's native library.
21
21
  - [ ] Verify ownership/conflict policy when manual file editing, IL settings saves and automatic pack updates overlap.
@@ -1,6 +1,6 @@
1
1
  # Current status
2
2
 
3
- Updated: 2026-10-18. Baseline: `0.1.0.dev10`.
3
+ Updated: 2026-10-18. Baseline: `0.1.0.dev11`.
4
4
  The state of deployed servers has not been checked.
5
5
 
6
6
  ## Implementation
@@ -12,9 +12,9 @@ rule files and settings persistence. It does not create HA entities or publish M
12
12
 
13
13
  | Component | Declared requirement |
14
14
  |---|---|
15
- | rustuya-local | `0.1.0.dev10` |
15
+ | rustuya-local | `0.1.0.dev11` |
16
16
  | Python | Base package `>=3.13`; full tests use 3.14 |
17
- | tuya-interpreter | `>=0.1.0a2` |
17
+ | tuya-interpreter | `>=0.1.0rc2` |
18
18
  | pyrustuyabridge | `>=0.4` |
19
19
  | paho-mqtt | `>=2.0` |
20
20
  | rustuya-manager | `>=0.2.3` in the manager test extra and HA manifest |
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "rustuya-local"
7
- version = "0.1.0.dev10"
7
+ version = "0.1.0.dev12"
8
8
  description = "Local Tuya control: rustuya-bridge -> tuya-interpreter -> IL (ildevice). HA-independent core."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
11
11
  license = "MIT"
12
- dependencies = ["tuya-interpreter>=0.1.0a2", "pyrustuyabridge>=0.4", "paho-mqtt>=2.0"]
12
+ dependencies = ["tuya-interpreter>=0.1.0rc2", "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,4 @@
1
+ ### Changed
2
+ - Promote the 0.1 development line to master; preserve the previous 0.0 line on `legacy/0.0.x`.
3
+ - Publish retained IL descriptors at `<prefix>/<id>/descriptor`. Reserve `descriptor` and `reject` as property names.
4
+ Reconciliation and purge use the new descriptor path; old root topics are left untouched.
@@ -87,15 +87,6 @@ class Hub:
87
87
  if not usable_id(device_id):
88
88
  raise ValueError(f'device id {device_id!r} is not usable as an il-mqtt topic level (M-2)')
89
89
 
90
- @staticmethod
91
- def _same(old: TuyaDriver, new: TuyaDriver) -> bool:
92
- # Version-specific driver configuration metadata, compared without interpreting Tuya DPs.
93
- return (old.rules_id == new.rules_id
94
- and old.state['block'] == new.state['block']
95
- and old.state['delta'] == new.state['delta']
96
- and old.descriptor == new.descriptor
97
- and [c['settings'] for c in old.state['covers']] == [c['settings'] for c in new.state['covers']])
98
-
99
90
  def _adopt(self, old: TuyaDriver | None, new: TuyaDriver, seed: Sequence, now: float,
100
91
  *, rules: dict) -> list:
101
92
  device_id = new.descriptor['id']
@@ -160,7 +151,7 @@ class Hub:
160
151
  replacements = {}
161
152
  for device_id, new in fresh.items():
162
153
  old = self.drivers[device_id]
163
- if self._same(old, new):
154
+ if old.same_config(new):
164
155
  continue
165
156
  seed = [Connected()] if old.linked else []
166
157
  if old.synced:
@@ -72,7 +72,7 @@ class IlTopics:
72
72
  self.prefix, self.source = prefix, source
73
73
 
74
74
  def descriptor(self, device_id: str) -> str:
75
- return f"{self.prefix}/{device_id}"
75
+ return f"{self.prefix}/{device_id}/descriptor"
76
76
 
77
77
  def state(self, device_id: str, prop: str) -> str:
78
78
  return f"{self.prefix}/{device_id}/{prop}"
@@ -106,6 +106,9 @@ class IlTopics:
106
106
  actions = []
107
107
  for output in outputs:
108
108
  if isinstance(output, Descriptor):
109
+ reserved = {"descriptor", "reject"} & output.desc.get("props", {}).keys()
110
+ if reserved:
111
+ raise ValueError(f"reserved IL property names: {', '.join(sorted(reserved))}")
109
112
  desc = {**output.desc, "source": self.source}
110
113
  actions.append(Publish(IL, self.descriptor(device_id), json.dumps(desc, ensure_ascii=False), True))
111
114
  elif isinstance(output, Value):
@@ -360,8 +360,10 @@ class Service:
360
360
  def _owned_ids(retained: dict, prefix: str, source: str) -> set[str]:
361
361
  ids = set()
362
362
  for topic, payload in retained.items():
363
- device_id = topic[len(prefix) + 1:]
364
- if "/" in device_id or device_id.startswith("_"):
363
+ if not topic.startswith(f"{prefix}/") or not topic.endswith("/descriptor"):
364
+ continue
365
+ device_id = topic[len(prefix) + 1:-len("/descriptor")]
366
+ if "/" in device_id or not device_id or device_id.startswith("_"):
365
367
  continue
366
368
  try:
367
369
  desc = json.loads(payload)
@@ -373,8 +375,9 @@ def _owned_ids(retained: dict, prefix: str, source: str) -> set[str]:
373
375
 
374
376
 
375
377
  def _device_topics(retained: dict, prefix: str, ids: set[str]) -> list[str]:
376
- descriptors = {f"{prefix}/{i}" for i in ids}
377
- return sorted((t for t in retained if t[len(prefix) + 1:].split("/", 1)[0] in ids),
378
+ descriptors = {f"{prefix}/{i}/descriptor" for i in ids}
379
+ roots = tuple(f"{prefix}/{i}/" for i in ids)
380
+ return sorted((t for t in retained if t.startswith(roots)),
378
381
  key=lambda t: (t in descriptors, t))
379
382
 
380
383
 
@@ -382,8 +385,9 @@ async def purge_il(il: Transport, prefix: str, source: str, *, settle: float = 0
382
385
  """Take a producer out of IL for good (the integration deleted, not just stopped): every retained topic it left is
383
386
  cleared, so IL consumers drop its devices. `stop()` only says `offline`; the descriptors and values stay retained on
384
387
  purpose then, since the producer is expected back. What is cleared is found on the broker, not in a Hub: every
385
- descriptor `<prefix>/<id>` whose `source` is this one (devices published by an earlier run included), each retained
386
- topic below it, and the presence `<prefix>/_producer/<source>`. Returns the device ids taken out.
388
+ descriptor `<prefix>/<id>/descriptor` whose `source` is this one
389
+ (devices published by an earlier run included), each retained topic under that device, and the presence
390
+ `<prefix>/_producer/<source>`. Returns the device ids taken out.
387
391
 
388
392
  Refuses (`AnotherProducer`) while a producer still answers on that presence: it would publish them all again."""
389
393
  presence = IlTopics(prefix, source).presence
@@ -32,7 +32,7 @@ async def settle(runner, bridge_client, *buses):
32
32
 
33
33
 
34
34
  def advertised(il):
35
- return {t.split("/")[1] for t, m in il.retained.items() if t.count("/") == 1 and not t.startswith("il/_") and m.payload}
35
+ return {t.split("/")[1] for t, m in il.retained.items() if t.count("/") == 2 and t.endswith("/descriptor") and not t.startswith("il/_") and m.payload}
36
36
 
37
37
 
38
38
  async def reply(bridge, payload):
@@ -99,7 +99,7 @@ async def test_devices_can_be_added_and_removed_while_running(chain):
99
99
  assert set(model.devices) == {"lamp1", "lamp2"}
100
100
  runner.remove_device("lamp2")
101
101
  await settle(runner, bridge_client, bridge, il)
102
- assert set(model.devices) == {"lamp1"} and "il/lamp2" not in il.retained
102
+ assert set(model.devices) == {"lamp1"} and "il/lamp2/descriptor" not in il.retained
103
103
  assert not [t for t in il.retained if t.startswith("il/lamp2/")] # M-11: values cleared before the descriptor
104
104
 
105
105
 
@@ -46,7 +46,7 @@ async def mqtt_chain(broker):
46
46
  # leave no retained state behind for the next test
47
47
  cleaner = MqttTransport("127.0.0.1", broker, client_id="cleaner")
48
48
  await cleaner.connect()
49
- for topic in ("il/lamp1", "il/lamp1/available", "il/lamp1/switch_led", "il/lamp1/brightness",
49
+ for topic in ("il/lamp1/descriptor", "il/lamp1/available", "il/lamp1/switch_led", "il/lamp1/brightness",
50
50
  "il/lamp1/color_temperature", "il/_producer/tuya", "rustuya/error/lamp1", "rustuya/event/state/lamp1"):
51
51
  await cleaner.publish(topic, "", 1, True)
52
52
  await asyncio.sleep(0.1)
@@ -27,7 +27,7 @@ async def test_the_daemon_publishes_devices_and_goes_offline_on_sigterm(broker,
27
27
 
28
28
  def on_message(message):
29
29
  seen[message.topic] = message.payload.decode()
30
- if seen.get("ild/_producer/tuya") == "online" and "ild/daemon1" in seen:
30
+ if seen.get("ild/_producer/tuya") == "online" and "ild/daemon1/descriptor" in seen:
31
31
  online.set()
32
32
  if seen.get("ild/_producer/tuya") == "offline":
33
33
  offline.set()
@@ -42,7 +42,7 @@ async def test_the_daemon_publishes_devices_and_goes_offline_on_sigterm(broker,
42
42
  # Startup includes the producer-presence deadline and the missing
43
43
  # bridge-config fallback. Wait for readiness, not 100 polling ticks.
44
44
  await asyncio.wait_for(online.wait(), 10)
45
- assert json.loads(seen["ild/daemon1"])["kind"] == "light"
45
+ assert json.loads(seen["ild/daemon1/descriptor"])["kind"] == "light"
46
46
  proc.send_signal(signal.SIGTERM)
47
47
  assert await asyncio.to_thread(proc.wait, 10) == 0
48
48
  await asyncio.wait_for(offline.wait(), 10)
@@ -50,7 +50,7 @@ async def test_the_daemon_publishes_devices_and_goes_offline_on_sigterm(broker,
50
50
  finally:
51
51
  if proc.poll() is None:
52
52
  proc.kill()
53
- cleaner = ["ild/daemon1", "ild/daemon1/available", "ild/daemon1/switch_led", "ild/daemon1/brightness",
53
+ cleaner = ["ild/daemon1/descriptor", "ild/daemon1/available", "ild/daemon1/switch_led", "ild/daemon1/brightness",
54
54
  "ild/daemon1/color_temperature", "ild/_producer/tuya"]
55
55
  for t in cleaner:
56
56
  await watcher.publish(t, "", 1, True)
@@ -82,14 +82,14 @@ async def test_the_daemon_uses_the_bridges_templates_and_follows_the_device_file
82
82
  raise AssertionError(what)
83
83
 
84
84
  try:
85
- await until(lambda: "ilw/w1" in seen, "w1 was not published")
85
+ await until(lambda: "ilw/w1/descriptor" in seen, "w1 was not published")
86
86
  await t.publish("rb/error/w1", '{"errorCode":0,"errorMsg":"Connection Successful"}', 1, True)
87
87
  await t.publish("rb/ev/state/w1", '{"20":true,"22":1000,"23":0}', 1, True)
88
88
  await until(lambda: seen.get("ilw/w1/brightness") == "100", "the state on the bridge's own topic was not followed")
89
89
  devfile.write_text(json.dumps([lamp("w1"), lamp("w2")])) # manager adds a device
90
- await until(lambda: "ilw/w2" in seen, "the new device was not picked up")
90
+ await until(lambda: "ilw/w2/descriptor" in seen, "the new device was not picked up")
91
91
  devfile.write_text(json.dumps([lamp("w2")])) # and removes one
92
- await until(lambda: seen.get("ilw/w1") == "" and seen.get("ilw/w1/brightness") == "", "w1 was not removed")
92
+ await until(lambda: seen.get("ilw/w1/descriptor") == "" and seen.get("ilw/w1/brightness") == "", "w1 was not removed")
93
93
  finally:
94
94
  proc.send_signal(signal.SIGTERM)
95
95
  proc.wait(10)
@@ -101,7 +101,7 @@ def stack(broker, tmp_path_factory):
101
101
  "devices": "tuyadevices.json", "watch_interval": 0.2}))
102
102
  daemon = subprocess.Popen([sys.executable, "-m", "rustuya_local", "run", "--config", str(cfg)],
103
103
  stderr=subprocess.PIPE, text=True)
104
- assert wait(lambda: obs.last.get(f"{prefix}/{DEV}"), 20), "the daemon never published the descriptor"
104
+ assert wait(lambda: obs.last.get(f"{prefix}/{DEV}/descriptor"), 20), "the daemon never published the descriptor"
105
105
  st = type("Stack", (), {"obs": obs, "mock": mock, "root": root, "prefix": prefix, "daemon": daemon, "bridge": bridge,
106
106
  "devfile": devfile})
107
107
  yield st
@@ -123,7 +123,7 @@ def il(stack, prop):
123
123
 
124
124
 
125
125
  def test_the_device_appears_as_an_il_light_with_its_real_state(stack):
126
- desc = json.loads(stack.obs.last[f"{stack.prefix}/{DEV}"])
126
+ desc = json.loads(stack.obs.last[f"{stack.prefix}/{DEV}/descriptor"])
127
127
  assert desc["kind"] == "light" and desc["source"] == "tuya" and desc["props"]["brightness"]["role"] == "brightness"
128
128
  assert stack.obs.last[f"{stack.prefix}/_producer/tuya"] == "online"
129
129
  assert wait(lambda: il(stack, "available") == "true"), "the device never became available"
@@ -48,7 +48,7 @@ def test_a_custom_event_command_and_payload_template_all_work_together(broker, t
48
48
  daemon = subprocess.Popen([sys.executable, "-m", "rustuya_local", "run", "--config", str(cfg)],
49
49
  stderr=subprocess.PIPE, text=True)
50
50
  try:
51
- assert wait(lambda: obs.last.get(f"{prefix}/{DEV}"), 20), "the daemon never published the descriptor"
51
+ assert wait(lambda: obs.last.get(f"{prefix}/{DEV}/descriptor"), 20), "the daemon never published the descriptor"
52
52
  assert wait(lambda: obs.last.get(f"{prefix}/{DEV}/switch_led") == "true"
53
53
  and obs.last.get(f"{prefix}/{DEV}/brightness") == "49", 20), \
54
54
  "state through the custom event/payload templates never reached IL"
@@ -42,14 +42,14 @@ async def test_the_plugin_runs_the_service_under_the_manager(broker, tmp_path):
42
42
  await answer_status(w, "rmp", ["mp1", "mp2"])
43
43
  task = asyncio.ensure_future(reg.services[0]())
44
44
  try:
45
- await until(lambda: "ilmp/mp1" in seen, "not published")
45
+ await until(lambda: "ilmp/mp1/descriptor" in seen, "not published")
46
46
  assert seen["ilmp/_producer/tuya"] == "online"
47
47
  await until(lambda: (state.get_plugin_data("rustuya-local") or {}).get("devices"), "no status", n=300)
48
48
  status = state.get_plugin_data("rustuya-local")
49
49
  assert [d["id"] for d in status["devices"]] == ["mp1"]
50
50
 
51
51
  await state.set_cloud({"mp1": Device.from_dict(lamp("mp1")), "mp2": Device.from_dict(lamp("mp2"))})
52
- await until(lambda: "ilmp/mp2" in seen, "mp2 not followed")
52
+ await until(lambda: "ilmp/mp2/descriptor" in seen, "mp2 not followed")
53
53
  assert (tmp_path / "rustuya-local" / "custom_converters").is_dir()
54
54
  finally:
55
55
  task.cancel()
@@ -181,7 +181,7 @@ async def test_a_settings_change_restarts_the_service_and_bad_settings_wait_for_
181
181
  assert not task.done() # waits for a fix, no crash-and-retry
182
182
  api.save_settings(data, {"il": {"prefix": "ilr1"}, "options": {"pack": False}})
183
183
  await plugin.restart()
184
- await until(lambda: seen.get("ilr1/_producer/tuya") == "online" and "ilr1/mp1" in seen, "not started")
184
+ await until(lambda: seen.get("ilr1/_producer/tuya") == "online" and "ilr1/mp1/descriptor" in seen, "not started")
185
185
 
186
186
  previous = plugin.service
187
187
  (data / "settings.json").write_text('{"options":{"pack":"false"}}')
@@ -195,9 +195,9 @@ async def test_a_settings_change_restarts_the_service_and_bad_settings_wait_for_
195
195
 
196
196
  api.save_settings(data, {"il": {"prefix": "ilr2"}, "options": {"pack": False}})
197
197
  await plugin.restart()
198
- await until(lambda: seen.get("ilr2/_producer/tuya") == "online" and "ilr2/mp1" in seen, "not restarted")
198
+ await until(lambda: seen.get("ilr2/_producer/tuya") == "online" and "ilr2/mp1/descriptor" in seen, "not restarted")
199
199
  assert seen["ilr1/_producer/tuya"] == "" # the old namespace was retired
200
- assert seen["ilr1/mp1"] == ""
200
+ assert seen["ilr1/mp1/descriptor"] == ""
201
201
  finally:
202
202
  task.cancel()
203
203
  with pytest.raises(asyncio.CancelledError):
@@ -248,7 +248,7 @@ async def test_another_producer_is_shown_and_the_plugin_starts_once_it_is_gone(b
248
248
  task.cancel()
249
249
  with pytest.raises(asyncio.CancelledError):
250
250
  await task
251
- for topic in ("ilo/_producer/tuya", "ilo/mp1"):
251
+ for topic in ("ilo/_producer/tuya", "ilo/mp1/descriptor"):
252
252
  await w.publish(topic, "", 1, True)
253
253
  await asyncio.sleep(0.2)
254
254
  await w.close()
@@ -32,15 +32,15 @@ async def _retained(t: InProcessTransport, topic_filter: str) -> dict[str, str]:
32
32
 
33
33
  async def test_purge_il_clears_one_source_and_leaves_the_others():
34
34
  t = InProcessTransport()
35
- for topic, payload in {"il/a": _desc("a", "tuya"), "il/a/power": "true", "il/a/brightness": "40",
36
- "il/b": _desc("b", "tuya"), "il/z": _desc("z", "zigbee"), "il/z/power": "false",
35
+ for topic, payload in {"il/a": _desc("a", "tuya"), "il/a/descriptor": _desc("a", "tuya"), "il/a/power": "true", "il/a/brightness": "40",
36
+ "il/b/descriptor": _desc("b", "tuya"), "il/z/descriptor": _desc("z", "zigbee"), "il/z/power": "false",
37
37
  "il/_producer/tuya": "offline", "il/_producer/zigbee": "online"}.items():
38
38
  await t.publish(topic, payload, 1, True)
39
39
 
40
40
  ids = await purge_il(t, "il", "tuya", settle=0.05)
41
41
 
42
42
  assert ids == ["a", "b"]
43
- assert await _retained(t, "il/#") == {"il/z": _desc("z", "zigbee"), "il/z/power": "false",
43
+ assert await _retained(t, "il/#") == {"il/a": _desc("a", "tuya"), "il/z/descriptor": _desc("z", "zigbee"), "il/z/power": "false",
44
44
  "il/_producer/zigbee": "online"}
45
45
 
46
46
 
@@ -51,11 +51,11 @@ async def test_purge_il_refuses_while_the_producer_answers():
51
51
  runner = Runner(Hub([], il=IlTopics("il", "tuya")), t)
52
52
  await runner.start()
53
53
  await runner.drain()
54
- await t.publish("il/a", _desc("a", "tuya"), 1, True)
54
+ await t.publish("il/a/descriptor", _desc("a", "tuya"), 1, True)
55
55
  try:
56
56
  with pytest.raises(AnotherProducer):
57
57
  await purge_il(t, "il", "tuya", settle=0.05)
58
- assert "il/a" in await _retained(t, "il/#")
58
+ assert "il/a/descriptor" in await _retained(t, "il/#")
59
59
  finally:
60
60
  await runner.stop()
61
61
 
@@ -86,7 +86,7 @@ def test_the_purge_command_clears_the_daemons_devices(broker, tmp_path, capsys):
86
86
  c.connect("127.0.0.1", broker)
87
87
  c.loop_start()
88
88
  try:
89
- for topic, payload in (("ilp/a", _desc("a", "tuya")), ("ilp/a/power", "true"),
89
+ for topic, payload in (("ilp/a/descriptor", _desc("a", "tuya")), ("ilp/a/power", "true"),
90
90
  ("ilp/_producer/tuya", "offline")):
91
91
  c.publish(topic, payload, 1, True).wait_for_publish(5)
92
92
  config = tmp_path / "config.json"
@@ -66,7 +66,7 @@ async def test_devices_the_bridge_holds_reach_il_and_stop_goes_offline(tmp_path,
66
66
  await service.start()
67
67
  await settle(service, t)
68
68
  assert t.wills == [("il/_producer/tuya", "offline", 1, True)]
69
- assert "il/lamp1" in t.il.retained and "il/lamp2" not in t.il.retained # lamp2: not on the bridge
69
+ assert "il/lamp1/descriptor" in t.il.retained and "il/lamp2/descriptor" not in t.il.retained # lamp2: not on the bridge
70
70
  await service.stop()
71
71
  await service.stop() # idempotent
72
72
  assert t.il.retained["il/_producer/tuya"].payload == "offline"
@@ -88,7 +88,7 @@ async def test_an_overrides_directory_is_applied_and_followed(tmp_path, monkeypa
88
88
  hub_options={"overrides": {"lamp1": {"device": {"label": "Inline"}}}})
89
89
  await service.start()
90
90
  await settle(service, t)
91
- desc = json.loads(t.il.retained["il/lamp1"].payload)
91
+ desc = json.loads(t.il.retained["il/lamp1/descriptor"].payload)
92
92
  assert "glow" in desc["props"] and desc["model"] == "Desk lamp" and desc["label"] == "Inline"
93
93
 
94
94
  await t.bridge.publish("rustuya/error/lamp1", '{"errorCode":0}', 0, True)
@@ -99,7 +99,7 @@ async def test_an_overrides_directory_is_applied_and_followed(tmp_path, monkeypa
99
99
  (conv / "10.json").unlink() # followed: the converter goes away
100
100
  assert service.override_watcher.check()
101
101
  await settle(service, t)
102
- assert "glow" not in json.loads(t.il.retained["il/lamp1"].payload)["props"]
102
+ assert "glow" not in json.loads(t.il.retained["il/lamp1/descriptor"].payload)["props"]
103
103
  await service.stop()
104
104
 
105
105
 
@@ -123,7 +123,7 @@ async def test_a_cover_setting_written_through_il_is_saved_and_applied(tmp_path,
123
123
  await t.bridge.publish("rustuya/error/cur1", '{"errorCode":0}', 0, True)
124
124
  await t.bridge.publish("rustuya/event/state/cur1", '{"3":30}', 0, True)
125
125
  await settle(service, t)
126
- props = json.loads(t.il.retained["il/cur1"].payload)["props"]
126
+ props = json.loads(t.il.retained["il/cur1/descriptor"].payload)["props"]
127
127
  assert {"cover_invert_position", "cover_invert_set_position", "cover_state_source"} <= set(props)
128
128
  assert "cover_infer_motion" not in props # replaced by the state-source selector in 0.3.16
129
129
  assert "cover_invert_control" not in props # the target position opens and closes it
@@ -151,7 +151,7 @@ async def test_a_cover_setting_written_through_il_is_saved_and_applied(tmp_path,
151
151
  connect_il=t.connect_il)
152
152
  await service.start()
153
153
  await settle(service, t)
154
- assert not [p for p in json.loads(t.il.retained["il/cur1"].payload)["props"] if p.startswith("cover_")]
154
+ assert not [p for p in json.loads(t.il.retained["il/cur1/descriptor"].payload)["props"] if p.startswith("cover_")]
155
155
  await service.stop()
156
156
 
157
157
 
@@ -282,19 +282,23 @@ async def test_restart_removes_only_old_owned_devices():
282
282
  ids = ["lamp1", "lamp2"]
283
283
  await _answer_status(t.bridge, ids)
284
284
  # Another producer's data must survive reconciliation.
285
- await t.il.publish("il/other", '{"source":"other"}', 1, True)
285
+ await t.il.publish("il/other/descriptor", '{"source":"other"}', 1, True)
286
286
  first = Service(Settings(devices=[lamp(i) for i in ids]),
287
287
  connect_bridge=t.connect_bridge, connect_il=t.connect_il)
288
288
  await first.start()
289
289
  await settle(first, t)
290
290
  await first.stop()
291
+ # Simulate retained descriptors from a producer using the legacy layout.
292
+ for device_id in ids:
293
+ await t.il.publish(f"il/{device_id}", json.dumps({"id": device_id, "source": "tuya"}), 1, True)
291
294
  ids.remove("lamp2")
292
295
  second = Service(Settings(devices=[lamp("lamp1"), lamp("lamp2")]),
293
296
  connect_bridge=t.connect_bridge, connect_il=t.connect_il)
294
297
  await second.start()
295
298
  await settle(second, t)
296
- assert "il/lamp1" in t.il.retained and "il/other" in t.il.retained
297
- assert not any(topic == "il/lamp2" or topic.startswith("il/lamp2/") for topic in t.il.retained)
299
+ assert "il/lamp1/descriptor" in t.il.retained and "il/other/descriptor" in t.il.retained
300
+ assert "il/lamp1" in t.il.retained and "il/lamp2" in t.il.retained
301
+ assert not any(topic == "il/lamp2/descriptor" or topic.startswith("il/lamp2/") for topic in t.il.retained)
298
302
  await second.stop()
299
303
 
300
304
 
@@ -95,8 +95,8 @@ async def test_setup_publishes_and_unload_goes_offline(hass, broker, tmp_path, s
95
95
 
96
96
  await until(lambda: "il/_producer/tuya" in watcher.last)
97
97
  assert watcher.last["il/_producer/tuya"] == "online"
98
- await until(lambda: "il/lamp1" in watcher.last)
99
- assert json.loads(watcher.last["il/lamp1"])["kind"] == "light"
98
+ await until(lambda: "il/lamp1/descriptor" in watcher.last)
99
+ assert json.loads(watcher.last["il/lamp1/descriptor"])["kind"] == "light"
100
100
 
101
101
  # the runner asked the bridge for the device's state, exactly as the standalone daemon does
102
102
  watcher.publish("rustuya/error/lamp1", '{"errorCode":0,"errorMsg":"Connection Successful"}', retain=True)
@@ -141,7 +141,7 @@ async def test_setup_is_retried_while_another_producer_runs(hass, broker, tmp_pa
141
141
  finally:
142
142
  await runner.stop()
143
143
  await t.publish("il/_producer/tuya", "", 1, True)
144
- await t.publish("il/lamp1", "", 1, True)
144
+ await t.publish("il/lamp1/descriptor", "", 1, True)
145
145
  await t.close()
146
146
 
147
147
 
@@ -191,7 +191,7 @@ async def test_driving_the_first_device_does_no_blocking_io_in_the_event_loop(ha
191
191
  try:
192
192
  assert await hass.config_entries.async_setup(entry.entry_id)
193
193
  await hass.async_block_till_done()
194
- await until(lambda: "il/lamp1" in watcher.last)
194
+ await until(lambda: "il/lamp1/descriptor" in watcher.last)
195
195
  assert in_loop == []
196
196
  assert await hass.config_entries.async_unload(entry.entry_id)
197
197
  await hass.async_block_till_done()
@@ -213,25 +213,25 @@ async def test_deleting_the_entry_takes_its_devices_out_of_il(hass, broker, tmp_
213
213
  entry.add_to_hass(hass)
214
214
  watcher = Watcher(broker, registered=("lamp1",))
215
215
  try:
216
- watcher.publish("il/stale1", json.dumps({"il": 1, "id": "stale1", "source": "tuya", "props": {}}), retain=True)
216
+ watcher.publish("il/stale1/descriptor", json.dumps({"il": 1, "id": "stale1", "source": "tuya", "props": {}}), retain=True)
217
217
  watcher.publish("il/stale1/power", "true", retain=True)
218
- watcher.publish("il/zb1", json.dumps({"il": 1, "id": "zb1", "source": "zigbee", "props": {}}), retain=True)
218
+ watcher.publish("il/zb1/descriptor", json.dumps({"il": 1, "id": "zb1", "source": "zigbee", "props": {}}), retain=True)
219
219
  watcher.publish("il/zb1/power", "true", retain=True)
220
220
  assert await hass.config_entries.async_setup(entry.entry_id)
221
221
  await hass.async_block_till_done()
222
- await until(lambda: "il/lamp1" in watcher.last)
222
+ await until(lambda: "il/lamp1/descriptor" in watcher.last)
223
223
  watcher.publish("rustuya/event/state/lamp1", '{"20":true,"22":1000,"23":0}', retain=True)
224
224
  await until(lambda: watcher.last.get("il/lamp1/brightness") == "100")
225
225
 
226
226
  await hass.config_entries.async_remove(entry.entry_id)
227
227
  await hass.async_block_till_done()
228
228
 
229
- for topic in ("il/lamp1", "il/lamp1/brightness", "il/stale1", "il/stale1/power", "il/_producer/tuya"):
229
+ for topic in ("il/lamp1/descriptor", "il/lamp1/brightness", "il/stale1/descriptor", "il/stale1/power", "il/_producer/tuya"):
230
230
  try:
231
231
  await until(lambda t=topic: watcher.last.get(t) == "")
232
232
  except AssertionError:
233
233
  raise AssertionError((topic, dict(watcher.last)))
234
- assert watcher.last["il/zb1/power"] == "true" and json.loads(watcher.last["il/zb1"])["source"] == "zigbee"
234
+ assert watcher.last["il/zb1/power"] == "true" and json.loads(watcher.last["il/zb1/descriptor"])["source"] == "zigbee"
235
235
  # nothing is left retained for it: a fresh subscriber sees none of those topics
236
236
  late = Watcher(broker)
237
237
  await asyncio.sleep(0.5)
@@ -344,7 +344,7 @@ async def test_send_command_writes_a_raw_dp_and_refuses_a_lock(hass, broker, tmp
344
344
  try:
345
345
  assert await hass.config_entries.async_setup(entry.entry_id)
346
346
  await hass.async_block_till_done()
347
- await until(lambda: "il/lamp1" in watcher.last and "il/lock1" in watcher.last)
347
+ await until(lambda: "il/lamp1/descriptor" in watcher.last and "il/lock1/descriptor" in watcher.last)
348
348
 
349
349
  await hass.services.async_call(DOMAIN, "send_command", {"device_id": "lamp1", "dp": 20, "value": False},
350
350
  blocking=True)
@@ -75,7 +75,7 @@ async def running():
75
75
  async def test_start_publishes_presence_and_descriptors_and_values_flow_both_ways(running):
76
76
  il, runner, _, commands = running
77
77
  assert il.retained["il/_producer/tuya"].payload == "online"
78
- assert json.loads(il.retained["il/lamp1"].payload)["kind"] == "light"
78
+ assert json.loads(il.retained["il/lamp1/descriptor"].payload)["kind"] == "light"
79
79
 
80
80
  runner.on_bridge_message("lamp1", Connected())
81
81
  runner.on_bridge_message("lamp1", Message("state", {"20": True, "22": 1000, "23": 0}))
@@ -163,7 +163,7 @@ async def test_sync_adds_changes_and_removes_and_clears_before_removing(running)
163
163
  assert done == {"added": ["new"], "changed": ["edit"], "removed": ["gone"], "failed": []}
164
164
  await runner.drain()
165
165
  assert set(hub.records) == {"lamp1", "edit", "new"} and not [t for t in il.retained if t.startswith("il/gone")]
166
- assert json.loads(il.retained["il/edit"].payload)["label"] == "Renamed"
166
+ assert json.loads(il.retained["il/edit/descriptor"].payload)["label"] == "Renamed"
167
167
  bad = lamp("bad")
168
168
  bad["id"] = "_reserved"
169
169
  assert runner.sync_devices([lamp("lamp1"), bad])["failed"] == ["_reserved"]
@@ -175,10 +175,10 @@ async def test_a_device_an_override_refused_is_driven_once_the_overrides_are_fix
175
175
  assert runner.sync_devices([lamp("lamp1"), lamp("late")])["failed"] == ["late"] # it has no dp 99 to rename
176
176
  runner.reload({"late": {"dp": {"98": {"code": "gone"}}}}) # still refused
177
177
  await runner.drain()
178
- assert "late" not in hub.records and "il/late" not in il.retained
178
+ assert "late" not in hub.records and "il/late/descriptor" not in il.retained
179
179
  runner.reload({"late": {"device": {"label": "Late"}}}) # fixed: driven now, without a device-list change
180
180
  await runner.drain()
181
- assert "late" in hub.records and json.loads(il.retained["il/late"].payload)["label"] == "Late"
181
+ assert "late" in hub.records and json.loads(il.retained["il/late/descriptor"].payload)["label"] == "Late"
182
182
 
183
183
 
184
184
  async def test_overrides_the_engine_refuses_are_refused_whole_and_the_ones_in_effect_stay(running):
@@ -188,7 +188,7 @@ async def test_overrides_the_engine_refuses_are_refused_whole_and_the_ones_in_ef
188
188
  with pytest.raises(OverrideError): # any block, even one for no device here
189
189
  runner.reload({"lamp1": {"device": {"label": "Other"}}, "late": {"bogus": True}})
190
190
  await runner.drain()
191
- assert json.loads(il.retained["il/lamp1"].payload)["label"] == "Desk"
191
+ assert json.loads(il.retained["il/lamp1/descriptor"].payload)["label"] == "Desk"
192
192
 
193
193
 
194
194
  # ---- the device file --------------------------------------------------------------------------------------------------
@@ -299,7 +299,7 @@ async def test_the_override_watcher_reloads_the_hub(running, tmp_path):
299
299
  (tmp_path / "a.json").write_text(json.dumps({"overrides": {"p": {"converters": {"declarative": {**LIT, "word": "bright"}}}}}))
300
300
  assert watcher.check()
301
301
  await runner.drain()
302
- desc = json.loads(il.retained["il/lamp1"].payload)
302
+ desc = json.loads(il.retained["il/lamp1/descriptor"].payload)
303
303
  assert "lit" in desc["props"] and desc["label"] == "Desk"
304
304
  runner.on_bridge_message("lamp1", Connected())
305
305
  runner.on_bridge_message("lamp1", Message("state", {"20": True}))
@@ -309,16 +309,16 @@ async def test_the_override_watcher_reloads_the_hub(running, tmp_path):
309
309
  (tmp_path / "a.json").write_text(json.dumps({"overrides": {"p": {"converters": {"nope": {}}}}})) # refused: left out
310
310
  assert watcher.check()
311
311
  await runner.drain()
312
- assert "lit" not in json.loads(il.retained["il/lamp1"].payload)["props"]
312
+ assert "lit" not in json.loads(il.retained["il/lamp1/descriptor"].payload)["props"]
313
313
  assert [w.split(":")[0] for w in watcher.load().warnings] == ["a.json"]
314
314
  (tmp_path / "a.json").write_text(json.dumps({"overrides": {"p": {"converters": {"declarative": LIT}}}}))
315
315
  assert watcher.check()
316
316
  await runner.drain()
317
- assert "lit" in json.loads(il.retained["il/lamp1"].payload)["props"]
317
+ assert "lit" in json.loads(il.retained["il/lamp1/descriptor"].payload)["props"]
318
318
  (tmp_path / "a.json").unlink()
319
319
  assert watcher.check()
320
320
  await runner.drain()
321
- assert "lit" not in json.loads(il.retained["il/lamp1"].payload)["props"]
321
+ assert "lit" not in json.loads(il.retained["il/lamp1/descriptor"].payload)["props"]
322
322
 
323
323
 
324
324
  # ---- paho against a real broker --------------------------------------------------------------------------------------
@@ -371,7 +371,7 @@ async def test_paho_transport_runs_the_runner_survives_a_dropped_connection_and_
371
371
  runner = Runner(hub, il_side, on_bridge_command=commands.append)
372
372
  await runner.start()
373
373
  try:
374
- await until(lambda: seen.get("il/_producer/tuya") == "online" and "il/lamp1" in seen)
374
+ await until(lambda: seen.get("il/_producer/tuya") == "online" and "il/lamp1/descriptor" in seen)
375
375
  runner.on_bridge_message("lamp1", Connected())
376
376
  await until(lambda: BridgeCommand("lamp1", "get") in commands)
377
377
  runner.on_bridge_message("lamp1", Message("state", {"20": True, "22": 1000, "23": 0}))
@@ -15,7 +15,7 @@ def test_bridge_to_il_and_back():
15
15
  hub = Hub([light(), curtain()])
16
16
  start = topics(hub.start(), "il")
17
17
  assert start["il/_producer/tuya"].payload == "online" and start["il/_producer/tuya"].retain
18
- assert json.loads(start["il/lamp1"].payload)["kind"] == "light" and start["il/lamp1"].retain
18
+ assert json.loads(start["il/lamp1/descriptor"].payload)["kind"] == "light" and start["il/lamp1/descriptor"].retain
19
19
  assert start["il/lamp1/available"].payload == "false"
20
20
 
21
21
  # bridge says connected -> a `get` goes back to the bridge
@@ -85,7 +85,7 @@ def test_reload_overrides_republishes_and_clears_removed_props():
85
85
  pubs = hub.reload({"cur1": {"props": {"stop": {"hide": True}}, "device": {"label": "Bedroom"}}})
86
86
  by = topics(pubs)
87
87
  assert by["il/cur1/stop"].payload == "" and by["il/cur1/stop"].retain # M-11
88
- assert json.loads(by["il/cur1"].payload)["label"] == "Bedroom" and "stop" not in json.loads(by["il/cur1"].payload)["props"]
88
+ assert json.loads(by["il/cur1/descriptor"].payload)["label"] == "Bedroom" and "stop" not in json.loads(by["il/cur1/descriptor"].payload)["props"]
89
89
  assert by["il/cur1/position"].payload == "30" # worked out again from the last dps
90
90
  assert BridgeCommand("cur1", "get") not in pubs # which the host has: nothing to ask
91
91
  with pytest.raises(OverrideError):
@@ -114,7 +114,7 @@ def test_a_seeded_device_goes_out_up_with_its_values_and_never_false():
114
114
  hub = Hub([])
115
115
  out = hub.set_device(light(), [Connected(), Message("state", {"20": True, "22": 1000, "23": 0})])
116
116
  order = [p.topic for p in out if isinstance(p, Publish)]
117
- assert order[0] == "il/lamp1" # R-2: the descriptor first
117
+ assert order[0] == "il/lamp1/descriptor" # R-2: the descriptor first
118
118
  pubs = topics(out)
119
119
  assert pubs["il/lamp1/available"].payload == "true" and pubs["il/lamp1/brightness"].payload == "100"
120
120
  assert [p for p in out if isinstance(p, Publish) and p.topic == "il/lamp1/available"] == [pubs["il/lamp1/available"]]
@@ -124,7 +124,7 @@ def test_a_seeded_device_goes_out_up_with_its_values_and_never_false():
124
124
  assert topics(linked_only)["il/lamp1/available"].payload == "false" and BridgeCommand("lamp1", "get") in linked_only
125
125
  down = Hub([]).set_device(light(), [Disconnected()])
126
126
  assert topics(down)["il/lamp1/available"].payload == "false" and BridgeCommand("lamp1", "get") not in down
127
- assert next(p.topic for p in down if isinstance(p, Publish)) == "il/lamp1"
127
+ assert next(p.topic for p in down if isinstance(p, Publish)) == "il/lamp1/descriptor"
128
128
  # a sub-device has no link state of its own, only its retained snapshot: up with its values, and no `get`
129
129
  sub = Hub([]).set_device(light(), [Message("state", {"20": False, "22": 10, "23": 0})])
130
130
  assert topics(sub)["il/lamp1/available"].payload == "true" and not any(isinstance(p, BridgeCommand) for p in sub)
@@ -135,7 +135,7 @@ def test_set_device_adds_replaces_and_remove_clears_first():
135
135
  hub.start()
136
136
  cid = curtain()["id"]
137
137
  added = topics(hub.set_device(curtain())) # runtime add: descriptor out, retained
138
- assert added[f"il/{cid}"].retain and json.loads(added[f"il/{cid}"].payload)["kind"] == "cover"
138
+ assert added[f"il/{cid}/descriptor"].retain and json.loads(added[f"il/{cid}/descriptor"].payload)["kind"] == "cover"
139
139
  assert cid in hub.drivers
140
140
 
141
141
  # replacing a known device that lost a property clears that property before the new descriptor (M-11)
@@ -144,12 +144,12 @@ def test_set_device_adds_replaces_and_remove_clears_first():
144
144
  small["local_strategy"].pop("23")
145
145
  out = hub.set_device(small)
146
146
  order = [p.topic for p in out]
147
- assert order == ["il/lamp1/color_temperature", "il/lamp1", "il/lamp1/available"]
147
+ assert order == ["il/lamp1/color_temperature", "il/lamp1/descriptor", "il/lamp1/available"]
148
148
  assert out[0].payload == "" and out[0].retain
149
149
 
150
150
  # removal: every retained value cleared, then the descriptor last, and the device is gone
151
151
  out = hub.remove_device("lamp1")
152
- assert out[-1].topic == "il/lamp1" and out[-1].payload == "" and out[-1].retain
152
+ assert out[-1].topic == "il/lamp1/descriptor" and out[-1].payload == "" and out[-1].retain
153
153
  assert all(p.payload == "" and p.retain for p in out) and len(out) > 2
154
154
  assert hub.on_bridge_message(2, "lamp1", Message("state", {"20": True}), retained=True) == [] and hub.remove_device("lamp1") == []
155
155
 
@@ -0,0 +1,27 @@
1
+ """Descriptor routing and reserved transport topics."""
2
+ import pytest
3
+ from tuya_interpreter import Descriptor
4
+
5
+ from rustuya_local.host.il import IlTopics
6
+ from rustuya_local.service import _device_topics, _owned_ids
7
+
8
+
9
+ @pytest.mark.parametrize("prop", ["descriptor", "reject"])
10
+ def test_descriptor_refuses_properties_colliding_with_transport_topics(prop):
11
+ with pytest.raises(ValueError, match="reserved IL property"):
12
+ IlTopics().actions("d", [Descriptor({"id": "d", "props": {prop: {"type": "string"}}})])
13
+
14
+
15
+ def test_cleanup_only_recognizes_descriptor_topics_under_a_nested_prefix():
16
+ retained = {
17
+ "home/il/a": '{"source":"tuya"}',
18
+ "home/il/a/descriptor": '{"source":"tuya"}',
19
+ "home/il/a/power": 'true',
20
+ "home/il/old": '{"source":"tuya"}',
21
+ "home/il/b/descriptor": '{"source":"other"}',
22
+ "else/il/a/descriptor": '{"source":"tuya"}',
23
+ }
24
+ assert _owned_ids(retained, "home/il", "tuya") == {"a"}
25
+ assert _device_topics(retained, "home/il", {"a"}) == [
26
+ "home/il/a/power", "home/il/a/descriptor",
27
+ ]
@@ -1,9 +0,0 @@
1
- ### Changed
2
- - Wait for live bridge acknowledgements and the panel's registry refresh instead of sleeping after device commands.
3
- - Verify embedded bridge startup through a live MQTT config publication, supervise its native async task, and
4
- retain ownership when shutdown times out.
5
- - Wait for producer presence with an event and an explicit timeout instead of repeated short sleeps.
6
- - Use manager state-change notifications for wizard progress and cloud-fetch completion; remove status polling.
7
- - Use manager registry readiness instead of patching its private methods; require rustuya-manager 0.2.3.
8
- - Create session directories with a regular loop instead of a discarded list comprehension.
9
- - Wait for daemon readiness events in the subprocess test, allowing both startup timeout stages to finish.