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/__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()
|