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.
@@ -0,0 +1,93 @@
1
+ """Exceptions for the LoJack client library."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ class LoJackError(Exception):
7
+ """Base exception for all LoJack client errors."""
8
+
9
+ def __init__(self, message: str = "", *args: Any) -> None:
10
+ self.message = message
11
+ super().__init__(message, *args)
12
+
13
+
14
+ class AuthenticationError(LoJackError):
15
+ """Raised when authentication fails (invalid credentials, expired token, etc.)."""
16
+
17
+ pass
18
+
19
+
20
+ class AuthorizationError(LoJackError):
21
+ """Raised when the user is not authorized to perform an action."""
22
+
23
+ pass
24
+
25
+
26
+ class ApiError(LoJackError):
27
+ """Raised when the API returns an error response."""
28
+
29
+ def __init__(
30
+ self,
31
+ message: str = "",
32
+ status_code: int | None = None,
33
+ response_body: str | None = None,
34
+ ) -> None:
35
+ super().__init__(message)
36
+ self.status_code = status_code
37
+ self.response_body = response_body
38
+
39
+ def __str__(self) -> str:
40
+ if self.status_code:
41
+ return f"{self.message} (HTTP {self.status_code})"
42
+ return self.message
43
+
44
+
45
+ class ConnectionError(LoJackError):
46
+ """Raised when a connection to the API cannot be established."""
47
+
48
+ pass
49
+
50
+
51
+ class TimeoutError(LoJackError):
52
+ """Raised when an API request times out."""
53
+
54
+ pass
55
+
56
+
57
+ class DeviceNotFoundError(LoJackError):
58
+ """Raised when a requested device is not found."""
59
+
60
+ def __init__(self, device_id: str, message: str | None = None) -> None:
61
+ self.device_id = device_id
62
+ super().__init__(message or f"Device not found: {device_id}")
63
+
64
+
65
+ class CommandError(LoJackError):
66
+ """Raised when a device command fails."""
67
+
68
+ def __init__(
69
+ self,
70
+ command: str,
71
+ device_id: str,
72
+ message: str | None = None,
73
+ reason: str | None = None,
74
+ ) -> None:
75
+ self.command = command
76
+ self.device_id = device_id
77
+ self.reason = reason
78
+ super().__init__(
79
+ message or f"Command '{command}' failed for device {device_id}"
80
+ )
81
+
82
+
83
+ class InvalidParameterError(LoJackError):
84
+ """Raised when an invalid parameter is provided."""
85
+
86
+ def __init__(self, parameter: str, value: Any, reason: str | None = None) -> None:
87
+ self.parameter = parameter
88
+ self.value = value
89
+ self.reason = reason
90
+ msg = f"Invalid parameter '{parameter}': {value}"
91
+ if reason:
92
+ msg += f" ({reason})"
93
+ super().__init__(msg)
lojack_api/models.py ADDED
@@ -0,0 +1,285 @@
1
+ """Data models for LoJack API responses.
2
+
3
+ These are simple dataclasses representing the raw data from the API.
4
+ For objects with methods (refresh, lock, etc.), see device.py.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime, timezone
11
+ from typing import Any
12
+
13
+
14
+ @dataclass
15
+ class Location:
16
+ """A single location point from the API."""
17
+
18
+ latitude: float | None = None
19
+ longitude: float | None = None
20
+ timestamp: datetime | None = None
21
+ accuracy: float | None = None
22
+ speed: float | None = None
23
+ heading: float | None = None
24
+ address: str | None = None
25
+ raw: dict[str, Any] = field(default_factory=dict)
26
+
27
+ @classmethod
28
+ def from_api(cls, data: dict[str, Any]) -> Location:
29
+ """Parse a location from API response data."""
30
+ loc = cls(raw=data)
31
+
32
+ # Handle nested coordinates (Spireon format)
33
+ coords = data.get("coordinates", {})
34
+
35
+ loc.latitude = (
36
+ data.get("latitude")
37
+ or data.get("lat")
38
+ or coords.get("latitude")
39
+ or coords.get("lat")
40
+ )
41
+ loc.longitude = (
42
+ data.get("longitude")
43
+ or data.get("lng")
44
+ or data.get("lon")
45
+ or coords.get("longitude")
46
+ or coords.get("lng")
47
+ )
48
+ loc.accuracy = data.get("accuracy") or data.get("hdop")
49
+ loc.speed = data.get("speed")
50
+ loc.heading = data.get("heading") or data.get("bearing") or data.get("course")
51
+ loc.address = data.get("address") or data.get("formattedAddress")
52
+
53
+ # Parse timestamp - try multiple formats
54
+ ts = (
55
+ data.get("timestamp")
56
+ or data.get("time")
57
+ or data.get("recorded_at")
58
+ or data.get("eventDateTime")
59
+ or data.get("dateTime")
60
+ )
61
+ if ts:
62
+ loc.timestamp = _parse_timestamp(ts)
63
+
64
+ return loc
65
+
66
+ @classmethod
67
+ def from_event(cls, event_data: dict[str, Any]) -> Location:
68
+ """Parse a location from a Spireon event.
69
+
70
+ Spireon events have a nested structure:
71
+ {
72
+ "location": {"lat": 40.7128, "lng": -74.0060},
73
+ "heading": 180,
74
+ "speed": 35,
75
+ "date": "2024-01-15T12:00:00.000+0000",
76
+ ...
77
+ }
78
+ """
79
+ loc = cls(raw=event_data)
80
+
81
+ # Get nested location object
82
+ location_data = event_data.get("location", {})
83
+
84
+ # Parse coordinates from nested location or top-level
85
+ loc.latitude = (
86
+ location_data.get("lat")
87
+ or location_data.get("latitude")
88
+ or event_data.get("lat")
89
+ or event_data.get("latitude")
90
+ )
91
+ loc.longitude = (
92
+ location_data.get("lng")
93
+ or location_data.get("lon")
94
+ or location_data.get("longitude")
95
+ or event_data.get("lng")
96
+ or event_data.get("lon")
97
+ or event_data.get("longitude")
98
+ )
99
+
100
+ # Speed and heading are at top level in events
101
+ loc.speed = event_data.get("speed")
102
+ loc.heading = (
103
+ event_data.get("heading")
104
+ or event_data.get("bearing")
105
+ or event_data.get("course")
106
+ )
107
+ loc.accuracy = event_data.get("accuracy") or event_data.get("hdop")
108
+ loc.address = event_data.get("address") or event_data.get("formattedAddress")
109
+
110
+ # Parse timestamp - events use "date" field
111
+ ts = (
112
+ event_data.get("date")
113
+ or event_data.get("eventDateTime")
114
+ or event_data.get("dateTime")
115
+ or event_data.get("timestamp")
116
+ )
117
+ if ts:
118
+ loc.timestamp = _parse_timestamp(ts)
119
+
120
+ return loc
121
+
122
+
123
+ @dataclass
124
+ class DeviceInfo:
125
+ """Basic device information from the API."""
126
+
127
+ id: str
128
+ name: str | None = None
129
+ device_type: str | None = None
130
+ status: str | None = None
131
+ last_seen: datetime | None = None
132
+ raw: dict[str, Any] = field(default_factory=dict)
133
+
134
+ @classmethod
135
+ def from_api(cls, data: dict[str, Any]) -> DeviceInfo:
136
+ """Parse device info from API response data."""
137
+ # Handle Spireon's nested "attributes" structure
138
+ attrs = data.get("attributes", {})
139
+ status_obj = data.get("status", {})
140
+
141
+ device = cls(
142
+ id=data.get("id") or data.get("device_id") or data.get("assetId") or "",
143
+ name=data.get("name") or attrs.get("name") or data.get("device_name"),
144
+ device_type=data.get("type") or attrs.get("type") or data.get("device_type"),
145
+ status=(
146
+ status_obj.get("status")
147
+ if isinstance(status_obj, dict)
148
+ else data.get("status")
149
+ ),
150
+ raw=data,
151
+ )
152
+
153
+ ts = (
154
+ data.get("last_seen")
155
+ or data.get("lastSeen")
156
+ or data.get("last_updated")
157
+ or data.get("lastEventDateTime")
158
+ )
159
+ if ts:
160
+ device.last_seen = _parse_timestamp(ts)
161
+
162
+ return device
163
+
164
+
165
+ @dataclass
166
+ class VehicleInfo(DeviceInfo):
167
+ """Vehicle-specific information extending DeviceInfo."""
168
+
169
+ vin: str | None = None
170
+ make: str | None = None
171
+ model: str | None = None
172
+ year: int | None = None
173
+ license_plate: str | None = None
174
+ odometer: float | None = None
175
+
176
+ @classmethod
177
+ def from_api(cls, data: dict[str, Any]) -> VehicleInfo:
178
+ """Parse vehicle info from API response data."""
179
+ # Handle Spireon's nested "attributes" structure
180
+ attrs = data.get("attributes", {})
181
+ status_obj = data.get("status", {})
182
+
183
+ vehicle = cls(
184
+ id=(
185
+ data.get("id")
186
+ or data.get("device_id")
187
+ or data.get("vehicle_id")
188
+ or data.get("assetId")
189
+ or ""
190
+ ),
191
+ name=data.get("name") or attrs.get("name") or data.get("vehicle_name"),
192
+ device_type=(
193
+ data.get("type")
194
+ or attrs.get("type")
195
+ or data.get("device_type")
196
+ or "vehicle"
197
+ ),
198
+ status=(
199
+ status_obj.get("status")
200
+ if isinstance(status_obj, dict)
201
+ else data.get("status")
202
+ ),
203
+ raw=data,
204
+ vin=data.get("vin") or attrs.get("vin"),
205
+ make=data.get("make") or attrs.get("make"),
206
+ model=data.get("model") or attrs.get("model"),
207
+ license_plate=(
208
+ data.get("license_plate")
209
+ or data.get("licensePlate")
210
+ or attrs.get("licensePlate")
211
+ or attrs.get("license_plate")
212
+ ),
213
+ )
214
+
215
+ # Parse year from either top-level or attributes
216
+ year = data.get("year") or attrs.get("year")
217
+ if year is not None:
218
+ try:
219
+ vehicle.year = int(year)
220
+ except (ValueError, TypeError):
221
+ pass
222
+
223
+ # Parse odometer from either top-level or attributes
224
+ odometer = data.get("odometer") or data.get("mileage") or attrs.get("odometer")
225
+ if odometer is not None:
226
+ try:
227
+ vehicle.odometer = float(odometer)
228
+ except (ValueError, TypeError):
229
+ pass
230
+
231
+ ts = (
232
+ data.get("last_seen")
233
+ or data.get("lastSeen")
234
+ or data.get("last_updated")
235
+ or data.get("lastEventDateTime")
236
+ )
237
+ if ts:
238
+ vehicle.last_seen = _parse_timestamp(ts)
239
+
240
+ return vehicle
241
+
242
+
243
+ def _parse_timestamp(ts: Any) -> datetime | None:
244
+ """Parse various timestamp formats into datetime."""
245
+ if ts is None:
246
+ return None
247
+
248
+ if isinstance(ts, datetime):
249
+ return ts
250
+
251
+ if isinstance(ts, (int, float)):
252
+ # Unix timestamp (seconds or milliseconds)
253
+ if ts > 1e12: # Likely milliseconds
254
+ ts = ts / 1000
255
+ try:
256
+ return datetime.fromtimestamp(ts, tz=timezone.utc)
257
+ except (ValueError, OSError):
258
+ return None
259
+
260
+ if isinstance(ts, str):
261
+ # Try ISO format first
262
+ for fmt in (
263
+ "%Y-%m-%dT%H:%M:%S.%fZ",
264
+ "%Y-%m-%dT%H:%M:%SZ",
265
+ "%Y-%m-%dT%H:%M:%S.%f%z",
266
+ "%Y-%m-%dT%H:%M:%S%z",
267
+ "%Y-%m-%dT%H:%M:%S",
268
+ "%Y-%m-%d %H:%M:%S",
269
+ ):
270
+ try:
271
+ dt = datetime.strptime(ts, fmt)
272
+ if dt.tzinfo is None:
273
+ dt = dt.replace(tzinfo=timezone.utc)
274
+ return dt
275
+ except ValueError:
276
+ continue
277
+
278
+ # Try fromisoformat as fallback
279
+ try:
280
+ dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
281
+ return dt
282
+ except ValueError:
283
+ pass
284
+
285
+ return None
lojack_api/py.typed ADDED
File without changes
@@ -0,0 +1,184 @@
1
+ """HTTP transport layer using aiohttp.
2
+
3
+ This module handles all HTTP communication with the LoJack API.
4
+ It is designed to be compatible with Home Assistant's async patterns.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import ssl
11
+ from typing import Any
12
+
13
+ import aiohttp
14
+
15
+ from .exceptions import (
16
+ ApiError,
17
+ AuthenticationError,
18
+ AuthorizationError,
19
+ ConnectionError,
20
+ TimeoutError,
21
+ )
22
+
23
+
24
+ class AiohttpTransport:
25
+ """Async HTTP transport using aiohttp.
26
+
27
+ This class manages HTTP communication and can accept an external
28
+ aiohttp.ClientSession (recommended for Home Assistant) or create its own.
29
+
30
+ Args:
31
+ base_url: The base URL for API requests (e.g., "https://api.lojack.com").
32
+ session: Optional existing aiohttp.ClientSession to use.
33
+ timeout: Request timeout in seconds (default: 30).
34
+ ssl_context: Optional SSL context for custom certificate handling.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ base_url: str,
40
+ session: aiohttp.ClientSession | None = None,
41
+ timeout: float = 30.0,
42
+ ssl_context: ssl.SSLContext | None = None,
43
+ ) -> None:
44
+ self.base_url = base_url.rstrip("/")
45
+ self._external_session = session is not None
46
+ self._session = session
47
+ self._timeout = aiohttp.ClientTimeout(total=timeout)
48
+ self._ssl_context = ssl_context
49
+ self._closed = False
50
+
51
+ @property
52
+ def closed(self) -> bool:
53
+ """Return True if the transport has been closed."""
54
+ return self._closed
55
+
56
+ async def _get_session(self) -> aiohttp.ClientSession:
57
+ """Get or create the aiohttp session."""
58
+ if self._closed:
59
+ raise ConnectionError("Transport has been closed")
60
+
61
+ if self._session is None or self._session.closed:
62
+ ssl_arg = self._ssl_context if self._ssl_context is not None else False
63
+ connector = aiohttp.TCPConnector(ssl=ssl_arg)
64
+ self._session = aiohttp.ClientSession(
65
+ timeout=self._timeout,
66
+ connector=connector,
67
+ )
68
+ return self._session
69
+
70
+ async def request(
71
+ self,
72
+ method: str,
73
+ path: str,
74
+ *,
75
+ params: dict[str, Any] | None = None,
76
+ json: Any | None = None,
77
+ data: Any | None = None,
78
+ headers: dict[str, str] | None = None,
79
+ ) -> Any:
80
+ """Make an HTTP request to the API.
81
+
82
+ Args:
83
+ method: HTTP method (GET, POST, PUT, DELETE, etc.).
84
+ path: API path (will be joined with base_url).
85
+ params: Optional query parameters.
86
+ json: Optional JSON body (will be serialized).
87
+ data: Optional form data body.
88
+ headers: Optional additional headers.
89
+
90
+ Returns:
91
+ Parsed JSON response if Content-Type is application/json,
92
+ otherwise the response text.
93
+
94
+ Raises:
95
+ AuthenticationError: For 401 responses.
96
+ AuthorizationError: For 403 responses.
97
+ ApiError: For other HTTP errors.
98
+ ConnectionError: For network connectivity issues.
99
+ TimeoutError: For request timeouts.
100
+ """
101
+ session = await self._get_session()
102
+ url = f"{self.base_url}/{path.lstrip('/')}"
103
+
104
+ try:
105
+ async with session.request(
106
+ method,
107
+ url,
108
+ params=params,
109
+ json=json,
110
+ data=data,
111
+ headers=headers,
112
+ ssl=self._ssl_context,
113
+ ) as resp:
114
+ return await self._handle_response(resp)
115
+
116
+ except aiohttp.ClientResponseError as e:
117
+ raise self._map_http_error(e.status, str(e)) from e
118
+ except asyncio.TimeoutError as e:
119
+ raise TimeoutError(f"Request to {url} timed out") from e
120
+ except aiohttp.ClientConnectorError as e:
121
+ raise ConnectionError(f"Failed to connect to {url}: {e}") from e
122
+ except aiohttp.ClientError as e:
123
+ raise ConnectionError(f"Request failed: {e}") from e
124
+
125
+ async def _handle_response(self, resp: aiohttp.ClientResponse) -> Any:
126
+ """Process the HTTP response."""
127
+ # Check for error status codes
128
+ if resp.status >= 400:
129
+ body = await self._safe_read_body(resp)
130
+ raise self._map_http_error(resp.status, body, body)
131
+
132
+ # Parse response based on content type
133
+ content_type = resp.headers.get("Content-Type", "")
134
+
135
+ if "application/json" in content_type:
136
+ try:
137
+ return await resp.json()
138
+ except (aiohttp.ContentTypeError, ValueError):
139
+ # If JSON parsing fails, return text
140
+ text = await resp.text()
141
+ return text
142
+
143
+ return await resp.text()
144
+
145
+ async def _safe_read_body(self, resp: aiohttp.ClientResponse) -> str:
146
+ """Safely read response body for error messages."""
147
+ try:
148
+ return await resp.text()
149
+ except Exception:
150
+ return ""
151
+
152
+ def _map_http_error(
153
+ self,
154
+ status: int,
155
+ message: str,
156
+ body: str | None = None,
157
+ ) -> AuthenticationError | AuthorizationError | ApiError:
158
+ """Map HTTP status codes to appropriate exceptions."""
159
+ if status == 401:
160
+ return AuthenticationError(message or "Authentication failed")
161
+ if status == 403:
162
+ return AuthorizationError(message or "Access denied")
163
+ return ApiError(message or f"HTTP {status}", status_code=status, response_body=body)
164
+
165
+ async def close(self) -> None:
166
+ """Close the transport and release resources.
167
+
168
+ If the session was provided externally, it will NOT be closed
169
+ (the caller is responsible for managing it).
170
+ """
171
+ if self._closed:
172
+ return
173
+
174
+ self._closed = True
175
+
176
+ if self._session and not self._external_session:
177
+ if not self._session.closed:
178
+ await self._session.close()
179
+
180
+ self._session = None
181
+
182
+
183
+ # Keep backward compatibility alias
184
+ AiohttpClient = AiohttpTransport