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.
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: socketry
3
- Version: 0.1.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: paho-mqtt>=2.0
10
- Requires-Dist: requests>=2.31
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
  [![CI](https://github.com/jlopez/socketry/actions/workflows/ci.yml/badge.svg)](https://github.com/jlopez/socketry/actions/workflows/ci.yml)
18
+ ![Coverage](https://img.shields.io/badge/coverage-74%25-yellowgreen)
18
19
  ![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue)
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 --from git+https://github.com/jlopez/socketry socketry login --email you@example.com --password 'yourpass'
30
- uvx --from git+https://github.com/jlopez/socketry socketry get
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 git+https://github.com/jlopez/socketry
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 (from GitHub)
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
- # Authenticate (or load saved credentials)
174
- client = Client.login("email@example.com", "password")
175
- client.save_credentials()
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
- # Or load previously saved credentials
178
- client = Client.from_saved()
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
- # List and select devices
181
- devices = client.fetch_devices()
182
- client.select_device(0)
224
+ async def on_disconnect() -> None:
225
+ print("Reconnecting...")
183
226
 
184
- # Read properties
185
- props = client.get_all_properties()
186
- setting, value = client.get_property("battery")
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
- # Control
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
- - [ ] MQTT real-time monitor (subscribe to live property changes)
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
  [![CI](https://github.com/jlopez/socketry/actions/workflows/ci.yml/badge.svg)](https://github.com/jlopez/socketry/actions/workflows/ci.yml)
4
+ ![Coverage](https://img.shields.io/badge/coverage-74%25-yellowgreen)
4
5
  ![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue)
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 --from git+https://github.com/jlopez/socketry socketry login --email you@example.com --password 'yourpass'
16
- uvx --from git+https://github.com/jlopez/socketry socketry get
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 git+https://github.com/jlopez/socketry
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 (from GitHub)
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
- # Authenticate (or load saved credentials)
160
- client = Client.login("email@example.com", "password")
161
- client.save_credentials()
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
- # Or load previously saved credentials
164
- client = Client.from_saved()
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
- # List and select devices
167
- devices = client.fetch_devices()
168
- client.select_device(0)
210
+ async def on_disconnect() -> None:
211
+ print("Reconnecting...")
169
212
 
170
- # Read properties
171
- props = client.get_all_properties()
172
- setting, value = client.get_property("battery")
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
- # Control
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
- - [ ] MQTT real-time monitor (subscribe to live property changes)
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.1.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
- "paho-mqtt>=2.0",
14
- "requests>=2.31",
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": "1.2.0",
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
- typer.echo(
67
- f"Logged in. {n} device(s) found. Selected: {client.device_name} (SN: {client.device_sn})"
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()