rustuya-local 0.1.0.dev8__tar.gz → 0.1.0.dev10__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 (108) hide show
  1. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/CHANGELOG.md +12 -0
  2. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/PKG-INFO +2 -2
  3. rustuya_local-0.1.0.dev10/custom_components/rustuya/bridge_supervisor.py +104 -0
  4. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/config_flow.py +7 -13
  5. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/manager_session.py +7 -4
  6. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/manifest.json +3 -3
  7. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/panel.py +15 -16
  8. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/docs/ARCHITECTURE.md +3 -2
  9. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/docs/ROADMAP.md +2 -1
  10. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/docs/STATUS.md +3 -3
  11. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/pyproject.toml +2 -2
  12. rustuya_local-0.1.0.dev10/release-notes.md +9 -0
  13. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/bridge_client.py +38 -5
  14. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/runner.py +14 -5
  15. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/test_bridge_registration.py +52 -2
  16. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/test_daemon.py +16 -11
  17. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/ha/fake_manager.py +13 -3
  18. rustuya_local-0.1.0.dev10/tests/ha/test_bridge_supervisor.py +95 -0
  19. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/ha/test_manager_session.py +1 -1
  20. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/ha/test_panel.py +19 -6
  21. rustuya_local-0.1.0.dev8/custom_components/rustuya/bridge_supervisor.py +0 -66
  22. rustuya_local-0.1.0.dev8/custom_components/rustuya/manager_registry.py +0 -71
  23. rustuya_local-0.1.0.dev8/release-notes.md +0 -12
  24. rustuya_local-0.1.0.dev8/tests/ha/test_manager_registry.py +0 -69
  25. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/.github/dependabot.yml +0 -0
  26. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/.github/workflows/_test.yml +0 -0
  27. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/.github/workflows/ci.yml +0 -0
  28. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/.github/workflows/hacs.yml +0 -0
  29. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/.github/workflows/hassfest.yml +0 -0
  30. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/.github/workflows/release.yml +0 -0
  31. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/.gitignore +0 -0
  32. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/LICENSE +0 -0
  33. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/README.md +0 -0
  34. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/__init__.py +0 -0
  35. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/icon.png +0 -0
  36. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/icon.svg +0 -0
  37. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/icon@2x.png +0 -0
  38. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/logo.png +0 -0
  39. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/logo.svg +0 -0
  40. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/logo@2x.png +0 -0
  41. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/bridge_sync.py +0 -0
  42. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/broker.py +0 -0
  43. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/const.py +0 -0
  44. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/panel_bridge.py +0 -0
  45. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/services.yaml +0 -0
  46. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/strings.json +0 -0
  47. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/translations/en.json +0 -0
  48. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/translations/ko.json +0 -0
  49. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/www/rustuya-panel.js +0 -0
  50. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/docs/MIGRATING.md +0 -0
  51. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/docs/REDESIGN.md +0 -0
  52. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/docs/REVIEW.md +0 -0
  53. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/docs/history/ROADMAP-2026-10-12.md +0 -0
  54. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/hacs.json +0 -0
  55. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/scripts/build_dropin.py +0 -0
  56. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/scripts/release_notes.py +0 -0
  57. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/__init__.py +0 -0
  58. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/__main__.py +0 -0
  59. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/async_tools.py +0 -0
  60. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/cli.py +0 -0
  61. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/config.py +0 -0
  62. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/files.py +0 -0
  63. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/__init__.py +0 -0
  64. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/devices.py +0 -0
  65. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/hub.py +0 -0
  66. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/il.py +0 -0
  67. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/memory.py +0 -0
  68. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/mqtt.py +0 -0
  69. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/overrides.py +0 -0
  70. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/pack.py +0 -0
  71. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/rules.py +0 -0
  72. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/transport.py +0 -0
  73. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/manager_plugin/__init__.py +0 -0
  74. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/manager_plugin/api.py +0 -0
  75. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/manager_plugin/static/index.js +0 -0
  76. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/service.py +0 -0
  77. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/validation.py +0 -0
  78. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/conftest.py +0 -0
  79. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/devices.py +0 -0
  80. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/test_chain_in_process.py +0 -0
  81. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/test_chain_mqtt.py +0 -0
  82. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/test_full_stack.py +0 -0
  83. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/test_full_stack_custom_templates.py +0 -0
  84. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/test_full_stack_devices.py +0 -0
  85. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/test_manager_plugin.py +0 -0
  86. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/test_purge.py +0 -0
  87. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/test_service.py +0 -0
  88. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/ha/conftest.py +0 -0
  89. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/ha/test_bridge_probe.py +0 -0
  90. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/ha/test_config_flow.py +0 -0
  91. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/ha/test_init.py +0 -0
  92. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/ha/test_migrate.py +0 -0
  93. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_config.py +0 -0
  94. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_cover_settings.py +0 -0
  95. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_dropin.py +0 -0
  96. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_files.py +0 -0
  97. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_hazardous.py +0 -0
  98. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_host.py +0 -0
  99. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_hub.py +0 -0
  100. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_il_vectors.py +0 -0
  101. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_link_watcher.py +0 -0
  102. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_mqtt_lifecycle.py +0 -0
  103. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_pack.py +0 -0
  104. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_panel_js.py +0 -0
  105. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_plugin_api.py +0 -0
  106. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_release_notes.py +0 -0
  107. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_rule_files.py +0 -0
  108. {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/tuya_samples.py +0 -0
@@ -8,6 +8,18 @@ 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.dev10] — 2026-10-18
12
+
13
+ ### Changed
14
+ - Wait for live bridge acknowledgements and the panel's registry refresh instead of sleeping after device commands.
15
+ - Verify embedded bridge startup through a live MQTT config publication, supervise its native async task, and
16
+ retain ownership when shutdown times out.
17
+ - Wait for producer presence with an event and an explicit timeout instead of repeated short sleeps.
18
+ - Use manager state-change notifications for wizard progress and cloud-fetch completion; remove status polling.
19
+ - Use manager registry readiness instead of patching its private methods; require rustuya-manager 0.2.3.
20
+ - Create session directories with a regular loop instead of a discarded list comprehension.
21
+ - Wait for daemon readiness events in the subprocess test, allowing both startup timeout stages to finish.
22
+
11
23
  ## [0.1.0.dev8] — 2026-10-13
