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.
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/CHANGELOG.md +12 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/PKG-INFO +2 -2
- rustuya_local-0.1.0.dev10/custom_components/rustuya/bridge_supervisor.py +104 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/config_flow.py +7 -13
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/manager_session.py +7 -4
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/manifest.json +3 -3
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/panel.py +15 -16
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/docs/ARCHITECTURE.md +3 -2
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/docs/ROADMAP.md +2 -1
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/docs/STATUS.md +3 -3
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/pyproject.toml +2 -2
- rustuya_local-0.1.0.dev10/release-notes.md +9 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/bridge_client.py +38 -5
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/runner.py +14 -5
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/test_bridge_registration.py +52 -2
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/test_daemon.py +16 -11
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/ha/fake_manager.py +13 -3
- rustuya_local-0.1.0.dev10/tests/ha/test_bridge_supervisor.py +95 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/ha/test_manager_session.py +1 -1
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/ha/test_panel.py +19 -6
- rustuya_local-0.1.0.dev8/custom_components/rustuya/bridge_supervisor.py +0 -66
- rustuya_local-0.1.0.dev8/custom_components/rustuya/manager_registry.py +0 -71
- rustuya_local-0.1.0.dev8/release-notes.md +0 -12
- rustuya_local-0.1.0.dev8/tests/ha/test_manager_registry.py +0 -69
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/.github/dependabot.yml +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/.github/workflows/_test.yml +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/.github/workflows/ci.yml +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/.github/workflows/hacs.yml +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/.github/workflows/hassfest.yml +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/.github/workflows/release.yml +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/.gitignore +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/LICENSE +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/README.md +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/__init__.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/icon.png +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/icon.svg +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/icon@2x.png +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/logo.png +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/logo.svg +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/brand/logo@2x.png +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/bridge_sync.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/broker.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/const.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/panel_bridge.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/services.yaml +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/strings.json +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/translations/en.json +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/translations/ko.json +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/www/rustuya-panel.js +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/docs/MIGRATING.md +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/docs/REDESIGN.md +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/docs/REVIEW.md +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/docs/history/ROADMAP-2026-10-12.md +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/hacs.json +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/scripts/build_dropin.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/scripts/release_notes.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/__init__.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/__main__.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/async_tools.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/cli.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/config.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/files.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/__init__.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/devices.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/hub.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/il.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/memory.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/mqtt.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/overrides.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/pack.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/rules.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/host/transport.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/manager_plugin/__init__.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/manager_plugin/api.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/manager_plugin/static/index.js +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/service.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/src/rustuya_local/validation.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/conftest.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/devices.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/test_chain_in_process.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/test_chain_mqtt.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/test_full_stack.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/test_full_stack_custom_templates.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/test_full_stack_devices.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/test_manager_plugin.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/test_purge.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/test_service.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/ha/conftest.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/ha/test_bridge_probe.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/ha/test_config_flow.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/ha/test_init.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/ha/test_migrate.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_config.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_cover_settings.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_dropin.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_files.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_hazardous.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_host.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_hub.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_il_vectors.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_link_watcher.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_mqtt_lifecycle.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_pack.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_panel_js.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_plugin_api.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_release_notes.py +0 -0
- {rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/unit/test_rule_files.py +0 -0
- {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.
|
|
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.
|
|
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
|
{rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/config_flow.py
RENAMED
|
@@ -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.
|
|
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
|
|
180
|
-
"""
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
{rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/manager_session.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
{rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/custom_components/rustuya/manifest.json
RENAMED
|
@@ -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.
|
|
14
|
+
"rustuya-manager>=0.2.3",
|
|
15
15
|
"pyrustuyabridge>=0.4",
|
|
16
|
-
"rustuya-local>=0.1.0.
|
|
16
|
+
"rustuya-local>=0.1.0.dev10"
|
|
17
17
|
],
|
|
18
18
|
"single_config_entry": true,
|
|
19
|
-
"version": "0.1.0.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
78
|
-
|
|
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
|
-
- [
|
|
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-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
await asyncio.
|
|
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":
|
{rustuya_local-0.1.0.dev8 → rustuya_local-0.1.0.dev10}/tests/e2e/test_bridge_registration.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
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/#",
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|