socketry 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {socketry-0.2.0 → socketry-0.2.2}/PKG-INFO +2 -2
- {socketry-0.2.0 → socketry-0.2.2}/README.md +1 -1
- {socketry-0.2.0 → socketry-0.2.2}/pyproject.toml +1 -1
- socketry-0.2.2/src/socketry/__init__.py +23 -0
- {socketry-0.2.0 → socketry-0.2.2}/src/socketry/client.py +148 -39
- socketry-0.2.0/src/socketry/__init__.py +0 -6
- {socketry-0.2.0 → socketry-0.2.2}/src/socketry/__main__.py +0 -0
- {socketry-0.2.0 → socketry-0.2.2}/src/socketry/_constants.py +0 -0
- {socketry-0.2.0 → socketry-0.2.2}/src/socketry/_crypto.py +0 -0
- {socketry-0.2.0 → socketry-0.2.2}/src/socketry/cli.py +0 -0
- {socketry-0.2.0 → socketry-0.2.2}/src/socketry/properties.py +0 -0
- {socketry-0.2.0 → socketry-0.2.2}/src/socketry/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: socketry
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Python API and CLI for controlling Jackery portable power stations
|
|
5
5
|
Author: Jesus Lopez
|
|
6
6
|
Author-email: Jesus Lopez <jesus@jesusla.com>
|
|
@@ -15,7 +15,7 @@ Description-Content-Type: text/markdown
|
|
|
15
15
|
# socketry
|
|
16
16
|
|
|
17
17
|
[](https://github.com/jlopez/socketry/actions/workflows/ci.yml)
|
|
18
|
-

|
|
19
19
|

|
|
20
20
|
|
|
21
21
|
Python API and CLI for controlling Jackery portable power stations.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# socketry
|
|
2
2
|
|
|
3
3
|
[](https://github.com/jlopez/socketry/actions/workflows/ci.yml)
|
|
4
|
-

|
|
5
5
|

|
|
6
6
|
|
|
7
7
|
Python API and CLI for controlling Jackery portable power stations.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Python API and CLI for controlling Jackery portable power stations."""
|
|
2
|
+
|
|
3
|
+
from socketry.client import (
|
|
4
|
+
AuthenticationError,
|
|
5
|
+
Client,
|
|
6
|
+
Device,
|
|
7
|
+
MqttError,
|
|
8
|
+
SocketryError,
|
|
9
|
+
Subscription,
|
|
10
|
+
)
|
|
11
|
+
from socketry.properties import MODEL_NAMES, PROPERTIES, Setting
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AuthenticationError",
|
|
15
|
+
"Client",
|
|
16
|
+
"Device",
|
|
17
|
+
"MODEL_NAMES",
|
|
18
|
+
"MqttError",
|
|
19
|
+
"PROPERTIES",
|
|
20
|
+
"Setting",
|
|
21
|
+
"SocketryError",
|
|
22
|
+
"Subscription",
|
|
23
|
+
]
|
|
@@ -55,10 +55,41 @@ from socketry.properties import Setting, resolve
|
|
|
55
55
|
_TOKEN_EXPIRY_BUFFER = 3600 # seconds before expiry to trigger proactive refresh
|
|
56
56
|
|
|
57
57
|
|
|
58
|
-
class
|
|
58
|
+
class SocketryError(Exception):
|
|
59
|
+
"""Base exception for all socketry errors."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TokenExpiredError(SocketryError):
|
|
59
63
|
"""Raised when the Jackery API returns error code 10402 (token expired)."""
|
|
60
64
|
|
|
61
65
|
|
|
66
|
+
class AuthenticationError(SocketryError):
|
|
67
|
+
"""Raised when login fails after an automatic re-authentication attempt.
|
|
68
|
+
|
|
69
|
+
Triggered when the Jackery API returns an auth error (session invalidated
|
|
70
|
+
by the server) and the automatic re-login attempt also fails (e.g. bad
|
|
71
|
+
credentials, account suspended).
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _SessionInvalidatedError(SocketryError):
|
|
76
|
+
"""Internal sentinel: API returned a non-token-expiry auth/API error.
|
|
77
|
+
|
|
78
|
+
Raised by HTTP helpers to signal that the current session is rejected by
|
|
79
|
+
the server. :meth:`Client._relogin` catches this, attempts one re-login,
|
|
80
|
+
and re-raises as :class:`AuthenticationError` on failure.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class MqttError(SocketryError, ConnectionError):
|
|
85
|
+
"""Raised when an MQTT operation fails.
|
|
86
|
+
|
|
87
|
+
Wraps :class:`aiomqtt.MqttError` so callers do not need to import
|
|
88
|
+
``aiomqtt`` to catch MQTT-related failures from :meth:`Client.set_property`
|
|
89
|
+
or :meth:`Device.set_property`.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
|
|
62
93
|
class Device:
|
|
63
94
|
"""A specific Jackery device.
|
|
64
95
|
|
|
@@ -116,6 +147,10 @@ class Device:
|
|
|
116
147
|
self._client._creds["tokenExp"] = 0
|
|
117
148
|
await self._client._ensure_fresh_token()
|
|
118
149
|
return await _fetch_device_properties(self._client.token, self._id, session)
|
|
150
|
+
except _SessionInvalidatedError:
|
|
151
|
+
stale = self._client.token
|
|
152
|
+
await self._client._relogin(stale)
|
|
153
|
+
return await _fetch_device_properties(self._client.token, self._id, session)
|
|
119
154
|
|
|
120
155
|
async def get_property(self, name: str) -> tuple[Setting, object]:
|
|
121
156
|
"""Fetch a single property by slug or raw key.
|
|
@@ -168,22 +203,25 @@ class Device:
|
|
|
168
203
|
body: dict[str, object] = {setting.prop_key: int_value}
|
|
169
204
|
assert setting.action_id is not None
|
|
170
205
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
self.
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
206
|
+
try:
|
|
207
|
+
if wait:
|
|
208
|
+
result = await self._client._publish_and_wait(
|
|
209
|
+
self._sn,
|
|
210
|
+
setting.action_id,
|
|
211
|
+
body,
|
|
212
|
+
expected_keys=set(body.keys()),
|
|
213
|
+
verbose=verbose,
|
|
214
|
+
)
|
|
215
|
+
if result is not None:
|
|
216
|
+
resp = result.get("body")
|
|
217
|
+
if isinstance(resp, dict):
|
|
218
|
+
return resp
|
|
219
|
+
return None
|
|
220
|
+
else:
|
|
221
|
+
await self._client._publish_command(self._sn, setting.action_id, body)
|
|
222
|
+
return None
|
|
223
|
+
except aiomqtt.MqttError as e:
|
|
224
|
+
raise MqttError(str(e)) from e
|
|
187
225
|
|
|
188
226
|
|
|
189
227
|
class Client:
|
|
@@ -273,6 +311,11 @@ class Client:
|
|
|
273
311
|
"""Current JWT auth token."""
|
|
274
312
|
return str(self._creds.get("token", ""))
|
|
275
313
|
|
|
314
|
+
@property
|
|
315
|
+
def user_id(self) -> str:
|
|
316
|
+
"""Authenticated user ID."""
|
|
317
|
+
return str(self._creds.get("userId", ""))
|
|
318
|
+
|
|
276
319
|
# ------------------------------------------------------------------
|
|
277
320
|
# Token refresh
|
|
278
321
|
# ------------------------------------------------------------------
|
|
@@ -313,6 +356,46 @@ class Client:
|
|
|
313
356
|
if self._auto_save:
|
|
314
357
|
self.save_credentials()
|
|
315
358
|
|
|
359
|
+
async def _relogin(self, stale_token: str) -> None:
|
|
360
|
+
"""Re-authenticate using stored credentials and update the session.
|
|
361
|
+
|
|
362
|
+
Called automatically when the server rejects the current session
|
|
363
|
+
(:class:`_SessionInvalidatedError`). Raises :class:`AuthenticationError`
|
|
364
|
+
if the re-login attempt fails (wrong password, account suspended, etc.)
|
|
365
|
+
or if no credentials are stored.
|
|
366
|
+
|
|
367
|
+
*stale_token* is the token that was rejected. If another concurrent
|
|
368
|
+
call has already refreshed the token by the time this call acquires
|
|
369
|
+
:attr:`_refresh_lock`, the re-login is skipped (the session is already
|
|
370
|
+
healed).
|
|
371
|
+
|
|
372
|
+
Only the auth fields (``token``, ``mqttPassWord``, ``tokenExp``) are
|
|
373
|
+
updated so that device selection is preserved.
|
|
374
|
+
"""
|
|
375
|
+
email = str(self._creds.get("email", ""))
|
|
376
|
+
password = str(self._creds.get("password", ""))
|
|
377
|
+
if not email or not password:
|
|
378
|
+
raise AuthenticationError(
|
|
379
|
+
"Cannot re-authenticate: no email or password stored in credentials."
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
async with self._refresh_lock:
|
|
383
|
+
# Another concurrent task may have already refreshed the session
|
|
384
|
+
# while we were waiting for the lock. If the token has changed,
|
|
385
|
+
# the session is healed — skip the re-login.
|
|
386
|
+
if self.token != stale_token:
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
new_creds = await _http_login(email, password, fetch_devices=False)
|
|
391
|
+
except Exception as exc:
|
|
392
|
+
raise AuthenticationError(f"Re-authentication failed: {exc}") from exc
|
|
393
|
+
self._creds["token"] = new_creds["token"]
|
|
394
|
+
self._creds["mqttPassWord"] = new_creds["mqttPassWord"]
|
|
395
|
+
self._creds["tokenExp"] = new_creds.get("tokenExp")
|
|
396
|
+
if self._auto_save:
|
|
397
|
+
self.save_credentials()
|
|
398
|
+
|
|
316
399
|
# ------------------------------------------------------------------
|
|
317
400
|
# Device management
|
|
318
401
|
# ------------------------------------------------------------------
|
|
@@ -330,6 +413,10 @@ class Client:
|
|
|
330
413
|
self._creds["tokenExp"] = 0
|
|
331
414
|
await self._ensure_fresh_token()
|
|
332
415
|
all_devices = await _fetch_all_devices(self.token, session)
|
|
416
|
+
except _SessionInvalidatedError:
|
|
417
|
+
stale = self.token
|
|
418
|
+
await self._relogin(stale)
|
|
419
|
+
all_devices = await _fetch_all_devices(self.token, session)
|
|
333
420
|
self._creds["devices"] = all_devices
|
|
334
421
|
return all_devices
|
|
335
422
|
|
|
@@ -358,11 +445,17 @@ class Client:
|
|
|
358
445
|
|
|
359
446
|
Raises:
|
|
360
447
|
IndexError: If an integer index is out of range, or no
|
|
361
|
-
devices are cached.
|
|
362
|
-
KeyError: If a serial-number string is not found
|
|
448
|
+
devices are cached (integer lookup only).
|
|
449
|
+
KeyError: If a serial-number string is not found, or no
|
|
450
|
+
devices are cached (string lookup only).
|
|
363
451
|
"""
|
|
364
452
|
devs = self.devices
|
|
365
453
|
if not devs:
|
|
454
|
+
if isinstance(index_or_sn, str):
|
|
455
|
+
raise KeyError(
|
|
456
|
+
f"No device with SN '{index_or_sn}': device list is empty. "
|
|
457
|
+
"Call fetch_devices() first."
|
|
458
|
+
)
|
|
366
459
|
raise IndexError("No cached device list. Call fetch_devices() first.")
|
|
367
460
|
if isinstance(index_or_sn, int):
|
|
368
461
|
if index_or_sn < 0 or index_or_sn >= len(devs):
|
|
@@ -393,6 +486,10 @@ class Client:
|
|
|
393
486
|
self._creds["tokenExp"] = 0
|
|
394
487
|
await self._ensure_fresh_token()
|
|
395
488
|
return await _fetch_device_properties(self.token, self.device_id, session)
|
|
489
|
+
except _SessionInvalidatedError:
|
|
490
|
+
stale = self.token
|
|
491
|
+
await self._relogin(stale)
|
|
492
|
+
return await _fetch_device_properties(self.token, self.device_id, session)
|
|
396
493
|
|
|
397
494
|
async def get_property(self, name: str) -> tuple[Setting, object]:
|
|
398
495
|
"""Fetch a single property by slug or raw key.
|
|
@@ -440,7 +537,7 @@ class Client:
|
|
|
440
537
|
method cancels the background listener.
|
|
441
538
|
"""
|
|
442
539
|
task = asyncio.create_task(self._run_subscribe_loop(callback, on_disconnect=on_disconnect))
|
|
443
|
-
return Subscription(task)
|
|
540
|
+
return Subscription(task, self)
|
|
444
541
|
|
|
445
542
|
# ------------------------------------------------------------------
|
|
446
543
|
# Control (MQTT)
|
|
@@ -480,22 +577,25 @@ class Client:
|
|
|
480
577
|
body: dict[str, object] = {setting.prop_key: int_value}
|
|
481
578
|
assert setting.action_id is not None
|
|
482
579
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
self.
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
580
|
+
try:
|
|
581
|
+
if wait:
|
|
582
|
+
result = await self._publish_and_wait(
|
|
583
|
+
self.device_sn,
|
|
584
|
+
setting.action_id,
|
|
585
|
+
body,
|
|
586
|
+
expected_keys=set(body.keys()),
|
|
587
|
+
verbose=verbose,
|
|
588
|
+
)
|
|
589
|
+
if result is not None:
|
|
590
|
+
resp = result.get("body")
|
|
591
|
+
if isinstance(resp, dict):
|
|
592
|
+
return resp
|
|
593
|
+
return None
|
|
594
|
+
else:
|
|
595
|
+
await self._publish_command(self.device_sn, setting.action_id, body)
|
|
596
|
+
return None
|
|
597
|
+
except aiomqtt.MqttError as e:
|
|
598
|
+
raise MqttError(str(e)) from e
|
|
499
599
|
|
|
500
600
|
# ------------------------------------------------------------------
|
|
501
601
|
# Private MQTT methods
|
|
@@ -646,8 +746,14 @@ class Subscription:
|
|
|
646
746
|
subscription ends (e.g. via cancellation or error).
|
|
647
747
|
"""
|
|
648
748
|
|
|
649
|
-
def __init__(self, task: asyncio.Task[None]) -> None:
|
|
749
|
+
def __init__(self, task: asyncio.Task[None], client: Client) -> None:
|
|
650
750
|
self._task = task
|
|
751
|
+
self._client = client
|
|
752
|
+
|
|
753
|
+
@property
|
|
754
|
+
def is_connected(self) -> bool:
|
|
755
|
+
"""True when the MQTT broker connection is currently established."""
|
|
756
|
+
return self._client._active_mqtt is not None
|
|
651
757
|
|
|
652
758
|
async def stop(self) -> None:
|
|
653
759
|
"""Cancel the subscription and wait for cleanup."""
|
|
@@ -752,7 +858,7 @@ async def _http_login(
|
|
|
752
858
|
|
|
753
859
|
if body.get("code") != 0:
|
|
754
860
|
msg = body.get("msg", "unknown error")
|
|
755
|
-
raise
|
|
861
|
+
raise AuthenticationError(f"Login failed: {msg}")
|
|
756
862
|
|
|
757
863
|
data = body["data"]
|
|
758
864
|
token = body["token"]
|
|
@@ -796,6 +902,9 @@ async def _fetch_all_devices(token: str, session: aiohttp.ClientSession) -> list
|
|
|
796
902
|
dev_body = await dev_resp.json()
|
|
797
903
|
if dev_body.get("code") == 10402:
|
|
798
904
|
raise TokenExpiredError("Token expired (10402)")
|
|
905
|
+
if dev_body.get("code") != 0:
|
|
906
|
+
msg = dev_body.get("msg", "unknown error")
|
|
907
|
+
raise _SessionInvalidatedError(f"Device list fetch failed: {msg}")
|
|
799
908
|
for d in dev_body.get("data") or []:
|
|
800
909
|
all_devices.append(
|
|
801
910
|
{
|
|
@@ -863,7 +972,7 @@ async def _fetch_device_properties(
|
|
|
863
972
|
raise TokenExpiredError("Token expired (10402)")
|
|
864
973
|
if body.get("code") != 0:
|
|
865
974
|
msg = body.get("msg", "unknown error")
|
|
866
|
-
raise
|
|
975
|
+
raise _SessionInvalidatedError(f"Property fetch failed: {msg}")
|
|
867
976
|
return body.get("data") or {}
|
|
868
977
|
|
|
869
978
|
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
"""Python API and CLI for controlling Jackery portable power stations."""
|
|
2
|
-
|
|
3
|
-
from socketry.client import Client, Device, Subscription
|
|
4
|
-
from socketry.properties import MODEL_NAMES, PROPERTIES, Setting
|
|
5
|
-
|
|
6
|
-
__all__ = ["Client", "Device", "MODEL_NAMES", "PROPERTIES", "Setting", "Subscription"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|