12
24
 
13
25
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: rustuya-local
3
- Version: 0.1.0.dev8
3
+ Version: 0.1.0.dev10
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
@@ -14,7 +14,7 @@ Requires-Dist: tuya-interpreter>=0.1.0a2
14
14
  Provides-Extra: fullstack
15
15
  Requires-Dist: tuyamock; extra == 'fullstack'
16
16
  Provides-Extra: manager
17
- Requires-Dist: rustuya-manager[web]>=0.2.2; extra == 'manager'
17
+ Requires-Dist: rustuya-manager[web]>=0.2.3; extra == 'manager'
18
18
  Provides-Extra: test
19
19
  Requires-Dist: httpx2; extra == 'test'
20
20
  Requires-Dist: ildevice-homeassistant; extra == 'test'
@@ -0,0 +1,104 @@
1
+ """Own the embedded bridge's native async task and verify its MQTT startup signal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import secrets
9
+ from urllib.parse import urlsplit
10
+
11
+ _LOGGER = logging.getLogger(__name__)
12
+ START_TIMEOUT = 15.0
13
+ STOP_TIMEOUT = 10.0
14
+
15
+
16
+ class EmbeddedBridge:
17
+ def __init__(self, broker: str, root: str, state_file: str, log_level: str = "warn",
18
+ username: str | None = None, password: str | None = None) -> None:
19
+ self.broker = broker
20
+ self.root = root
21
+ self.state_file = state_file
22
+ self.log_level = log_level
23
+ self.username = username
24
+ self.password = password
25
+ self._server = None
26
+ self._task: asyncio.Future | None = None
27
+ self._stopping = False
28
+
29
+ async def start(self) -> None:
30
+ import pyrustuyabridge as pb
31
+
32
+ from rustuya_local.host.mqtt import MqttTransport
33
+
34
+ if self._task is not None:
35
+ raise RuntimeError("the embedded bridge has already been started")
36
+ address = urlsplit(self.broker)
37
+ observer = MqttTransport(address.hostname, address.port or 1883,
38
+ client_id=f"rustuya-startup-{secrets.token_hex(8)}",
39
+ username=self.username, password=self.password,
40
+ tls=address.scheme == "mqtts")
41
+ ready = asyncio.Event()
42
+
43
+ def on_config(message) -> None:
44
+ # A retained config may belong to an earlier, dead bridge instance.
45
+ if message.retain or not message.payload:
46
+ return
47
+ try:
48
+ config = json.loads(message.payload)
49
+ except (ValueError, UnicodeDecodeError):
50
+ return
51
+ if isinstance(config, dict) and config:
52
+ ready.set()
53
+
54
+ waiter = None
55
+ try:
56
+ async with asyncio.timeout(START_TIMEOUT):
57
+ await observer.connect()
58
+ await observer.subscribe(f"{self.root}/bridge/config", on_config)
59
+ kwargs = {"mqtt_broker": self.broker, "mqtt_root_topic": self.root, "mqtt_retain": True,
60
+ "state_file": self.state_file, "no_signals": True, "log_level": self.log_level}
61
+ if self.username:
62
+ kwargs["mqtt_username"] = self.username
63
+ if self.password:
64
+ kwargs["mqtt_password"] = self.password
65
+ self._server = pb.PyBridgeServer(**kwargs)
66
+ self._stopping = False
67
+ self._task = asyncio.ensure_future(self._server.start_async())
68
+ self._task.add_done_callback(self._exited)
69
+ waiter = asyncio.create_task(ready.wait())
70
+ await asyncio.wait((waiter, self._task), return_when=asyncio.FIRST_COMPLETED)
71
+ if self._task.done():
72
+ await self._task # preserve the native startup exception
73
+ raise RuntimeError("the embedded bridge exited during startup")
74
+ except BaseException:
75
+ await self.stop()
76
+ raise
77
+ finally:
78
+ if waiter is not None:
79
+ waiter.cancel()
80
+ await asyncio.gather(waiter, return_exceptions=True)
81
+ try:
82
+ await observer.close()
83
+ except BaseException:
84
+ # Startup has not returned ownership to the caller yet.
85
+ await self.stop()
86
+ raise
87
+
88
+ def _exited(self, task: asyncio.Future) -> None:
89
+ if task.cancelled():
90
+ return
91
+ error = task.exception()
92
+ if not self._stopping:
93
+ _LOGGER.error("the embedded bridge stopped unexpectedly: %s", error or "server exited")
94
+
95
+ async def stop(self) -> None:
96
+ if self._task is None:
97
+ return
98
+ self._stopping = True
99
+ self._server.stop()
100
+ # Shield the native task: a timed-out shutdown must retain ownership so
101
+ # another stop can still wait for cleanup, rather than losing the server.
102
+ await asyncio.wait_for(asyncio.shield(asyncio.gather(self._task, return_exceptions=True)), STOP_TIMEOUT)
103
+ self._task = None
104
+ self._server = None
@@ -54,8 +54,6 @@ from .const import (
54
54
 
55
55
  _LOGGER = logging.getLogger(__name__)
56
56
  PROBE_TIMEOUT = 5.0 # seconds each for the broker to accept us and the bridge's retained config to arrive
57
- TICK = 0.1 # seconds between looks at the login's state while its progress shows
58
- TICKS = 50 # looks before the progress step is redrawn anyway
59
57
 
60
58
 
61
59
  def _qr_schema(qr_url: str) -> vol.Schema:
@@ -164,7 +162,7 @@ class _ManagerSteps:
164
162
  return self.async_show_progress_done(next_step_id="cloud_wizard_scan")
165
163
  return self.async_show_progress(step_id="cloud_wizard_progress", progress_action="cloud_wizard_working",
166
164
  description_placeholders={"message": session.message},
167
- progress_task=self.hass.async_create_task(self._async_wizard_tick()))
165
+ progress_task=self.hass.async_create_task(self._async_wait_wizard_state()))
168
166
 
169
167
  async def async_step_cloud_wizard_scan(self, user_input: dict[str, Any] | None = None) -> FlowResult:
170
168
  """`async_show_progress` only takes plain description text -- no selector, so a QR needs an actual form
