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/auth.py ADDED
@@ -0,0 +1,285 @@
1
+ """Authentication manager for Spireon LoJack API.
2
+
3
+ Handles token-based authentication with automatic refresh and
4
+ session resumption support for Home Assistant integrations.
5
+
6
+ The Spireon LoJack API uses:
7
+ - Basic Auth for initial token retrieval from the identity service
8
+ - X-Nspire-Usertoken header for subsequent API calls
9
+ - X-Nspire-Apptoken and X-Nspire-Correlationid headers on all requests
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import uuid
16
+ from dataclasses import dataclass
17
+ from datetime import datetime, timedelta, timezone
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ from .exceptions import AuthenticationError
21
+
22
+ if TYPE_CHECKING:
23
+ from .transport import AiohttpTransport
24
+
25
+
26
+ # Default app token for LoJack (systemId=23, brandId=81)
27
+ DEFAULT_APP_TOKEN = "eyJzeXN0ZW1JZCI6MjMsImJyYW5kSWQiOjgxfQ=="
28
+
29
+
30
+ @dataclass
31
+ class AuthArtifacts:
32
+ """Exported authentication state for session resumption.
33
+
34
+ This allows Home Assistant to persist authentication across restarts
35
+ without storing the raw password.
36
+ """
37
+
38
+ access_token: str
39
+ expires_at: datetime | None = None
40
+ refresh_token: str | None = None
41
+ user_id: str | None = None
42
+
43
+ def to_dict(self) -> dict[str, Any]:
44
+ """Convert to a dictionary for JSON serialization."""
45
+ data: dict[str, Any] = {"access_token": self.access_token}
46
+ if self.expires_at:
47
+ data["expires_at"] = self.expires_at.isoformat()
48
+ if self.refresh_token:
49
+ data["refresh_token"] = self.refresh_token
50
+ if self.user_id:
51
+ data["user_id"] = self.user_id
52
+ return data
53
+
54
+ @classmethod
55
+ def from_dict(cls, data: dict[str, Any]) -> AuthArtifacts:
56
+ """Create from a dictionary (e.g., loaded from JSON)."""
57
+ expires_at = None
58
+ if exp := data.get("expires_at"):
59
+ if isinstance(exp, str):
60
+ try:
61
+ expires_at = datetime.fromisoformat(exp)
62
+ except ValueError:
63
+ pass
64
+ elif isinstance(exp, datetime):
65
+ expires_at = exp
66
+
67
+ return cls(
68
+ access_token=data["access_token"],
69
+ expires_at=expires_at,
70
+ refresh_token=data.get("refresh_token"),
71
+ user_id=data.get("user_id"),
72
+ )
73
+
74
+
75
+ def get_spireon_headers(
76
+ app_token: str = DEFAULT_APP_TOKEN,
77
+ user_token: str | None = None,
78
+ basic_auth: str | None = None,
79
+ ) -> dict[str, str]:
80
+ """Build headers for Spireon API requests.
81
+
82
+ Args:
83
+ app_token: The X-Nspire-Apptoken value.
84
+ user_token: Optional X-Nspire-Usertoken for authenticated requests.
85
+ basic_auth: Optional Basic auth string for identity requests.
86
+
87
+ Returns:
88
+ Dictionary of headers to use in requests.
89
+ """
90
+ headers = {
91
+ "X-Nspire-Apptoken": app_token,
92
+ "X-Nspire-Correlationid": str(uuid.uuid4()),
93
+ }
94
+ if user_token:
95
+ headers["X-Nspire-Usertoken"] = user_token
96
+ if basic_auth:
97
+ headers["Authorization"] = f"Basic {basic_auth}"
98
+ return headers
99
+
100
+
101
+ def encode_basic_auth(username: str, password: str) -> str:
102
+ """Encode username and password for Basic auth."""
103
+ credentials = f"{username}:{password}"
104
+ return base64.b64encode(credentials.encode("utf-8")).decode("ascii")
105
+
106
+
107
+ class AuthManager:
108
+ """Manages authentication tokens for the Spireon LoJack API.
109
+
110
+ Features:
111
+ - Basic Auth for initial token retrieval
112
+ - Automatic token refresh when expired (via re-login)
113
+ - Session resumption via export/import of auth artifacts
114
+ - Proper Spireon-specific headers
115
+
116
+ Args:
117
+ transport: The HTTP transport to use for auth requests.
118
+ username: LoJack account username/email.
119
+ password: LoJack account password.
120
+ app_token: The X-Nspire-Apptoken value (default: LoJack app token).
121
+ token_refresh_margin: Seconds before expiry to trigger refresh (default: 60).
122
+ """
123
+
124
+ def __init__(
125
+ self,
126
+ transport: AiohttpTransport,
127
+ username: str | None = None,
128
+ password: str | None = None,
129
+ app_token: str = DEFAULT_APP_TOKEN,
130
+ token_refresh_margin: int = 60,
131
+ ) -> None:
132
+ self._transport = transport
133
+ self._username = username
134
+ self._password = password
135
+ self._app_token = app_token
136
+ self._token_refresh_margin = token_refresh_margin
137
+
138
+ self._access_token: str | None = None
139
+ self._expires_at: datetime | None = None
140
+ self._user_id: str | None = None
141
+
142
+ @property
143
+ def is_authenticated(self) -> bool:
144
+ """Return True if we have a valid (non-expired) token."""
145
+ if not self._access_token:
146
+ return False
147
+ if self._expires_at:
148
+ return datetime.now(timezone.utc) < self._expires_at
149
+ return True
150
+
151
+ @property
152
+ def user_id(self) -> str | None:
153
+ """Return the authenticated user ID if available."""
154
+ return self._user_id
155
+
156
+ @property
157
+ def app_token(self) -> str:
158
+ """Return the app token used for requests."""
159
+ return self._app_token
160
+
161
+ def import_auth_artifacts(self, artifacts: AuthArtifacts) -> None:
162
+ """Import previously exported auth state for session resumption.
163
+
164
+ Args:
165
+ artifacts: Authentication artifacts from a previous session.
166
+ """
167
+ self._access_token = artifacts.access_token
168
+ self._expires_at = artifacts.expires_at
169
+ self._user_id = artifacts.user_id
170
+
171
+ def export_auth_artifacts(self) -> AuthArtifacts | None:
172
+ """Export current auth state for persistence.
173
+
174
+ Returns:
175
+ AuthArtifacts if authenticated, None otherwise.
176
+ """
177
+ if not self._access_token:
178
+ return None
179
+
180
+ return AuthArtifacts(
181
+ access_token=self._access_token,
182
+ expires_at=self._expires_at,
183
+ user_id=self._user_id,
184
+ )
185
+
186
+ async def login(self) -> str:
187
+ """Authenticate with the identity service using Basic Auth.
188
+
189
+ Returns:
190
+ The user token (X-Nspire-Usertoken).
191
+
192
+ Raises:
193
+ AuthenticationError: If credentials are missing or login fails.
194
+ """
195
+ if not self._username or not self._password:
196
+ raise AuthenticationError("Username and password are required for login")
197
+
198
+ basic_auth = encode_basic_auth(self._username, self._password)
199
+ headers = get_spireon_headers(app_token=self._app_token, basic_auth=basic_auth)
200
+
201
+ try:
202
+ data = await self._transport.request(
203
+ "GET", "/identity/token", headers=headers
204
+ )
205
+ except Exception as e:
206
+ raise AuthenticationError(f"Login failed: {e}") from e
207
+
208
+ if not isinstance(data, dict):
209
+ raise AuthenticationError("Invalid login response")
210
+
211
+ token_value = data.get("token") or data.get("access_token")
212
+ if not token_value:
213
+ error = data.get("error") or data.get("message") or "No token in response"
214
+ raise AuthenticationError(f"Login failed: {error}")
215
+
216
+ token: str = str(token_value)
217
+ self._access_token = token
218
+ self._user_id = data.get("userId") or data.get("user_id")
219
+
220
+ # Parse expiration - tokens typically expire after some time
221
+ expires_in = data.get("expiresIn") or data.get("expires_in")
222
+ if expires_in:
223
+ 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
229
+ self._expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
230
+ else:
231
+ # Default expiration if not provided
232
+ self._expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
233
+
234
+ return token
235
+
236
+ async def refresh(self) -> str:
237
+ """Refresh the access token by re-logging in.
238
+
239
+ The Spireon API doesn't have a separate refresh endpoint,
240
+ so we re-authenticate with credentials.
241
+
242
+ Returns:
243
+ The new user token.
244
+
245
+ Raises:
246
+ AuthenticationError: If refresh fails.
247
+ """
248
+ return await self.login()
249
+
250
+ async def get_token(self) -> str:
251
+ """Get a valid access token, refreshing if necessary.
252
+
253
+ Returns:
254
+ A valid user token.
255
+
256
+ Raises:
257
+ AuthenticationError: If unable to get a valid token.
258
+ """
259
+ if not self._access_token:
260
+ return await self.login()
261
+
262
+ # Check if token is expired or about to expire
263
+ if self._expires_at:
264
+ margin = timedelta(seconds=self._token_refresh_margin)
265
+ if datetime.now(timezone.utc) >= (self._expires_at - margin):
266
+ return await self.refresh()
267
+
268
+ return self._access_token
269
+
270
+ def get_auth_headers(self) -> dict[str, str]:
271
+ """Get headers for authenticated API requests.
272
+
273
+ Returns:
274
+ Headers dict with app token, correlation ID, and user token.
275
+ """
276
+ return get_spireon_headers(
277
+ app_token=self._app_token,
278
+ user_token=self._access_token,
279
+ )
280
+
281
+ def clear(self) -> None:
282
+ """Clear all authentication state."""
283
+ self._access_token = None
284
+ self._expires_at = None
285
+ self._user_id = None
lojack_api/device.py ADDED
@@ -0,0 +1,343 @@
1
+ """Device wrapper classes with high-level helper methods.
2
+
3
+ These classes wrap the raw device data and provide convenient
4
+ async methods for common operations like getting location,
5
+ sending commands, etc.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import AsyncIterator
11
+ from datetime import datetime, timezone
12
+ from typing import TYPE_CHECKING
13
+
14
+ from .exceptions import InvalidParameterError
15
+ from .models import DeviceInfo, Location, VehicleInfo
16
+
17
+ if TYPE_CHECKING:
18
+ from .api import LoJackClient
19
+
20
+
21
+ class Device:
22
+ """Device wrapper providing high-level helpers for a tracked device.
23
+
24
+ This class wraps a device and provides convenient async methods
25
+ for interacting with it through the LoJack API.
26
+
27
+ Attributes:
28
+ id: The device ID.
29
+ name: The device name (if available).
30
+ info: The underlying DeviceInfo dataclass with full device data.
31
+ client: Reference to the LoJackClient for API calls.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ client: LoJackClient,
37
+ info: DeviceInfo,
38
+ ) -> None:
39
+ """Initialize the device wrapper.
40
+
41
+ Args:
42
+ client: The LoJackClient instance for API calls.
43
+ info: The DeviceInfo dataclass with device data.
44
+ """
45
+ self._client = client
46
+ self._info = info
47
+ self._cached_location: Location | None = None
48
+ self._last_refresh: datetime | None = None
49
+
50
+ @property
51
+ def id(self) -> str:
52
+ """Return the device ID."""
53
+ return self._info.id
54
+
55
+ @property
56
+ def name(self) -> str | None:
57
+ """Return the device name."""
58
+ return self._info.name
59
+
60
+ @property
61
+ def info(self) -> DeviceInfo:
62
+ """Return the underlying DeviceInfo dataclass."""
63
+ return self._info
64
+
65
+ @property
66
+ def last_seen(self) -> datetime | None:
67
+ """Return when the device was last seen."""
68
+ return self._info.last_seen
69
+
70
+ @property
71
+ def cached_location(self) -> Location | None:
72
+ """Return the cached location (may be stale)."""
73
+ return self._cached_location
74
+
75
+ @property
76
+ def last_refresh(self) -> datetime | None:
77
+ """Return when the location was last refreshed."""
78
+ return self._last_refresh
79
+
80
+ async def refresh(self, *, force: bool = False) -> None:
81
+ """Refresh the device's cached location.
82
+
83
+ Args:
84
+ force: If True, always fetch new data even if cached.
85
+ """
86
+ if not force and self._cached_location is not None:
87
+ return
88
+
89
+ # First try to get current location from asset's lastLocation
90
+ # This is more current than fetching from events
91
+ location = await self._client.get_current_location(self.id)
92
+ if location and location.latitude is not None:
93
+ self._cached_location = location
94
+ else:
95
+ # Fall back to events endpoint
96
+ locations = await self._client.get_locations(self.id, limit=1)
97
+ if locations:
98
+ self._cached_location = locations[0]
99
+ else:
100
+ self._cached_location = None
101
+
102
+ self._last_refresh = datetime.now(timezone.utc)
103
+
104
+ async def get_location(self, *, force: bool = False) -> Location | None:
105
+ """Get the device's current location.
106
+
107
+ Args:
108
+ force: If True, fetch fresh data from the API.
109
+
110
+ Returns:
111
+ The device's location, or None if unavailable.
112
+ """
113
+ if force or self._cached_location is None:
114
+ await self.refresh(force=force)
115
+ return self._cached_location
116
+
117
+ async def get_history(
118
+ self,
119
+ *,
120
+ limit: int = 100,
121
+ start_time: datetime | None = None,
122
+ end_time: datetime | None = None,
123
+ ) -> AsyncIterator[Location]:
124
+ """Iterate over the device's location history.
125
+
126
+ Args:
127
+ limit: Maximum number of locations to return (-1 for all).
128
+ start_time: Optional start time filter.
129
+ end_time: Optional end time filter.
130
+
131
+ Yields:
132
+ Location objects from newest to oldest.
133
+ """
134
+ locations = await self._client.get_locations(
135
+ self.id,
136
+ limit=limit,
137
+ start_time=start_time,
138
+ end_time=end_time,
139
+ )
140
+ for loc in locations:
141
+ yield loc
142
+
143
+ async def send_command(self, command: str) -> bool:
144
+ """Send a raw command to the device.
145
+
146
+ Args:
147
+ command: The command string to send.
148
+
149
+ Returns:
150
+ True if the command was accepted.
151
+
152
+ Raises:
153
+ CommandError: If the command fails.
154
+ """
155
+ return await self._client.send_command(self.id, command)
156
+
157
+ async def request_location_update(self) -> bool:
158
+ """Request the device to report its current location.
159
+
160
+ Returns:
161
+ True if the request was accepted.
162
+ """
163
+ return await self.send_command("locate")
164
+
165
+ async def lock(
166
+ self,
167
+ *,
168
+ message: str | None = None,
169
+ passcode: str | None = None,
170
+ ) -> bool:
171
+ """Lock the device.
172
+
173
+ Args:
174
+ message: Optional message to display on the device.
175
+ passcode: Optional passcode for unlocking.
176
+
177
+ Returns:
178
+ True if the lock command was accepted.
179
+ """
180
+ command = "lock"
181
+
182
+ if message:
183
+ # Sanitize the message
184
+ sanitized = _sanitize_message(message)
185
+ if sanitized:
186
+ command = f"lock {sanitized}"
187
+
188
+ if passcode:
189
+ if not _is_valid_passcode(passcode):
190
+ raise InvalidParameterError(
191
+ "passcode",
192
+ passcode,
193
+ "Must be alphanumeric ASCII characters only",
194
+ )
195
+
196
+ return await self.send_command(command)
197
+
198
+ async def unlock(self) -> bool:
199
+ """Unlock the device.
200
+
201
+ Returns:
202
+ True if the unlock command was accepted.
203
+ """
204
+ return await self.send_command("unlock")
205
+
206
+ async def ring(self, *, duration: int | None = None) -> bool:
207
+ """Make the device ring/alarm.
208
+
209
+ Args:
210
+ duration: Optional duration in seconds.
211
+
212
+ Returns:
213
+ True if the ring command was accepted.
214
+ """
215
+ command = "ring"
216
+ if duration is not None:
217
+ if duration < 1 or duration > 300:
218
+ raise InvalidParameterError(
219
+ "duration",
220
+ duration,
221
+ "Must be between 1 and 300 seconds",
222
+ )
223
+ command = f"ring {duration}"
224
+ return await self.send_command(command)
225
+
226
+ def __repr__(self) -> str:
227
+ return f"Device(id={self.id!r}, name={self.name!r})"
228
+
229
+
230
+ class Vehicle(Device):
231
+ """Vehicle-specific device wrapper with additional vehicle data.
232
+
233
+ Extends Device with vehicle-specific properties like VIN,
234
+ make, model, etc.
235
+ """
236
+
237
+ def __init__(
238
+ self,
239
+ client: LoJackClient,
240
+ info: VehicleInfo,
241
+ ) -> None:
242
+ """Initialize the vehicle wrapper.
243
+
244
+ Args:
245
+ client: The LoJackClient instance for API calls.
246
+ info: The VehicleInfo dataclass with vehicle data.
247
+ """
248
+ super().__init__(client, info)
249
+ self._vehicle_info = info
250
+
251
+ @property
252
+ def info(self) -> VehicleInfo:
253
+ """Return the underlying VehicleInfo dataclass."""
254
+ return self._vehicle_info
255
+
256
+ @property
257
+ def vin(self) -> str | None:
258
+ """Return the vehicle's VIN."""
259
+ return self._vehicle_info.vin
260
+
261
+ @property
262
+ def make(self) -> str | None:
263
+ """Return the vehicle's make."""
264
+ return self._vehicle_info.make
265
+
266
+ @property
267
+ def model(self) -> str | None:
268
+ """Return the vehicle's model."""
269
+ return self._vehicle_info.model
270
+
271
+ @property
272
+ def year(self) -> int | None:
273
+ """Return the vehicle's year."""
274
+ return self._vehicle_info.year
275
+
276
+ @property
277
+ def license_plate(self) -> str | None:
278
+ """Return the vehicle's license plate."""
279
+ return self._vehicle_info.license_plate
280
+
281
+ @property
282
+ def odometer(self) -> float | None:
283
+ """Return the vehicle's odometer reading."""
284
+ return self._vehicle_info.odometer
285
+
286
+ async def start_engine(self) -> bool:
287
+ """Remote start the vehicle's engine.
288
+
289
+ Returns:
290
+ True if the command was accepted.
291
+ """
292
+ return await self.send_command("start")
293
+
294
+ async def stop_engine(self) -> bool:
295
+ """Remote stop the vehicle's engine.
296
+
297
+ Returns:
298
+ True if the command was accepted.
299
+ """
300
+ return await self.send_command("stop")
301
+
302
+ async def honk_horn(self) -> bool:
303
+ """Honk the vehicle's horn.
304
+
305
+ Returns:
306
+ True if the command was accepted.
307
+ """
308
+ return await self.send_command("honk")
309
+
310
+ async def flash_lights(self) -> bool:
311
+ """Flash the vehicle's lights.
312
+
313
+ Returns:
314
+ True if the command was accepted.
315
+ """
316
+ return await self.send_command("flash")
317
+
318
+ def __repr__(self) -> str:
319
+ return f"Vehicle(id={self.id!r}, name={self.name!r}, vin={self.vin!r})"
320
+
321
+
322
+ def _sanitize_message(message: str, max_length: int = 120) -> str:
323
+ """Sanitize a message for sending to a device.
324
+
325
+ Removes potentially dangerous characters and limits length.
326
+ """
327
+ # Normalize whitespace
328
+ sanitized = " ".join(message.strip().split())
329
+
330
+ # Remove potentially dangerous characters
331
+ for char in ['"', "'", "`", ";", "\\", "\n", "\r"]:
332
+ sanitized = sanitized.replace(char, "")
333
+
334
+ # Limit length
335
+ if len(sanitized) > max_length:
336
+ sanitized = sanitized[:max_length]
337
+
338
+ return sanitized
339
+
340
+
341
+ def _is_valid_passcode(passcode: str) -> bool:
342
+ """Validate a passcode contains only safe characters."""
343
+ return all(c.isalnum() and ord(c) < 128 for c in passcode)