socketry 0.1.0__tar.gz → 0.2.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.
- {socketry-0.1.0 → socketry-0.2.0}/PKG-INFO +66 -26
- {socketry-0.1.0 → socketry-0.2.0}/README.md +63 -23
- {socketry-0.1.0 → socketry-0.2.0}/pyproject.toml +6 -4
- socketry-0.2.0/src/socketry/__init__.py +6 -0
- {socketry-0.1.0 → socketry-0.2.0}/src/socketry/_constants.py +10 -1
- {socketry-0.1.0 → socketry-0.2.0}/src/socketry/cli.py +145 -7
- socketry-0.2.0/src/socketry/client.py +949 -0
- socketry-0.1.0/src/socketry/__init__.py +0 -6
- socketry-0.1.0/src/socketry/client.py +0 -520
- {socketry-0.1.0 → socketry-0.2.0}/src/socketry/__main__.py +0 -0
- {socketry-0.1.0 → socketry-0.2.0}/src/socketry/_crypto.py +0 -0
- {socketry-0.1.0 → socketry-0.2.0}/src/socketry/properties.py +0 -0
- {socketry-0.1.0 → socketry-0.2.0}/src/socketry/py.typed +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: socketry
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python API and CLI for controlling Jackery portable power stations
|
|
5
5
|
Author: Jesus Lopez
|
|
6
6
|
Author-email: Jesus Lopez <jesus@jesusla.com>
|
|
7
7
|
License-Expression: MIT
|
|
8
8
|
Requires-Dist: typer>=0.9
|
|
9
|
-
Requires-Dist:
|
|
10
|
-
Requires-Dist:
|
|
9
|
+
Requires-Dist: aiomqtt>=2.0
|
|
10
|
+
Requires-Dist: aiohttp>=3.9
|
|
11
11
|
Requires-Dist: pycryptodome>=3.19
|
|
12
12
|
Requires-Python: >=3.11
|
|
13
13
|
Description-Content-Type: text/markdown
|
|
@@ -15,6 +15,7 @@ Description-Content-Type: text/markdown
|
|
|
15
15
|
# socketry
|
|
16
16
|
|
|
17
17
|
[](https://github.com/jlopez/socketry/actions/workflows/ci.yml)
|
|
18
|
+

|
|
18
19
|

|
|
19
20
|
|
|
20
21
|
Python API and CLI for controlling Jackery portable power stations.
|
|
@@ -26,14 +27,14 @@ the device or its firmware.
|
|
|
26
27
|
## Quick start
|
|
27
28
|
|
|
28
29
|
```bash
|
|
29
|
-
uvx
|
|
30
|
-
uvx
|
|
30
|
+
uvx socketry login --email you@example.com --password 'yourpass'
|
|
31
|
+
uvx socketry get
|
|
31
32
|
```
|
|
32
33
|
|
|
33
34
|
Or install it once and use `socketry` directly:
|
|
34
35
|
|
|
35
36
|
```bash
|
|
36
|
-
uv tool install
|
|
37
|
+
uv tool install socketry
|
|
37
38
|
socketry login --email you@example.com --password 'yourpass'
|
|
38
39
|
socketry get
|
|
39
40
|
```
|
|
@@ -61,12 +62,12 @@ properties returned by newer firmware are displayed as raw key/value pairs.
|
|
|
61
62
|
## Install
|
|
62
63
|
|
|
63
64
|
```bash
|
|
64
|
-
# Install as a CLI tool
|
|
65
|
-
uv tool install git+https://github.com/jlopez/socketry
|
|
66
|
-
|
|
67
|
-
# Or from PyPI (once published)
|
|
65
|
+
# Install as a CLI tool
|
|
68
66
|
uv tool install socketry
|
|
69
67
|
|
|
68
|
+
# Or run directly without installing
|
|
69
|
+
uvx socketry --help
|
|
70
|
+
|
|
70
71
|
# Or install as a library
|
|
71
72
|
pip install socketry
|
|
72
73
|
```
|
|
@@ -165,30 +166,69 @@ Writable settings:
|
|
|
165
166
|
| `sfc` | on / off | Super fast charge |
|
|
166
167
|
| `ups` | on / off | UPS mode |
|
|
167
168
|
|
|
169
|
+
### Watching live updates (`watch`)
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
# Stream all property changes in real time
|
|
173
|
+
socketry watch
|
|
174
|
+
|
|
175
|
+
# Filter to a single property
|
|
176
|
+
socketry watch battery
|
|
177
|
+
|
|
178
|
+
# Example output:
|
|
179
|
+
# [14:32:01] 855124121010657 Output power (output-power): 182W
|
|
180
|
+
# [14:32:05] 855124121010657 Battery (battery): 85%
|
|
181
|
+
# [14:32:10] Disconnected, reconnecting...
|
|
182
|
+
# [14:32:15] 855124121010657 Battery (battery): 84%
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Press Ctrl+C to stop. The connection automatically reconnects if the broker
|
|
186
|
+
disconnects (e.g. when a `set` command runs from another terminal).
|
|
187
|
+
|
|
188
|
+
> **Note:** The Jackery broker only allows one MQTT connection per account.
|
|
189
|
+
> Running `socketry set` while `watch` is active will briefly disconnect the
|
|
190
|
+
> watcher. It reconnects automatically, but updates during the reconnection
|
|
191
|
+
> window are lost.
|
|
192
|
+
|
|
168
193
|
## Library usage
|
|
169
194
|
|
|
170
195
|
```python
|
|
196
|
+
import asyncio
|
|
171
197
|
from socketry import Client
|
|
172
198
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
client.
|
|
199
|
+
async def main():
|
|
200
|
+
# Authenticate (or load saved credentials)
|
|
201
|
+
client = await Client.login("email@example.com", "password")
|
|
202
|
+
client.save_credentials()
|
|
203
|
+
|
|
204
|
+
# Or load previously saved credentials
|
|
205
|
+
client = Client.from_saved()
|
|
206
|
+
|
|
207
|
+
# List and select devices
|
|
208
|
+
devices = await client.fetch_devices()
|
|
209
|
+
client.select_device(0)
|
|
210
|
+
|
|
211
|
+
# Read properties
|
|
212
|
+
props = await client.get_all_properties()
|
|
213
|
+
setting, value = await client.get_property("battery")
|
|
214
|
+
print(f"{setting.name}: {setting.format_value(value)}")
|
|
215
|
+
|
|
216
|
+
# Control
|
|
217
|
+
await client.set_property("ac", "on")
|
|
218
|
+
result = await client.set_property("light", "high", wait=True)
|
|
176
219
|
|
|
177
|
-
#
|
|
178
|
-
|
|
220
|
+
# Subscribe to real-time updates
|
|
221
|
+
async def on_update(device_sn: str, properties: dict) -> None:
|
|
222
|
+
print(f"{device_sn}: {properties}")
|
|
179
223
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
client.select_device(0)
|
|
224
|
+
async def on_disconnect() -> None:
|
|
225
|
+
print("Reconnecting...")
|
|
183
226
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
print(f"{setting.name}: {setting.format_value(value)}")
|
|
227
|
+
subscription = await client.subscribe(on_update, on_disconnect=on_disconnect)
|
|
228
|
+
# ... later
|
|
229
|
+
await subscription.stop()
|
|
188
230
|
|
|
189
|
-
|
|
190
|
-
client.set_property("ac", "on")
|
|
191
|
-
result = client.set_property("light", "high", wait=True)
|
|
231
|
+
asyncio.run(main())
|
|
192
232
|
```
|
|
193
233
|
|
|
194
234
|
## How it works
|
|
@@ -205,7 +245,7 @@ protocol specification.
|
|
|
205
245
|
|
|
206
246
|
## Roadmap
|
|
207
247
|
|
|
208
|
-
- [
|
|
248
|
+
- [x] MQTT real-time monitor (`socketry watch` + `client.subscribe()`)
|
|
209
249
|
- [ ] Token auto-refresh (JWT expires ~30 days)
|
|
210
250
|
|
|
211
251
|
## License
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# socketry
|
|
2
2
|
|
|
3
3
|
[](https://github.com/jlopez/socketry/actions/workflows/ci.yml)
|
|
4
|
+

|
|
4
5
|

|
|
5
6
|
|
|
6
7
|
Python API and CLI for controlling Jackery portable power stations.
|
|
@@ -12,14 +13,14 @@ the device or its firmware.
|
|
|
12
13
|
## Quick start
|
|
13
14
|
|
|
14
15
|
```bash
|
|
15
|
-
uvx
|
|
16
|
-
uvx
|
|
16
|
+
uvx socketry login --email you@example.com --password 'yourpass'
|
|
17
|
+
uvx socketry get
|
|
17
18
|
```
|
|
18
19
|
|
|
19
20
|
Or install it once and use `socketry` directly:
|
|
20
21
|
|
|
21
22
|
```bash
|
|
22
|
-
uv tool install
|
|
23
|
+
uv tool install socketry
|
|
23
24
|
socketry login --email you@example.com --password 'yourpass'
|
|
24
25
|
socketry get
|
|
25
26
|
```
|
|
@@ -47,12 +48,12 @@ properties returned by newer firmware are displayed as raw key/value pairs.
|
|
|
47
48
|
## Install
|
|
48
49
|
|
|
49
50
|
```bash
|
|
50
|
-
# Install as a CLI tool
|
|
51
|
-
uv tool install git+https://github.com/jlopez/socketry
|
|
52
|
-
|
|
53
|
-
# Or from PyPI (once published)
|
|
51
|
+
# Install as a CLI tool
|
|
54
52
|
uv tool install socketry
|
|
55
53
|
|
|
54
|
+
# Or run directly without installing
|
|
55
|
+
uvx socketry --help
|
|
56
|
+
|
|
56
57
|
# Or install as a library
|
|
57
58
|
pip install socketry
|
|
58
59
|
```
|
|
@@ -151,30 +152,69 @@ Writable settings:
|
|
|
151
152
|
| `sfc` | on / off | Super fast charge |
|
|
152
153
|
| `ups` | on / off | UPS mode |
|
|
153
154
|
|
|
155
|
+
### Watching live updates (`watch`)
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
# Stream all property changes in real time
|
|
159
|
+
socketry watch
|
|
160
|
+
|
|
161
|
+
# Filter to a single property
|
|
162
|
+
socketry watch battery
|
|
163
|
+
|
|
164
|
+
# Example output:
|
|
165
|
+
# [14:32:01] 855124121010657 Output power (output-power): 182W
|
|
166
|
+
# [14:32:05] 855124121010657 Battery (battery): 85%
|
|
167
|
+
# [14:32:10] Disconnected, reconnecting...
|
|
168
|
+
# [14:32:15] 855124121010657 Battery (battery): 84%
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Press Ctrl+C to stop. The connection automatically reconnects if the broker
|
|
172
|
+
disconnects (e.g. when a `set` command runs from another terminal).
|
|
173
|
+
|
|
174
|
+
> **Note:** The Jackery broker only allows one MQTT connection per account.
|
|
175
|
+
> Running `socketry set` while `watch` is active will briefly disconnect the
|
|
176
|
+
> watcher. It reconnects automatically, but updates during the reconnection
|
|
177
|
+
> window are lost.
|
|
178
|
+
|
|
154
179
|
## Library usage
|
|
155
180
|
|
|
156
181
|
```python
|
|
182
|
+
import asyncio
|
|
157
183
|
from socketry import Client
|
|
158
184
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
client.
|
|
185
|
+
async def main():
|
|
186
|
+
# Authenticate (or load saved credentials)
|
|
187
|
+
client = await Client.login("email@example.com", "password")
|
|
188
|
+
client.save_credentials()
|
|
189
|
+
|
|
190
|
+
# Or load previously saved credentials
|
|
191
|
+
client = Client.from_saved()
|
|
192
|
+
|
|
193
|
+
# List and select devices
|
|
194
|
+
devices = await client.fetch_devices()
|
|
195
|
+
client.select_device(0)
|
|
196
|
+
|
|
197
|
+
# Read properties
|
|
198
|
+
props = await client.get_all_properties()
|
|
199
|
+
setting, value = await client.get_property("battery")
|
|
200
|
+
print(f"{setting.name}: {setting.format_value(value)}")
|
|
201
|
+
|
|
202
|
+
# Control
|
|
203
|
+
await client.set_property("ac", "on")
|
|
204
|
+
result = await client.set_property("light", "high", wait=True)
|
|
162
205
|
|
|
163
|
-
#
|
|
164
|
-
|
|
206
|
+
# Subscribe to real-time updates
|
|
207
|
+
async def on_update(device_sn: str, properties: dict) -> None:
|
|
208
|
+
print(f"{device_sn}: {properties}")
|
|
165
209
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
client.select_device(0)
|
|
210
|
+
async def on_disconnect() -> None:
|
|
211
|
+
print("Reconnecting...")
|
|
169
212
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
print(f"{setting.name}: {setting.format_value(value)}")
|
|
213
|
+
subscription = await client.subscribe(on_update, on_disconnect=on_disconnect)
|
|
214
|
+
# ... later
|
|
215
|
+
await subscription.stop()
|
|
174
216
|
|
|
175
|
-
|
|
176
|
-
client.set_property("ac", "on")
|
|
177
|
-
result = client.set_property("light", "high", wait=True)
|
|
217
|
+
asyncio.run(main())
|
|
178
218
|
```
|
|
179
219
|
|
|
180
220
|
## How it works
|
|
@@ -191,7 +231,7 @@ protocol specification.
|
|
|
191
231
|
|
|
192
232
|
## Roadmap
|
|
193
233
|
|
|
194
|
-
- [
|
|
234
|
+
- [x] MQTT real-time monitor (`socketry watch` + `client.subscribe()`)
|
|
195
235
|
- [ ] Token auto-refresh (JWT expires ~30 days)
|
|
196
236
|
|
|
197
237
|
## License
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "socketry"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "Python API and CLI for controlling Jackery portable power stations"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -10,8 +10,8 @@ requires-python = ">=3.11"
|
|
|
10
10
|
license = "MIT"
|
|
11
11
|
dependencies = [
|
|
12
12
|
"typer>=0.9",
|
|
13
|
-
"
|
|
14
|
-
"
|
|
13
|
+
"aiomqtt>=2.0",
|
|
14
|
+
"aiohttp>=3.9",
|
|
15
15
|
"pycryptodome>=3.19",
|
|
16
16
|
]
|
|
17
17
|
|
|
@@ -24,12 +24,13 @@ build-backend = "uv_build"
|
|
|
24
24
|
|
|
25
25
|
[dependency-groups]
|
|
26
26
|
dev = [
|
|
27
|
+
"aioresponses>=0.7",
|
|
27
28
|
"mypy>=1.19.1",
|
|
28
29
|
"pre-commit>=4.5.1",
|
|
29
30
|
"pytest>=9.0.2",
|
|
31
|
+
"pytest-asyncio>=0.26",
|
|
30
32
|
"pytest-cov>=6.0.0",
|
|
31
33
|
"ruff>=0.14.14",
|
|
32
|
-
"types-requests>=2.31.0",
|
|
33
34
|
]
|
|
34
35
|
|
|
35
36
|
[tool.ruff]
|
|
@@ -98,6 +99,7 @@ addopts = [
|
|
|
98
99
|
"--cov-report=html",
|
|
99
100
|
"--cov-report=json",
|
|
100
101
|
]
|
|
102
|
+
asyncio_mode = "auto"
|
|
101
103
|
|
|
102
104
|
[tool.coverage.run]
|
|
103
105
|
source = ["src/socketry"]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Python API and CLI for controlling Jackery portable power stations."""
|
|
2
|
+
|
|
3
|
+
from socketry.client import Client, Device, Subscription
|
|
4
|
+
from socketry.properties import MODEL_NAMES, PROPERTIES, Setting
|
|
5
|
+
|
|
6
|
+
__all__ = ["Client", "Device", "MODEL_NAMES", "PROPERTIES", "Setting", "Subscription"]
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import platform
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
|
|
7
8
|
API_BASE = "https://iot.jackeryapp.com/v1"
|
|
@@ -45,8 +46,16 @@ UJLtYvgsxiBKAnK8YkAyu51Jm8uLz1BZ1RANf22vv0QUTW+SGdgc5Q1h610G9N1i
|
|
|
45
46
|
CRED_DIR = Path.home() / ".config" / "socketry"
|
|
46
47
|
CRED_FILE = CRED_DIR / "credentials.json"
|
|
47
48
|
|
|
49
|
+
# The server requires Android-style headers for some endpoints (notably
|
|
50
|
+
# /device/bind/list returns error 10600 without them). Values match the
|
|
51
|
+
# decompiled APK v1.0.7 request interceptor (AppApplication.u).
|
|
48
52
|
APP_HEADERS: dict[str, str] = {
|
|
49
53
|
"platform": "2",
|
|
50
|
-
"app_version": "
|
|
54
|
+
"app_version": "v1.0.7",
|
|
55
|
+
"app_version_code": "107",
|
|
56
|
+
"Accept-Language": "en",
|
|
57
|
+
"sys_version": f"Python {platform.python_version()}",
|
|
58
|
+
"device_model": f"{platform.system()}/{platform.machine()}",
|
|
59
|
+
"network": "wifi",
|
|
51
60
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
52
61
|
}
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
5
7
|
import json
|
|
6
8
|
import sys
|
|
9
|
+
from datetime import datetime
|
|
7
10
|
|
|
8
11
|
import typer
|
|
9
12
|
|
|
@@ -60,12 +63,19 @@ def login(
|
|
|
60
63
|
) -> None:
|
|
61
64
|
"""Authenticate with Jackery and save credentials locally."""
|
|
62
65
|
typer.echo(f"Logging in as {email}...")
|
|
63
|
-
client = Client.login(email, password)
|
|
66
|
+
client = asyncio.run(Client.login(email, password))
|
|
64
67
|
client.save_credentials()
|
|
65
68
|
n = len(client.devices)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
if n == 0:
|
|
70
|
+
typer.echo(
|
|
71
|
+
"Logged in, but no devices found. Run `socketry debug` to troubleshoot.",
|
|
72
|
+
err=True,
|
|
73
|
+
)
|
|
74
|
+
else:
|
|
75
|
+
typer.echo(
|
|
76
|
+
f"Logged in. {n} device(s) found."
|
|
77
|
+
f" Selected: {client.device_name} (SN: {client.device_sn})"
|
|
78
|
+
)
|
|
69
79
|
|
|
70
80
|
|
|
71
81
|
@app.command()
|
|
@@ -73,7 +83,7 @@ def devices() -> None:
|
|
|
73
83
|
"""List all devices (owned and shared). Refreshes from API."""
|
|
74
84
|
client = _ensure_client()
|
|
75
85
|
typer.echo("Fetching devices...")
|
|
76
|
-
all_devices = client.fetch_devices()
|
|
86
|
+
all_devices = asyncio.run(client.fetch_devices())
|
|
77
87
|
if not all_devices:
|
|
78
88
|
typer.echo("No devices found.", err=True)
|
|
79
89
|
raise typer.Exit(1)
|
|
@@ -105,6 +115,68 @@ def select(index: int) -> None:
|
|
|
105
115
|
typer.echo(f"Selected: {dev['devName']} — {model} (SN: {dev['devSn']})")
|
|
106
116
|
|
|
107
117
|
|
|
118
|
+
@app.command()
|
|
119
|
+
def debug() -> None:
|
|
120
|
+
"""Dump raw API responses for troubleshooting device access."""
|
|
121
|
+
asyncio.run(_debug_async())
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def _debug_async() -> None:
|
|
125
|
+
"""Async implementation of the debug command."""
|
|
126
|
+
import aiohttp
|
|
127
|
+
|
|
128
|
+
from socketry._constants import API_BASE, APP_HEADERS
|
|
129
|
+
|
|
130
|
+
client = _ensure_client()
|
|
131
|
+
auth_headers = {**APP_HEADERS, "token": client.token}
|
|
132
|
+
timeout = aiohttp.ClientTimeout(total=15)
|
|
133
|
+
|
|
134
|
+
async with aiohttp.ClientSession() as session:
|
|
135
|
+
typer.echo("=== GET /device/bind/list (owned devices) ===")
|
|
136
|
+
try:
|
|
137
|
+
async with session.get(
|
|
138
|
+
f"{API_BASE}/device/bind/list",
|
|
139
|
+
headers=auth_headers,
|
|
140
|
+
timeout=timeout,
|
|
141
|
+
) as resp:
|
|
142
|
+
typer.echo(f"Status: {resp.status}")
|
|
143
|
+
_print_json(await resp.json())
|
|
144
|
+
except Exception as e:
|
|
145
|
+
typer.echo(f"Error: {e}")
|
|
146
|
+
|
|
147
|
+
typer.echo("\n=== GET /device/bind/shared (share relationships) ===")
|
|
148
|
+
try:
|
|
149
|
+
async with session.get(
|
|
150
|
+
f"{API_BASE}/device/bind/shared",
|
|
151
|
+
headers=auth_headers,
|
|
152
|
+
timeout=timeout,
|
|
153
|
+
) as resp:
|
|
154
|
+
typer.echo(f"Status: {resp.status}")
|
|
155
|
+
_print_json(await resp.json())
|
|
156
|
+
except Exception as e:
|
|
157
|
+
typer.echo(f"Error: {e}")
|
|
158
|
+
|
|
159
|
+
devs = client.devices
|
|
160
|
+
if devs:
|
|
161
|
+
dev = devs[0]
|
|
162
|
+
dev_id = dev.get("devId", "")
|
|
163
|
+
if dev_id:
|
|
164
|
+
typer.echo(f"\n=== GET /device/property?deviceId={dev_id} (first device) ===")
|
|
165
|
+
try:
|
|
166
|
+
async with session.get(
|
|
167
|
+
f"{API_BASE}/device/property",
|
|
168
|
+
params={"deviceId": str(dev_id)},
|
|
169
|
+
headers=auth_headers,
|
|
170
|
+
timeout=timeout,
|
|
171
|
+
) as resp:
|
|
172
|
+
typer.echo(f"Status: {resp.status}")
|
|
173
|
+
_print_json(await resp.json())
|
|
174
|
+
except Exception as e:
|
|
175
|
+
typer.echo(f"Error: {e}")
|
|
176
|
+
else:
|
|
177
|
+
typer.echo(f"\n(!) First device has empty devId: {json.dumps(dev)}")
|
|
178
|
+
|
|
179
|
+
|
|
108
180
|
@app.command("get", context_settings={"help_option_names": ["-h", "--help"]})
|
|
109
181
|
def get_property(
|
|
110
182
|
ctx: typer.Context,
|
|
@@ -121,7 +193,7 @@ def get_property(
|
|
|
121
193
|
client = _ensure_client()
|
|
122
194
|
|
|
123
195
|
try:
|
|
124
|
-
data = client.get_all_properties()
|
|
196
|
+
data = asyncio.run(client.get_all_properties())
|
|
125
197
|
except (ValueError, RuntimeError) as e:
|
|
126
198
|
typer.echo(str(e), err=True)
|
|
127
199
|
raise typer.Exit(1) from None
|
|
@@ -256,7 +328,7 @@ def set_setting(
|
|
|
256
328
|
typer.echo(f"Setting {s.slug} to {value}...")
|
|
257
329
|
|
|
258
330
|
try:
|
|
259
|
-
result = client.set_property(setting, value, wait=wait, verbose=verbose)
|
|
331
|
+
result = asyncio.run(client.set_property(setting, value, wait=wait, verbose=verbose))
|
|
260
332
|
except (KeyError, ValueError) as e:
|
|
261
333
|
typer.echo(str(e), err=True)
|
|
262
334
|
raise typer.Exit(1) from None
|
|
@@ -273,3 +345,69 @@ def set_setting(
|
|
|
273
345
|
typer.echo("No response from device (timeout).", err=True)
|
|
274
346
|
else:
|
|
275
347
|
typer.echo(f"Command sent to {client.device_name}.")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@app.command()
|
|
351
|
+
def watch(
|
|
352
|
+
name: str | None = typer.Argument(None, help="Property name to filter (optional)"),
|
|
353
|
+
) -> None:
|
|
354
|
+
"""Watch real-time property updates from the device via MQTT.
|
|
355
|
+
|
|
356
|
+
\b
|
|
357
|
+
Without arguments, shows all property changes.
|
|
358
|
+
With a property name, shows only that property.
|
|
359
|
+
Press Ctrl+C to stop.
|
|
360
|
+
"""
|
|
361
|
+
client = _ensure_client()
|
|
362
|
+
|
|
363
|
+
setting: Setting | None = None
|
|
364
|
+
if name is not None:
|
|
365
|
+
setting = resolve(name)
|
|
366
|
+
if setting is None:
|
|
367
|
+
typer.echo(f"Unknown property '{name}'.", err=True)
|
|
368
|
+
raise typer.Exit(1)
|
|
369
|
+
|
|
370
|
+
with contextlib.suppress(KeyboardInterrupt):
|
|
371
|
+
asyncio.run(_watch_async(client, setting))
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
async def _watch_async(client: Client, setting: Setting | None) -> None:
|
|
375
|
+
"""Async implementation of the watch command."""
|
|
376
|
+
is_tty = sys.stdout.isatty()
|
|
377
|
+
if setting:
|
|
378
|
+
typer.echo(f"Watching {setting.name} ({setting.slug})... (Ctrl+C to stop)")
|
|
379
|
+
else:
|
|
380
|
+
typer.echo("Watching for property updates... (Ctrl+C to stop)")
|
|
381
|
+
|
|
382
|
+
async def on_update(device_sn: str, properties: dict[str, object]) -> None:
|
|
383
|
+
ts = datetime.now().strftime("%H:%M:%S")
|
|
384
|
+
for key, value in properties.items():
|
|
385
|
+
if setting and key != setting.id:
|
|
386
|
+
continue
|
|
387
|
+
s = _by_id.get(key)
|
|
388
|
+
if s:
|
|
389
|
+
label = f"{s.name} ({s.slug})"
|
|
390
|
+
formatted = s.format_value(value)
|
|
391
|
+
else:
|
|
392
|
+
label = key
|
|
393
|
+
formatted = str(value)
|
|
394
|
+
if is_tty:
|
|
395
|
+
typer.echo(
|
|
396
|
+
f"[{ts}] {typer.style(device_sn, bold=True)} "
|
|
397
|
+
f"{typer.style(label, fg='cyan')}: {formatted}"
|
|
398
|
+
)
|
|
399
|
+
else:
|
|
400
|
+
typer.echo(f"[{ts}] {device_sn} {label}: {formatted}")
|
|
401
|
+
|
|
402
|
+
async def on_disconnect() -> None:
|
|
403
|
+
ts = datetime.now().strftime("%H:%M:%S")
|
|
404
|
+
if is_tty:
|
|
405
|
+
typer.echo(typer.style(f"[{ts}] Disconnected, reconnecting...", fg="yellow"))
|
|
406
|
+
else:
|
|
407
|
+
typer.echo(f"[{ts}] Disconnected, reconnecting...")
|
|
408
|
+
|
|
409
|
+
subscription = await client.subscribe(on_update, on_disconnect=on_disconnect)
|
|
410
|
+
try:
|
|
411
|
+
await subscription.wait()
|
|
412
|
+
finally:
|
|
413
|
+
await subscription.stop()
|