@@ -176,18 +174,14 @@ class _ManagerSteps:
176
174
  return self.async_show_form(step_id="cloud_wizard_scan",
177
175
  data_schema=_qr_schema(self._manager.wizard.session.qr_url))
178
176
 
179
- async def _async_wizard_tick(self) -> None:
180
- """`async_show_progress` needs a task to await; the wizard already runs in the background
181
- (`WizardManager._run`), so this just waits for its state to change (or a short cap) so the
182
- frontend's re-poll of `cloud_wizard_progress` promptly redraws instead of only on its own timer."""
177
+ async def _async_wait_wizard_state(self) -> None:
178
+ """Wake HA progress when the manager reports a wizard state change."""
183
179
  from rustuya_manager.wizard import WizardState
184
180
 
185
- session = self._manager.wizard.session
186
- start_state = session.state
187
- for _ in range(TICKS):
188
- if session.state != start_state or session.state in (WizardState.DONE, WizardState.ERROR):
189
- return
190
- await asyncio.sleep(TICK)
181
+ wizard = self._manager.wizard
182
+ state = wizard.session.state
183
+ if state not in (WizardState.DONE, WizardState.ERROR):
184
+ await wizard.wait_state_change(state)
191
185
 
192
186
  def _sync_form(self, step_id: str, diff: Any, error: str | None = None) -> FlowResult:
193
187
  self._sync_shown = True
