lojack-api 0.7.0__tar.gz → 0.7.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.
Files changed (24) hide show
  1. {lojack_api-0.7.0 → lojack_api-0.7.2}/PKG-INFO +1 -1
  2. {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api/__init__.py +1 -1
  3. {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api/api.py +8 -1
  4. {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api/auth.py +35 -12
  5. {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api/device.py +30 -12
  6. {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api.egg-info/PKG-INFO +1 -1
  7. {lojack_api-0.7.0 → lojack_api-0.7.2}/pyproject.toml +1 -1
  8. {lojack_api-0.7.0 → lojack_api-0.7.2}/tests/test_auth.py +94 -1
  9. {lojack_api-0.7.0 → lojack_api-0.7.2}/LICENSE +0 -0
  10. {lojack_api-0.7.0 → lojack_api-0.7.2}/README.md +0 -0
  11. {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api/exceptions.py +0 -0
  12. {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api/models.py +0 -0
  13. {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api/py.typed +0 -0
  14. {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api/transport.py +0 -0
  15. {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api.egg-info/SOURCES.txt +0 -0
  16. {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api.egg-info/dependency_links.txt +0 -0
  17. {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api.egg-info/requires.txt +0 -0
  18. {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api.egg-info/top_level.txt +0 -0
  19. {lojack_api-0.7.0 → lojack_api-0.7.2}/setup.cfg +0 -0
  20. {lojack_api-0.7.0 → lojack_api-0.7.2}/tests/test_client.py +0 -0
  21. {lojack_api-0.7.0 → lojack_api-0.7.2}/tests/test_device.py +0 -0
  22. {lojack_api-0.7.0 → lojack_api-0.7.2}/tests/test_exceptions.py +0 -0
  23. {lojack_api-0.7.0 → lojack_api-0.7.2}/tests/test_models.py +0 -0
  24. {lojack_api-0.7.0 → lojack_api-0.7.2}/tests/test_transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lojack_api
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: An async Python client library for the LoJack API, designed for Home Assistant integrations.
5
5
  Author: Devin Slick
6
6
  License: MIT
@@ -45,7 +45,7 @@ from .models import (
45
45
  )
46
46
  from .transport import AiohttpTransport
47
47
 
48
- __version__ = "0.7.0"
48
+ __version__ = "0.7.2"
49
49
 
50
50
  __all__ = [
51
51
  # Main client
@@ -338,7 +338,14 @@ class LoJackClient:
338
338
  A list of Location objects.
339
339
  """
340
340
  headers = await self._get_headers()
341
- params: dict[str, Any] = {}
341
+ params: dict[str, Any] = {
342
+ # Sort by date descending so the most recent event is first.
343
+ # Without this, the Spireon API may return events in an order
344
+ # where real-time AUTO_LOC events are buried behind older
345
+ # trip/sleep events, causing the integration to report stale
346
+ # location data.
347
+ "sort": "-date",
348
+ }
342
349
 
343
350
  if limit != -1:
344
351
  params["limit"] = limit
@@ -12,10 +12,11 @@ The Spireon LoJack API uses:
12
12
  from __future__ import annotations
13
13
 
14
14
  import base64
15
+ import json
15
16
  import uuid
16
17
  from dataclasses import dataclass
17
18
  from datetime import datetime, timedelta, timezone
18
- from typing import TYPE_CHECKING, Any
19
+ from typing import TYPE_CHECKING, Any, cast
19
20
 
20
21
  from .exceptions import AuthenticationError
21
22
 
@@ -183,6 +184,19 @@ class AuthManager:
183
184
  user_id=self._user_id,
184
185
  )
185
186
 
187
+ @staticmethod
188
+ def _extract_user_id_from_jwt(token: str) -> str | None:
189
+ """Extract user ID from Spireon JWT ns:u claim (no signature verification needed)."""
190
+ try:
191
+ parts = token.split(".")
192
+ if len(parts) != 3:
193
+ return None
194
+ padding = "=" * (4 - len(parts[1]) % 4)
195
+ payload = json.loads(base64.urlsafe_b64decode(parts[1] + padding))
196
+ return cast("str | None", payload.get("ns:u") or payload.get("sub"))
197
+ except Exception:
198
+ return None
199
+
186
200
  async def login(self) -> str:
187
201
  """Authenticate with the identity service using Basic Auth.
188
202
 
@@ -215,21 +229,30 @@ class AuthManager:
215
229
 
216
230
  token: str = str(token_value)
217
231
  self._access_token = token
218
- self._user_id = data.get("userId") or data.get("user_id")
232
+ self._user_id = (
233
+ self._extract_user_id_from_jwt(token)
234
+ or data.get("userId")
235
+ or data.get("user_id")
236
+ )
219
237
 
220
- # Parse expiration - tokens typically expire after some time
221
- expires_in = data.get("expiresIn") or data.get("expires_in")
222
- if expires_in:
238
+ # Parse expiration - prefer ISO 8601 expiresOn, fall back to expiresIn seconds
239
+ expires_on = data.get("expiresOn")
240
+ if expires_on:
223
241
  try:
224
- self._expires_at = datetime.now(timezone.utc) + timedelta(
225
- seconds=int(expires_in)
226
- )
227
- except (ValueError, TypeError):
228
- # Default to 1 hour if not specified
242
+ self._expires_at = datetime.fromisoformat(expires_on.replace("Z", "+00:00"))
243
+ except ValueError:
229
244
  self._expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
230
245
  else:
231
- # Default expiration if not provided
232
- self._expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
246
+ expires_in = data.get("expiresIn") or data.get("expires_in")
247
+ if expires_in:
248
+ try:
249
+ self._expires_at = datetime.now(timezone.utc) + timedelta(
250
+ seconds=int(expires_in)
251
+ )
252
+ except (ValueError, TypeError):
253
+ self._expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
254
+ else:
255
+ self._expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
233
256
 
234
257
  return token
235
258
 
@@ -86,9 +86,13 @@ class Device:
86
86
  async def refresh(self, *, force: bool = False) -> None:
87
87
  """Refresh the device's cached location.
88
88
 
89
- Fetches location from the asset's lastLocation for coordinates,
90
- then enriches it with telemetry data from the latest event
91
- (speed, battery_voltage, signal_strength, etc.).
89
+ Fetches location from the asset's lastLocation for coordinates
90
+ AND the latest event (sorted newest-first). Compares timestamps
91
+ and uses whichever source is more recent.
92
+
93
+ The asset's ``lastLocation`` / ``locationLastReported`` can lag
94
+ behind real-time ``AUTO_LOC`` events by 30+ minutes, so we must
95
+ check both and pick the freshest.
92
96
 
93
97
  Args:
94
98
  force: If True, always fetch new data even if cached.
@@ -96,20 +100,34 @@ class Device:
96
100
  if not force and self._cached_location is not None:
97
101
  return
98
102
 
99
- # First try to get current location from asset's lastLocation
100
- location = await self._client.get_current_location(self.id)
101
-
102
- # Always fetch latest event for telemetry data
103
+ # Fetch both sources in parallel-safe order
104
+ asset_location = await self._client.get_current_location(self.id)
103
105
  events = await self._client.get_locations(self.id, limit=1)
104
106
  latest_event = events[0] if events else None
105
107
 
106
- if location and location.latitude is not None:
107
- # Enrich the lastLocation with telemetry from the event
108
+ # Determine which source is freshest by comparing timestamps.
109
+ # The events endpoint (now sorted by -date) returns AUTO_LOC and
110
+ # other real-time pings that the asset endpoint may not reflect.
111
+ asset_ts = asset_location.timestamp if asset_location else None
112
+ event_ts = latest_event.timestamp if latest_event else None
113
+
114
+ use_event = False
115
+ if latest_event and latest_event.latitude is not None:
116
+ if asset_ts is None and event_ts is not None:
117
+ use_event = True
118
+ elif event_ts is not None and asset_ts is not None:
119
+ use_event = event_ts >= asset_ts
120
+
121
+ if use_event and latest_event is not None:
122
+ # Event is newer — use it directly (it already has telemetry)
123
+ self._cached_location = latest_event
124
+ elif asset_location and asset_location.latitude is not None:
125
+ # Asset is newer or event unavailable — enrich with telemetry
108
126
  if latest_event:
109
- _enrich_location_from_event(location, latest_event)
110
- self._cached_location = location
127
+ _enrich_location_from_event(asset_location, latest_event)
128
+ self._cached_location = asset_location
111
129
  elif latest_event:
112
- # Use the event location directly (it has all the telemetry)
130
+ # Fallback to event even without coordinates comparison
113
131
  self._cached_location = latest_event
114
132
  else:
115
133
  self._cached_location = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lojack_api
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: An async Python client library for the LoJack API, designed for Home Assistant integrations.
5
5
  Author: Devin Slick
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lojack_api"
7
- version = "0.7.0"
7
+ version = "0.7.2"
8
8
  description = "An async Python client library for the LoJack API, designed for Home Assistant integrations."
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Devin Slick" }]
@@ -100,7 +100,7 @@ class TestAuthManager:
100
100
 
101
101
  @pytest.mark.asyncio
102
102
  async def test_login_success(self, auth, transport):
103
- """Test successful login."""
103
+ """Test successful login with legacy userId field."""
104
104
  transport.request.return_value = {
105
105
  "token": "new-token",
106
106
  "expiresIn": 3600,
@@ -114,6 +114,83 @@ class TestAuthManager:
114
114
  assert auth.user_id == "user-123"
115
115
  transport.request.assert_called_once()
116
116
 
117
+ @pytest.mark.asyncio
118
+ async def test_login_user_id_from_jwt(self, auth, transport):
119
+ """Test that user_id is extracted from JWT ns:u claim (real API response shape)."""
120
+ import base64
121
+ import json
122
+
123
+ claims = {"sub": "fnmrihikdnykh7rng", "ns:u": "da05c72d-6a30-466f-8d86-763bd1e8844a"}
124
+ payload = base64.urlsafe_b64encode(
125
+ json.dumps(claims).encode()
126
+ ).decode().rstrip("=")
127
+ jwt_token = f"header.{payload}.signature"
128
+
129
+ transport.request.return_value = {
130
+ "token": jwt_token,
131
+ "scope": "ACCOUNT_USER_SCOPE",
132
+ "expiresOn": "2026-03-27T03:12:54Z",
133
+ "refreshBy": "2026-03-27T03:11:54Z",
134
+ "refreshInSeconds": 86339,
135
+ }
136
+
137
+ token = await auth.login()
138
+
139
+ assert token == jwt_token
140
+ assert auth.user_id == "da05c72d-6a30-466f-8d86-763bd1e8844a"
141
+
142
+ @pytest.mark.asyncio
143
+ async def test_login_user_id_falls_back_to_sub(self, auth, transport):
144
+ """Test that user_id falls back to JWT sub claim when ns:u is absent."""
145
+ import base64
146
+ import json
147
+
148
+ payload = base64.urlsafe_b64encode(
149
+ json.dumps({"sub": "fallback-sub-id"}).encode()
150
+ ).decode().rstrip("=")
151
+ jwt_token = f"header.{payload}.signature"
152
+
153
+ transport.request.return_value = {
154
+ "token": jwt_token,
155
+ "expiresOn": "2026-03-27T03:12:54Z",
156
+ }
157
+
158
+ await auth.login()
159
+
160
+ assert auth.user_id == "fallback-sub-id"
161
+
162
+ @pytest.mark.asyncio
163
+ async def test_login_expires_on_parsed(self, auth, transport):
164
+ """Test that expiresOn ISO 8601 timestamp is parsed correctly."""
165
+ transport.request.return_value = {
166
+ "token": "new-token",
167
+ "expiresOn": "2026-03-27T03:12:54Z",
168
+ }
169
+
170
+ await auth.login()
171
+
172
+ assert auth._expires_at is not None
173
+ assert auth._expires_at.year == 2026
174
+ assert auth._expires_at.month == 3
175
+ assert auth._expires_at.day == 27
176
+
177
+ @pytest.mark.asyncio
178
+ async def test_login_expires_on_invalid_falls_back(self, auth, transport):
179
+ """Test that an invalid expiresOn falls back to 1-hour default."""
180
+ from datetime import datetime, timezone
181
+
182
+ transport.request.return_value = {
183
+ "token": "new-token",
184
+ "expiresOn": "not-a-timestamp",
185
+ }
186
+
187
+ before = datetime.now(timezone.utc)
188
+ await auth.login()
189
+
190
+ assert auth._expires_at is not None
191
+ delta = auth._expires_at - before
192
+ assert 3590 < delta.total_seconds() < 3610 # ~1 hour
193
+
117
194
  @pytest.mark.asyncio
118
195
  async def test_login_missing_credentials(self, transport):
119
196
  """Test login without credentials."""
@@ -361,6 +438,22 @@ class TestGetSpireonHeaders:
361
438
  assert headers["Authorization"] == "Basic base64-credentials"
362
439
 
363
440
 
441
+ class TestExtractUserIdFromJwt:
442
+ """Tests for AuthManager._extract_user_id_from_jwt."""
443
+
444
+ def test_returns_none_for_non_three_part_token(self):
445
+ """Token without three dot-separated parts returns None."""
446
+ from lojack_api.auth import AuthManager
447
+ assert AuthManager._extract_user_id_from_jwt("not.a.valid.jwt.parts") is None
448
+
449
+ def test_returns_none_for_invalid_json_payload(self):
450
+ """Payload that is valid base64 but not JSON triggers except and returns None."""
451
+ import base64
452
+ from lojack_api.auth import AuthManager
453
+ bad_payload = base64.urlsafe_b64encode(b"not-json").decode().rstrip("=")
454
+ assert AuthManager._extract_user_id_from_jwt(f"header.{bad_payload}.sig") is None
455
+
456
+
364
457
  class TestEncodeBasicAuth:
365
458
  """Tests for encode_basic_auth function."""
366
459
 
File without changes
File without changes
File without changes