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.
Files changed (103) hide show
  1. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/CHANGELOG.md +11 -0
  2. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/PKG-INFO +13 -6
  3. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/README.md +12 -5
  4. rustuya_local-0.1.0.dev5/custom_components/rustuya/manager_registry.py +71 -0
  5. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/manager_session.py +23 -1
  6. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/manifest.json +2 -2
  7. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/panel.py +8 -4
  8. rustuya_local-0.1.0.dev5/custom_components/rustuya/panel_bridge.py +48 -0
  9. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/www/rustuya-panel.js +82 -30
  10. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/pyproject.toml +1 -1
  11. rustuya_local-0.1.0.dev5/release-notes.md +8 -0
  12. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/bridge_client.py +30 -3
  13. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/host/pack.py +23 -0
  14. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/service.py +26 -5
  15. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/test_bridge_registration.py +70 -0
  16. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/test_service.py +40 -0
  17. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/ha/fake_manager.py +4 -0
  18. rustuya_local-0.1.0.dev5/tests/ha/test_manager_registry.py +69 -0
  19. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/ha/test_panel.py +50 -0
  20. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_cover_settings.py +2 -1
  21. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_host.py +2 -2
  22. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_link_watcher.py +25 -3
  23. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_pack.py +33 -0
  24. rustuya_local-0.1.0.dev5/tests/unit/test_panel_js.py +198 -0
  25. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_rule_files.py +1 -1
  26. rustuya_local-0.1.0.dev4/release-notes.md +0 -5
  27. rustuya_local-0.1.0.dev4/tests/unit/test_panel_js.py +0 -80
  28. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/.github/dependabot.yml +0 -0
  29. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/.github/workflows/_test.yml +0 -0
  30. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/.github/workflows/ci.yml +0 -0
  31. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/.github/workflows/hacs.yml +0 -0
  32. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/.github/workflows/hassfest.yml +0 -0
  33. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/.github/workflows/release.yml +0 -0
  34. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/.gitignore +0 -0
  35. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/LICENSE +0 -0
  36. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/__init__.py +0 -0
  37. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/brand/icon.png +0 -0
  38. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/brand/icon.svg +0 -0
  39. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/brand/icon@2x.png +0 -0
  40. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/brand/logo.png +0 -0
  41. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/brand/logo.svg +0 -0
  42. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/brand/logo@2x.png +0 -0
  43. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/bridge_supervisor.py +0 -0
  44. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/bridge_sync.py +0 -0
  45. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/broker.py +0 -0
  46. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/config_flow.py +0 -0
  47. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/const.py +0 -0
  48. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/services.yaml +0 -0
  49. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/strings.json +0 -0
  50. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/translations/en.json +0 -0
  51. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/custom_components/rustuya/translations/ko.json +0 -0
  52. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/docs/MIGRATING.md +0 -0
  53. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/docs/REDESIGN.md +0 -0
  54. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/docs/ROADMAP.md +0 -0
  55. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/docs/STATUS.md +0 -0
  56. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/hacs.json +0 -0
  57. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/scripts/build_dropin.py +0 -0
  58. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/scripts/release_notes.py +0 -0
  59. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/__init__.py +0 -0
  60. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/__main__.py +0 -0
  61. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/async_tools.py +0 -0
  62. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/cli.py +0 -0
  63. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/config.py +0 -0
  64. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/files.py +0 -0
  65. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/host/__init__.py +0 -0
  66. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/host/devices.py +0 -0
  67. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/host/hub.py +0 -0
  68. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/host/il.py +0 -0
  69. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/host/memory.py +0 -0
  70. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/host/mqtt.py +0 -0
  71. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/host/overrides.py +0 -0
  72. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/host/rules.py +0 -0
  73. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/host/runner.py +0 -0
  74. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/host/transport.py +0 -0
  75. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/manager_plugin/__init__.py +0 -0
  76. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/manager_plugin/api.py +0 -0
  77. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/manager_plugin/static/index.js +0 -0
  78. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/src/rustuya_local/validation.py +0 -0
  79. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/conftest.py +0 -0
  80. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/devices.py +0 -0
  81. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/test_chain_in_process.py +0 -0
  82. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/test_chain_mqtt.py +0 -0
  83. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/test_daemon.py +0 -0
  84. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/test_full_stack.py +0 -0
  85. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/test_full_stack_custom_templates.py +0 -0
  86. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/test_full_stack_devices.py +0 -0
  87. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/test_manager_plugin.py +0 -0
  88. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/e2e/test_purge.py +0 -0
  89. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/ha/conftest.py +0 -0
  90. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/ha/test_bridge_probe.py +0 -0
  91. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/ha/test_config_flow.py +0 -0
  92. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/ha/test_init.py +0 -0
  93. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/ha/test_manager_session.py +0 -0
  94. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/ha/test_migrate.py +0 -0
  95. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_config.py +0 -0
  96. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_dropin.py +0 -0
  97. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_files.py +0 -0
  98. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_hazardous.py +0 -0
  99. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_hub.py +0 -0
  100. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_il_vectors.py +0 -0
  101. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_plugin_api.py +0 -0
  102. {rustuya_local-0.1.0.dev4 → rustuya_local-0.1.0.dev5}/tests/unit/test_release_notes.py +0 -0
  103. {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.dev4
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. The daemon uses `"pack": false`
111
- to disable online updates; the manager plugin has the same option in its settings. Installed builtin rules remain
112
- available when online updates are off. Updates only replace or remove files they downloaded, preserving user edits.
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 as rustuya-manager does (missing, bridge only, mismatch, synced, each a filter you can
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. *Register manually* adds Wi-Fi devices or sub-devices without a cloud login, using the same
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. The daemon uses `"pack": false`
85
- to disable online updates; the manager plugin has the same option in its settings. Installed builtin rules remain
86
- available when online updates are off. Updates only replace or remove files they downloaded, preserving user edits.
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 as rustuya-manager does (missing, bridge only, mismatch, synced, each a filter you can
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. *Register manually* adds Wi-Fi devices or sub-devices without a cloud login, using the same
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")
@@ -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
- await manager.wait_ready()
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."""
@@ -13,8 +13,8 @@
13
13
  "paho-mqtt>=2.0",
14
14
  "rustuya-manager>=0.2.2",
15
15
  "pyrustuyabridge>=0.4",
16
- "rustuya-local>=0.1.0.dev4"
16
+ "rustuya-local>=0.1.0.dev5"
17
17
  ],
18
18
  "single_config_entry": true,
19
- "version": "0.1.0.dev4"
19
+ "version": "0.1.0.dev5"
20
20
  }
@@ -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
- """POST: sync the override pack now rather than at the end of its day; `pack` in the list shows the result."""
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 what the bridge holds, through a rustuya-manager session like the options flow's
540
- `bridge_sync` step (and sharing its lock: one session at a time). GET: every device with its category. POST
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.open_for_entry(request.app["hass"], entry.data)
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
@@ -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
- pack_is_off: "Builtin rule updates are off", cannot_sync: "Cannot update: {error}", pack_changed: "Builtin rules updated",
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
- pack_is_off: "Builtin 규칙 업데이트가 꺼져 있습니다", cannot_sync: "업데이트할 수 없습니다: {error}", pack_changed: "Builtin 규칙이 업데이트되었습니다",
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", "synced"]; // rustuya-manager's order and filter tabs
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.filter((c) => CATEGORIES.includes(c)) : CATEGORIES);
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 in this.links) return this.links[id];
539
- if (id in this.online) return { online: this.online[id], code: null, message: "" };
540
- return null;
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) => d.category === cat).length;
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 all its sub-devices, for context (as in rustuya-manager).
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[d.category] : d.id;
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.category) : this.filters.has("missing");
617
- const passing = e.kids.filter((k) => this.filters.has(k.category));
618
- if (shown || passing.length) out.push({ ...e, kids: shown ? e.kids : passing });
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-${d.category}` }, t(`cat_${d.category}`)));
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", d.category, () => this.openManual(d)));
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-${d.category}${child ? " child" : ""}${live === "offline" ? " offline" : ""}`,
649
- title: `${t(`cat_${d.category}`)} · ${s.type}${live ? ` · ${live === "online" ? t("link_online") : t("link_offline")}` : ""}`,
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._syncBtn.disabled = this._syncing || !pack || !pack.enabled;
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 || !pack.enabled) return show(t("pack_off"));
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("pack_never"));
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("pack_is_off"));
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.dev4"
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.