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 +73 -0
- lojack_api/api.py +465 -0
- lojack_api/auth.py +285 -0
- lojack_api/device.py +343 -0
- lojack_api/exceptions.py +93 -0
- lojack_api/models.py +285 -0
- lojack_api/py.typed +0 -0
- lojack_api/transport.py +184 -0
- lojack_api-0.5.0.dist-info/METADATA +317 -0
- lojack_api-0.5.0.dist-info/RECORD +13 -0
- lojack_api-0.5.0.dist-info/WHEEL +5 -0
- lojack_api-0.5.0.dist-info/licenses/LICENSE +14 -0
- lojack_api-0.5.0.dist-info/top_level.txt +1 -0
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)
|