rustuya-local 0.1.0.dev4__tar.gz → 0.1.0.dev5__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.dev4 → rustuya_local-0.1.0.dev5}/CHANGELOG.md +11 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/PKG-INFO +13 -6
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/README.md +12 -5
- rustuya_local-0.1.0.dev5/custom_components/rustuya/manager_registry.py +71 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/manager_session.py +23 -1
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/manifest.json +2 -2
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/panel.py +8 -4
- rustuya_local-0.1.0.dev5/custom_components/rustuya/panel_bridge.py +48 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/www/rustuya-panel.js +82 -30
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/pyproject.toml +1 -1
- rustuya_local-0.1.0.dev5/release-notes.md +8 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/bridge_client.py +30 -3
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/host/pack.py +23 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/service.py +26 -5
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/test_bridge_registration.py +70 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/test_service.py +40 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/ha/fake_manager.py +4 -0
- rustuya_local-0.1.0.dev5/tests/ha/test_manager_registry.py +69 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/ha/test_panel.py +50 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_cover_settings.py +2 -1
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_host.py +2 -2
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_link_watcher.py +25 -3
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_pack.py +33 -0
- rustuya_local-0.1.0.dev5/tests/unit/test_panel_js.py +198 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_rule_files.py +1 -1
- rustuya_local-0.1.0.dev4/release-notes.md +0 -5
- rustuya_local-0.1.0.dev4/tests/unit/test_panel_js.py +0 -80
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/.github/dependabot.yml +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/.github/workflows/_test.yml +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/.github/workflows/ci.yml +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/.github/workflows/hacs.yml +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/.github/workflows/hassfest.yml +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/.github/workflows/release.yml +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/.gitignore +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/LICENSE +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/__init__.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/brand/icon.png +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/brand/icon.svg +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/brand/icon@2x.png +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/brand/logo.png +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/brand/logo.svg +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/brand/logo@2x.png +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/bridge_supervisor.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/bridge_sync.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/broker.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/config_flow.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/const.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/services.yaml +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/strings.json +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/translations/en.json +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/translations/ko.json +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/docs/MIGRATING.md +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/docs/REDESIGN.md +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/docs/ROADMAP.md +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/docs/STATUS.md +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/hacs.json +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/scripts/build_dropin.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/scripts/release_notes.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/__init__.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/__main__.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/async_tools.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/cli.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/config.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/files.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/host/__init__.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/host/devices.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/host/hub.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/host/il.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/host/memory.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/host/mqtt.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/host/overrides.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/host/rules.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/host/runner.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/host/transport.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/manager_plugin/__init__.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/manager_plugin/api.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/manager_plugin/static/index.js +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/validation.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/conftest.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/devices.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/test_chain_in_process.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/test_chain_mqtt.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/test_daemon.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/test_full_stack.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/test_full_stack_custom_templates.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/test_full_stack_devices.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/test_manager_plugin.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/test_purge.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/ha/conftest.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/ha/test_bridge_probe.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/ha/test_config_flow.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/ha/test_init.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/ha/test_manager_session.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/ha/test_migrate.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_config.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_dropin.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_files.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_hazardous.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_hub.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_il_vectors.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_plugin_api.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_release_notes.py +0 -0
- {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/tuya_samples.py +0 -0
|
@@ -8,6 +8,17 @@ stable release.
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.1.0.dev5] — 2026-10-11
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- Share the running service's complete bridge registry and MQTT command connection with the panel, so panel listing and edits do not open a second registry requester. Require complete contiguous registry pages for standalone setup sessions.
|
|
15
|
+
- Keep explicit offline connection errors from being overwritten by replayed device state snapshots in the panel.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- Split the HA panel synced filter into online and offline, with live sub-device/gateway state and migration of saved filters.
|
|
19
|
+
- Check builtin rules when opening the HA panel; automatically install available updates when enabled, or enable the manual update button with a yellow dot when automatic updates are off.
|
|
20
|
+
- Allow manual builtin rule updates while automatic updates are disabled.
|
|
21
|
+
|
|
11
22
|
## [0.1.0.dev4] — 2026-10-11
|
|
12
23
|
|
|
13
24
|
### 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.dev5
|
|
4
4
|
Summary: Local Tuya control: rustuya-bridge -> tuya2ildevice -> IL (ildevice). HA-independent core.
|
|
5
5
|
Project-URL: Homepage, https://github.com/3735943886/rustuya-local
|
|
6
6
|
Project-URL: Repository, https://github.com/3735943886/rustuya-local
|
|
@@ -107,9 +107,13 @@ ship with tuya2ildevice and update when that package is upgraded. They are appli
|
|
|
107
107
|
|
|
108
108
|
Online updates fetch the latest [builtin rules](https://github.com/3735943886/tuya2ildevice/tree/feat/rust-rule-engine/rules/builtin)
|
|
109
109
|
into `rules/builtin/` inside the overrides directory at start and daily. In the Rustuya panel, use *Update builtin rules*
|
|
110
|
-
to fetch them now or *Automatically update builtin rules* to control automatic updates.
|
|
111
|
-
|
|
112
|
-
available
|
|
110
|
+
to fetch them now or *Automatically update builtin rules* to control automatic updates. Opening the panel checks for
|
|
111
|
+
updates: with automatic updates on, available changes are installed immediately without a yellow dot. With automatic
|
|
112
|
+
updates off, available changes enable the manual update button with a yellow dot. The button is disabled while checking
|
|
113
|
+
or when the installed rules are current. The daemon uses `"pack": false`
|
|
114
|
+
to disable automatic updates; the manager plugin has the same option in its settings. Installed builtin rules remain
|
|
115
|
+
available when automatic updates are off, and the panel still allows manual updates. Updates only replace or remove
|
|
116
|
+
files they downloaded, preserving user edits.
|
|
113
117
|
|
|
114
118
|
### Cover settings
|
|
115
119
|
|
|
@@ -136,9 +140,12 @@ Older `cover.infer_motion` and `converters.cover_motion` blocks are upgraded on
|
|
|
136
140
|
|
|
137
141
|
In Home Assistant the files can be edited from a sidebar panel: the integration's *Configure* → *Add the Rustuya
|
|
138
142
|
panel to the sidebar* adds it and links to it. The panel is for administrators. It fetches the device list from Tuya Cloud, shows the
|
|
139
|
-
bridge's devices against the cloud list
|
|
143
|
+
bridge's devices using the running service's complete registry and MQTT connection against the cloud list (missing, bridge only, mismatch, online and offline, each a filter you can
|
|
140
144
|
turn off; sub-devices under their gateway; each device's bridge connection, live while the panel is open) with add /
|
|
141
|
-
update / remove.
|
|
145
|
+
update / remove. Online and offline split devices whose cloud and bridge records match; selecting both shows the
|
|
146
|
+
previous synced view. Sub-devices use their own link state, falling back to their gateway when unavailable; an offline
|
|
147
|
+
gateway makes its sub-devices offline. Unknown state is grouped under offline. A gateway stays visible for context
|
|
148
|
+
when one of its children matches the filters. *Register manually* adds Wi-Fi devices or sub-devices without a cloud login, using the same
|
|
142
149
|
fields as rustuya-manager. Devices absent from the cloud list appear under *Bridge only*; their removal is not
|
|
143
150
|
preselected in bulk sync. The panel lists, edits and deletes user converter files (drop JSON rule files on it to copy them in),
|
|
144
151
|
updates builtin rules on demand, and has the
|
|
@@ -81,9 +81,13 @@ ship with tuya2ildevice and update when that package is upgraded. They are appli
|
|
|
81
81
|
|
|
82
82
|
Online updates fetch the latest [builtin rules](https://github.com/3735943886/tuya2ildevice/tree/feat/rust-rule-engine/rules/builtin)
|
|
83
83
|
into `rules/builtin/` inside the overrides directory at start and daily. In the Rustuya panel, use *Update builtin rules*
|
|
84
|
-
to fetch them now or *Automatically update builtin rules* to control automatic updates.
|
|
85
|
-
|
|
86
|
-
available
|
|
84
|
+
to fetch them now or *Automatically update builtin rules* to control automatic updates. Opening the panel checks for
|
|
85
|
+
updates: with automatic updates on, available changes are installed immediately without a yellow dot. With automatic
|
|
86
|
+
updates off, available changes enable the manual update button with a yellow dot. The button is disabled while checking
|
|
87
|
+
or when the installed rules are current. The daemon uses `"pack": false`
|
|
88
|
+
to disable automatic updates; the manager plugin has the same option in its settings. Installed builtin rules remain
|
|
89
|
+
available when automatic updates are off, and the panel still allows manual updates. Updates only replace or remove
|
|
90
|
+
files they downloaded, preserving user edits.
|
|
87
91
|
|
|
88
92
|
### Cover settings
|
|
89
93
|
|
|
@@ -110,9 +114,12 @@ Older `cover.infer_motion` and `converters.cover_motion` blocks are upgraded on
|
|
|
110
114
|
|
|
111
115
|
In Home Assistant the files can be edited from a sidebar panel: the integration's *Configure* → *Add the Rustuya
|
|
112
116
|
panel to the sidebar* adds it and links to it. The panel is for administrators. It fetches the device list from Tuya Cloud, shows the
|
|
113
|
-
bridge's devices against the cloud list
|
|
117
|
+
bridge's devices using the running service's complete registry and MQTT connection against the cloud list (missing, bridge only, mismatch, online and offline, each a filter you can
|
|
114
118
|
turn off; sub-devices under their gateway; each device's bridge connection, live while the panel is open) with add /
|
|
115
|
-
update / remove.
|
|
119
|
+
update / remove. Online and offline split devices whose cloud and bridge records match; selecting both shows the
|
|
120
|
+
previous synced view. Sub-devices use their own link state, falling back to their gateway when unavailable; an offline
|
|
121
|
+
gateway makes its sub-devices offline. Unknown state is grouped under offline. A gateway stays visible for context
|
|
122
|
+
when one of its children matches the filters. *Register manually* adds Wi-Fi devices or sub-devices without a cloud login, using the same
|
|
116
123
|
fields as rustuya-manager. Devices absent from the cloud list appear under *Bridge only*; their removal is not
|
|
117
124
|
preselected in bulk sync. The panel lists, edits and deletes user converter files (drop JSON rule files on it to copy them in),
|
|
118
125
|
updates builtin rules on demand, and has the
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Per-session compatibility guard for manager versions accepting incomplete status pages.
|
|
2
|
+
|
|
3
|
+
The response topic is broadcast: the service and a short-lived Manager can receive each other's
|
|
4
|
+
pages. Only a contiguous snapshot starting at zero may become the panel/config flow's registry.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def ready_registry(manager: Any, timeout: float) -> None:
|
|
14
|
+
client = getattr(manager, "client", None)
|
|
15
|
+
if client is None or not hasattr(client, "_handle_status_page"):
|
|
16
|
+
await manager.wait_ready()
|
|
17
|
+
return
|
|
18
|
+
|
|
19
|
+
request = client._request_status
|
|
20
|
+
handle = client._handle_status_page
|
|
21
|
+
offset = 0
|
|
22
|
+
total = None
|
|
23
|
+
ids: set[str] = set()
|
|
24
|
+
complete = False
|
|
25
|
+
|
|
26
|
+
async def request_status() -> None:
|
|
27
|
+
nonlocal offset, total, complete
|
|
28
|
+
if (not client._status_active or client._status_started is None
|
|
29
|
+
or time.monotonic() - client._status_started >= client._STATUS_CYCLE_TIMEOUT):
|
|
30
|
+
offset, total, complete = 0, None, False
|
|
31
|
+
ids.clear()
|
|
32
|
+
client._status_accum = None
|
|
33
|
+
await request()
|
|
34
|
+
|
|
35
|
+
async def handle_page(parsed: dict[str, Any]) -> None:
|
|
36
|
+
nonlocal offset, total, complete
|
|
37
|
+
if not client._status_active:
|
|
38
|
+
return
|
|
39
|
+
devices = parsed.get("devices")
|
|
40
|
+
start = parsed.get("offset", 0)
|
|
41
|
+
returned = parsed.get("returned", len(devices) if isinstance(devices, dict) else 0)
|
|
42
|
+
count = parsed.get("device_count")
|
|
43
|
+
more = parsed.get("has_more", False)
|
|
44
|
+
if (not isinstance(devices, dict) or type(start) is not int or start != offset
|
|
45
|
+
or type(returned) is not int or returned != len(devices)
|
|
46
|
+
or type(more) is not bool or (more and not returned)
|
|
47
|
+
or ids.intersection(devices)):
|
|
48
|
+
return
|
|
49
|
+
if count is not None and (type(count) is not int or count < start + returned
|
|
50
|
+
or more != (start + returned < count)):
|
|
51
|
+
return
|
|
52
|
+
if offset and count != total:
|
|
53
|
+
return
|
|
54
|
+
total = count
|
|
55
|
+
offset += returned
|
|
56
|
+
ids.update(devices)
|
|
57
|
+
complete = not more
|
|
58
|
+
await handle(parsed)
|
|
59
|
+
|
|
60
|
+
client._request_status = request_status
|
|
61
|
+
client._handle_status_page = handle_page
|
|
62
|
+
await client.wait_bootstrap(timeout=timeout)
|
|
63
|
+
if not client._bootstrap_done.is_set():
|
|
64
|
+
raise TimeoutError("bridge subscriptions are not ready")
|
|
65
|
+
# Bootstrap may have received pages before the guard was installed. Start a fresh
|
|
66
|
+
# offset-zero cycle after subscription readiness instead of trusting that snapshot.
|
|
67
|
+
client._status_started = None
|
|
68
|
+
await client._request_status()
|
|
69
|
+
await asyncio.wait_for(client._status_idle.wait(), timeout)
|
|
70
|
+
if not complete:
|
|
71
|
+
raise TimeoutError("incomplete bridge registry")
|
{rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/manager_session.py
RENAMED
|
@@ -68,7 +68,9 @@ 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
|
-
|
|
71
|
+
from .manager_registry import ready_registry
|
|
72
|
+
|
|
73
|
+
await ready_registry(manager, WAIT)
|
|
72
74
|
except BaseException:
|
|
73
75
|
await close_manager(manager)
|
|
74
76
|
raise
|
|
@@ -107,6 +109,26 @@ async def open_for_entry(hass: Any, data: dict[str, Any], *, run_bridge: bool =
|
|
|
107
109
|
devices_path=data[CONF_DEVICES_PATH], username=username, password=password, **bridge)
|
|
108
110
|
|
|
109
111
|
|
|
112
|
+
async def open_panel_for_entry(hass: Any, data: dict[str, Any], service: Any):
|
|
113
|
+
"""Share the loaded service's registry and command transport; no Manager MQTT connection."""
|
|
114
|
+
from .const import CONF_DEVICES_PATH
|
|
115
|
+
from .panel_bridge import PanelBridge
|
|
116
|
+
|
|
117
|
+
lock = _lock()
|
|
118
|
+
try:
|
|
119
|
+
await asyncio.wait_for(lock.acquire(), WAIT)
|
|
120
|
+
except TimeoutError as e:
|
|
121
|
+
raise Busy from e
|
|
122
|
+
session = PanelBridge(hass, service, data[CONF_DEVICES_PATH])
|
|
123
|
+
_HOLDERS[session] = lock
|
|
124
|
+
try:
|
|
125
|
+
await session.ready()
|
|
126
|
+
except BaseException:
|
|
127
|
+
await close_manager(session)
|
|
128
|
+
raise
|
|
129
|
+
return session
|
|
130
|
+
|
|
131
|
+
|
|
110
132
|
async def saved_user_code(hass: Any, data: dict[str, Any]) -> str | None:
|
|
111
133
|
"""The user code of the saved Tuya login (`tuyacreds.json` beside the device file, where a session's wizard keeps
|
|
112
134
|
it), read without a session: no broker connection and no lock, so the panel can fill it in before a fetch starts."""
|
|
@@ -349,11 +349,15 @@ async def _restart(hass: HomeAssistant, entry: Any, *, data: dict[str, Any] | No
|
|
|
349
349
|
|
|
350
350
|
|
|
351
351
|
class PackView(_View):
|
|
352
|
-
"""
|
|
352
|
+
"""GET checks for updates without writes; POST requests a sync."""
|
|
353
353
|
|
|
354
354
|
url = "/api/rustuya/pack"
|
|
355
355
|
name = "api:rustuya:pack"
|
|
356
356
|
|
|
357
|
+
@_entry_view
|
|
358
|
+
async def get(self, request: web.Request, entry: Any, runtime: Any) -> web.Response:
|
|
359
|
+
return self.json(await runtime.service.check_pack())
|
|
360
|
+
|
|
357
361
|
@_entry_view
|
|
358
362
|
async def post(self, request: web.Request, entry: Any, runtime: Any) -> web.Response:
|
|
359
363
|
return self.json({"started": runtime.service.sync_pack_now()})
|
|
@@ -536,8 +540,8 @@ def _manual_device(body: Any) -> dict[str, str]:
|
|
|
536
540
|
|
|
537
541
|
|
|
538
542
|
class BridgeView(_View):
|
|
539
|
-
"""The cloud list against
|
|
540
|
-
|
|
543
|
+
"""The cloud list against the running service's complete bridge registry, sharing the management session lock.
|
|
544
|
+
GET: every device with its category. POST
|
|
541
545
|
`{"add": [...], "update": [...], "remove": [...]}`: those commands (only for ids the diff has in that category),
|
|
542
546
|
then the list again. PATCH edits an existing device; PUT registers a manual device (id/name, WiFi key/ip/version or SubDevice cid/parent_id).
|
|
543
547
|
`online` is the running service's link state by id."""
|
|
@@ -592,7 +596,7 @@ class BridgeView(_View):
|
|
|
592
596
|
if not manager_session.available():
|
|
593
597
|
return self.json_message("rustuya-manager is not installed", HTTPStatus.NOT_IMPLEMENTED)
|
|
594
598
|
try:
|
|
595
|
-
manager = await manager_session.
|
|
599
|
+
manager = await manager_session.open_panel_for_entry(request.app["hass"], entry.data, runtime.service)
|
|
596
600
|
except manager_session.Busy:
|
|
597
601
|
return self.json_message("a Tuya login or device sync window is open; close it and try again",
|
|
598
602
|
HTTPStatus.CONFLICT)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Panel device management through the running service, without another MQTT registry requester."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from types import SimpleNamespace
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PanelBridge:
|
|
11
|
+
def __init__(self, hass: Any, service: Any, devices_path: str) -> None:
|
|
12
|
+
self.hass = hass
|
|
13
|
+
self.client = getattr(service, "bridge_client", None)
|
|
14
|
+
self.path = Path(devices_path)
|
|
15
|
+
self.state = SimpleNamespace(cloud={}, bridge={})
|
|
16
|
+
|
|
17
|
+
async def ready(self) -> None:
|
|
18
|
+
if self.client is None:
|
|
19
|
+
raise RuntimeError("Rustuya service is not ready")
|
|
20
|
+
await self.sync()
|
|
21
|
+
|
|
22
|
+
async def sync(self):
|
|
23
|
+
from rustuya_manager.diff import diff
|
|
24
|
+
from rustuya_manager.models import Device
|
|
25
|
+
|
|
26
|
+
def cloud():
|
|
27
|
+
if not self.path.exists():
|
|
28
|
+
return {}
|
|
29
|
+
data = json.loads(self.path.read_text("utf-8"))
|
|
30
|
+
records = data if isinstance(data, list) else data.values()
|
|
31
|
+
return {d["id"]: Device.from_dict(d) for d in records if "id" in d}
|
|
32
|
+
|
|
33
|
+
self.state.cloud = await self.hass.async_add_executor_job(cloud)
|
|
34
|
+
records = await self.client.registry()
|
|
35
|
+
self.state.bridge = {id: Device.from_dict({**record, "id": id}) for id, record in records.items()}
|
|
36
|
+
return diff(self.state.cloud, self.state.bridge)
|
|
37
|
+
|
|
38
|
+
async def publish_command(self, action: str, **kw) -> None:
|
|
39
|
+
await self.client.publish_command(action, **kw)
|
|
40
|
+
|
|
41
|
+
async def remove_device(self, target_id: str) -> None:
|
|
42
|
+
await self.publish_command("remove", target_id=target_id)
|
|
43
|
+
|
|
44
|
+
async def wait_registry_refresh(self) -> None:
|
|
45
|
+
await self.client.registry(refresh=True)
|
|
46
|
+
|
|
47
|
+
async def __aexit__(self, *exc) -> None:
|
|
48
|
+
pass
|
{rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/www/rustuya-panel.js
RENAMED
|
@@ -45,6 +45,8 @@ const STYLE = `
|
|
|
45
45
|
button { font: inherit; font-size: 14px; padding: 6px 14px; border-radius: 6px; cursor: pointer;
|
|
46
46
|
border: 1px solid var(--divider-color); background: var(--card-background-color); color: var(--primary-text-color); }
|
|
47
47
|
button.primary { background: var(--primary-color); border-color: var(--primary-color); color: var(--text-primary-color, #fff); }
|
|
48
|
+
button.update-available::before { content: ""; display: inline-block; width: 8px; height: 8px;
|
|
49
|
+
border-radius: 50%; background: #fbc02d; margin-inline-end: 8px; }
|
|
48
50
|
button:disabled { opacity: .5; cursor: default; }
|
|
49
51
|
.note { margin-top: 8px; }
|
|
50
52
|
.cloud { display: grid; gap: 8px; }
|
|
@@ -59,7 +61,7 @@ const STYLE = `
|
|
|
59
61
|
:host([narrow]) .setting { grid-template-columns: 1fr; }
|
|
60
62
|
:host([narrow]) .setting > .muted { grid-column: 1; }
|
|
61
63
|
/* bridge devices: rustuya-manager's category colors (sky / rose / amber / emerald) */
|
|
62
|
-
.cat-missing { --cat: #0ea5e9; } .cat-orphan { --cat: #f43f5e; } .cat-mismatch { --cat: #f59e0b; } .cat-synced { --cat: #10b981; }
|
|
64
|
+
.cat-missing { --cat: #0ea5e9; } .cat-orphan { --cat: #f43f5e; } .cat-mismatch { --cat: #f59e0b; } .cat-synced, .cat-online { --cat: #10b981; } .cat-offline { --cat: #94a3b8; }
|
|
63
65
|
.head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
64
66
|
.head h2 { margin: 0; }
|
|
65
67
|
.head .end { margin-left: auto; display: flex; gap: 8px; align-items: center; }
|
|
@@ -96,7 +98,7 @@ const STYLE = `
|
|
|
96
98
|
background: color-mix(in srgb, var(--cat) 8%, var(--card-background-color)); cursor: pointer;
|
|
97
99
|
-webkit-user-select: text; user-select: text; }
|
|
98
100
|
.dev .fields, .dev .reasons { cursor: text; }
|
|
99
|
-
.dev.cat-synced { background: var(--card-background-color); }
|
|
101
|
+
.dev.cat-online, .dev.cat-offline, .dev.cat-synced { background: var(--card-background-color); }
|
|
100
102
|
.dev.cat-synced.offline { --cat: #94a3b8; }
|
|
101
103
|
.dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; box-sizing: border-box; }
|
|
102
104
|
.dot.online { background: #10b981; }
|
|
@@ -150,7 +152,7 @@ const I18N = {
|
|
|
150
152
|
close_panel: "Close panel",
|
|
151
153
|
menu: "Panel menu", edit_device: "Edit device", edit_hint: "Edit the bridge values. Device ID and type cannot be changed.",
|
|
152
154
|
manual_add: "Register manually", manual_hint: "Register without Tuya Cloud. Leave IP/version blank for automatic detection. ID may be generated from IP or CID and name.",
|
|
153
|
-
cat_all: "all", cat_missing: "missing", cat_orphan: "Bridge only", cat_mismatch: "mismatch", cat_synced: "synced",
|
|
155
|
+
cat_all: "all", cat_missing: "missing", cat_orphan: "Bridge only", cat_mismatch: "mismatch", cat_synced: "synced", cat_online: "online", cat_offline: "offline",
|
|
154
156
|
cloud_code: "User code (only for a new login)", cloud_fetch: "Fetch", cancel: "Cancel",
|
|
155
157
|
cloud_qr_alt: "QR code to scan with the Smart Life or Tuya Smart app",
|
|
156
158
|
cloud_intro: "A saved Tuya login is reused. Without one (or when it has expired) a QR code to scan follows; the user code is only needed for that first login (Smart Life → Me → Settings → Account and Security).",
|
|
@@ -204,6 +206,8 @@ const I18N = {
|
|
|
204
206
|
confirm_hide_unsaved: "Unsaved changes in {names} will be lost. Remove the Rustuya panel from the sidebar anyway?",
|
|
205
207
|
and: " and ", not_hidden: "Not hidden: {error}", files_unreadable: "Cannot list the files: {error}",
|
|
206
208
|
no_files: "No files yet. Built-in fixes apply without any.",
|
|
209
|
+
pack_checking: "Checking for updates…", pack_current: "Builtin rules are up to date.",
|
|
210
|
+
pack_available: "Builtin rule updates are available.", pack_check_error: "Cannot check for updates: {error}",
|
|
207
211
|
pack_syncing: "Updating…", pack_off: "Online updates are off. Using the installed rules.", pack_never: "Using bundled rules; no online update yet.",
|
|
208
212
|
pack_added: "added", pack_updated: "updated", pack_removed: "removed", pack_kept: "kept",
|
|
209
213
|
pack_failed: "; failed: {list}", pack_error: "Update failed ({error}), {when}", pack_synced: "Updated {when}",
|
|
@@ -212,14 +216,14 @@ const I18N = {
|
|
|
212
216
|
name_first: "Name the file first (*.json)",
|
|
213
217
|
confirm_edit_pack: "{name} comes from the override pack. Once edited it is yours: the pack no longer updates or removes it.",
|
|
214
218
|
saved: "Saved {name}", confirm_delete: "Delete {name}?", deleted: "Deleted {name}", not_deleted: "Not deleted: {error}",
|
|
215
|
-
|
|
219
|
+
pack_unavailable: "Builtin rule updater is unavailable", cannot_sync: "Cannot update: {error}", pack_changed: "Builtin rules updated",
|
|
216
220
|
},
|
|
217
221
|
ko: {
|
|
218
222
|
origin_pack: "팩", origin_pack_edited: "팩, 수정됨",
|
|
219
223
|
close_panel: "패널 닫기",
|
|
220
224
|
menu: "패널 메뉴", edit_device: "기기 수정", edit_hint: "브리지에 등록된 값을 수정합니다. 기기 ID와 유형은 변경할 수 없습니다.",
|
|
221
225
|
manual_add: "수동 등록", manual_hint: "클라우드 없이 등록합니다. IP·버전을 비우면 자동 탐색합니다. ID는 IP 또는 CID와 이름으로 자동 생성할 수 있습니다.",
|
|
222
|
-
cat_all: "전체", cat_missing: "없음", cat_orphan: "브릿지 전용", cat_mismatch: "불일치", cat_synced: "일치",
|
|
226
|
+
cat_all: "전체", cat_missing: "없음", cat_orphan: "브릿지 전용", cat_mismatch: "불일치", cat_synced: "일치", cat_online: "온라인", cat_offline: "오프라인",
|
|
223
227
|
cloud_code: "사용자 코드 (새 로그인일 때만)", cloud_fetch: "가져오기", cancel: "취소",
|
|
224
228
|
cloud_qr_alt: "Smart Life 또는 Tuya Smart 앱으로 스캔할 QR 코드",
|
|
225
229
|
cloud_intro: "저장된 Tuya 로그인이 있으면 그대로 씁니다. 없거나 만료됐으면 스캔할 QR 코드가 이어서 나옵니다. 사용자 코드는 그 첫 로그인에만 필요합니다(Smart Life → 나 → 설정 → 계정 및 보안).",
|
|
@@ -273,6 +277,8 @@ const I18N = {
|
|
|
273
277
|
confirm_hide_unsaved: "{names}의 저장하지 않은 변경이 사라집니다. 그래도 사이드바에서 Rustuya 패널을 없앨까요?",
|
|
274
278
|
and: ", ", not_hidden: "숨기지 못했습니다: {error}", files_unreadable: "파일 목록을 읽을 수 없습니다: {error}",
|
|
275
279
|
no_files: "아직 파일이 없습니다. 기본 수정은 파일 없이도 적용됩니다.",
|
|
280
|
+
pack_checking: "업데이트 확인 중…", pack_current: "최신 Builtin 규칙을 사용 중입니다.",
|
|
281
|
+
pack_available: "Builtin 규칙 업데이트가 있습니다.", pack_check_error: "업데이트 확인 실패: {error}",
|
|
276
282
|
pack_syncing: "업데이트하는 중…", pack_off: "온라인 업데이트가 꺼져 있습니다. 설치된 규칙을 사용합니다.", pack_never: "패키지에 포함된 규칙 사용 중; 아직 온라인 업데이트하지 않았습니다.",
|
|
277
283
|
pack_added: "추가", pack_updated: "갱신", pack_removed: "삭제", pack_kept: "유지",
|
|
278
284
|
pack_failed: "; 실패: {list}", pack_error: "업데이트 실패 ({error}), {when}", pack_synced: "{when}에 업데이트함",
|
|
@@ -281,7 +287,7 @@ const I18N = {
|
|
|
281
287
|
name_first: "먼저 파일 이름을 정하세요 (*.json)",
|
|
282
288
|
confirm_edit_pack: "{name}은(는) 오버라이드 팩의 파일입니다. 수정하면 내 파일이 되어 팩이 더 이상 갱신하거나 지우지 않습니다.",
|
|
283
289
|
saved: "{name} 저장함", confirm_delete: "{name}을(를) 삭제할까요?", deleted: "{name} 삭제함", not_deleted: "삭제하지 못했습니다: {error}",
|
|
284
|
-
|
|
290
|
+
pack_unavailable: "Builtin 규칙 업데이트를 실행할 수 없습니다", cannot_sync: "업데이트할 수 없습니다: {error}", pack_changed: "Builtin 규칙이 업데이트되었습니다",
|
|
285
291
|
},
|
|
286
292
|
};
|
|
287
293
|
|
|
@@ -302,7 +308,7 @@ function t(key, vars = {}) {
|
|
|
302
308
|
|
|
303
309
|
// ---- bridge devices ------------------------------------------------------------------------------------------------
|
|
304
310
|
|
|
305
|
-
const CATEGORIES = ["missing", "orphan", "mismatch", "
|
|
311
|
+
const CATEGORIES = ["missing", "orphan", "mismatch", "online", "offline"];
|
|
306
312
|
const RANK = Object.fromEntries(CATEGORIES.map((c, i) => [c, i]));
|
|
307
313
|
const SORTS = ["id", "name", "category"]; // label: t(`sort_${key}`)
|
|
308
314
|
|
|
@@ -441,7 +447,7 @@ class BridgeSection {
|
|
|
441
447
|
this.links = {}; // live from rustuya/subscribe_links while the panel is open
|
|
442
448
|
this.unsub = null;
|
|
443
449
|
const f = stored("rustuya.filters", CATEGORIES);
|
|
444
|
-
this.filters = new Set(Array.isArray(f) ? f.
|
|
450
|
+
this.filters = new Set(Array.isArray(f) ? f.flatMap((c) => c === "synced" ? ["online", "offline"] : CATEGORIES.includes(c) ? [c] : []) : CATEGORIES);
|
|
445
451
|
const sort = stored("rustuya.sort", "id");
|
|
446
452
|
this.sort = SORTS.includes(sort) ? sort : "id";
|
|
447
453
|
this.expanded = new Set();
|
|
@@ -534,14 +540,26 @@ class BridgeSection {
|
|
|
534
540
|
requestAnimationFrame(() => { this.painting = false; this.paint(); });
|
|
535
541
|
}
|
|
536
542
|
|
|
537
|
-
link(id) {
|
|
538
|
-
if (id
|
|
539
|
-
|
|
540
|
-
|
|
543
|
+
link(id, seen = new Set()) {
|
|
544
|
+
if (seen.has(id)) return null;
|
|
545
|
+
seen.add(id);
|
|
546
|
+
const own = this.links[id] || (id in this.online ? { online: this.online[id], code: null, message: "" } : null);
|
|
547
|
+
const d = (this.devices || []).find((d) => d.id === id);
|
|
548
|
+
const s = d && (d.bridge || side(d));
|
|
549
|
+
if (s?.type === "SubDevice" && s.parent_id) {
|
|
550
|
+
const parent = this.link(s.parent_id, seen);
|
|
551
|
+
if (parent && !parent.online) return parent;
|
|
552
|
+
return own || parent;
|
|
553
|
+
}
|
|
554
|
+
return own;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
category(d) {
|
|
558
|
+
return d.category === "synced" ? (this.link(d.id)?.online ? "online" : "offline") : d.category;
|
|
541
559
|
}
|
|
542
560
|
|
|
543
561
|
count(cat) {
|
|
544
|
-
return (this.devices || []).filter((d) =>
|
|
562
|
+
return (this.devices || []).filter((d) => this.category(d) === cat).length;
|
|
545
563
|
}
|
|
546
564
|
|
|
547
565
|
paint() {
|
|
@@ -586,7 +604,7 @@ class BridgeSection {
|
|
|
586
604
|
|
|
587
605
|
// Gateways and WiFi devices at the top level with their sub-devices under them; a sub-device whose gateway is in
|
|
588
606
|
// neither list hangs under a placeholder. An entry shows when it or one of its sub-devices passes the filters; a
|
|
589
|
-
// shown gateway shows
|
|
607
|
+
// shown gateway shows its children for context, except when filtering synced devices by one link state.
|
|
590
608
|
tree() {
|
|
591
609
|
const byId = new Map(this.devices.map((d) => [d.id, d]));
|
|
592
610
|
const kids = new Map();
|
|
@@ -604,7 +622,7 @@ class BridgeSection {
|
|
|
604
622
|
for (const d of top) kids.delete(d.id);
|
|
605
623
|
for (const [id, list] of kids) if (!byId.has(id)) entries.push({ id, device: null, kids: list });
|
|
606
624
|
const value = (d) => this.sort === "name" ? (side(d).name || "").toLowerCase()
|
|
607
|
-
: this.sort === "category" ? RANK[
|
|
625
|
+
: this.sort === "category" ? RANK[this.category(d)] : d.id;
|
|
608
626
|
// a placeholder (a missing gateway) sorts as a missing device, by its id
|
|
609
627
|
const key = (e) => (e.device ? value(e.device) : this.sort === "category" ? RANK.missing : e.id);
|
|
610
628
|
const cmp = (a, b) => {
|
|
@@ -613,9 +631,11 @@ class BridgeSection {
|
|
|
613
631
|
};
|
|
614
632
|
const out = [];
|
|
615
633
|
for (const e of entries) {
|
|
616
|
-
const shown = e.device ? this.filters.has(e.device
|
|
617
|
-
const passing = e.kids.filter((k) => this.filters.has(
|
|
618
|
-
|
|
634
|
+
const shown = e.device ? this.filters.has(this.category(e.device)) : this.filters.has("missing");
|
|
635
|
+
const passing = e.kids.filter((k) => this.filters.has(this.category(k)));
|
|
636
|
+
const context = shown && (!e.device || e.device.category !== "synced"
|
|
637
|
+
|| (this.filters.has("online") && this.filters.has("offline")));
|
|
638
|
+
if (shown || passing.length) out.push({ ...e, kids: context ? e.kids : passing });
|
|
619
639
|
}
|
|
620
640
|
out.sort(cmp);
|
|
621
641
|
for (const e of out) e.kids.sort((a, b) => cmp({ device: a, id: a.id }, { device: b, id: b.id }));
|
|
@@ -631,22 +651,23 @@ class BridgeSection {
|
|
|
631
651
|
card(d, child) {
|
|
632
652
|
const s = side(d);
|
|
633
653
|
const name = nameOf(d) || d.id;
|
|
654
|
+
const category = this.category(d);
|
|
634
655
|
// the bridge's word on the connection (live while the panel is open, else the service's); none for a missing one
|
|
635
656
|
const ln = d.category === "missing" ? null : this.link(d.id);
|
|
636
657
|
const live = ln ? (ln.online ? "online" : "offline") : null;
|
|
637
658
|
const acts = el("span", { class: "acts" },
|
|
638
659
|
live ? el("span", { class: `dot ${live}`, title: live === "online" ? t("link_online") : t("link_offline") }) : "",
|
|
639
|
-
el("span", { class: `pill cat-${
|
|
660
|
+
el("span", { class: `pill cat-${category}` }, t(`cat_${category}`)));
|
|
640
661
|
const act = (label, icon, cat, fn) => el("button", {
|
|
641
662
|
class: `act icon-button cat-${cat}`, title: label, "aria-label": label,
|
|
642
663
|
onclick: (e) => { e.stopPropagation(); fn(); },
|
|
643
664
|
}, el("ha-icon", { icon: `mdi:${icon}` }));
|
|
644
665
|
if (d.category === "missing") acts.append(act(t("act_add"), "plus", "missing", () => this.one("add", d)));
|
|
645
666
|
if (d.category === "mismatch") acts.append(act(t("act_update"), "sync", "mismatch", () => this.one("update", d)));
|
|
646
|
-
if (d.category !== "missing") acts.append(act(t("edit_device"), "pencil",
|
|
667
|
+
if (d.category !== "missing") acts.append(act(t("edit_device"), "pencil", category, () => this.openManual(d)));
|
|
647
668
|
if (d.category !== "missing") acts.append(act(t("act_remove"), "trash-can-outline", "orphan", () => this.one("remove", d)));
|
|
648
|
-
const card = el("div", { class: `dev cat-${
|
|
649
|
-
title: `${t(`cat_${
|
|
669
|
+
const card = el("div", { class: `dev cat-${category}${child ? " child" : ""}${live === "offline" ? " offline" : ""}`,
|
|
670
|
+
title: `${t(`cat_${category}`)} · ${s.type}${live ? ` · ${live === "online" ? t("link_online") : t("link_offline")}` : ""}`,
|
|
650
671
|
onclick: (e) => {
|
|
651
672
|
// Selecting text can also fire click. Repainting here would discard
|
|
652
673
|
// the selection, including selections inside the panel's shadow root.
|
|
@@ -885,14 +906,14 @@ class RustuyaPanel extends HTMLElement {
|
|
|
885
906
|
if (!this._built) {
|
|
886
907
|
this._built = true;
|
|
887
908
|
this._build();
|
|
888
|
-
this._refresh();
|
|
909
|
+
this._refresh().then(() => this._checkPack());
|
|
889
910
|
this._bridge.load();
|
|
890
911
|
if (this.isConnected) this._bridge.watch();
|
|
891
912
|
}
|
|
892
913
|
}
|
|
893
914
|
|
|
894
915
|
connectedCallback() {
|
|
895
|
-
if (this._bridge) this._bridge.watch();
|
|
916
|
+
if (this._bridge) { this._bridge.watch(); this._refresh().then(() => this._checkPack()); }
|
|
896
917
|
}
|
|
897
918
|
|
|
898
919
|
disconnectedCallback() {
|
|
@@ -929,7 +950,7 @@ class RustuyaPanel extends HTMLElement {
|
|
|
929
950
|
this._options = new OptionsSection(this);
|
|
930
951
|
this._settings = new SettingsSection(this);
|
|
931
952
|
this._pack = el("div", { class: "muted" }, "…");
|
|
932
|
-
this._syncBtn = el("button", { onclick: () => this._syncPack() }, t("sync_now"));
|
|
953
|
+
this._syncBtn = el("button", { disabled: true, onclick: () => this._syncPack() }, t("sync_now"));
|
|
933
954
|
this._list = el("ul", { class: "files" });
|
|
934
955
|
this._warnings = el("div", { class: "warn note" });
|
|
935
956
|
this._name = el("input", { placeholder: "10_mine.json", spellcheck: "false" });
|
|
@@ -1087,15 +1108,22 @@ class RustuyaPanel extends HTMLElement {
|
|
|
1087
1108
|
}
|
|
1088
1109
|
|
|
1089
1110
|
_paintPack(pack) {
|
|
1090
|
-
this.
|
|
1111
|
+
this._packState = pack;
|
|
1112
|
+
const check = this._packCheck;
|
|
1113
|
+
const available = !this._checkingPack && check?.available && !check.error;
|
|
1114
|
+
this._syncBtn.disabled = this._syncing || !pack || !available;
|
|
1115
|
+
this._syncBtn.classList.toggle("update-available", !!(pack && !pack.enabled && available && !this._syncing));
|
|
1091
1116
|
const show = (text, cls = "muted") => {
|
|
1092
1117
|
this._pack.textContent = text;
|
|
1093
1118
|
this._pack.className = cls;
|
|
1094
1119
|
};
|
|
1095
1120
|
if (this._syncing) return show(t("pack_syncing"));
|
|
1096
|
-
if (!pack
|
|
1121
|
+
if (!pack) return show(t("pack_checking"));
|
|
1122
|
+
if (this._checkingPack || !check) return show(t("pack_checking"));
|
|
1123
|
+
if (check.error) return show(t("pack_check_error", { error: check.error }), "warn");
|
|
1124
|
+
if (available) return show(t("pack_available"), pack.enabled ? "muted" : "warn");
|
|
1097
1125
|
const p = pack.status;
|
|
1098
|
-
if (!p) return show(t("
|
|
1126
|
+
if (!p || (!p.error && !(p.failed || []).length)) return show(t("pack_current"));
|
|
1099
1127
|
const when = new Date(p.at * 1000).toLocaleString(LANG);
|
|
1100
1128
|
if (p.error) return show(t("pack_error", { error: p.error, when }), "warn");
|
|
1101
1129
|
const changes = PACK_CHANGES.filter((k) => p[k] && p[k].length).map((k) => `${t(`pack_${k}`)} ${p[k].join(", ")}`);
|
|
@@ -1166,26 +1194,50 @@ class RustuyaPanel extends HTMLElement {
|
|
|
1166
1194
|
}
|
|
1167
1195
|
}
|
|
1168
1196
|
|
|
1197
|
+
async _checkPack(autoUpdate = true) {
|
|
1198
|
+
if (this._checkingPack || this._syncing) return;
|
|
1199
|
+
this._checkingPack = true;
|
|
1200
|
+
this._paintPack(this._packState);
|
|
1201
|
+
try {
|
|
1202
|
+
this._packCheck = await this._api("GET", "pack");
|
|
1203
|
+
} catch (e) {
|
|
1204
|
+
this._packCheck = { available: false, error: message(e) };
|
|
1205
|
+
} finally {
|
|
1206
|
+
this._checkingPack = false;
|
|
1207
|
+
this._paintPack(this._packState);
|
|
1208
|
+
}
|
|
1209
|
+
if (autoUpdate && this._packState?.enabled && this._packCheck?.available && !this._packCheck.error) {
|
|
1210
|
+
await this._syncPack();
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1169
1214
|
async _syncPack() {
|
|
1215
|
+
if (this._syncBtn.disabled) return;
|
|
1216
|
+
this._syncing = true;
|
|
1217
|
+
this._paintPack(this._packState);
|
|
1170
1218
|
const before = this._files.length, r0 = await this._refresh();
|
|
1171
1219
|
const at = r0 && r0.pack && r0.pack.status ? r0.pack.status.at : 0;
|
|
1172
1220
|
try {
|
|
1173
1221
|
const r = await this._api("POST", "pack");
|
|
1174
1222
|
if (!r.started) {
|
|
1175
|
-
this._toast(t("
|
|
1223
|
+
this._toast(t("pack_unavailable"));
|
|
1224
|
+
this._syncing = false;
|
|
1225
|
+
this._paintPack(this._packState);
|
|
1176
1226
|
return;
|
|
1177
1227
|
}
|
|
1178
1228
|
} catch (e) {
|
|
1179
1229
|
this._toast(t("cannot_sync", { error: message(e) }));
|
|
1230
|
+
this._syncing = false;
|
|
1231
|
+
this._paintPack(this._packState);
|
|
1180
1232
|
return;
|
|
1181
1233
|
}
|
|
1182
|
-
this._syncing = true;
|
|
1183
1234
|
for (let i = 0; i < 30; i++) { // up to ~30 s; the result lands in pack.status
|
|
1184
1235
|
await new Promise((ok) => setTimeout(ok, 1000));
|
|
1185
1236
|
const r = await this._refresh();
|
|
1186
1237
|
if (!r || (r.pack && r.pack.status && r.pack.status.at > at)) break;
|
|
1187
1238
|
}
|
|
1188
1239
|
this._syncing = false;
|
|
1240
|
+
await this._checkPack(false);
|
|
1189
1241
|
await this._refresh();
|
|
1190
1242
|
if (this._files.length !== before) this._toast(t("pack_changed"));
|
|
1191
1243
|
}
|
|
@@ -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.dev5"
|
|
8
8
|
description = "Local Tuya control: rustuya-bridge -> tuya2ildevice -> IL (ildevice). HA-independent core."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.13"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
### Fixed
|
|
2
|
+
- Share the running service's complete bridge registry and MQTT command connection with the panel, so panel listing and edits do not open a second registry requester. Require complete contiguous registry pages for standalone setup sessions.
|
|
3
|
+
- Keep explicit offline connection errors from being overwritten by replayed device state snapshots in the panel.
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- Split the HA panel synced filter into online and offline, with live sub-device/gateway state and migration of saved filters.
|
|
7
|
+
- Check builtin rules when opening the HA panel; automatically install available updates when enabled, or enable the manual update button with a yellow dot when automatic updates are off.
|
|
8
|
+
- Allow manual builtin rule updates while automatic updates are disabled.
|