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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: socketry
3
- Version: 0.2.0
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
  [![CI](https://github.com/jlopez/socketry/actions/workflows/ci.yml/badge.svg)](https://github.com/jlopez/socketry/actions/workflows/ci.yml)
18
- ![Coverage](https://img.shields.io/badge/coverage-74%25-yellowgreen)
18
+ ![Coverage](https://img.shields.io/badge/coverage-76%25-yellowgreen)
19
19
  ![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue)
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
  [![CI](https://github.com/jlopez/socketry/actions/workflows/ci.yml/badge.svg)](https://github.com/jlopez/socketry/actions/workflows/ci.yml)
4
- ![Coverage](https://img.shields.io/badge/coverage-74%25-yellowgreen)
4
+ ![Coverage](https://img.shields.io/badge/coverage-76%25-yellowgreen)
5
5
  ![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue)
6
6
 
7
7
  Python API and CLI for controlling Jackery portable power stations.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "socketry"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Python API and CLI for controlling Jackery portable power stations"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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 TokenExpiredError(RuntimeError):
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
- if wait:
172
- result = await self._client._publish_and_wait(
173
- self._sn,
174
- setting.action_id,
175
- body,
176
- expected_keys=set(body.keys()),
177
- verbose=verbose,
178
- )
179
- if result is not None:
180
- resp = result.get("body")
181
- if isinstance(resp, dict):
182
- return resp
183
- return None
184
- else:
185
- await self._client._publish_command(self._sn, setting.action_id, body)
186
- return None
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
- if wait:
484
- result = await self._publish_and_wait(
485
- self.device_sn,
486
- setting.action_id,
487
- body,
488
- expected_keys=set(body.keys()),
489
- verbose=verbose,
490
- )
491
- if result is not None:
492
- resp = result.get("body")
493
- if isinstance(resp, dict):
494
- return resp
495
- return None
496
- else:
497
- await self._publish_command(self.device_sn, setting.action_id, body)
498
- return None
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 RuntimeError(f"Login failed: {msg}")
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 RuntimeError(f"Property fetch failed: {msg}")
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