@@ -68,9 +68,7 @@ async def open_manager(*, broker: str, root: str, devices_path: str, username: s
68
68
  # `sync()`'s diff is only meaningful once the bridge's device list has arrived; without this every cloud
69
69
  # device looks "missing" (an empty bridge side), which is how a whole device list ended up offered for
70
70
  # registration.
71
- from .manager_registry import ready_registry
72
-
73
- await ready_registry(manager, WAIT)
71
+ await manager.wait_ready(bootstrap_timeout=WAIT, bridge_timeout=WAIT)
74
72
  except BaseException:
75
73
  await close_manager(manager)
76
74
  raise
@@ -103,7 +101,12 @@ async def open_for_entry(hass: Any, data: dict[str, Any], *, run_bridge: bool =
103
101
  dirs.append(os.path.dirname(state_file))
104
102
  bridge = {"bridge_state": state_file,
105
103
  "bridge_log_level": data.get(CONF_BRIDGE_LOG_LEVEL, DEFAULT_BRIDGE_LOG_LEVEL)}
106
- await hass.async_add_executor_job(lambda: [os.makedirs(d, exist_ok=True) for d in dirs if d])
104
+ def ensure_directories() -> None:
105
+ for directory in dirs:
106
+ if directory:
107
+ os.makedirs(directory, exist_ok=True)
108
+
109
+ await hass.async_add_executor_job(ensure_directories)
107
110
  username, password = broker.credentials(data)
108
111
  return await open_manager(broker=broker.url(data), root=data[CONF_BRIDGE_ROOT],
109
112
  devices_path=data[CONF_DEVICES_PATH], username=username, password=password, **bridge)
@@ -11,10 +11,10 @@
11
11
  "requirements": [
12
12
  "tuya-interpreter>=0.1.0a2",
13
13
  "paho-mqtt>=2.0",
14
- "rustuya-manager>=0.2.2",
14
+ "rustuya-manager>=0.2.3",
15
15
  "pyrustuyabridge>=0.4",
16
- "rustuya-local>=0.1.0.dev8"
16
+ "rustuya-local>=0.1.0.dev10"
17
17
  ],
18
18
  "single_config_entry": true,
19
- "version": "0.1.0.dev8"
19
+ "version": "0.1.0.dev10"
20
20
  }
@@ -392,7 +392,6 @@ class CloudFetch:
392
392
 
393
393
  IDLE_TIMEOUT = 30.0 # seconds without a poll before the session is closed
394
394
  MAX_TIME = 600.0 # a QR not scanned in 10 minutes is given up
395
- WATCH_INTERVAL = 0.5 # seconds between looks at the wizard's state
396
395
 
397
396
  def __init__(self, hass: HomeAssistant, entry: Any) -> None:
398
397
  self.hass, self.entry = hass, entry
@@ -435,14 +434,21 @@ class CloudFetch:
435
434
 
436
435
  async def _watch(self) -> None:
437
436
  while self.running and not self._finishing:
438
- await asyncio.sleep(self.WATCH_INTERVAL)
439
437
  session = self.manager.wizard.session
440
438
  state = _wizard_state(session)
441
- now = self.hass.loop.time()
442
439
  if state in ("done", "error"):
443
440
  await self._finish(_outcome(state, session.message or "", session.error))
444
- elif now - self._polled > self.IDLE_TIMEOUT or now - self._started > self.MAX_TIME:
441
+ return
442
+ now = self.hass.loop.time()
443
+ remaining = min(self._polled + self.IDLE_TIMEOUT, self._started + self.MAX_TIME) - now
444
+ if remaining <= 0:
445
445
  await self._finish(_outcome("cancelled", "Stopped: the page stopped asking, or it took too long"))
446
+ return
447
+ try:
448
+ await asyncio.wait_for(self.manager.wizard.wait_state_change(session.state), remaining)
449
+ except TimeoutError:
450
+ # A page poll may have extended the idle deadline while we waited.
451
+ continue
446
452
 
447
453
  async def _finish(self, final: dict[str, Any]) -> None:
448
454
  from . import async_refresh_devices, manager_session
@@ -457,6 +463,9 @@ class CloudFetch:
457
463
  finally:
458
464
  # reported only now: the page reloads the bridge list on `done`, which needs the session lock this held
459
465
  self.final = final
466
+ if self._task is not None and self._task is not asyncio.current_task():
467
+ self._task.cancel()
468
+ await asyncio.gather(self._task, return_exceptions=True)
460
469
 
461
470
 
462
471
  def _fetch_status(fetch: CloudFetch | None) -> dict[str, Any]:
@@ -554,7 +563,6 @@ class BridgeView(_View):
554
563
 
555
564
  url = "/api/rustuya/bridge"
556
565
  name = "api:rustuya:bridge"
557
- SETTLE = 1.5 # seconds for the bridge's replies to land before the list is read again after a POST
558
566
 
559
567
  @_entry_view
560
568
  async def get(self, request: web.Request, entry: Any, runtime: Any) -> web.Response:
@@ -587,13 +595,6 @@ class BridgeView(_View):
587
595
  return self.json_message(str(e), HTTPStatus.BAD_REQUEST)
588
596
  return await self._session(request, entry, runtime, None, manual=device, editing=True)
589
597
 
590
- @staticmethod
591
- async def _wait_registry_refresh(manager: Any) -> None:
592
- # Older manager versions refresh immediately and have no wait method.
593
- wait = getattr(manager, "wait_registry_refresh", None)
594
- if wait is not None:
595
- await wait()
596
-
597
598
  async def _session(self, request: web.Request, entry: Any, runtime: Any,
598
599
  selection: dict[str, list[str]] | None, *, manual: dict[str, str] | None = None,
599
600
  editing: bool = False) -> web.Response:
@@ -622,14 +623,12 @@ class BridgeView(_View):
622
623
  await manager.publish_command("add", target_id=manual["id"], target_name=manual.get("name"),
623
624
  extra={k: v for k, v in manual.items() if k not in ("id", "name")} or None)
624
625
  sent = 1
625
- await asyncio.sleep(self.SETTLE)
626
- await self._wait_registry_refresh(manager)
626
+ await manager.wait_registry_refresh()
627
627
  diff = await manager.sync()
628
628
  if selection is not None:
629
629
  sent = await bridge_sync.apply(manager, diff, selection) # gateways first, as the flows do
630
630
  if sent:
631
- await asyncio.sleep(self.SETTLE)
632
- await self._wait_registry_refresh(manager)
631
+ await manager.wait_registry_refresh()
633
632
  diff = await manager.sync()
634
633
  state = getattr(manager, "state", None)
635
634
  return self.json({"devices": bridge_sync.listing(diff, getattr(state, "bridge", None)), "sent": sent,
@@ -74,5 +74,6 @@ requests share a lock covering the current-entry read, patch merge, validation,
74
74
  ## Verification and coupling
75
75
 
76
76
  Unit tests, in-process services, real MQTT brokers, Tuya emulators and HA integration tests verify different
77
- boundaries. They do not replace real LAN-device tests. Driver state internals and the manager's private registry API
78
- remain coupling points that need checking when dependencies change. See [REVIEW](REVIEW.md) for fixes and limits.
77
+ boundaries. They do not replace real LAN-device tests. Driver state internals remain a coupling point that needs
78
+ checking when dependencies change. Manager sessions use public registry readiness and wizard state-change APIs;
79
+ HA no longer patches manager internals or polls wizard state. See [REVIEW](REVIEW.md) for fixes and limits.
@@ -14,7 +14,8 @@ Completed milestones and earlier deployment records are preserved in the [previo
14
14
 
15
15
  ## Maintenance
16
16
 
17
- - [ ] Reduce dependence on driver state internals in `Hub._same` and private manager registry APIs through public contracts.
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
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.
19
20
  - [ ] Define the supported drop-in OS/architectures and provide platform assets as needed. Each archive includes its build host's native library.
20
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-13. Baseline: this repository's `0.1.0.dev8` code.
3
+ Updated: 2026-10-18. Baseline: `0.1.0.dev10`.
4
4
  The state of deployed servers has not been checked.
5
5
 
6
6
  ## Implementation
@@ -12,12 +12,12 @@ 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.dev8` |
15
+ | rustuya-local | `0.1.0.dev10` |
16
16
  | Python | Base package `>=3.13`; full tests use 3.14 |
17
17
  | tuya-interpreter | `>=0.1.0a2` |
18
18
  | pyrustuyabridge | `>=0.4` |
19
19
  | paho-mqtt | `>=2.0` |
20
- | rustuya-manager | `>=0.2.2` in the manager test extra and HA manifest |
20
+ | rustuya-manager | `>=0.2.3` in the manager test extra and HA manifest |
21
21
 
22
22
  These are minimum requirements; installed versions may differ.
23
23
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "rustuya-local"
7
- version = "0.1.0.dev8"
7
+ version = "0.1.0.dev10"
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"
@@ -31,7 +31,7 @@ rustuya-local = "rustuya_local.manager_plugin:register"
31
31
  test = ["pytest", "pytest-asyncio", "ildevice-homeassistant", "httpx2"] # ildevice-homeassistant: the IL consumer; httpx2: Starlette TestClient
32
32
  test-ha = ["pytest-homeassistant-custom-component"] # custom_components/rustuya's own tests
33
33
  fullstack = ["tuyamock"] # the device emulator, for tests/e2e/test_full_stack*.py (pyrustuyabridge is now a base dep)
34
- manager = ["rustuya-manager[web]>=0.2.2"] # tests/e2e/test_manager_plugin*.py: the manager's real plugin host, its web app
34
+ manager = ["rustuya-manager[web]>=0.2.3"] # tests/e2e/test_manager_plugin*.py: the manager's real plugin host, its web app
35
35
 
36
36
  [tool.hatch.build.targets.wheel]
37
37
  packages = ["src/rustuya_local"]
@@ -0,0 +1,9 @@
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.
@@ -168,6 +168,7 @@ class BridgeClient(_BridgeFollower):
168
168
  super().__init__(transport, root)
169
169
  self.runner: Runner | None = None
170
170
  self._pending: set[asyncio.Task] = set()
171
+ self._command_waiters: dict[tuple[str, str], asyncio.Future] = {}
171
172
  # registration tracking (only when `sync_devices` was ever called; otherwise the caller owns the Hub's set)
172
173
  self._records: dict[str, dict] | None = None # every device in the device file, by id
173
174
  self._registered: set[str] | None = None # what the bridge holds; None until its first `status`
@@ -186,6 +187,9 @@ class BridgeClient(_BridgeFollower):
186
187
  self._last_state: dict[str, dict] = {}
187
188
 
188
189
  def stop(self) -> None:
190
+ for waiter in self._command_waiters.values():
191
+ if not waiter.done():
192
+ waiter.set_exception(RuntimeError("bridge client stopped"))
189
193
  self._registry_ready.clear()
190
194
  if self._status_timer is not None:
191
195
  self._status_timer.cancel()
@@ -223,11 +227,30 @@ class BridgeClient(_BridgeFollower):
223
227
  return {key: dict(value) for key, value in (self._registry or {}).items()}
224
228
 
225
229
  async def publish_command(self, action: str, *, target_id: str, target_name: str | None = None,
226
- extra: dict[str, Any] | None = None) -> None:
227
- """Send a panel command through the service's established connection."""
228
- await asyncio.wait_for(self._ready.wait(), 15.0)
229
- await self._publish(action, target_id, {**(extra or {}), **({"name": target_name} if target_name else {})},
230
- strict=True)
230
+ extra: dict[str, Any] | None = None, timeout: float = 15.0) -> None:
231
+ """Send a panel mutation and wait for its live bridge acknowledgement.
232
+
233
+ A subsequent status request can otherwise overtake a concurrent mutation.
234
+ The bridge has no request IDs; reject overlapping commands for the same action/id.
235
+ """
236
+ key = (action, target_id)
237
+ if key in self._command_waiters:
238
+ raise RuntimeError(f"bridge {action} for {target_id} is already pending")
239
+ waiter = asyncio.get_running_loop().create_future()
240
+ self._command_waiters[key] = waiter
241
+ try:
242
+ async with asyncio.timeout(timeout):
243
+ await self._ready.wait()
244
+ await self._publish(action, target_id,
245
+ {**(extra or {}), **({"name": target_name} if target_name else {})}, strict=True)
246
+ await waiter
247
+ finally:
248
+ self._command_waiters.pop(key, None)
249
+ # Retrieve a shutdown error even if publishing failed at the same time.
250
+ if waiter.done() and not waiter.cancelled():
251
+ waiter.exception()
252
+ else:
253
+ waiter.cancel()
231
254
 
232
255
  async def _publish(self, action: str, device_id: str, extra: dict[str, Any], *, strict: bool = False) -> None:
233
256
  if self._templates is None:
@@ -355,6 +378,16 @@ class BridgeClient(_BridgeFollower):
355
378
  self._last_conn[device_id] = conn
356
379
  self.runner.on_bridge_message(device_id, conn, retained=retained) # a retained one asks for no `get`
357
380
  return
381
+ if not retained:
382
+ key = (parsed.get("action"), parsed.get("id") or vars_.get("id"))
383
+ # Only string identities are valid, including in malformed broker input.
384
+ if all(isinstance(part, str) for part in key):
385
+ waiter = self._command_waiters.get(key)
386
+ if waiter is not None and not waiter.done():
387
+ if parsed.get("status") == "ok":
388
+ waiter.set_result(None)
389
+ elif parsed.get("status") == "error":
390
+ waiter.set_exception(RuntimeError(str(parsed.get("error") or "bridge command failed")))
358
391
  if self._records is None or retained: # a retained reply is an old one; `status` is asked live
359
392
  return
360
393
  if parsed.get("action") == "status":
@@ -44,12 +44,21 @@ async def producer_running(il: Transport, presence: str, timeout: float = 2.0) -
44
44
  None: it is `online` but nothing answered: a producer whose Last Will never reached the broker (a power cut that
45
45
  took the broker along), or one on tuya2ildevice before 0.3.5, which does not answer."""
46
46
  seen: list[str] = []
47
- unsub = await il.subscribe(presence, lambda m: seen.append(_text(m.payload)) if m.retain else None)
47
+ received = asyncio.Event()
48
+
49
+ def on_presence(message: Message) -> None:
50
+ if message.retain:
51
+ seen.append(_text(message.payload))
52
+ received.set()
53
+
54
+ unsub = await il.subscribe(presence, on_presence)
48
55
  try:
49
- for _ in range(10): # the retained replay, if any
50
- if seen:
51
- break
52
- await asyncio.sleep(0.05)
56
+ # MQTT has no end-of-retained-replay signal. Bound the wait explicitly;
57
+ # an arriving presence wakes us immediately instead of waiting for a tick.
58
+ try:
59
+ await asyncio.wait_for(received.wait(), timeout)
60
+ except TimeoutError:
61
+ return False
53
62
  finally:
54
63
  unsub()
55
64
  if not seen or seen[-1] != "online":
@@ -321,10 +321,19 @@ async def test_panel_uses_service_registry_and_transport_without_a_manager_conne
321
321
  diff = await panel.sync()
322
322
  assert {d.id for d in diff.synced} == {'a', 'sub'}
323
323
  assert len(status_requests(bridge)) == asked
324
- await panel.publish_command('add', target_id='new', target_name='New', extra={'cid': 'c2', 'parent_id': 'a'})
324
+ command = asyncio.create_task(panel.publish_command(
325
+ 'add', target_id='new', target_name='New', extra={'cid': 'c2', 'parent_id': 'a'}))
326
+ await settle(runner, client, bridge, il)
327
+ await asyncio.sleep(0)
325
328
  assert json.loads(bridge.published[-1][1]) == {
326
329
  'action': 'add', 'id': 'new', 'name': 'New', 'cid': 'c2', 'parent_id': 'a'}
327
- await panel.remove_device('a')
330
+ assert not command.done()
331
+ await reply(bridge, json.dumps({'action': 'add', 'id': 'new', 'status': 'ok'}))
332
+ await command
333
+ command = asyncio.create_task(panel.remove_device('a'))
334
+ await asyncio.sleep(0)
335
+ await reply(bridge, json.dumps({'action': 'remove', 'id': 'a', 'status': 'ok'}))
336
+ await command
328
337
  refresh = asyncio.create_task(panel.wait_registry_refresh())
329
338
  await asyncio.sleep(0)
330
339
  await settle(runner, client, bridge, il)
@@ -334,3 +343,44 @@ async def test_panel_uses_service_registry_and_transport_without_a_manager_conne
334
343
  diff = await panel.sync()
335
344
  assert [d.id for d in diff.missing] == ['a']
336
345
  assert [d.id for d in diff.orphaned] == ['new']
346
+
347
+
348
+ async def test_panel_command_ignores_retained_and_unrelated_acknowledgements(chain):
349
+ import asyncio
350
+
351
+ from rustuya_local.host.transport import Message
352
+
353
+ bridge, _il, _runner, client = chain
354
+ command = asyncio.create_task(client.publish_command('add', target_id='new'))
355
+ await asyncio.sleep(0)
356
+ client._on_message(Message('rustuya/response/new', '{"action":"add","id":"new","status":"ok"}', True))
357
+ await reply(bridge, '{"action":"add","id":"other","status":"ok"}')
358
+ await reply(bridge, '{"action":"remove","id":"new","status":"ok"}')
359
+ await asyncio.sleep(0)
360
+ assert not command.done()
361
+ await reply(bridge, '{"action":"add","id":"new","status":"ok"}')
362
+ await command
363
+ assert not client._command_waiters
364
+
365
+
366
+ @pytest.mark.parametrize('outcome', ['timeout', 'cancel', 'stop', 'error'])
367
+ async def test_panel_command_cleans_up_pending_acknowledgement(chain, outcome):
368
+ import asyncio
369
+
370
+ bridge, _il, _runner, client = chain
371
+ command = asyncio.create_task(client.publish_command('add', target_id='new', timeout=0.02))
372
+ await asyncio.sleep(0)
373
+ if outcome == 'cancel':
374
+ command.cancel()
375
+ expected = asyncio.CancelledError
376
+ elif outcome == 'stop':
377
+ client.stop()
378
+ expected = RuntimeError
379
+ elif outcome == 'error':
380
+ await reply(bridge, '{"action":"add","id":"new","status":"error","error":"rejected"}')
381
+ expected = RuntimeError
382
+ else:
383
+ expected = TimeoutError
384
+ with pytest.raises(expected):
385
+ await command
386
+ assert not client._command_waiters
@@ -23,24 +23,29 @@ async def test_the_daemon_publishes_devices_and_goes_offline_on_sigterm(broker,
23
23
  cfg.write_text(json.dumps({"bridge": {"port": broker}, "il": {"port": broker, "prefix": "ild"},
24
24
  "devices": [lamp("daemon1")]}))
25
25
  seen = {}
26
+ online, offline = asyncio.Event(), asyncio.Event()
27
+
28
+ def on_message(message):
29
+ seen[message.topic] = message.payload.decode()
30
+ if seen.get("ild/_producer/tuya") == "online" and "ild/daemon1" in seen:
31
+ online.set()
32
+ if seen.get("ild/_producer/tuya") == "offline":
33
+ offline.set()
34
+
26
35
  watcher = MqttTransport("127.0.0.1", broker, client_id="watcher")
27
36
  await watcher.connect()
28
- await watcher.subscribe("ild/#", lambda m: seen.__setitem__(m.topic, m.payload.decode()))
37
+ await watcher.subscribe("ild/#", on_message)
29
38
  await answer_status(watcher, "rustuya", ["daemon1"])
30
39
  proc = subprocess.Popen([sys.executable, "-m", "rustuya_local", "run", "--config", str(cfg)], # noqa: ASYNC220 (spawning does not block; the test waits on the broker)
31
40
  stderr=subprocess.DEVNULL)
32
41
  try:
33
- for _ in range(100):
34
- if seen.get("ild/_producer/tuya") == "online" and "ild/daemon1" in seen:
35
- break
36
- await asyncio.sleep(0.05)
37
- assert seen["ild/_producer/tuya"] == "online" and json.loads(seen["ild/daemon1"])["kind"] == "light"
42
+ # Startup includes the producer-presence deadline and the missing
43
+ # bridge-config fallback. Wait for readiness, not 100 polling ticks.
44
+ await asyncio.wait_for(online.wait(), 10)
45
+ assert json.loads(seen["ild/daemon1"])["kind"] == "light"
38
46
  proc.send_signal(signal.SIGTERM)
39
- assert proc.wait(10) == 0
40
- for _ in range(100):
41
- if seen["ild/_producer/tuya"] == "offline":
42
- break
43
- await asyncio.sleep(0.05)
47
+ assert await asyncio.to_thread(proc.wait, 10) == 0
48
+ await asyncio.wait_for(offline.wait(), 10)
44
49
  assert seen["ild/_producer/tuya"] == "offline"
45
50
  finally:
46
51
  if proc.poll() is None:
@@ -3,6 +3,7 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
+ import asyncio
6
7
  from dataclasses import dataclass, field
7
8
 
8
9
 
@@ -23,12 +24,11 @@ class Session:
23
24
  class FakeWizard:
24
25
  """No background task, no wall-clock timing: `session.state` only changes when a test calls `advance()` (or the
25
26
  convenience `autoplay`, for a test that just wants a normal login to finish). `async_step_cloud_wizard_progress`
26
- polls with a real `asyncio.sleep`-based task (`_async_wait_wizard_tick`), so driving state changes from a task
27
- the flow's own machinery can observe — not a task of the fake's own that Home Assistant doesn't track — is what
28
- keeps `hass.async_block_till_done()` able to settle deterministically instead of racing it."""
27
+ waits for `wait_state_change()`, so `advance()` wakes its progress task without a polling timer."""
29
28
 
30
29
  def __init__(self, script: list[str] | None = None, autoplay: bool = True) -> None:
31
30
  self.session = Session()
31
+ self._changed = asyncio.Event()
32
32
  self._script = script or [WizardState.REQUESTING_QR, WizardState.AWAITING_SCAN, WizardState.LOGGED_IN,
33
33
  WizardState.FETCHING, WizardState.DONE]
34
34
  self._i = 0
@@ -40,6 +40,11 @@ class FakeWizard:
40
40
  self._advance()
41
41
  return self.session
42
42
 
43
+ async def wait_state_change(self, state):
44
+ while self.session.state == state:
45
+ await self._changed.wait()
46
+ return self.session
47
+
43
48
  async def read_saved_user_code(self):
44
49
  return None
45
50
 
@@ -49,6 +54,8 @@ class FakeWizard:
49
54
  self._advance()
50
55
 
51
56
  def _advance(self) -> None:
57
+ changed, self._changed = self._changed, asyncio.Event()
58
+ changed.set()
52
59
  self.session.state = self._script[self._i]
53
60
  if self.session.state == WizardState.AWAITING_SCAN:
54
61
  self.session.qr_url = "tuyaSmart--qrLogin?token=fake"
@@ -108,6 +115,9 @@ class FakeManager:
108
115
  async def __aexit__(self, *exc):
109
116
  self.closed = True
110
117
 
118
+ async def wait_registry_refresh(self):
119
+ pass
120
+
111
121
  async def sync(self):
112
122
  return self._sync_result
113
123