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.
- {lojack_api-0.7.0 → lojack_api-0.7.2}/PKG-INFO +1 -1
- {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api/__init__.py +1 -1
- {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api/api.py +8 -1
- {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api/auth.py +35 -12
- {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api/device.py +30 -12
- {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api.egg-info/PKG-INFO +1 -1
- {lojack_api-0.7.0 → lojack_api-0.7.2}/pyproject.toml +1 -1
- {lojack_api-0.7.0 → lojack_api-0.7.2}/tests/test_auth.py +94 -1
- {lojack_api-0.7.0 → lojack_api-0.7.2}/LICENSE +0 -0
- {lojack_api-0.7.0 → lojack_api-0.7.2}/README.md +0 -0
- {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api/exceptions.py +0 -0
- {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api/models.py +0 -0
- {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api/py.typed +0 -0
- {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api/transport.py +0 -0
- {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api.egg-info/SOURCES.txt +0 -0
- {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api.egg-info/dependency_links.txt +0 -0
- {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api.egg-info/requires.txt +0 -0
- {lojack_api-0.7.0 → lojack_api-0.7.2}/lojack_api.egg-info/top_level.txt +0 -0
- {lojack_api-0.7.0 → lojack_api-0.7.2}/setup.cfg +0 -0
- {lojack_api-0.7.0 → lojack_api-0.7.2}/tests/test_client.py +0 -0
- {lojack_api-0.7.0 → lojack_api-0.7.2}/tests/test_device.py +0 -0
- {lojack_api-0.7.0 → lojack_api-0.7.2}/tests/test_exceptions.py +0 -0
- {lojack_api-0.7.0 → lojack_api-0.7.2}/tests/test_models.py +0 -0
- {lojack_api-0.7.0 → lojack_api-0.7.2}/tests/test_transport.py +0 -0
|
@@ -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 =
|
|
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 -
|
|
221
|
-
|
|
222
|
-
if
|
|
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.
|
|
225
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
#
|
|
100
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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(
|
|
110
|
-
self._cached_location =
|
|
127
|
+
_enrich_location_from_event(asset_location, latest_event)
|
|
128
|
+
self._cached_location = asset_location
|
|
111
129
|
elif latest_event:
|
|
112
|
-
#
|
|
130
|
+
# Fallback to event even without coordinates comparison
|
|
113
131
|
self._cached_location = latest_event
|
|
114
132
|
else:
|
|
115
133
|
self._cached_location = None
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lojack_api"
|
|
7
|
-
version = "0.7.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|