rustuya-manager 0.1.0__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 (61) hide show
  1. rustuya_manager-0.1.0/PKG-INFO +20 -0
  2. rustuya_manager-0.1.0/README.md +302 -0
  3. rustuya_manager-0.1.0/pyproject.toml +76 -0
  4. rustuya_manager-0.1.0/setup.cfg +4 -0
  5. rustuya_manager-0.1.0/src/rustuya_manager/__init__.py +12 -0
  6. rustuya_manager-0.1.0/src/rustuya_manager/__main__.py +6 -0
  7. rustuya_manager-0.1.0/src/rustuya_manager/catalog.py +443 -0
  8. rustuya_manager-0.1.0/src/rustuya_manager/cli.py +821 -0
  9. rustuya_manager-0.1.0/src/rustuya_manager/cloud.py +82 -0
  10. rustuya_manager-0.1.0/src/rustuya_manager/data/plugin-sources.json +10 -0
  11. rustuya_manager-0.1.0/src/rustuya_manager/data/plugins.json +15 -0
  12. rustuya_manager-0.1.0/src/rustuya_manager/diff.py +58 -0
  13. rustuya_manager-0.1.0/src/rustuya_manager/models.py +130 -0
  14. rustuya_manager-0.1.0/src/rustuya_manager/mqtt.py +1152 -0
  15. rustuya_manager-0.1.0/src/rustuya_manager/plugins.py +704 -0
  16. rustuya_manager-0.1.0/src/rustuya_manager/requirements.py +235 -0
  17. rustuya_manager-0.1.0/src/rustuya_manager/scan.py +141 -0
  18. rustuya_manager-0.1.0/src/rustuya_manager/state.py +422 -0
  19. rustuya_manager-0.1.0/src/rustuya_manager/static/api.js +137 -0
  20. rustuya_manager-0.1.0/src/rustuya_manager/static/app.js +456 -0
  21. rustuya_manager-0.1.0/src/rustuya_manager/static/cards.js +415 -0
  22. rustuya_manager-0.1.0/src/rustuya_manager/static/dom.js +197 -0
  23. rustuya_manager-0.1.0/src/rustuya_manager/static/header-actions.js +125 -0
  24. rustuya_manager-0.1.0/src/rustuya_manager/static/i18n.js +164 -0
  25. rustuya_manager-0.1.0/src/rustuya_manager/static/locales/en.json +295 -0
  26. rustuya_manager-0.1.0/src/rustuya_manager/static/locales/ko.json +295 -0
  27. rustuya_manager-0.1.0/src/rustuya_manager/static/modal-confirm.js +56 -0
  28. rustuya_manager-0.1.0/src/rustuya_manager/static/modal-device.js +172 -0
  29. rustuya_manager-0.1.0/src/rustuya_manager/static/modal-log.js +81 -0
  30. rustuya_manager-0.1.0/src/rustuya_manager/static/modal-plugins.js +275 -0
  31. rustuya_manager-0.1.0/src/rustuya_manager/static/modal-sync.js +313 -0
  32. rustuya_manager-0.1.0/src/rustuya_manager/static/modal-wizard.js +258 -0
  33. rustuya_manager-0.1.0/src/rustuya_manager/static/plugins.js +272 -0
  34. rustuya_manager-0.1.0/src/rustuya_manager/static/render.js +663 -0
  35. rustuya_manager-0.1.0/src/rustuya_manager/static/state.js +54 -0
  36. rustuya_manager-0.1.0/src/rustuya_manager/static/ws.js +72 -0
  37. rustuya_manager-0.1.0/src/rustuya_manager/templates/index.html +441 -0
  38. rustuya_manager-0.1.0/src/rustuya_manager/versions.py +195 -0
  39. rustuya_manager-0.1.0/src/rustuya_manager/web.py +993 -0
  40. rustuya_manager-0.1.0/src/rustuya_manager/wizard.py +334 -0
  41. rustuya_manager-0.1.0/src/rustuya_manager.egg-info/PKG-INFO +20 -0
  42. rustuya_manager-0.1.0/src/rustuya_manager.egg-info/SOURCES.txt +59 -0
  43. rustuya_manager-0.1.0/src/rustuya_manager.egg-info/dependency_links.txt +1 -0
  44. rustuya_manager-0.1.0/src/rustuya_manager.egg-info/entry_points.txt +2 -0
  45. rustuya_manager-0.1.0/src/rustuya_manager.egg-info/requires.txt +15 -0
  46. rustuya_manager-0.1.0/src/rustuya_manager.egg-info/top_level.txt +1 -0
  47. rustuya_manager-0.1.0/tests/test_catalog.py +475 -0
  48. rustuya_manager-0.1.0/tests/test_cli.py +500 -0
  49. rustuya_manager-0.1.0/tests/test_cloud.py +152 -0
  50. rustuya_manager-0.1.0/tests/test_e2e_bridge.py +662 -0
  51. rustuya_manager-0.1.0/tests/test_integration_mosquitto.py +294 -0
  52. rustuya_manager-0.1.0/tests/test_models.py +158 -0
  53. rustuya_manager-0.1.0/tests/test_mqtt_client.py +1343 -0
  54. rustuya_manager-0.1.0/tests/test_plugins.py +826 -0
  55. rustuya_manager-0.1.0/tests/test_requirements.py +144 -0
  56. rustuya_manager-0.1.0/tests/test_scan.py +204 -0
  57. rustuya_manager-0.1.0/tests/test_state.py +197 -0
  58. rustuya_manager-0.1.0/tests/test_topic_helpers.py +281 -0
  59. rustuya_manager-0.1.0/tests/test_versions.py +121 -0
  60. rustuya_manager-0.1.0/tests/test_web.py +544 -0
  61. rustuya_manager-0.1.0/tests/test_wizard.py +390 -0
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: rustuya-manager
3
+ Version: 0.1.0
4
+ Summary: Management tool for rustuya-bridge โ€” keeps Tuya Cloud devices and the bridge in sync
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: aiomqtt>=2.0
8
+ Requires-Dist: rich>=13.0
9
+ Requires-Dist: fastapi>=0.115
10
+ Requires-Dist: uvicorn[standard]>=0.30
11
+ Requires-Dist: pyrustuyabridge<0.4,>=0.3.0
12
+ Requires-Dist: tuyawizard>=0.1.9
13
+ Requires-Dist: cryptography>=41
14
+ Requires-Dist: packaging>=23
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=8; extra == "dev"
17
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
18
+ Requires-Dist: pytest-playwright>=0.5; extra == "dev"
19
+ Requires-Dist: httpx>=0.27; extra == "dev"
20
+ Requires-Dist: ruff>=0.6; extra == "dev"
@@ -0,0 +1,302 @@
1
+ # Rustuya Manager
2
+
3
+ A management tool for [rustuya-bridge](https://github.com/3735943886/rustuya-bridge) that diffs Tuya Cloud devices against the running bridge and syncs add / remove / update operations. Includes a web UI with built-in Tuya Cloud login.
4
+
5
+ ![rustuya-manager web UI](docs/screenshots/main-annotated.png)
6
+ <sub>Desktop view โ€” sync categories highlighted with their actions; the header's <b>โ˜ฐ Menu</b> holds add device, cloud login, <b>๐Ÿ“ก Scan</b>, theme, refresh, and <b>๐Ÿ”ง Reconfigure bridge</b>. Each row also carries a live-status dot and per-device โœŽ edit / ๐Ÿ—‘ remove / โ†ป query-status icons.</sub>
7
+
8
+ <img src="docs/screenshots/main-mobile.png" alt="Mobile layout" width="280">
9
+ <br><sub>Mobile view โ€” layout adapts to narrow viewports.</sub>
10
+
11
+ <sub>Other views: [unannotated](docs/screenshots/main-light.png) ยท [dark](docs/screenshots/main-dark.png) ยท [bulk sync](docs/screenshots/sync-modal.png)</sub>
12
+
13
+ ## Key Features
14
+
15
+ - **Status dashboard** โ€” Missing / Orphaned / Mismatched / Synced categories by diffing the Tuya Cloud device list against the bridge's live state.
16
+ - **Built-in Tuya Cloud login** โ€” fetch the device list straight from the web UI; no external tooling needed. A `tuyadevices.json` upload / drop-zone is still available for offline workflows.
17
+ - **No separate config** โ€” picks up the bridge's topic and payload templates from its retained `bridge/config`.
18
+ - **Live updates over MQTT** โ€” DPS values stream into the UI in real time.
19
+ - **Web UI** โ€” single-page UI with search, sort, sub-device tree, per-device add / edit / remove and bulk-sync.
20
+
21
+ ## Usage
22
+
23
+ Start with `--web` (the Docker image does this by default). The
24
+ dashboard loads every device known to either side and categorizes
25
+ it by how the bridge's view compares to the Tuya Cloud-of-record
26
+ โ€” uploaded as `tuyadevices.json` or pulled in-app via the header **โ˜ฐ Menu โ†’ โ˜ Fetch from cloud**:
27
+
28
+ - ๐ŸŸฆ **Missing** โ€” in cloud, not yet on the bridge. Click **Add** on
29
+ the card to publish it; the bridge picks it up and starts polling.
30
+ - ๐ŸŸฅ **Orphan** โ€” on the bridge, not in cloud (or dropped from cloud
31
+ since last sync). Click **๐Ÿ—‘** to remove it from the bridge.
32
+ - ๐ŸŸจ **Mismatch** โ€” in both, but a field drifted (IP / key / version
33
+ differ). Click **Update** to push the cloud values; expand the row
34
+ to see exactly which fields are out of sync.
35
+ - ๐ŸŸฉ **Synced** โ€” in both, fields match. No action needed.
36
+
37
+ Every per-card action has a bulk path โ€” the buttons above the list
38
+ (**Add missing** / **Remove orphan** / **Update mismatch** / **Apply
39
+ all**) open a modal showing the full plan, let individual rows be
40
+ unchecked to skip, then run them sequentially with per-row status.
41
+
42
+ Click any row to expand it: live DP values stream in via MQTT, plus
43
+ the bridge's last error/status message and the resolved IP / key /
44
+ version. The pencil โœŽ opens an editor that re-publishes the device
45
+ to the bridge with the modified fields (the cloud-of-record JSON is
46
+ unchanged); the trash ๐Ÿ—‘ removes the device from the bridge.
47
+
48
+ ### Refreshing from Tuya Cloud
49
+
50
+ The header **โ˜ฐ Menu โ†’ โ˜ Fetch from cloud** opens the in-app login wizard. Sign in
51
+ once via QR with the Smart Life or Tuya Smart app โ€” credentials are
52
+ cached in `tuyacreds.json`, so subsequent re-fetches skip the scan and
53
+ go straight to the device list.
54
+
55
+ <img src="docs/screenshots/wizard-modal.png" alt="Tuya Cloud login modal" width="420">
56
+
57
+ **Scan device IPs after fetch** (off by default) decides what the
58
+ manager writes into the bridge record:
59
+
60
+ - **Off (default)** โ€” devices ship to the bridge with no IP. The
61
+ bridge runs its own LAN scan at runtime and catches DHCP IP
62
+ changes automatically. Recommended unless every Tuya device on the
63
+ LAN has a pinned address.
64
+ - **On** โ€” best performance: the bridge **never scans the LAN** and
65
+ every reconnect goes straight to the recorded address. Only useful
66
+ when every device has a pinned IP (manual static or DHCP
67
+ reservation on the router). On DHCP networks where leases rotate
68
+ the bridge ends up retrying stale addresses; the header **โ˜ฐ Menu โ†’
69
+ ๐Ÿ“ก Scan LAN** (or a fresh cloud re-fetch) recovers visibility.
70
+
71
+ The toggle state is persisted per browser.
72
+
73
+ **๐Ÿ“ก Scan LAN** (header **โ˜ฐ Menu**) asks the bridge for a one-shot LAN
74
+ scan. Any device registered with an explicit (non-auto) IP that has drifted
75
+ surfaces as `ERR_STATE 906` in the MSG line, so the right device can
76
+ be fixed at the router.
77
+
78
+ ## Quick Start
79
+
80
+ Requires Python 3.10+ and a running [rustuya-bridge](https://github.com/3735943886/rustuya-bridge) reachable via MQTT.
81
+
82
+ ### Install
83
+
84
+ **pipx (recommended)** โ€” drops a `rustuya-manager` shim into `~/.local/bin/`, no activate step:
85
+ ```bash
86
+ sudo apt install -y pipx # if not already
87
+ pipx ensurepath
88
+ pipx install rustuya-manager
89
+ ```
90
+
91
+ **venv + pip** โ€” alternative install without pipx:
92
+ ```bash
93
+ python3 -m venv ~/.venvs/rustuya-manager
94
+ ~/.venvs/rustuya-manager/bin/pip install rustuya-manager
95
+ ~/.venvs/rustuya-manager/bin/rustuya-manager --help
96
+ ```
97
+ Run it by full path, or activate the venv first (`source ~/.venvs/rustuya-manager/bin/activate`). The systemd unit in the next section assumes the pipx path โ€” change `ExecStart` to `%h/.venvs/rustuya-manager/bin/rustuya-manager` for the venv install.
98
+
99
+ ### Run
100
+
101
+ ```bash
102
+ rustuya-manager --broker mqtt://localhost:1883 --root rustuya \
103
+ --web --port 8373 --auth admin:CHANGE_ME
104
+ ```
105
+ Then open the URL printed at startup. The default bind is `127.0.0.1` so the UI is reachable only from the same machine. To open it to the LAN add `--host 0.0.0.0` โ€” pair with a real `--auth user:pass`.
106
+
107
+ Common flags:
108
+ - `--cloud PATH` (default `tuyadevices.json`) โ€” Tuya devices JSON. If
109
+ missing, the web UI offers an in-app Tuya Cloud login or a JSON
110
+ drop-zone.
111
+ - `--broker URL` (default `mqtt://localhost:1883`) โ€” accepts
112
+ `mqtt://[user:pass@]host:port`.
113
+ - `--root TOPIC` (default `rustuya`) โ€” must match the bridge's
114
+ `--mqtt-root-topic`.
115
+ - `--host`, `--port` (default `127.0.0.1:8373`) โ€” web server bind.
116
+ - `--auth USER:PASS` (default off) โ€” HTTP Basic auth for the web UI.
117
+ - `--embed-bridge` (default off) โ€” run the bridge inside this process
118
+ via the `pyrustuyabridge` bindings (single-process deploy). Refused
119
+ at startup if another bridge already publishes on `--root`.
120
+ - `--bridge-state PATH` (default: `rustuya.json` in the same
121
+ directory as `--cloud`, matching the standalone bridge's filename) โ€”
122
+ embedded bridge's device state file. **Only meaningful with
123
+ `--embed-bridge`.**
124
+ - `--bridge-config PATH` (default off) โ€” JSON config for the embedded
125
+ bridge, same format as `rustuya-bridge --config` (read and merged;
126
+ auto-created if missing). Sets custom topics / MQTT auth / scanner
127
+ options without re-exposing every bridge flag here. **Only meaningful
128
+ with `--embed-bridge`.** For the three fields the manager and bridge
129
+ share (`mqtt_broker`, `mqtt_root_topic`, `state_file`) the manager
130
+ adopts the config's values as its own defaults; a CLI flag still wins.
131
+
132
+ ### Run as a service (systemd, user-level, no sudo)
133
+
134
+ ```bash
135
+ mkdir -p ~/.config/systemd/user ~/.local/share/rustuya-manager
136
+ cp examples/rustuya-manager.service ~/.config/systemd/user/
137
+ # edit the file โ€” change --auth, --broker, --root to match the local setup
138
+ systemctl --user daemon-reload
139
+ systemctl --user enable --now rustuya-manager
140
+ journalctl --user -u rustuya-manager -f # follow logs
141
+ ```
142
+
143
+ To keep the service running after logout (one-time, the only sudo step):
144
+ ```bash
145
+ sudo loginctl enable-linger $USER
146
+ ```
147
+
148
+ ### Update
149
+
150
+ ```bash
151
+ pipx upgrade rustuya-manager # pipx install
152
+ # or, for the venv install:
153
+ ~/.venvs/rustuya-manager/bin/pip install -U rustuya-manager
154
+ systemctl --user restart rustuya-manager
155
+ ```
156
+
157
+ ## Docker
158
+
159
+ Single-container deploy with the bridge bundled in โ€” aimed at HA OS,
160
+ unraid, CasaOS and similar container-first setups (distinct from the
161
+ pipx + systemd track above, which keeps `rustuya-bridge` as a separate
162
+ service).
163
+
164
+ ```bash
165
+ docker run -d \
166
+ --name rustuya-manager \
167
+ --network host \
168
+ --restart unless-stopped \
169
+ -e AUTH=admin:CHANGE_ME \
170
+ -v rustuya-manager-data:/data \
171
+ 3735943886/rustuya-manager:latest
172
+ ```
173
+
174
+ The image runs `rustuya-manager --web --embed-bridge`, so manager and
175
+ bridge share one process and the only external dependency is an MQTT
176
+ broker. Key points:
177
+
178
+ - **`--network host` is required** โ€” the embedded bridge discovers Tuya
179
+ devices with UDP broadcasts (ports 6666/6667), which Docker's default
180
+ bridge network drops, so devices are never seen.
181
+ - **Broker** defaults to `mqtt://localhost:1883` (host-local mosquitto
182
+ under `--network host`). For a remote broker, set `mqtt_broker` in
183
+ `/data/config.json` โ€” the embedded bridge auto-creates that file and
184
+ treats it as the single source of truth.
185
+ - **Already running a separate bridge?** Pass `-e EMBED_BRIDGE=0` so the
186
+ container doesn't spawn a second one that double-publishes on the same
187
+ topics.
188
+ - **`--restart unless-stopped`** lets Docker recover the manager โ€” and
189
+ with it a fresh embedded bridge โ€” after the in-process supervisor hits
190
+ its restart rate limit.
191
+
192
+ Environment variables (defaults shown; all optional unless noted):
193
+
194
+ | Variable | Default | Maps to |
195
+ |---|---|---|
196
+ | `HOST` | `0.0.0.0` | `--host` |
197
+ | `PORT` | `8373` | `--port` |
198
+ | `BROKER` | *(unset โ†’ bridge-config, then `mqtt://localhost:1883`)* | `--broker` |
199
+ | `ROOT` | *(unset โ†’ bridge-config, then `rustuya`)* | `--root` |
200
+ | `AUTH` | *(off)* | `--auth USER:PASS` |
201
+ | `CLOUD` | `/data/tuyadevices.json` | `--cloud` |
202
+ | `PLUGIN_DIR` | `/data/plugins` | `--plugin-dir` |
203
+ | `BRIDGE_CONFIG` | `/data/config.json` | `--bridge-config` |
204
+ | `BRIDGE_STATE` | *(unset โ†’ bridge-config `state_file`, then `/data/rustuya.json`)* | `--bridge-state` |
205
+ | `PUID` | `1000` | UID the app runs as |
206
+ | `PGID` | `1000` | GID the app runs as |
207
+ | `EMBED_BRIDGE` | `1` | `--embed-bridge` (set `0` when an external bridge is already on the broker) |
208
+
209
+ All persistent state lives under `/data` โ€” cloud cache
210
+ (`tuyadevices.json`), wizard credentials (`tuyacreds.json`), bridge
211
+ config (`config.json`) and bridge state (`rustuya.json`) โ€” so the volume
212
+ is the only backup target. Pass an empty value to disable an optional
213
+ flag (e.g. `-e BRIDGE_CONFIG=`).
214
+
215
+ For **bind-mounted** host directories (`-v /host/path:/data`), pass
216
+ `PUID`/`PGID` so the in-container user can write to them:
217
+
218
+ ```bash
219
+ -e PUID=$(id -u) -e PGID=$(id -g)
220
+ ```
221
+
222
+ The entrypoint renumbers its internal `manager` user to that UID/GID and
223
+ `chown`s `/data` on startup. Named volumes need none of this โ€” Docker
224
+ handles ownership.
225
+
226
+ ### Plugins
227
+
228
+ A plugin can add a UI tab, REST routes, an MQTT subscription, and its
229
+ own slice of state. They arrive three ways:
230
+
231
+ 1. **From the in-UI catalog** (recommended) โ€” the **Manage plugins** (๐Ÿงฉ)
232
+ item in the โ˜ฐ menu lists a curated catalog you install from with one
233
+ click, no shell or `pip`.
234
+ 2. **As a pip-installed package** under the `rustuya_manager.plugins`
235
+ entry-point group.
236
+ 3. **Hand-dropped** into the plugin directory (handy for development or
237
+ Docker, where you'd otherwise rebuild the image).
238
+
239
+ All three run **in-process with no sandbox**, so the trust anchor is the
240
+ same in every case: only install plugins you trust. The catalog is
241
+ curated for exactly this reason โ€” there is no arbitrary-URL field.
242
+
243
+ #### Installing from the catalog
244
+
245
+ **Manage plugins** (โ˜ฐ โ†’ ๐Ÿงฉ) opens a modal listing each catalog plugin
246
+ with its install state and actions:
247
+
248
+ - **Install** downloads the plugin into the managed plugin directory,
249
+ verifies its checksum, and wires it up **live** โ€” the new tab appears
250
+ with no restart.
251
+ - **Update**, **Enable/Disable**, and **Uninstall** act on an installed
252
+ plugin. These need a manager restart to take effect (already-loaded
253
+ code can't be swapped or unloaded at runtime), so the modal offers a
254
+ **Restart now** button when an action requires it.
255
+
256
+ Installs need a writable plugin directory (see below). Under Docker that
257
+ means a mounted `PLUGIN_DIR`.
258
+
259
+ #### The plugin directory
260
+
261
+ The managed plugin directory is where the catalog installs plugins and
262
+ where you can also hand-drop your own. It defaults to a `plugins/` folder
263
+ next to the cloud file; override it with `--plugin-dir DIR` (env
264
+ `RUSTUYA_MANAGER_PLUGIN_DIR`). Under Docker the image defaults
265
+ `PLUGIN_DIR=/data/plugins` โ€” mount a folder there so installs persist.
266
+
267
+ To hand-drop a plugin, place a package (a directory with `__init__.py`
268
+ exposing `register(ctx)`) or a single `*.py` file in that directory:
269
+
270
+ ```
271
+ <plugin-dir>/
272
+ rustuya_hello/ # package plugin
273
+ __init__.py # defines register(ctx)
274
+ static/ # its UI assets (served automatically)
275
+ quicktweak.py # single-file plugin: just register(ctx)
276
+ ```
277
+
278
+ One caveat for hand-dropped plugins: they **can't install their own
279
+ dependencies** (they get the standard library plus what the manager
280
+ already provides โ€” for anything heavier, install it as an entry-point
281
+ package or via the catalog instead). See
282
+ [`examples/hello_plugin`](examples/hello_plugin) for a complete plugin.
283
+
284
+ #### Loading a hand-dropped plugin
285
+
286
+ Installing from the catalog ("Manage plugins" ๐Ÿงฉ) loads the plugin live, no
287
+ restart. If you instead copy a plugin package into the plugin dir by hand, pick
288
+ it up with:
289
+
290
+ - **Restart manager** (โ™ป, โ˜ฐ-menu) โ€” restarts the manager process in place (same
291
+ PID, via re-exec). This is the full reload: it discovers hand-dropped plugins,
292
+ picks up edited plugin code, drops removed/disabled plugins, and respawns an
293
+ embedded bridge โ€” lighter than a container restart and works outside Docker
294
+ too. The UI reconnects automatically; an embedded bridge briefly disconnects
295
+ its devices.
296
+
297
+ (The add-only `POST /api/plugins/scan` endpoint still loads newly-dropped
298
+ plugins without a restart, but it's no longer surfaced as a menu item โ€” a
299
+ restart is the simpler story for the rare hand-drop.)
300
+
301
+ ## License
302
+ MIT
@@ -0,0 +1,76 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "rustuya-manager"
7
+ # Single source of truth: src/rustuya_manager/__init__.py:__version__. Bump it
8
+ # there only โ€” this resolves it at build time, web.py reads it at runtime, and
9
+ # check.sh reads it from the installed package. See [tool.setuptools.dynamic].
10
+ dynamic = ["version"]
11
+ description = "Management tool for rustuya-bridge โ€” keeps Tuya Cloud devices and the bridge in sync"
12
+ requires-python = ">=3.10"
13
+ license = { text = "MIT" }
14
+ dependencies = [
15
+ "aiomqtt>=2.0",
16
+ "rich>=13.0",
17
+ "fastapi>=0.115",
18
+ "uvicorn[standard]>=0.30",
19
+ "pyrustuyabridge>=0.3.0,<0.4", # Floor is the 0.3.0 stable line: it supersedes the 0.3.0rc28 the manager was developed against (by PEP 440, a stable 0.3.0 already includes everything through rc28), and clears the last pre-release pin from the tree for the final manager release. The rc-by-rc history below is retained as the rationale for why the manager needs this line. 0.2.0rc11 added the `reconfigure` self-terminate that _EmbeddedBridgeSupervisor (cli.py) drives. rc12โ€“rc16 are bridge-side correctness fixes the manager benefits from: rc12 retained cleanup covers the whole fleet, rc13 fixes a cleanup deadlock + coalesced-removes gap, rc15 counts PubAcks instead of buffered writes, rc16 adds a liveness-probe singleton check so post-power-loss startup can't deadlock. rc17 makes multi-target commands (remove cascade, clear) reply per-id so the manager can act on each ack individually instead of guessing which devices the bridge wiped. rc18 moves the cache-mode retained snapshot from `{type}=passive` to `{type}=state` (passive becomes a no-retain delta); the manager wildcards `{type}` so no code change, but pinning rc18 keeps a mixed deployment from leaving orphan `passive` retained snapshots that briefly seed `retained_only` before the bridge's scavenger clears them. rc19 exposes `parse_seed_dps` so `_extract_dps_from_event` reads multi-DP event payloads (no `{dp}` in topic, bare DPS object โ€” e.g. a `get` reply on `event/passive/{id}`) byte-identically to the bridge instead of dropping them; the manager now calls it, so this is a hard pin. rc20 bumps the core rustuya crate (bridge-internal). rc21 lifts rumqttc's 10KiB client packet cap and paginates the `status` reply so large fleets aren't truncated mid-device-list, and caps connection-establishment concurrency so a big fleet's startup storm can't overwhelm the bridge. rc22 adds background-task panic logging (diagnostic). rc23 fixes a Python-binding shutdown-time process abort โ€” a tokio worker forwarding a log record to Python during interpreter finalization could force-terminate the process at the `panic=abort` boundary; since the manager drives the embedded bridge's `stop()`/`close()` (_EmbeddedBridgeSupervisor), pinning rc23 keeps an embedded-bridge shutdown from aborting the manager process. rc23 also pulls rustuya rc7's replay-latched-status fix so a late-attaching listener still gets a large fleet's straggler device states. rc24 fixes fleet-scale fan-out under QoS1: a name-addressed `set`/`get` or gateway cascade emitting hundreds of deltas+responses at once stalled the MQTT event loop (PUBACK round-trips + the in-flight window) and reset ~60% of a 500-device fleet's connections, so most responses were lost (~99/500 delivered). Live messages now publish at QoS0 (retained state stays QoS1) and the publish loop uses non-blocking try_publish; fan-out also runs bounded-concurrent. A 500-device name fan-out now completes with 0 connection resets โ€” the manager issues exactly these name/cascade commands, so this is the fix that makes whole-fleet ops reliable. rc25 stops publishing mqtt_user/mqtt_password into the retained {root}/bridge/config (skip_serializing) and injects the bridge `version` into that payload. The manager exposes the raw bridge config to third-party plugins via ctx.bridge_config(), and can run an embedded bridge through this binding โ€” so pinning rc25 guarantees the embedded path never writes credentials to the broker in the first place (the manager also redacts defensively in plugins.py for external/older bridges). The `version` field is surfaced in the UI's bridge-diagnostics panel. The pin moved 0.2.xโ†’0.3.x at the same rc25: the wheel was re-tagged 0.3.0rc25 to match the bridge crate version it ships (`CARGO_PKG_VERSION`, which the bridge publishes into {root}/bridge/config), so the pip label and the status-reported version finally agree โ€” same code, aligned numbering. The Info panel's online version check (PyPI max, prereleases included) relies on this alignment. rc26 makes the retain scavenger sweep in repeated passes (re-subscribe โ†’ collect โ†’ clear, until a pass clears nothing) instead of a single replay window with no retry: a mass `clear`/`remove` on a large fleet, where the broker's one-shot retained replay is gappy or slow, previously stranded most retained device state ("scavenger left orphans"). The manager issues exactly these mass clear/remove commands, so pinning rc26 is what makes whole-fleet teardown reliably leave no orphan retained state. rc27 bumps the core rustuya crate rc7โ†’rc9 (bridge-internal, transparent through the binding): device connect now accepts IPv6 literals, link-local zone ids, and NAT64-synthesized addresses (the connect path passes `(host, port)` as a tuple instead of a formatted `addr:port` string, which mangled bracketless IPv6), and the crypto stack moves to aes-gcm 0.11.0 final, clearing the last pre-release dependency from the tree. No manager-facing API change. rc28 adds a `set_config` MQTT command (alias `configure`) that patches the six topic/template/retain settings into the config file and optionally chains a `reconfigure`, and โ€” BREAKING for the bridge's own CLI/env surface โ€” makes those same six settings (mqtt_command_topic, mqtt_event_topic, mqtt_message_topic, mqtt_scanner_topic, mqtt_payload_template, mqtt_retain) config-file/set_config only, dropping their `--mqtt-*` flags and `MQTT_*` env vars so a runtime config write can't be silently overridden on the next restart. This doesn't affect the manager: `_spawn_embedded_bridge` (cli.py) only forwards mqtt_broker/mqtt_root_topic/state_file/log_level (+ optional config_path) and broker creds as kwargs โ€” it never passed any of those six as flags or env, so the embedded path already complies with the config-file-only rule, and the manager reads templates from the retained {root}/bridge/config topic regardless. The new set_config command is available to surface in the UI later but is not yet used.
20
+ "tuyawizard>=0.1.9", # 0.1.8 added close()/context manager to stop the per-call requests.Session leak (~750-950 KB/cycle); 0.1.9 then changed close()'s default so it no longer POSTs to `/v1.0/m/token/terminal/expire` โ€” the previous default revoked the saved tokens server-side after every wizard run, forcing a fresh QR scan on the next call. Manager's wizard.close() is called with no args so it picks up the new revoke_terminal=False default automatically.
21
+ "cryptography>=41", # missing dep of tuya-sharing-sdk
22
+ "packaging>=23", # PEP440 parse/compare for the Info panel's PyPI update check (max-incl-prereleases vs installed); already present transitively, declared since versions.py imports it directly
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ dev = [
27
+ "pytest>=8",
28
+ "pytest-asyncio>=0.23",
29
+ "pytest-playwright>=0.5",
30
+ "httpx>=0.27",
31
+ "ruff>=0.6",
32
+ ]
33
+
34
+ [project.scripts]
35
+ rustuya-manager = "rustuya_manager.cli:main"
36
+
37
+ [tool.setuptools.dynamic]
38
+ version = { attr = "rustuya_manager.__version__" }
39
+
40
+ [tool.setuptools.packages.find]
41
+ where = ["src"]
42
+
43
+ [tool.setuptools.package-data]
44
+ rustuya_manager = ["templates/*.html", "static/*.js", "static/*.css", "static/locales/*.json", "data/*.json"]
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+ asyncio_mode = "auto"
49
+ # e2e_ui boots uvicorn in a session-scoped daemon thread and drives the page
50
+ # via Playwright's sync API (greenlet bridge). Both leak event-loop state into
51
+ # the main thread, which then collides with pytest-asyncio's per-test Runner
52
+ # when async unit tests run in the same session. Default to excluding e2e here
53
+ # so a bare `pytest` is fast and conflict-free; CI runs `pytest -m e2e` in a
54
+ # separate step.
55
+ addopts = "-m 'not e2e'"
56
+ markers = [
57
+ "e2e: browser-driven UI smoke tests (Playwright + uvicorn); run separately",
58
+ ]
59
+
60
+ [tool.ruff]
61
+ line-length = 100
62
+ target-version = "py310"
63
+ extend-exclude = ["build", "dist"]
64
+
65
+ [tool.ruff.lint]
66
+ select = ["E", "F", "I", "W", "B", "UP"]
67
+ ignore = [
68
+ "E501", # formatter handles line length
69
+ "B008", # FastAPI Depends/Body in defaults
70
+ ]
71
+
72
+ [tool.ruff.lint.isort]
73
+ known-first-party = ["rustuya_manager"]
74
+
75
+ [tool.ruff.format]
76
+ quote-style = "double"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,12 @@
1
+ """rustuya-manager โ€” sync layer between Tuya Cloud and rustuya-bridge.
2
+
3
+ The package exposes domain modules (models, diff, state, mqtt) plus a
4
+ `cli` entry point that preserves the original interactive workflow.
5
+ Topic and payload templating is delegated to `pyrustuyabridge` so the
6
+ manager's interpretation is byte-identical to the bridge's behavior.
7
+ """
8
+
9
+ # The one place the version is defined. pyproject.toml resolves it at build
10
+ # time via [tool.setuptools.dynamic]; web.py uses it for the FastAPI title;
11
+ # check.sh reads it from the installed package. Bump here and nowhere else.
12
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Allow `python -m rustuya_manager` invocation."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())