lojack-api 0.5.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.
lojack_api/__init__.py ADDED
@@ -0,0 +1,73 @@
1
+ """LoJack Clients - An async Python library for the Spireon LoJack API.
2
+
3
+ This library provides a clean, async interface for interacting with
4
+ LoJack devices. It is designed to be compatible with Home Assistant
5
+ integrations and avoids the httpx dependency conflict.
6
+
7
+ Example usage:
8
+ from lojack_api import LoJackClient
9
+
10
+ async with await LoJackClient.create(username, password) as client:
11
+ devices = await client.list_devices()
12
+ for device in devices:
13
+ location = await device.get_location()
14
+ print(f"{device.name}: {location.latitude}, {location.longitude}")
15
+ """
16
+
17
+ from .api import IDENTITY_URL, SERVICES_URL, LoJackClient
18
+ from .auth import (
19
+ DEFAULT_APP_TOKEN,
20
+ AuthArtifacts,
21
+ AuthManager,
22
+ encode_basic_auth,
23
+ get_spireon_headers,
24
+ )
25
+ from .device import Device, Vehicle
26
+ from .exceptions import (
27
+ ApiError,
28
+ AuthenticationError,
29
+ AuthorizationError,
30
+ CommandError,
31
+ ConnectionError,
32
+ DeviceNotFoundError,
33
+ InvalidParameterError,
34
+ LoJackError,
35
+ TimeoutError,
36
+ )
37
+ from .models import DeviceInfo, Location, VehicleInfo
38
+ from .transport import AiohttpTransport
39
+
40
+ __version__ = "0.5.0"
41
+
42
+ __all__ = [
43
+ # Main client
44
+ "LoJackClient",
45
+ # API URLs
46
+ "IDENTITY_URL",
47
+ "SERVICES_URL",
48
+ # Device wrappers
49
+ "Device",
50
+ "Vehicle",
51
+ # Data models
52
+ "DeviceInfo",
53
+ "VehicleInfo",
54
+ "Location",
55
+ # Auth
56
+ "AuthArtifacts",
57
+ "AuthManager",
58
+ "DEFAULT_APP_TOKEN",
59
+ "encode_basic_auth",
60
+ "get_spireon_headers",
61
+ # Transport
62
+ "AiohttpTransport",
63
+ # Exceptions
64
+ "LoJackError",
65
+ "AuthenticationError",
66
+ "AuthorizationError",
67
+ "ApiError",
68
+ "ConnectionError",
69
+ "TimeoutError",
70
+ "DeviceNotFoundError",
71
+ "CommandError",
72
+ "InvalidParameterError",
73
+ ]
lojack_api/api.py ADDED
@@ -0,0 +1,465 @@
1
+ """High-level Spireon LoJack API client.
2
+
3
+ This module provides the main entry point for interacting with the
4
+ Spireon LoJack API. It follows Home Assistant best practices for async
5
+ integrations.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ssl
11
+ from datetime import datetime
12
+ from typing import Any
13
+
14
+ import aiohttp
15
+
16
+ from .auth import DEFAULT_APP_TOKEN, AuthArtifacts, AuthManager
17
+ from .device import Device, Vehicle
18
+ from .exceptions import ApiError, DeviceNotFoundError
19
+ from .models import DeviceInfo, Location, VehicleInfo
20
+ from .transport import AiohttpTransport
21
+
22
+ # Default Spireon API endpoints
23
+ IDENTITY_URL = "https://identity.spireon.com"
24
+ SERVICES_URL = "https://services.spireon.com/v0/rest"
25
+
26
+
27
+ class LoJackClient:
28
+ """High-level async client for the Spireon LoJack API.
29
+
30
+ This client provides a clean interface for interacting with LoJack
31
+ devices. It supports both context manager usage and manual lifecycle
32
+ management.
33
+
34
+ The Spireon LoJack API uses separate services:
35
+ - Identity service for authentication
36
+ - Services API for device/asset management
37
+
38
+ Example usage with context manager (recommended):
39
+ async with await LoJackClient.create(username, password) as client:
40
+ devices = await client.list_devices()
41
+ for device in devices:
42
+ location = await device.get_location()
43
+
44
+ Example usage with manual lifecycle:
45
+ client = await LoJackClient.create(username, password)
46
+ try:
47
+ devices = await client.list_devices()
48
+ finally:
49
+ await client.close()
50
+
51
+ Example usage with session resumption:
52
+ # First time - login and save auth
53
+ client = await LoJackClient.create(username, password)
54
+ auth_data = client.export_auth().to_dict()
55
+ save_to_storage(auth_data)
56
+ await client.close()
57
+
58
+ # Later - resume without password
59
+ auth = AuthArtifacts.from_dict(load_from_storage())
60
+ client = await LoJackClient.from_auth(auth)
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ username: str | None = None,
66
+ password: str | None = None,
67
+ identity_url: str = IDENTITY_URL,
68
+ services_url: str = SERVICES_URL,
69
+ session: aiohttp.ClientSession | None = None,
70
+ timeout: float = 30.0,
71
+ ssl_context: ssl.SSLContext | None = None,
72
+ app_token: str = DEFAULT_APP_TOKEN,
73
+ ) -> None:
74
+ """Initialize the client.
75
+
76
+ Note: Use the `create()` classmethod for proper async initialization.
77
+
78
+ Args:
79
+ username: LoJack account username/email.
80
+ password: LoJack account password.
81
+ identity_url: URL for the identity service (auth).
82
+ services_url: URL for the services API.
83
+ session: Optional existing aiohttp session to use.
84
+ timeout: Request timeout in seconds.
85
+ ssl_context: Optional SSL context for custom certificates.
86
+ app_token: The X-Nspire-Apptoken value.
87
+ """
88
+ self._identity_url = identity_url.rstrip("/")
89
+ self._services_url = services_url.rstrip("/")
90
+
91
+ # Separate transports for identity and services
92
+ self._identity_transport = AiohttpTransport(
93
+ identity_url,
94
+ session=session,
95
+ timeout=timeout,
96
+ ssl_context=ssl_context,
97
+ )
98
+ self._services_transport = AiohttpTransport(
99
+ services_url,
100
+ session=session,
101
+ timeout=timeout,
102
+ ssl_context=ssl_context,
103
+ )
104
+
105
+ self._auth = AuthManager(
106
+ self._identity_transport,
107
+ username,
108
+ password,
109
+ app_token=app_token,
110
+ )
111
+ self._closed = False
112
+
113
+ @classmethod
114
+ async def create(
115
+ cls,
116
+ username: str,
117
+ password: str,
118
+ identity_url: str = IDENTITY_URL,
119
+ services_url: str = SERVICES_URL,
120
+ session: aiohttp.ClientSession | None = None,
121
+ timeout: float = 30.0,
122
+ ssl_context: ssl.SSLContext | None = None,
123
+ app_token: str = DEFAULT_APP_TOKEN,
124
+ ) -> LoJackClient:
125
+ """Create a new client and authenticate.
126
+
127
+ This is the recommended way to create a client instance.
128
+
129
+ Args:
130
+ username: LoJack account username/email.
131
+ password: LoJack account password.
132
+ identity_url: URL for the identity service.
133
+ services_url: URL for the services API.
134
+ session: Optional existing aiohttp session to use.
135
+ timeout: Request timeout in seconds.
136
+ ssl_context: Optional SSL context for custom certificates.
137
+ app_token: The X-Nspire-Apptoken value.
138
+
139
+ Returns:
140
+ An authenticated LoJackClient instance.
141
+
142
+ Raises:
143
+ AuthenticationError: If login fails.
144
+ """
145
+ client = cls(
146
+ username=username,
147
+ password=password,
148
+ identity_url=identity_url,
149
+ services_url=services_url,
150
+ session=session,
151
+ timeout=timeout,
152
+ ssl_context=ssl_context,
153
+ app_token=app_token,
154
+ )
155
+ await client._auth.login()
156
+ return client
157
+
158
+ @classmethod
159
+ async def from_auth(
160
+ cls,
161
+ auth_artifacts: AuthArtifacts,
162
+ identity_url: str = IDENTITY_URL,
163
+ services_url: str = SERVICES_URL,
164
+ session: aiohttp.ClientSession | None = None,
165
+ timeout: float = 30.0,
166
+ ssl_context: ssl.SSLContext | None = None,
167
+ app_token: str = DEFAULT_APP_TOKEN,
168
+ username: str | None = None,
169
+ password: str | None = None,
170
+ ) -> LoJackClient:
171
+ """Create a client from previously exported auth artifacts.
172
+
173
+ This allows session resumption without re-entering credentials.
174
+ The token will be refreshed if expired (requires username/password).
175
+
176
+ Args:
177
+ auth_artifacts: Previously exported authentication state.
178
+ identity_url: URL for the identity service.
179
+ services_url: URL for the services API.
180
+ session: Optional existing aiohttp session to use.
181
+ timeout: Request timeout in seconds.
182
+ ssl_context: Optional SSL context for custom certificates.
183
+ app_token: The X-Nspire-Apptoken value.
184
+ username: Optional username for token refresh fallback.
185
+ password: Optional password for token refresh fallback.
186
+
187
+ Returns:
188
+ A LoJackClient instance with restored authentication.
189
+ """
190
+ client = cls(
191
+ username=username,
192
+ password=password,
193
+ identity_url=identity_url,
194
+ services_url=services_url,
195
+ session=session,
196
+ timeout=timeout,
197
+ ssl_context=ssl_context,
198
+ app_token=app_token,
199
+ )
200
+ client._auth.import_auth_artifacts(auth_artifacts)
201
+ return client
202
+
203
+ async def __aenter__(self) -> LoJackClient:
204
+ """Enter async context manager."""
205
+ return self
206
+
207
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
208
+ """Exit async context manager and clean up resources."""
209
+ await self.close()
210
+
211
+ @property
212
+ def is_authenticated(self) -> bool:
213
+ """Return True if the client has valid authentication."""
214
+ return self._auth.is_authenticated
215
+
216
+ @property
217
+ def user_id(self) -> str | None:
218
+ """Return the authenticated user ID if available."""
219
+ return self._auth.user_id
220
+
221
+ def export_auth(self) -> AuthArtifacts | None:
222
+ """Export current authentication state for later resumption.
223
+
224
+ Returns:
225
+ AuthArtifacts if authenticated, None otherwise.
226
+ """
227
+ return self._auth.export_auth_artifacts()
228
+
229
+ async def _get_headers(self) -> dict[str, str]:
230
+ """Get headers for authenticated service requests."""
231
+ # Ensure we have a valid token
232
+ await self._auth.get_token()
233
+ return self._auth.get_auth_headers()
234
+
235
+ async def list_devices(self) -> list[Device | Vehicle]:
236
+ """List all assets (devices/vehicles) associated with the account.
237
+
238
+ Returns:
239
+ A list of Device or Vehicle objects.
240
+ """
241
+ headers = await self._get_headers()
242
+ data = await self._services_transport.request("GET", "/assets", headers=headers)
243
+
244
+ devices: list[Device | Vehicle] = []
245
+
246
+ # Handle Spireon response format: { "content": [...] }
247
+ items: list[Any] = []
248
+ if isinstance(data, dict):
249
+ items = (
250
+ data.get("content")
251
+ or data.get("devices")
252
+ or data.get("assets")
253
+ or data.get("vehicles")
254
+ or []
255
+ )
256
+ elif isinstance(data, list):
257
+ items = data
258
+
259
+ for item in items:
260
+ if not isinstance(item, dict):
261
+ continue
262
+
263
+ # Determine if this is a vehicle or generic device
264
+ # Spireon assets typically have "attributes" with vehicle info
265
+ attrs = item.get("attributes", {})
266
+ if attrs.get("vin") or item.get("vin"):
267
+ vehicle_info = VehicleInfo.from_api(item)
268
+ devices.append(Vehicle(self, vehicle_info))
269
+ else:
270
+ device_info = DeviceInfo.from_api(item)
271
+ devices.append(Device(self, device_info))
272
+
273
+ return devices
274
+
275
+ async def get_device(self, device_id: str) -> Device | Vehicle:
276
+ """Get a specific asset by ID.
277
+
278
+ Args:
279
+ device_id: The asset ID to fetch.
280
+
281
+ Returns:
282
+ A Device or Vehicle object.
283
+
284
+ Raises:
285
+ DeviceNotFoundError: If the asset is not found.
286
+ """
287
+ headers = await self._get_headers()
288
+ path = f"/assets/{device_id}"
289
+
290
+ try:
291
+ data = await self._services_transport.request("GET", path, headers=headers)
292
+ except ApiError as e:
293
+ if e.status_code == 404:
294
+ raise DeviceNotFoundError(device_id) from e
295
+ raise
296
+
297
+ if not isinstance(data, dict):
298
+ raise DeviceNotFoundError(device_id)
299
+
300
+ # Check for nested data
301
+ item: dict[str, Any] = data.get("content") or data.get("asset") or data
302
+
303
+ attrs = item.get("attributes", {})
304
+ if attrs.get("vin") or item.get("vin"):
305
+ vehicle_info = VehicleInfo.from_api(item)
306
+ return Vehicle(self, vehicle_info)
307
+ else:
308
+ device_info = DeviceInfo.from_api(item)
309
+ return Device(self, device_info)
310
+
311
+ async def get_locations(
312
+ self,
313
+ device_id: str,
314
+ *,
315
+ limit: int = -1,
316
+ start_time: datetime | None = None,
317
+ end_time: datetime | None = None,
318
+ skip_empty: bool = False,
319
+ ) -> list[Location]:
320
+ """Get location history (events) for a device.
321
+
322
+ Args:
323
+ device_id: The asset ID.
324
+ limit: Maximum number of locations to return (-1 for all).
325
+ start_time: Optional start time filter.
326
+ end_time: Optional end time filter.
327
+ skip_empty: If True, skip empty location entries.
328
+
329
+ Returns:
330
+ A list of Location objects.
331
+ """
332
+ headers = await self._get_headers()
333
+ params: dict[str, Any] = {}
334
+
335
+ if limit != -1:
336
+ params["limit"] = limit
337
+ if start_time:
338
+ # Spireon uses this format: 2022-05-10T03:59:59.999+0000
339
+ params["startDate"] = start_time.strftime("%Y-%m-%dT%H:%M:%S.000+0000")
340
+ if end_time:
341
+ params["endDate"] = end_time.strftime("%Y-%m-%dT%H:%M:%S.000+0000")
342
+
343
+ # Spireon uses /assets/{id}/events endpoint for location history
344
+ path = f"/assets/{device_id}/events"
345
+ data = await self._services_transport.request(
346
+ "GET", path, params=params, headers=headers
347
+ )
348
+
349
+ # Parse response
350
+ raw_events: list[Any] = []
351
+ if isinstance(data, dict):
352
+ raw_events = (
353
+ data.get("content")
354
+ or data.get("events")
355
+ or data.get("locations")
356
+ or data.get("history")
357
+ or []
358
+ )
359
+ elif isinstance(data, list):
360
+ raw_events = data
361
+
362
+ locations: list[Location] = []
363
+ for item in raw_events:
364
+ if isinstance(item, dict):
365
+ loc = Location.from_event(item)
366
+
367
+ # Skip empty if requested
368
+ if skip_empty and loc.latitude is None and loc.longitude is None:
369
+ continue
370
+
371
+ locations.append(loc)
372
+
373
+ return locations
374
+
375
+ async def get_current_location(self, device_id: str) -> Location | None:
376
+ """Get the current location for a device from the asset data.
377
+
378
+ This returns the lastLocation from the asset, which is more
379
+ current than fetching from events.
380
+
381
+ Args:
382
+ device_id: The asset ID.
383
+
384
+ Returns:
385
+ The current Location, or None if unavailable.
386
+ """
387
+ headers = await self._get_headers()
388
+ path = f"/assets/{device_id}"
389
+
390
+ try:
391
+ data = await self._services_transport.request("GET", path, headers=headers)
392
+ except ApiError:
393
+ return None
394
+
395
+ if not isinstance(data, dict):
396
+ return None
397
+
398
+ # Get lastLocation from asset
399
+ last_location = data.get("lastLocation")
400
+ if not last_location:
401
+ return None
402
+
403
+ loc = Location.from_api(last_location)
404
+
405
+ # Add timestamp from locationLastReported
406
+ if not loc.timestamp:
407
+ ts = data.get("locationLastReported")
408
+ if ts:
409
+ from .models import _parse_timestamp
410
+ loc.timestamp = _parse_timestamp(ts)
411
+
412
+ # Add speed from asset
413
+ if loc.speed is None:
414
+ speed = data.get("speed")
415
+ if speed is not None:
416
+ try:
417
+ loc.speed = float(speed)
418
+ except (ValueError, TypeError):
419
+ pass
420
+
421
+ return loc
422
+
423
+ async def send_command(self, device_id: str, command: str) -> bool:
424
+ """Send a command to a device.
425
+
426
+ Args:
427
+ device_id: The asset ID.
428
+ command: The command type to send.
429
+
430
+ Returns:
431
+ True if the command was accepted.
432
+ """
433
+ headers = await self._get_headers()
434
+
435
+ # Spireon uses specific command format
436
+ payload = {
437
+ "command": command.upper(),
438
+ "responseStrategy": "ASYNC",
439
+ }
440
+
441
+ path = f"/assets/{device_id}/commands"
442
+ data = await self._services_transport.request(
443
+ "POST", path, json=payload, headers=headers
444
+ )
445
+
446
+ if isinstance(data, dict):
447
+ # Check for successful command submission
448
+ return bool(
449
+ data.get("id")
450
+ or data.get("commandId")
451
+ or data.get("ok")
452
+ or data.get("accepted")
453
+ or data.get("success")
454
+ or data.get("status") in ("ok", "PENDING", "SUBMITTED")
455
+ )
456
+ return True
457
+
458
+ async def close(self) -> None:
459
+ """Close the client and release resources."""
460
+ if self._closed:
461
+ return
462
+
463
+ self._closed = True
464
+ await self._identity_transport.close()
465
+ await self._services_transport.close()