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 +6 -0
- socketry/__main__.py +5 -0
- socketry/_constants.py +52 -0
- socketry/_crypto.py +51 -0
- socketry/cli.py +275 -0
- socketry/client.py +520 -0
- socketry/properties.py +161 -0
- socketry/py.typed +0 -0
- socketry-0.1.0.dist-info/METADATA +213 -0
- socketry-0.1.0.dist-info/RECORD +12 -0
- socketry-0.1.0.dist-info/WHEEL +4 -0
- socketry-0.1.0.dist-info/entry_points.txt +3 -0
socketry/__init__.py
ADDED
socketry/__main__.py
ADDED
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
|
+
[](https://github.com/jlopez/socketry/actions/workflows/ci.yml)
|
|
18
|
+

|
|
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,,
|