socketry 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Python API and CLI for controlling Jackery portable power stations."""
2
+
3
+ from socketry.client import Client
4
+ from socketry.properties import MODEL_NAMES, PROPERTIES, Setting
5
+
6
+ __all__ = ["Client", "MODEL_NAMES", "PROPERTIES", "Setting"]
socketry/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running with ``python -m socketry``."""
2
+
3
+ from socketry.cli import app
4
+
5
+ app()
socketry/_constants.py ADDED
@@ -0,0 +1,52 @@
1
+ """Internal constants extracted from the decompiled Jackery APK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ API_BASE = "https://iot.jackeryapp.com/v1"
8
+
9
+ MQTT_HOST = "emqx.jackeryapp.com"
10
+ MQTT_PORT = 8883
11
+
12
+ RSA_PUBLIC_KEY_B64 = (
13
+ "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCVmzgJy/4XolxPnkfu32YtJqYG"
14
+ "FLYqf9/rnVgURJED+8J9J3Pccd6+9L97/+7COZE5OkejsgOkqeLNC9C3r5mhpE4z"
15
+ "k/HStss7Q8/5DqkGD1annQ+eoICo3oi0dITZ0Qll56Dowb8lXi6WHViVDdih/oeU"
16
+ "wVJY89uJNtTWrz7t7QIDAQAB"
17
+ )
18
+
19
+ # Self-signed CA cert for emqx.jackeryapp.com (from APK res/raw/ca.crt)
20
+ CA_CERT_PEM = """\
21
+ -----BEGIN CERTIFICATE-----
22
+ MIIDtTCCAp2gAwIBAgIJAPvYSRLMmPACMA0GCSqGSIb3DQEBCwUAMHAxCzAJBgNV
23
+ BAYTAkNOMRIwEAYDVQQIDAlHdWFuZ2RvbmcxETAPBgNVBAcMCFNoZW56aGVuMRQw
24
+ EgYDVQQKDAtqYWNrZXJ5LmNvbTELMAkGA1UECwwCY2ExFzAVBgNVBAMMDmNhLmph
25
+ Y2tlcnkuY29tMCAXDTIyMTIyMzEwMTc0N1oYDzIwNzcwOTI1MTAxNzQ3WjBwMQsw
26
+ CQYDVQQGEwJDTjESMBAGA1UECAwJR3Vhbmdkb25nMREwDwYDVQQHDAhTaGVuemhl
27
+ bjEUMBIGA1UECgwLamFja2VyeS5jb20xCzAJBgNVBAsMAmNhMRcwFQYDVQQDDA5j
28
+ YS5qYWNrZXJ5LmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOrf
29
+ QltVp+PDphQ20tfQbCh/YlqIAK8VkIcYXq7DlVsX1HGl5x+6UkEahzLRtZFaWRkH
30
+ HiHSvol8I+cvq6BHte0VsjKAzl7Mae7P/UyQXwpgNa+hliZHoEqflghzYvxjlZeP
31
+ eOGGcHxg1p2M8+PeNWkX5VkVTSYi/abDz86+D5y1gq7S8n+tYk1WhKFHvIrfX3nN
32
+ 4QXfDO7vAQMd1uc6YdDqRanWjxIgOSDk9B+Mblz0TxCR+hnuDDQpAE4ONjByjArS
33
+ MC/QS8BIq/TL6nixzA8y0vOHySmuOLfuhFpNoO2mujhBGN/Dmq/pZwmsKSK91PxE
34
+ dn3YO8N8q7flHd/Qw4UCAwEAAaNQME4wHQYDVR0OBBYEFL/rQk0x4WclVgw3WLsl
35
+ YH3k0dvgMB8GA1UdIwQYMBaAFL/rQk0x4WclVgw3WLslYH3k0dvgMAwGA1UdEwQF
36
+ MAMBAf8wDQYJKoZIhvcNAQELBQADggEBALZM+xA4bUnO/7/0giZ3xUPEKzwFDp4G
37
+ 5UPI/5grLYxp38t2M84tlJ94W/HKH+f1CYbJ6m28dSZfWtnRzQ3Tgq0whrsmYiK9
38
+ 1Txcl3HPBiL7yAn3yE8DjHV+S2eSnN0o26/rcXCe+9bghSqqGaVDOJyk+Fm4l17e
39
+ Hzx99PvPGkpGUglun3UEp/Vp5ZUl9uDYT813HJ9jK80i1MDlzBJWmg7gzh27/Qls
40
+ UJLtYvgsxiBKAnK8YkAyu51Jm8uLz1BZ1RANf22vv0QUTW+SGdgc5Q1h610G9N1i
41
+ 4BaijfWnto9ka32QKgZA0gHXsT3wiwdbEow0lp7y40aiXq4kazDT7ws=
42
+ -----END CERTIFICATE-----
43
+ """
44
+
45
+ CRED_DIR = Path.home() / ".config" / "socketry"
46
+ CRED_FILE = CRED_DIR / "credentials.json"
47
+
48
+ APP_HEADERS: dict[str, str] = {
49
+ "platform": "2",
50
+ "app_version": "1.2.0",
51
+ "Content-Type": "application/x-www-form-urlencoded",
52
+ }
socketry/_crypto.py ADDED
@@ -0,0 +1,51 @@
1
+ """Internal cryptographic helpers for Jackery API authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import uuid
7
+
8
+ from Crypto.Cipher import AES, PKCS1_v1_5
9
+ from Crypto.PublicKey import RSA
10
+ from Crypto.Util.Padding import pad
11
+
12
+
13
+ def aes_ecb_encrypt(plaintext: bytes, key: bytes) -> bytes:
14
+ """AES/ECB/PKCS5Padding encryption (used for HTTP login body)."""
15
+ cipher = AES.new(key, AES.MODE_ECB)
16
+ result: bytes = cipher.encrypt(pad(plaintext, AES.block_size))
17
+ return result
18
+
19
+
20
+ def rsa_encrypt(plaintext: bytes, pub_key_b64: str) -> bytes:
21
+ """RSA/ECB/PKCS1Padding encryption (used for encrypting AES key)."""
22
+ der = base64.b64decode(pub_key_b64)
23
+ key = RSA.import_key(der)
24
+ cipher = PKCS1_v1_5.new(key)
25
+ result: bytes = cipher.encrypt(plaintext)
26
+ return result
27
+
28
+
29
+ def aes_cbc_encrypt(plaintext: bytes, key: bytes, iv: bytes) -> bytes:
30
+ """AES/CBC/PKCS5Padding encryption (used for MQTT password derivation)."""
31
+ cipher = AES.new(key, AES.MODE_CBC, iv)
32
+ result: bytes = cipher.encrypt(pad(plaintext, AES.block_size))
33
+ return result
34
+
35
+
36
+ def derive_mqtt_password(username: str, mqtt_password_b64: str) -> str:
37
+ """Derive the MQTT connection password from the stored mqttPassWord.
38
+
39
+ The app does: AES/CBC/PKCS5Padding(username, key=b64decode(mqttPassWord), iv=key[:16])
40
+ then base64-encodes the result and sends it as the MQTT password string.
41
+ """
42
+ key = base64.b64decode(mqtt_password_b64)
43
+ iv = key[:16]
44
+ encrypted = aes_cbc_encrypt(username.encode("utf-8"), key, iv)
45
+ return base64.b64encode(encrypted).decode("ascii")
46
+
47
+
48
+ def get_mac_id() -> str:
49
+ """Generate a stable MAC-like identifier for this machine."""
50
+ node = uuid.getnode()
51
+ return ":".join(f"{(node >> (8 * i)) & 0xFF:02x}" for i in range(5, -1, -1))
socketry/cli.py ADDED
@@ -0,0 +1,275 @@
1
+ """Thin CLI wrapper over :class:`socketry.Client`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ import typer
9
+
10
+ from socketry.client import Client
11
+ from socketry.properties import GROUP_TITLES, MODEL_NAMES, PROPERTIES, Setting, resolve
12
+
13
+ app = typer.Typer(help="Control Jackery power stations.", invoke_without_command=True)
14
+
15
+ _by_id: dict[str, Setting] = {s.id: s for s in PROPERTIES}
16
+
17
+
18
+ @app.callback()
19
+ def main(ctx: typer.Context) -> None:
20
+ """Control Jackery power stations."""
21
+ if ctx.invoked_subcommand is None:
22
+ typer.echo(ctx.get_help())
23
+ raise typer.Exit(0)
24
+
25
+
26
+ def _print_json(obj: object) -> None:
27
+ """Print JSON — syntax-highlighted when stdout is a TTY, compact otherwise."""
28
+ if sys.stdout.isatty():
29
+ try:
30
+ from rich.console import Console
31
+ from rich.syntax import Syntax
32
+
33
+ Console().print(Syntax(json.dumps(obj, indent=2), "json"))
34
+ except ImportError:
35
+ typer.echo(json.dumps(obj, indent=2))
36
+ else:
37
+ typer.echo(json.dumps(obj))
38
+
39
+
40
+ def _ensure_client() -> Client:
41
+ """Load saved credentials or exit with an error."""
42
+ try:
43
+ return Client.from_saved()
44
+ except FileNotFoundError:
45
+ typer.echo("No saved credentials. Run `socketry login` first.", err=True)
46
+ raise typer.Exit(1) from None
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Commands
51
+ # ---------------------------------------------------------------------------
52
+
53
+
54
+ @app.command()
55
+ def login(
56
+ email: str = typer.Option(..., prompt=True, help="Jackery account email"),
57
+ password: str = typer.Option(
58
+ ..., prompt=True, hide_input=True, help="Jackery account password"
59
+ ),
60
+ ) -> None:
61
+ """Authenticate with Jackery and save credentials locally."""
62
+ typer.echo(f"Logging in as {email}...")
63
+ client = Client.login(email, password)
64
+ client.save_credentials()
65
+ 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
+
70
+
71
+ @app.command()
72
+ def devices() -> None:
73
+ """List all devices (owned and shared). Refreshes from API."""
74
+ client = _ensure_client()
75
+ typer.echo("Fetching devices...")
76
+ all_devices = client.fetch_devices()
77
+ if not all_devices:
78
+ typer.echo("No devices found.", err=True)
79
+ raise typer.Exit(1)
80
+
81
+ client.save_credentials()
82
+
83
+ selected_sn = client.device_sn
84
+ for i, dev in enumerate(all_devices):
85
+ marker = "*" if dev["devSn"] == selected_sn else " "
86
+ model = MODEL_NAMES.get(int(str(dev.get("modelCode", 0) or 0)), "Unknown model")
87
+ shared = f" (shared by {dev['sharedBy']})" if dev.get("shared") else ""
88
+ typer.echo(f" {marker} [{i}] {dev['devName']} — {model}{shared}")
89
+ typer.echo(f" SN: {dev['devSn']}")
90
+
91
+ typer.echo("\n * = selected. Use `socketry select <index>` to change.")
92
+
93
+
94
+ @app.command()
95
+ def select(index: int) -> None:
96
+ """Select the active device by index (see ``devices`` for the list)."""
97
+ client = _ensure_client()
98
+ try:
99
+ dev = client.select_device(index)
100
+ except IndexError as e:
101
+ typer.echo(str(e), err=True)
102
+ raise typer.Exit(1) from None
103
+ client.save_credentials()
104
+ model = MODEL_NAMES.get(int(str(dev.get("modelCode", 0) or 0)), "Unknown model")
105
+ typer.echo(f"Selected: {dev['devName']} — {model} (SN: {dev['devSn']})")
106
+
107
+
108
+ @app.command("get", context_settings={"help_option_names": ["-h", "--help"]})
109
+ def get_property(
110
+ ctx: typer.Context,
111
+ name: str | None = typer.Argument(None, help="Property name or raw key"),
112
+ as_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
113
+ ) -> None:
114
+ """Query device properties.
115
+
116
+ \b
117
+ Without arguments, shows all properties grouped.
118
+ With a property name, shows just that value.
119
+ Accepts CLI names (battery, ac) and raw keys (rb, oac).
120
+ """
121
+ client = _ensure_client()
122
+
123
+ try:
124
+ data = client.get_all_properties()
125
+ except (ValueError, RuntimeError) as e:
126
+ typer.echo(str(e), err=True)
127
+ raise typer.Exit(1) from None
128
+
129
+ if not data:
130
+ typer.echo("No properties returned.", err=True)
131
+ raise typer.Exit(1)
132
+
133
+ props = data.get("properties") or data
134
+ if not isinstance(props, dict):
135
+ typer.echo("Unexpected response format.", err=True)
136
+ raise typer.Exit(1)
137
+
138
+ # Single property query
139
+ if name is not None:
140
+ s = resolve(name)
141
+ if s is None:
142
+ typer.echo(f"Unknown property '{name}'.", err=True)
143
+ raise typer.Exit(1)
144
+ if s.id not in props:
145
+ typer.echo(f"Property '{name}' ({s.id}) not reported by device.", err=True)
146
+ raise typer.Exit(1)
147
+ raw = props[s.id]
148
+ if as_json:
149
+ _print_json({s.id: raw})
150
+ else:
151
+ typer.echo(f"{s.name}: {s.format_value(raw)}")
152
+ return
153
+
154
+ # All properties
155
+ if as_json:
156
+ _print_json(props)
157
+ return
158
+
159
+ is_tty = sys.stdout.isatty()
160
+
161
+ if is_tty:
162
+ typer.echo(typer.style(client.device_name, bold=True))
163
+ else:
164
+ typer.echo(client.device_name)
165
+
166
+ device_meta = data.get("device")
167
+ if isinstance(device_meta, dict):
168
+ online = device_meta.get("onlineStatus")
169
+ if online is not None:
170
+ typer.echo(f" Online: {'yes' if online == 1 else 'no'}")
171
+
172
+ shown: set[str] = set()
173
+ for group in ("battery", "io", "settings", "power"):
174
+ group_settings = [s for s in PROPERTIES if s.group == group and s.id in props]
175
+ if not group_settings:
176
+ continue
177
+ title = GROUP_TITLES[group]
178
+ if is_tty:
179
+ typer.echo(f"\n {typer.style(title, bold=True)}")
180
+ else:
181
+ typer.echo(f"\n {title}")
182
+ for s in group_settings:
183
+ raw = props[s.id]
184
+ formatted = s.format_value(raw)
185
+ if is_tty:
186
+ typer.echo(f" {typer.style(f'{s.name} ({s.slug})', fg='cyan')}: {formatted}")
187
+ else:
188
+ typer.echo(f" {s.name} ({s.slug}): {formatted}")
189
+ shown.add(s.id)
190
+
191
+ # Remaining unknown keys
192
+ remaining = {k: v for k, v in props.items() if k not in shown}
193
+ if remaining:
194
+ if is_tty:
195
+ typer.echo(f"\n {typer.style('Other', bold=True)}")
196
+ else:
197
+ typer.echo("\n Other")
198
+ for k, v in remaining.items():
199
+ s = _by_id.get(k)
200
+ if s:
201
+ label = f"{s.name} ({s.slug})"
202
+ formatted = s.format_value(v)
203
+ else:
204
+ label = k
205
+ formatted = str(v)
206
+ if is_tty:
207
+ typer.echo(f" {typer.style(label, fg='cyan')}: {formatted}")
208
+ else:
209
+ typer.echo(f" {label}: {formatted}")
210
+
211
+
212
+ @app.command("set", context_settings={"help_option_names": ["-h", "--help"]})
213
+ def set_setting(
214
+ ctx: typer.Context,
215
+ setting: str | None = typer.Argument(None, help="Setting name"),
216
+ value: str | None = typer.Argument(None, help="Value to set"),
217
+ wait: bool = typer.Option(False, "--wait", "-w", help="Wait for device confirmation"),
218
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show MQTT traffic"),
219
+ ) -> None:
220
+ """Change a device setting via MQTT.
221
+
222
+ \b
223
+ Settings (named values):
224
+ ac, dc, usb, car, ac-in, dc-in on | off
225
+ light off | low | high | sos
226
+ charge-speed fast | mute
227
+ battery-protection full | eco
228
+ sfc, ups on | off
229
+
230
+ \b
231
+ Settings (integer values):
232
+ screen-timeout, auto-shutdown, energy-saving
233
+ """
234
+ if setting is None:
235
+ typer.echo(ctx.get_help())
236
+ raise typer.Exit(0)
237
+
238
+ s = resolve(setting)
239
+ if s is None or not s.writable:
240
+ writable = [p for p in PROPERTIES if p.writable]
241
+ names = ", ".join(p.slug for p in writable)
242
+ if s and not s.writable:
243
+ typer.echo(f"Property '{setting}' is read-only. Writable: {names}", err=True)
244
+ else:
245
+ typer.echo(f"Unknown setting '{setting}'. Available: {names}", err=True)
246
+ raise typer.Exit(1)
247
+
248
+ if value is None:
249
+ if s.values:
250
+ typer.echo(f"Setting '{s.slug}' expects a value: {' | '.join(s.values)}")
251
+ else:
252
+ typer.echo(f"Setting '{s.slug}' expects a value: <integer>")
253
+ raise typer.Exit(0)
254
+
255
+ client = _ensure_client()
256
+ typer.echo(f"Setting {s.slug} to {value}...")
257
+
258
+ try:
259
+ result = client.set_property(setting, value, wait=wait, verbose=verbose)
260
+ except (KeyError, ValueError) as e:
261
+ typer.echo(str(e), err=True)
262
+ raise typer.Exit(1) from None
263
+
264
+ if wait:
265
+ if result and isinstance(result, dict):
266
+ for k, v in result.items():
267
+ rs = _by_id.get(k)
268
+ if rs:
269
+ typer.echo(f" {rs.name}: {rs.format_value(v)}")
270
+ else:
271
+ typer.echo(f" {k}: {v}")
272
+ else:
273
+ typer.echo("No response from device (timeout).", err=True)
274
+ else:
275
+ typer.echo(f"Command sent to {client.device_name}.")
socketry/client.py ADDED
@@ -0,0 +1,520 @@
1
+ """Jackery power station API client.
2
+
3
+ Provides programmatic access to Jackery power stations via their cloud
4
+ HTTP API and MQTT broker. The :class:`Client` class is the main entry point::
5
+
6
+ from socketry import Client
7
+
8
+ client = Client.login("email@example.com", "password")
9
+ props = client.get_all_properties()
10
+ client.set_property("ac", "on", wait=True)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import json
17
+ import os
18
+ import secrets
19
+ import ssl
20
+ import tempfile
21
+ import time
22
+
23
+ import paho.mqtt.client as mqtt
24
+ import requests
25
+
26
+ from socketry._constants import (
27
+ API_BASE,
28
+ APP_HEADERS,
29
+ CA_CERT_PEM,
30
+ CRED_DIR,
31
+ CRED_FILE,
32
+ MQTT_HOST,
33
+ MQTT_PORT,
34
+ RSA_PUBLIC_KEY_B64,
35
+ )
36
+ from socketry._crypto import (
37
+ aes_ecb_encrypt,
38
+ derive_mqtt_password,
39
+ get_mac_id,
40
+ rsa_encrypt,
41
+ )
42
+ from socketry.properties import Setting, resolve
43
+
44
+
45
+ class Client:
46
+ """Jackery power station API client.
47
+
48
+ Use :meth:`login` to authenticate, or :meth:`from_saved` to load
49
+ previously saved credentials.
50
+ """
51
+
52
+ def __init__(self, credentials: dict[str, object]) -> None:
53
+ self._creds = credentials
54
+
55
+ # ------------------------------------------------------------------
56
+ # Construction
57
+ # ------------------------------------------------------------------
58
+
59
+ @classmethod
60
+ def login(cls, email: str, password: str) -> Client:
61
+ """Authenticate with Jackery and return a new client.
62
+
63
+ Credentials are **not** saved automatically — call
64
+ :meth:`save_credentials` to persist them.
65
+ """
66
+ creds = _http_login(email, password)
67
+ return cls(creds)
68
+
69
+ @classmethod
70
+ def from_saved(cls) -> Client:
71
+ """Load a client from previously saved credentials.
72
+
73
+ Raises :class:`FileNotFoundError` if no credentials file exists.
74
+ """
75
+ if not CRED_FILE.exists():
76
+ raise FileNotFoundError(
77
+ f"No saved credentials at {CRED_FILE}. Call Client.login() first."
78
+ )
79
+ creds = json.loads(CRED_FILE.read_text())
80
+ return cls(creds)
81
+
82
+ def save_credentials(self) -> None:
83
+ """Persist credentials to ``~/.config/socketry/credentials.json``."""
84
+ CRED_DIR.mkdir(parents=True, exist_ok=True)
85
+ CRED_FILE.write_text(json.dumps(self._creds, indent=2))
86
+ CRED_FILE.chmod(0o600)
87
+
88
+ # ------------------------------------------------------------------
89
+ # Properties
90
+ # ------------------------------------------------------------------
91
+
92
+ @property
93
+ def device_sn(self) -> str:
94
+ """Serial number of the currently selected device."""
95
+ return str(self._creds.get("deviceSn", ""))
96
+
97
+ @property
98
+ def device_name(self) -> str:
99
+ """Display name of the currently selected device."""
100
+ return str(self._creds.get("deviceName", self.device_sn))
101
+
102
+ @property
103
+ def device_id(self) -> str:
104
+ """Internal device ID (used for HTTP property queries)."""
105
+ return str(self._creds.get("deviceId", ""))
106
+
107
+ @property
108
+ def devices(self) -> list[dict[str, object]]:
109
+ """Cached list of all devices (owned + shared)."""
110
+ devs = self._creds.get("devices")
111
+ if isinstance(devs, list):
112
+ return devs
113
+ return []
114
+
115
+ @property
116
+ def token(self) -> str:
117
+ """Current JWT auth token."""
118
+ return str(self._creds.get("token", ""))
119
+
120
+ # ------------------------------------------------------------------
121
+ # Device management
122
+ # ------------------------------------------------------------------
123
+
124
+ def fetch_devices(self) -> list[dict[str, object]]:
125
+ """Refresh the device list from the API.
126
+
127
+ Updates the internal cache and returns all devices.
128
+ """
129
+ all_devices = _fetch_all_devices(self.token)
130
+ self._creds["devices"] = all_devices
131
+ return all_devices
132
+
133
+ def select_device(self, index: int) -> dict[str, object]:
134
+ """Select the active device by index.
135
+
136
+ Raises :class:`IndexError` if the index is out of range.
137
+ """
138
+ devs = self.devices
139
+ if not devs:
140
+ raise IndexError("No cached device list. Call fetch_devices() first.")
141
+ if index < 0 or index >= len(devs):
142
+ raise IndexError(f"Invalid index {index}. Must be 0..{len(devs) - 1}.")
143
+ dev = devs[index]
144
+ self._creds["deviceSn"] = dev["devSn"]
145
+ self._creds["deviceId"] = dev["devId"]
146
+ self._creds["deviceName"] = dev["devName"]
147
+ return dev
148
+
149
+ # ------------------------------------------------------------------
150
+ # Status (HTTP)
151
+ # ------------------------------------------------------------------
152
+
153
+ def get_all_properties(self) -> dict[str, object]:
154
+ """Fetch the full property map for the active device.
155
+
156
+ Returns the raw ``data`` dict from the HTTP API, containing
157
+ ``device`` metadata and ``properties`` map.
158
+ """
159
+ if not self.device_id:
160
+ raise ValueError("No deviceId. Call login() or select_device() first.")
161
+ return _fetch_device_properties(self.token, self.device_id)
162
+
163
+ def get_property(self, name: str) -> tuple[Setting, object]:
164
+ """Fetch a single property by slug or raw key.
165
+
166
+ Returns ``(setting, raw_value)``.
167
+
168
+ Raises :class:`KeyError` if the property is unknown or not
169
+ reported by the device.
170
+ """
171
+ setting = resolve(name)
172
+ if setting is None:
173
+ raise KeyError(f"Unknown property '{name}'.")
174
+ data = self.get_all_properties()
175
+ props = data.get("properties") or data
176
+ if not isinstance(props, dict) or setting.id not in props:
177
+ raise KeyError(f"Property '{name}' ({setting.id}) not reported by device.")
178
+ return setting, props[setting.id]
179
+
180
+ # ------------------------------------------------------------------
181
+ # Control (MQTT)
182
+ # ------------------------------------------------------------------
183
+
184
+ def set_property(
185
+ self,
186
+ name: str,
187
+ value: str | int,
188
+ *,
189
+ wait: bool = False,
190
+ verbose: bool = False,
191
+ ) -> dict[str, object] | None:
192
+ """Change a device setting via MQTT.
193
+
194
+ Args:
195
+ name: Setting slug or raw key (e.g. ``"ac"``, ``"oac"``).
196
+ value: Named value (``"on"``, ``"off"``) or integer.
197
+ wait: If ``True``, wait for device confirmation.
198
+ verbose: If ``True``, log MQTT traffic.
199
+
200
+ Returns:
201
+ The device response body if *wait* is ``True`` and the device
202
+ responds, otherwise ``None``.
203
+
204
+ Raises:
205
+ KeyError: If the setting is unknown.
206
+ ValueError: If the setting is read-only or the value is invalid.
207
+ """
208
+ setting = resolve(name)
209
+ if setting is None:
210
+ raise KeyError(f"Unknown setting '{name}'.")
211
+ if not setting.writable:
212
+ raise ValueError(f"Property '{name}' is read-only.")
213
+
214
+ int_value = _resolve_value(setting, value)
215
+ body: dict[str, object] = {setting.prop_key: int_value}
216
+ assert setting.action_id is not None
217
+
218
+ if wait:
219
+ result = _publish_and_wait(self._creds, setting.action_id, body, verbose=verbose)
220
+ if result is not None:
221
+ resp = result.get("body")
222
+ if isinstance(resp, dict):
223
+ return resp
224
+ return None
225
+ else:
226
+ _publish_command(self._creds, setting.action_id, body)
227
+ return None
228
+
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # Private helpers
232
+ # ---------------------------------------------------------------------------
233
+
234
+
235
+ def _resolve_value(setting: Setting, value: str | int) -> int:
236
+ """Convert a user-facing value to the integer used in MQTT commands."""
237
+ if isinstance(value, int):
238
+ return value
239
+ if setting.values is not None:
240
+ if value not in setting.values:
241
+ raise ValueError(
242
+ f"Invalid value '{value}' for {setting.slug}. "
243
+ f"Expected: {' | '.join(setting.values)}"
244
+ )
245
+ return setting.values.index(value)
246
+ try:
247
+ return int(value)
248
+ except ValueError:
249
+ raise ValueError(
250
+ f"Invalid value '{value}' for {setting.slug}. Expected an integer."
251
+ ) from None
252
+
253
+
254
+ def _http_login(email: str, password: str) -> dict[str, object]:
255
+ """Perform the encrypted HTTP login and return credentials."""
256
+ mac_id = get_mac_id()
257
+ login_bean = json.dumps(
258
+ {
259
+ "account": email,
260
+ "password": password,
261
+ "loginType": 2,
262
+ "registerAppId": "com.hbxn.jackery",
263
+ "macId": mac_id,
264
+ },
265
+ separators=(",", ":"),
266
+ )
267
+
268
+ # Generate random AES key: 16 bytes -> base64 -> 24-char string.
269
+ # The app uses this base64 STRING's bytes (24 bytes, AES-192) as the actual
270
+ # AES key material for encrypting the body AND as the RSA plaintext.
271
+ aes_key_str = base64.b64encode(secrets.token_bytes(16)).decode("ascii")
272
+ aes_key_bytes = aes_key_str.encode("utf-8") # 24 bytes
273
+
274
+ encrypted_body = aes_ecb_encrypt(login_bean.encode("utf-8"), aes_key_bytes)
275
+ aes_encrypt_data = base64.b64encode(encrypted_body).decode("ascii")
276
+
277
+ encrypted_key = rsa_encrypt(aes_key_bytes, RSA_PUBLIC_KEY_B64)
278
+ rsa_for_aes_key = base64.b64encode(encrypted_key).decode("ascii")
279
+
280
+ resp = requests.post(
281
+ f"{API_BASE}/auth/login",
282
+ params={"aesEncryptData": aes_encrypt_data, "rsaForAesKey": rsa_for_aes_key},
283
+ headers=APP_HEADERS,
284
+ timeout=15,
285
+ )
286
+ resp.raise_for_status()
287
+ body = resp.json()
288
+ if body.get("code") != 0:
289
+ msg = body.get("msg", "unknown error")
290
+ raise RuntimeError(f"Login failed: {msg}")
291
+
292
+ data = body["data"]
293
+ token = body["token"]
294
+
295
+ all_devices = _fetch_all_devices(token)
296
+ if not all_devices:
297
+ raise RuntimeError("No owned or shared devices found.")
298
+
299
+ device = all_devices[0]
300
+ return {
301
+ "userId": data["userId"],
302
+ "mqttPassWord": data["mqttPassWord"],
303
+ "token": token,
304
+ "deviceSn": device["devSn"],
305
+ "deviceId": device["devId"],
306
+ "deviceName": device["devName"],
307
+ "email": email,
308
+ "macId": mac_id,
309
+ "devices": all_devices,
310
+ }
311
+
312
+
313
+ def _fetch_all_devices(token: str) -> list[dict[str, object]]:
314
+ """Fetch all devices (owned + shared) using the given auth token."""
315
+ auth_headers = {**APP_HEADERS, "token": token}
316
+ all_devices: list[dict[str, object]] = []
317
+
318
+ # Owned devices
319
+ dev_resp = requests.get(f"{API_BASE}/device/bind/list", headers=auth_headers, timeout=15)
320
+ dev_resp.raise_for_status()
321
+ for d in dev_resp.json().get("data") or []:
322
+ all_devices.append(
323
+ {
324
+ "devSn": d["devSn"],
325
+ "devName": d.get("devNickname") or d.get("devName") or d["devSn"],
326
+ "devId": d.get("devId", ""),
327
+ "modelCode": d.get("modelCode", 0),
328
+ "shared": False,
329
+ }
330
+ )
331
+
332
+ # Shared devices
333
+ shared_resp = requests.get(f"{API_BASE}/device/bind/shared", headers=auth_headers, timeout=15)
334
+ shared_resp.raise_for_status()
335
+ shared_data = shared_resp.json().get("data") or {}
336
+ for share in shared_data.get("receive", []):
337
+ mgr_resp = requests.post(
338
+ f"{API_BASE}/device/bind/share/list",
339
+ data={
340
+ "bindUserId": str(share["bindUserId"]),
341
+ "level": str(share["level"]),
342
+ },
343
+ headers=auth_headers,
344
+ timeout=15,
345
+ )
346
+ mgr_resp.raise_for_status()
347
+ for d in mgr_resp.json().get("data", []):
348
+ all_devices.append(
349
+ {
350
+ "devSn": d["devSn"],
351
+ "devName": d.get("devNickname") or d.get("devName") or d["devSn"],
352
+ "devId": d.get("devId", ""),
353
+ "modelCode": d.get("modelCode", 0),
354
+ "shared": True,
355
+ "sharedBy": share.get("userName", ""),
356
+ }
357
+ )
358
+
359
+ return all_devices
360
+
361
+
362
+ def _fetch_device_properties(token: str, device_id: str) -> dict[str, object]:
363
+ """Fetch full property map for a device via HTTP API."""
364
+ auth_headers = {**APP_HEADERS, "token": token}
365
+ resp = requests.get(
366
+ f"{API_BASE}/device/property",
367
+ params={"deviceId": device_id},
368
+ headers=auth_headers,
369
+ timeout=15,
370
+ )
371
+ resp.raise_for_status()
372
+ body = resp.json()
373
+ if body.get("code") != 0:
374
+ msg = body.get("msg", "unknown error")
375
+ raise RuntimeError(f"Property fetch failed: {msg}")
376
+ return body.get("data") or {}
377
+
378
+
379
+ # ---------------------------------------------------------------------------
380
+ # MQTT
381
+ # ---------------------------------------------------------------------------
382
+
383
+
384
+ def _make_mqtt_client(creds: dict[str, object]) -> tuple[mqtt.Client, str]:
385
+ """Create a configured MQTT client. Returns (client, ca_temp_path)."""
386
+ user_id = creds["userId"]
387
+ mac_id = str(creds.get("macId") or get_mac_id())
388
+ mqtt_pw_b64 = str(creds["mqttPassWord"])
389
+
390
+ client_id = f"{user_id}@APP"
391
+ username = f"{user_id}@{mac_id}"
392
+ password = derive_mqtt_password(username, mqtt_pw_b64)
393
+
394
+ ca_fd, ca_path = tempfile.mkstemp(suffix=".pem")
395
+ os.write(ca_fd, CA_CERT_PEM.encode())
396
+ os.close(ca_fd)
397
+
398
+ client = mqtt.Client(
399
+ callback_api_version=mqtt.CallbackAPIVersion.VERSION2, # type: ignore[attr-defined]
400
+ client_id=client_id,
401
+ protocol=mqtt.MQTTv311,
402
+ )
403
+ client.username_pw_set(username, password)
404
+
405
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
406
+ ctx.load_verify_locations(ca_path)
407
+ client.tls_set_context(ctx)
408
+
409
+ return client, ca_path
410
+
411
+
412
+ def _connect_and_wait(client: mqtt.Client, timeout: float = 5) -> None:
413
+ """Connect to the MQTT broker and block until connected."""
414
+ client.connect(MQTT_HOST, MQTT_PORT, keepalive=10)
415
+ client.loop_start()
416
+ deadline = time.time() + timeout
417
+ while not client.is_connected() and time.time() < deadline:
418
+ time.sleep(0.05)
419
+ if not client.is_connected():
420
+ raise ConnectionError("Failed to connect to MQTT broker.")
421
+
422
+
423
+ def _build_command_payload(device_sn: str, action_id: int, body: dict[str, object] | str) -> str:
424
+ """Build the JSON command payload for MQTT publish."""
425
+ ts = int(time.time() * 1000)
426
+ return json.dumps(
427
+ {
428
+ "deviceSn": device_sn,
429
+ "id": ts,
430
+ "version": 0,
431
+ "messageType": "DevicePropertyChange",
432
+ "actionId": action_id,
433
+ "timestamp": ts,
434
+ "body": body,
435
+ },
436
+ separators=(",", ":"),
437
+ )
438
+
439
+
440
+ def _publish_command(creds: dict[str, object], action_id: int, body: dict[str, object]) -> None:
441
+ """Connect to the MQTT broker and publish a device command."""
442
+ user_id = creds["userId"]
443
+ device_sn = str(creds["deviceSn"])
444
+ topic = f"hb/app/{user_id}/command"
445
+ payload = _build_command_payload(device_sn, action_id, body)
446
+
447
+ client, ca_path = _make_mqtt_client(creds)
448
+ try:
449
+ _connect_and_wait(client)
450
+ info = client.publish(topic, payload, qos=1)
451
+ info.wait_for_publish(timeout=5)
452
+ client.disconnect()
453
+ client.loop_stop()
454
+ finally:
455
+ os.unlink(ca_path)
456
+
457
+
458
+ def _publish_and_wait(
459
+ creds: dict[str, object],
460
+ action_id: int,
461
+ body: dict[str, object],
462
+ timeout: float = 10,
463
+ verbose: bool = False,
464
+ ) -> dict[str, object] | None:
465
+ """Publish a command and wait for a DevicePropertyChange response."""
466
+ user_id = creds["userId"]
467
+ device_sn = str(creds["deviceSn"])
468
+ cmd_topic = f"hb/app/{user_id}/command"
469
+ dev_topic = f"hb/app/{user_id}/device"
470
+ payload = _build_command_payload(device_sn, action_id, body)
471
+
472
+ result: dict[str, object] | None = None
473
+ done = False
474
+
475
+ def on_connect(
476
+ client: mqtt.Client,
477
+ _ud: object,
478
+ _flags: object,
479
+ rc: int,
480
+ _props: object = None,
481
+ ) -> None:
482
+ if rc == 0:
483
+ client.subscribe(dev_topic, qos=1)
484
+
485
+ def on_message(
486
+ client: mqtt.Client,
487
+ _ud: object,
488
+ msg: mqtt.MQTTMessage,
489
+ ) -> None:
490
+ nonlocal result, done
491
+ try:
492
+ data = json.loads(msg.payload)
493
+ except json.JSONDecodeError:
494
+ return
495
+ if data.get("deviceSn") != device_sn:
496
+ return
497
+ msg_type = data.get("messageType", "")
498
+ if msg_type == "DevicePropertyChange" and isinstance(data.get("body"), dict):
499
+ resp_body = data["body"]
500
+ if list(resp_body.keys()) == ["messageId"]:
501
+ return
502
+ result = data
503
+ done = True
504
+
505
+ client, ca_path = _make_mqtt_client(creds)
506
+ client.on_connect = on_connect
507
+ client.on_message = on_message
508
+ try:
509
+ _connect_and_wait(client)
510
+ time.sleep(0.2)
511
+ client.publish(cmd_topic, payload, qos=1)
512
+ deadline = time.time() + timeout
513
+ while not done and time.time() < deadline:
514
+ time.sleep(0.1)
515
+ client.disconnect()
516
+ client.loop_stop()
517
+ finally:
518
+ os.unlink(ca_path)
519
+
520
+ return result
socketry/properties.py ADDED
@@ -0,0 +1,161 @@
1
+ """Device property definitions — single source of truth for Jackery protocol keys."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class Setting:
10
+ """A device property definition.
11
+
12
+ Maps between raw protocol keys (e.g. ``oac``), CLI-friendly slugs
13
+ (e.g. ``ac``), and human-readable labels (e.g. ``AC output``).
14
+ """
15
+
16
+ id: str
17
+ """Raw key from the API (``oac``, ``rb``, ``sltb``)."""
18
+
19
+ slug: str
20
+ """CLI name (``ac``, ``battery``, ``screen-timeout``)."""
21
+
22
+ name: str
23
+ """Human-readable label."""
24
+
25
+ group: str
26
+ """Display group (``battery``, ``io``, ``settings``, ``power``, ``other``)."""
27
+
28
+ action_id: int | None = None
29
+ """MQTT action ID; ``None`` = read-only."""
30
+
31
+ values: list[str] | None = None
32
+ """Enum values (index = int value); ``None`` = integer."""
33
+
34
+ unit: str = ""
35
+ """Suffix for display (``%``, ``W``, ``Hz``, ``C``, ``h``)."""
36
+
37
+ scale: float = 1.0
38
+ """Divide raw value by this for display."""
39
+
40
+ decimals: int = 1
41
+ """Decimal places when *scale* != 1."""
42
+
43
+ write_id: str | None = None
44
+ """Override property key for MQTT writes (e.g. ``slt`` for ``sltb``)."""
45
+
46
+ @property
47
+ def writable(self) -> bool:
48
+ """Whether this property can be set via MQTT."""
49
+ return self.action_id is not None
50
+
51
+ @property
52
+ def prop_key(self) -> str:
53
+ """The property key to use in MQTT command bodies."""
54
+ return self.write_id or self.id
55
+
56
+ def format_value(self, raw: object) -> str:
57
+ """Format a raw property value for human display."""
58
+ if self.values is not None:
59
+ try:
60
+ idx = int(str(raw))
61
+ if 0 <= idx < len(self.values):
62
+ label: str = self.values[idx]
63
+ if set(self.values) == {"on", "off"}:
64
+ return label.upper()
65
+ return label
66
+ except (ValueError, TypeError):
67
+ pass
68
+ return str(raw)
69
+ if isinstance(raw, (int, float)):
70
+ if self.unit == "h" and raw == 0:
71
+ return "--"
72
+ if self.scale != 1:
73
+ return f"{raw / self.scale:.{self.decimals}f}{self.unit}"
74
+ if self.unit:
75
+ return f"{raw}{self.unit}"
76
+ return str(raw)
77
+
78
+
79
+ # Model code -> human-readable name (from ha/c.java)
80
+ MODEL_NAMES: dict[int, str] = {
81
+ 1: "Explorer 3000 Pro",
82
+ 2: "Explorer 2000 Plus",
83
+ 4: "Explorer 300 Plus",
84
+ 5: "Explorer 1000 Plus",
85
+ 6: "Explorer 700 Plus",
86
+ 7: "Explorer 280 Plus",
87
+ 8: "Explorer 1000 Pro2",
88
+ 9: "Explorer 600 Plus",
89
+ 10: "Explorer 240",
90
+ 12: "Explorer 2000",
91
+ }
92
+
93
+ GROUP_TITLES: dict[str, str] = {
94
+ "battery": "Battery & Power",
95
+ "io": "I/O State",
96
+ "settings": "Settings",
97
+ "power": "AC / Power Detail",
98
+ "other": "Other",
99
+ }
100
+
101
+
102
+ PROPERTIES: list[Setting] = [
103
+ # Battery & Power
104
+ Setting("rb", "battery", "Battery", "battery", unit="%"),
105
+ Setting("bt", "battery-temp", "Battery temp", "battery", unit="C", scale=10),
106
+ Setting("bs", "battery-state", "Battery state", "battery"),
107
+ Setting("ip", "input-power", "Input power", "battery", unit="W"),
108
+ Setting("op", "output-power", "Output power", "battery", unit="W"),
109
+ Setting("it", "input-time", "Input time remaining", "battery", unit="h", scale=10),
110
+ Setting("ot", "output-time", "Output time remaining", "battery", unit="h", scale=10),
111
+ # I/O State
112
+ Setting("oac", "ac", "AC output", "io", action_id=4, values=["off", "on"]),
113
+ Setting("odc", "dc", "DC output", "io", action_id=1, values=["off", "on"]),
114
+ Setting("odcu", "usb", "USB output", "io", action_id=2, values=["off", "on"]),
115
+ Setting("odcc", "car", "Car output", "io", action_id=3, values=["off", "on"]),
116
+ Setting("iac", "ac-in", "AC input", "io", action_id=5, values=["off", "on"]),
117
+ Setting("idc", "dc-in", "DC input", "io", action_id=6, values=["off", "on"]),
118
+ Setting("lm", "light", "Light mode", "io", action_id=7, values=["off", "low", "high", "sos"]),
119
+ Setting("wss", "wireless", "Wireless charging", "io", values=["off", "on"]),
120
+ # Settings
121
+ Setting(
122
+ "cs", "charge-speed", "Charge speed", "settings", action_id=10, values=["fast", "mute"]
123
+ ),
124
+ Setting("ast", "auto-shutdown", "Auto shutdown", "settings", action_id=9),
125
+ Setting("pm", "energy-saving", "Energy saving", "settings", action_id=12),
126
+ Setting(
127
+ "lps",
128
+ "battery-protection",
129
+ "Battery protection",
130
+ "settings",
131
+ action_id=11,
132
+ values=["full", "eco"],
133
+ ),
134
+ Setting("sfc", "sfc", "Super fast charge", "settings", action_id=13, values=["off", "on"]),
135
+ Setting("ups", "ups", "UPS mode", "settings", action_id=14, values=["off", "on"]),
136
+ Setting("sltb", "screen-timeout", "Screen timeout", "settings", action_id=8, write_id="slt"),
137
+ # AC / Power Detail
138
+ Setting("acip", "ac-input-power", "AC input power", "power", unit="W"),
139
+ Setting("cip", "car-input-power", "Car input power", "power", unit="W"),
140
+ Setting("acov", "ac-voltage", "AC output voltage", "power", unit="V", scale=10, decimals=0),
141
+ Setting("acohz", "ac-freq", "AC output freq", "power", unit="Hz"),
142
+ Setting("acps", "ac-power", "AC power", "power", unit="W"),
143
+ Setting("acpss", "ac-power-2", "AC power (secondary)", "power", unit="W"),
144
+ Setting("acpsp", "ac-socket-power", "AC socket power", "power", unit="W"),
145
+ # Other / Alarms
146
+ Setting("ec", "error-code", "Error code", "other"),
147
+ Setting("ta", "temp-alarm", "Temp alarm", "other"),
148
+ Setting("pal", "power-alarm", "Power alarm", "other"),
149
+ Setting("pmb", "power-mode-battery", "Power mode battery", "other"),
150
+ Setting("tt", "total-temp", "Total temp", "other"),
151
+ Setting("ss", "system-status", "System status", "other"),
152
+ Setting("pc", "power-capacity", "Power capacity", "other"),
153
+ ]
154
+
155
+ _by_slug: dict[str, Setting] = {s.slug: s for s in PROPERTIES}
156
+ _by_id: dict[str, Setting] = {s.id: s for s in PROPERTIES}
157
+
158
+
159
+ def resolve(name: str) -> Setting | None:
160
+ """Look up a Setting by slug or raw property key."""
161
+ return _by_slug.get(name) or _by_id.get(name)
socketry/py.typed ADDED
File without changes
@@ -0,0 +1,213 @@
1
+ Metadata-Version: 2.4
2
+ Name: socketry
3
+ Version: 0.1.0
4
+ Summary: Python API and CLI for controlling Jackery portable power stations
5
+ Author: Jesus Lopez
6
+ Author-email: Jesus Lopez <jesus@jesusla.com>
7
+ License-Expression: MIT
8
+ Requires-Dist: typer>=0.9
9
+ Requires-Dist: paho-mqtt>=2.0
10
+ Requires-Dist: requests>=2.31
11
+ Requires-Dist: pycryptodome>=3.19
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+
15
+ # socketry
16
+
17
+ [![CI](https://github.com/jlopez/socketry/actions/workflows/ci.yml/badge.svg)](https://github.com/jlopez/socketry/actions/workflows/ci.yml)
18
+ ![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue)
19
+
20
+ Python API and CLI for controlling Jackery portable power stations.
21
+
22
+ Reverse-engineered from the Jackery Android APK (v1.0.7) and iOS app (v1.2.0).
23
+ Communicates via Jackery's cloud MQTT broker and HTTP API — no modifications to
24
+ the device or its firmware.
25
+
26
+ ## Quick start
27
+
28
+ ```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
31
+ ```
32
+
33
+ Or install it once and use `socketry` directly:
34
+
35
+ ```bash
36
+ uv tool install git+https://github.com/jlopez/socketry
37
+ socketry login --email you@example.com --password 'yourpass'
38
+ socketry get
39
+ ```
40
+
41
+ ## Supported devices
42
+
43
+ All 10 models in the current Jackery app share the same protocol:
44
+
45
+ | Model | Code |
46
+ |-------|------|
47
+ | Explorer 3000 Pro | 1 |
48
+ | Explorer 2000 Plus | 2 |
49
+ | Explorer 300 Plus | 4 |
50
+ | Explorer 1000 Plus | 5 |
51
+ | Explorer 700 Plus | 6 |
52
+ | Explorer 280 Plus | 7 |
53
+ | Explorer 1000 Pro2 | 8 |
54
+ | Explorer 600 Plus | 9 |
55
+ | Explorer 240 | 10 |
56
+ | Explorer 2000 | 12 |
57
+
58
+ Properties and MQTT action IDs are exhaustive for this APK version. Unknown
59
+ properties returned by newer firmware are displayed as raw key/value pairs.
60
+
61
+ ## Install
62
+
63
+ ```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)
68
+ uv tool install socketry
69
+
70
+ # Or install as a library
71
+ pip install socketry
72
+ ```
73
+
74
+ ## CLI usage
75
+
76
+ ### Login
77
+
78
+ ```bash
79
+ # Authenticates and discovers all devices (owned + shared with you)
80
+ socketry login --email you@example.com --password 'yourpass'
81
+
82
+ # List devices and select the active one
83
+ socketry devices
84
+ socketry select 0
85
+ ```
86
+
87
+ Credentials are saved to `~/.config/socketry/credentials.json` (mode 0600).
88
+
89
+ ### Reading properties (`get`)
90
+
91
+ ```bash
92
+ # All properties (colored + grouped on a TTY)
93
+ socketry get
94
+
95
+ # Single property — by CLI name or raw protocol key
96
+ socketry get battery # Battery: 85%
97
+ socketry get rb # Battery: 85% (same thing)
98
+ socketry get ac # AC output: ON
99
+
100
+ # JSON output (indented on TTY, compact when piped)
101
+ socketry get --json
102
+ socketry get ac --json # {"oac": 1}
103
+ socketry get --json | jq .rb # pipe-friendly
104
+ ```
105
+
106
+ Available properties:
107
+
108
+ | Group | Names |
109
+ |-------|-------|
110
+ | Battery & Power | `battery`, `battery-temp`, `battery-state`, `input-power`, `output-power`, `input-time`, `output-time` |
111
+ | I/O State | `ac`, `dc`, `usb`, `car`, `ac-in`, `dc-in`, `light`, `wireless` |
112
+ | Settings | `charge-speed`, `auto-shutdown`, `energy-saving`, `battery-protection`, `sfc`, `ups`, `screen-timeout` |
113
+ | AC / Power Detail | `ac-input-power`, `car-input-power`, `ac-voltage`, `ac-freq`, `ac-power`, `ac-power-2`, `ac-socket-power` |
114
+ | Other / Alarms | `error-code`, `temp-alarm`, `power-alarm`, `power-mode-battery`, `total-temp`, `system-status`, `power-capacity` |
115
+
116
+ Raw protocol keys (`rb`, `oac`, `bt`, ...) are also accepted.
117
+
118
+ ### Changing settings (`set`)
119
+
120
+ ```bash
121
+ # I/O toggles
122
+ socketry set ac on
123
+ socketry set dc off
124
+ socketry set usb on
125
+ socketry set car off
126
+
127
+ # Light
128
+ socketry set light high # off | low | high | sos
129
+
130
+ # Device settings
131
+ socketry set charge-speed mute # fast | mute
132
+ socketry set battery-protection eco # full | eco
133
+ socketry set ups on
134
+ socketry set sfc on
135
+
136
+ # Integer settings
137
+ socketry set screen-timeout 30
138
+ socketry set auto-shutdown 60
139
+ socketry set energy-saving 30
140
+
141
+ # Wait for device confirmation
142
+ socketry set ac on --wait
143
+
144
+ # Show available settings
145
+ socketry set
146
+ socketry set light # "expects a value: off | low | high | sos"
147
+ ```
148
+
149
+ Writable settings:
150
+
151
+ | Setting | Values | Description |
152
+ |---------|--------|-------------|
153
+ | `ac` | on / off | AC output |
154
+ | `dc` | on / off | DC output |
155
+ | `usb` | on / off | USB output |
156
+ | `car` | on / off | Car (12V) output |
157
+ | `ac-in` | on / off | AC input |
158
+ | `dc-in` | on / off | DC input |
159
+ | `light` | off / low / high / sos | Light mode |
160
+ | `screen-timeout` | integer | Screen timeout |
161
+ | `auto-shutdown` | integer | Auto shutdown timer |
162
+ | `charge-speed` | fast / mute | Charge speed mode |
163
+ | `battery-protection` | full / eco | Battery protection level |
164
+ | `energy-saving` | integer | Energy saving timeout |
165
+ | `sfc` | on / off | Super fast charge |
166
+ | `ups` | on / off | UPS mode |
167
+
168
+ ## Library usage
169
+
170
+ ```python
171
+ from socketry import Client
172
+
173
+ # Authenticate (or load saved credentials)
174
+ client = Client.login("email@example.com", "password")
175
+ client.save_credentials()
176
+
177
+ # Or load previously saved credentials
178
+ client = Client.from_saved()
179
+
180
+ # List and select devices
181
+ devices = client.fetch_devices()
182
+ client.select_device(0)
183
+
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)}")
188
+
189
+ # Control
190
+ client.set_property("ac", "on")
191
+ result = client.set_property("light", "high", wait=True)
192
+ ```
193
+
194
+ ## How it works
195
+
196
+ ```
197
+ socketry ──HTTP──> iot.jackeryapp.com (login, device list, properties)
198
+ socketry ──MQTT──> emqx.jackeryapp.com (device control via encrypted TLS)
199
+ ```
200
+
201
+ Login uses AES-192/ECB + RSA-1024 encrypted HTTP POST. Device control commands
202
+ are published over MQTT (TLS 1.2 with a self-signed CA). Status polling uses the
203
+ HTTP property endpoint. See [docs/protocol.md](docs/protocol.md) for the full
204
+ protocol specification.
205
+
206
+ ## Roadmap
207
+
208
+ - [ ] MQTT real-time monitor (subscribe to live property changes)
209
+ - [ ] Token auto-refresh (JWT expires ~30 days)
210
+
211
+ ## License
212
+
213
+ MIT
@@ -0,0 +1,12 @@
1
+ socketry/__init__.py,sha256=49C2S5Yg06VgoISgCM4fkXHAL1DVIVNfkvuh7fpo_mE,237
2
+ socketry/__main__.py,sha256=Xbzkpml_p65fp0cPshuA4gKjSllCRpKcSm6q1oK1XPc,86
3
+ socketry/_constants.py,sha256=KqJEn9Ft-nwPQLyIqTW2At5fiItLkZmFcpG90Z4EsjY,2170
4
+ socketry/_crypto.py,sha256=4xs1PEWHKtO3mWNAB3ZLvMWpNO2S55Ow66MMt04s8Wo,1796
5
+ socketry/cli.py,sha256=AoQs33nxEaTYxQSgyEN8vznJSBrYRMFt1OrH6cvMuBw,9199
6
+ socketry/client.py,sha256=gIEya-FfMoRtfsbm1eF8VDhDdPHhedloD51okVmB9II,17210
7
+ socketry/properties.py,sha256=y2BRw4mT2l--uCtgt0IpkVoD1Deoc0kfVa25VUlPuSI,6158
8
+ socketry/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ socketry-0.1.0.dist-info/WHEEL,sha256=iHtWm8nRfs0VRdCYVXocAWFW8ppjHL-uTJkAdZJKOBM,80
10
+ socketry-0.1.0.dist-info/entry_points.txt,sha256=1ZOtKJ-ufHKUk3qc_LuB_57u0-yV2dE9eCB0j3DOF_E,47
11
+ socketry-0.1.0.dist-info/METADATA,sha256=Lk0uCs9hKAhqyAEF4HImT8bTE8CW1ZNTo4mb5dnzaFI,6080
12
+ socketry-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.30
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ socketry = socketry.cli:app
3
+