tescmd 0.1.2__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.
Files changed (81) hide show
  1. tescmd/__init__.py +3 -0
  2. tescmd/__main__.py +5 -0
  3. tescmd/_internal/__init__.py +0 -0
  4. tescmd/_internal/async_utils.py +25 -0
  5. tescmd/_internal/permissions.py +43 -0
  6. tescmd/_internal/vin.py +44 -0
  7. tescmd/api/__init__.py +1 -0
  8. tescmd/api/charging.py +102 -0
  9. tescmd/api/client.py +189 -0
  10. tescmd/api/command.py +540 -0
  11. tescmd/api/energy.py +146 -0
  12. tescmd/api/errors.py +76 -0
  13. tescmd/api/partner.py +40 -0
  14. tescmd/api/sharing.py +65 -0
  15. tescmd/api/signed_command.py +277 -0
  16. tescmd/api/user.py +38 -0
  17. tescmd/api/vehicle.py +150 -0
  18. tescmd/auth/__init__.py +1 -0
  19. tescmd/auth/oauth.py +312 -0
  20. tescmd/auth/server.py +108 -0
  21. tescmd/auth/token_store.py +273 -0
  22. tescmd/ble/__init__.py +0 -0
  23. tescmd/cache/__init__.py +6 -0
  24. tescmd/cache/keys.py +51 -0
  25. tescmd/cache/response_cache.py +213 -0
  26. tescmd/cli/__init__.py +0 -0
  27. tescmd/cli/_client.py +603 -0
  28. tescmd/cli/_options.py +126 -0
  29. tescmd/cli/auth.py +682 -0
  30. tescmd/cli/billing.py +240 -0
  31. tescmd/cli/cache.py +85 -0
  32. tescmd/cli/charge.py +610 -0
  33. tescmd/cli/climate.py +501 -0
  34. tescmd/cli/energy.py +385 -0
  35. tescmd/cli/key.py +611 -0
  36. tescmd/cli/main.py +601 -0
  37. tescmd/cli/media.py +146 -0
  38. tescmd/cli/nav.py +242 -0
  39. tescmd/cli/partner.py +112 -0
  40. tescmd/cli/raw.py +75 -0
  41. tescmd/cli/security.py +495 -0
  42. tescmd/cli/setup.py +786 -0
  43. tescmd/cli/sharing.py +188 -0
  44. tescmd/cli/software.py +81 -0
  45. tescmd/cli/status.py +106 -0
  46. tescmd/cli/trunk.py +240 -0
  47. tescmd/cli/user.py +145 -0
  48. tescmd/cli/vehicle.py +837 -0
  49. tescmd/config/__init__.py +0 -0
  50. tescmd/crypto/__init__.py +19 -0
  51. tescmd/crypto/ecdh.py +46 -0
  52. tescmd/crypto/keys.py +122 -0
  53. tescmd/deploy/__init__.py +0 -0
  54. tescmd/deploy/github_pages.py +268 -0
  55. tescmd/models/__init__.py +85 -0
  56. tescmd/models/auth.py +108 -0
  57. tescmd/models/command.py +18 -0
  58. tescmd/models/config.py +63 -0
  59. tescmd/models/energy.py +56 -0
  60. tescmd/models/sharing.py +26 -0
  61. tescmd/models/user.py +37 -0
  62. tescmd/models/vehicle.py +185 -0
  63. tescmd/output/__init__.py +5 -0
  64. tescmd/output/formatter.py +132 -0
  65. tescmd/output/json_output.py +83 -0
  66. tescmd/output/rich_output.py +809 -0
  67. tescmd/protocol/__init__.py +23 -0
  68. tescmd/protocol/commands.py +175 -0
  69. tescmd/protocol/encoder.py +122 -0
  70. tescmd/protocol/metadata.py +116 -0
  71. tescmd/protocol/payloads.py +621 -0
  72. tescmd/protocol/protobuf/__init__.py +6 -0
  73. tescmd/protocol/protobuf/messages.py +564 -0
  74. tescmd/protocol/session.py +318 -0
  75. tescmd/protocol/signer.py +84 -0
  76. tescmd/py.typed +0 -0
  77. tescmd-0.1.2.dist-info/METADATA +458 -0
  78. tescmd-0.1.2.dist-info/RECORD +81 -0
  79. tescmd-0.1.2.dist-info/WHEEL +4 -0
  80. tescmd-0.1.2.dist-info/entry_points.txt +2 -0
  81. tescmd-0.1.2.dist-info/licenses/LICENSE +21 -0
tescmd/api/errors.py ADDED
@@ -0,0 +1,76 @@
1
+ """Exception hierarchy for Tesla Fleet API errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class TeslaAPIError(Exception):
7
+ """Base exception for all Tesla Fleet API errors."""
8
+
9
+ def __init__(self, message: str, status_code: int | None = None) -> None:
10
+ super().__init__(message)
11
+ self.status_code = status_code
12
+
13
+
14
+ class AuthError(TeslaAPIError):
15
+ """Raised on 401 / authentication failures."""
16
+
17
+
18
+ class VehicleAsleepError(TeslaAPIError):
19
+ """Raised when the vehicle is asleep (HTTP 408)."""
20
+
21
+
22
+ class VehicleNotFoundError(TeslaAPIError):
23
+ """Raised when the requested vehicle does not exist."""
24
+
25
+
26
+ class CommandFailedError(TeslaAPIError):
27
+ """Raised when a vehicle command is rejected by the API."""
28
+
29
+ def __init__(
30
+ self,
31
+ message: str,
32
+ reason: str,
33
+ status_code: int | None = None,
34
+ ) -> None:
35
+ super().__init__(message, status_code)
36
+ self.reason = reason
37
+
38
+
39
+ class RateLimitError(TeslaAPIError):
40
+ """Raised on HTTP 429 — too many requests."""
41
+
42
+ def __init__(
43
+ self,
44
+ message: str = "Rate limit exceeded",
45
+ retry_after: int | None = None,
46
+ ) -> None:
47
+ super().__init__(message, status_code=429)
48
+ self.retry_after = retry_after
49
+
50
+
51
+ class RegistrationRequiredError(TeslaAPIError):
52
+ """Raised on HTTP 412 — app not registered with regional Fleet API."""
53
+
54
+
55
+ class NetworkError(TeslaAPIError):
56
+ """Raised on connection / timeout errors."""
57
+
58
+
59
+ class ConfigError(Exception):
60
+ """Raised for configuration problems (not an API error)."""
61
+
62
+
63
+ class TierError(ConfigError):
64
+ """Command requires full-tier setup but only readonly is configured."""
65
+
66
+
67
+ class SessionError(TeslaAPIError):
68
+ """ECDH session handshake failed."""
69
+
70
+
71
+ class MissingScopesError(AuthError):
72
+ """Raised on HTTP 403 when the token lacks required OAuth scopes."""
73
+
74
+
75
+ class KeyNotEnrolledError(TeslaAPIError):
76
+ """Vehicle rejected the command — key not enrolled."""
tescmd/api/partner.py ADDED
@@ -0,0 +1,40 @@
1
+ """Partner account API — public key retrieval and fleet telemetry errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ if TYPE_CHECKING:
8
+ from tescmd.api.client import TeslaFleetClient
9
+
10
+
11
+ class PartnerAPI:
12
+ """Partner-level endpoints (require partner token, not user token)."""
13
+
14
+ def __init__(self, client: TeslaFleetClient) -> None:
15
+ self._client = client
16
+
17
+ async def public_key(self, *, domain: str) -> dict[str, Any]:
18
+ """Get the public key registered for *domain*."""
19
+ data = await self._client.get(
20
+ "/api/1/partner_accounts/public_key",
21
+ params={"domain": domain},
22
+ )
23
+ result: dict[str, Any] = data.get("response", data)
24
+ return result
25
+
26
+ async def fleet_telemetry_error_vins(self) -> list[str]:
27
+ """List VINs that have recent fleet telemetry errors."""
28
+ data = await self._client.get(
29
+ "/api/1/partner_accounts/fleet_telemetry_error_vins",
30
+ )
31
+ result: list[str] = data.get("response", [])
32
+ return result
33
+
34
+ async def fleet_telemetry_errors(self) -> list[dict[str, Any]]:
35
+ """Get recent fleet telemetry errors across all vehicles."""
36
+ data = await self._client.get(
37
+ "/api/1/partner_accounts/fleet_telemetry_errors",
38
+ )
39
+ result: list[dict[str, Any]] = data.get("response", [])
40
+ return result
tescmd/api/sharing.py ADDED
@@ -0,0 +1,65 @@
1
+ """Vehicle sharing API — driver management and invites."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from tescmd.models.sharing import ShareDriverInfo, ShareInvite
8
+
9
+ if TYPE_CHECKING:
10
+ from tescmd.api.client import TeslaFleetClient
11
+
12
+
13
+ class SharingAPI:
14
+ """Vehicle sharing operations (drivers and invites)."""
15
+
16
+ def __init__(self, client: TeslaFleetClient) -> None:
17
+ self._client = client
18
+
19
+ async def add_driver(self, vin: str, *, email: str) -> ShareDriverInfo:
20
+ """Add a driver by email."""
21
+ data = await self._client.post(
22
+ f"/api/1/vehicles/{vin}/drivers",
23
+ json={"email": email},
24
+ )
25
+ return ShareDriverInfo.model_validate(data.get("response", {}))
26
+
27
+ async def remove_driver(self, vin: str, *, share_user_id: int) -> dict[str, Any]:
28
+ """Remove a driver by their share user ID."""
29
+ data = await self._client.delete(
30
+ f"/api/1/vehicles/{vin}/drivers",
31
+ json={"share_user_id": share_user_id},
32
+ )
33
+ result: dict[str, Any] = data.get("response", {})
34
+ return result
35
+
36
+ async def create_invite(self, vin: str) -> ShareInvite:
37
+ """Create a vehicle share invite."""
38
+ data = await self._client.post(f"/api/1/vehicles/{vin}/invitations")
39
+ return ShareInvite.model_validate(data.get("response", {}))
40
+
41
+ async def redeem_invite(self, *, code: str) -> ShareInvite:
42
+ """Redeem a vehicle share invite code.
43
+
44
+ This is an account-level endpoint (no VIN required) — the invite
45
+ code itself encodes the vehicle association.
46
+ """
47
+ data = await self._client.post(
48
+ "/api/1/invitations/redeem",
49
+ json={"code": code},
50
+ )
51
+ return ShareInvite.model_validate(data.get("response", {}))
52
+
53
+ async def revoke_invite(self, vin: str, *, invite_id: str) -> dict[str, Any]:
54
+ """Revoke a vehicle share invite."""
55
+ data = await self._client.post(
56
+ f"/api/1/vehicles/{vin}/invitations/{invite_id}/revoke",
57
+ )
58
+ result: dict[str, Any] = data.get("response", {})
59
+ return result
60
+
61
+ async def list_invites(self, vin: str) -> list[ShareInvite]:
62
+ """List active vehicle share invites."""
63
+ data = await self._client.get(f"/api/1/vehicles/{vin}/invitations")
64
+ raw_list: list[dict[str, Any]] = data.get("response", [])
65
+ return [ShareInvite.model_validate(inv) for inv in raw_list]
@@ -0,0 +1,277 @@
1
+ """Signed command API — Vehicle Command Protocol over the signed_command endpoint.
2
+
3
+ Routes commands through either the signed ECDH/HMAC path or the unsigned
4
+ REST path depending on the command's registry entry.
5
+
6
+ This class matches the :class:`CommandAPI` interface so callers (CLI modules)
7
+ don't need to know which protocol path is in use.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import logging
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from tescmd.api.errors import KeyNotEnrolledError, SessionError, TeslaAPIError
17
+ from tescmd.models.command import CommandResponse, CommandResult
18
+ from tescmd.protocol.commands import get_command_spec
19
+ from tescmd.protocol.encoder import (
20
+ build_signed_command,
21
+ default_expiry,
22
+ encode_routable_message,
23
+ )
24
+ from tescmd.protocol.metadata import encode_metadata
25
+ from tescmd.protocol.payloads import build_command_payload
26
+ from tescmd.protocol.protobuf.messages import (
27
+ FAULT_DESCRIPTIONS,
28
+ KEY_FAULTS,
29
+ MessageFault,
30
+ RoutableMessage,
31
+ )
32
+ from tescmd.protocol.signer import compute_hmac_tag
33
+
34
+ if TYPE_CHECKING:
35
+ from tescmd.api.client import TeslaFleetClient
36
+ from tescmd.api.command import CommandAPI
37
+ from tescmd.protocol.protobuf.messages import Domain
38
+ from tescmd.protocol.session import SessionManager
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ # Faults indicating the ECDH session is stale and a fresh handshake is needed.
43
+ _STALE_SESSION_FAULTS: frozenset[MessageFault] = frozenset(
44
+ {
45
+ MessageFault.ERROR_INVALID_SIGNATURE,
46
+ MessageFault.ERROR_INVALID_TOKEN_OR_COUNTER,
47
+ MessageFault.ERROR_INCORRECT_EPOCH,
48
+ MessageFault.ERROR_TIME_EXPIRED,
49
+ }
50
+ )
51
+
52
+
53
+ class SignedCommandAPI:
54
+ """Vehicle command API using the Vehicle Command Protocol (signed channel).
55
+
56
+ Commands registered in the protocol command registry with
57
+ ``requires_signing=True`` are routed through the ECDH session + HMAC
58
+ path. Unsigned commands (e.g. ``wake_up``) pass through to the
59
+ legacy REST endpoint via the wrapped :class:`CommandAPI`.
60
+
61
+ Named methods (``charge_start``, ``door_lock``, etc.) are created
62
+ dynamically via :meth:`__getattr__` — each returns a dispatch wrapper
63
+ that routes through :meth:`_command`, which picks the signed or
64
+ unsigned path based on the command registry.
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ client: TeslaFleetClient,
70
+ session_mgr: SessionManager,
71
+ unsigned_api: CommandAPI,
72
+ ) -> None:
73
+ self._client = client
74
+ self._session_mgr = session_mgr
75
+ self._unsigned_api = unsigned_api
76
+
77
+ async def _command(
78
+ self, vin: str, command: str, body: dict[str, Any] | None = None
79
+ ) -> CommandResponse:
80
+ """Execute a command — signed or unsigned depending on registry."""
81
+ spec = get_command_spec(command)
82
+
83
+ # Unknown commands or unsigned commands → legacy REST path
84
+ if spec is None or not spec.requires_signing:
85
+ return await self._unsigned_api._command(vin, command, body)
86
+
87
+ return await self._signed_command(vin, command, body, domain=spec.domain)
88
+
89
+ async def _signed_command(
90
+ self,
91
+ vin: str,
92
+ command: str,
93
+ body: dict[str, Any] | None,
94
+ *,
95
+ domain: Domain,
96
+ _retried: bool = False,
97
+ ) -> CommandResponse:
98
+ """Build, sign, and send a command via the signed_command endpoint."""
99
+ session = await self._session_mgr.get_session(vin, domain)
100
+
101
+ # Build protobuf payload for the command
102
+ payload = build_command_payload(command, body)
103
+
104
+ # Metadata + HMAC
105
+ counter = session.next_counter()
106
+ expires_at = default_expiry(clock_offset=session.clock_offset)
107
+ metadata_bytes = encode_metadata(
108
+ epoch=session.epoch,
109
+ expires_at=expires_at,
110
+ counter=counter,
111
+ domain=domain,
112
+ vin=vin,
113
+ )
114
+ hmac_tag = compute_hmac_tag(
115
+ session.signing_key,
116
+ metadata_bytes,
117
+ payload,
118
+ domain=domain,
119
+ )
120
+
121
+ logger.debug(
122
+ "Signed command '%s' for %s: counter=%d, expires_at=%d "
123
+ "(clock_offset=%d), payload=%s, metadata=%s, hmac_tag=%s",
124
+ command,
125
+ vin,
126
+ counter,
127
+ expires_at,
128
+ session.clock_offset,
129
+ payload.hex(),
130
+ metadata_bytes.hex(),
131
+ hmac_tag.hex(),
132
+ )
133
+
134
+ # Build RoutableMessage
135
+ msg = build_signed_command(
136
+ domain=domain,
137
+ payload=payload,
138
+ client_public_key=self._session_mgr.client_public_key,
139
+ epoch=session.epoch,
140
+ counter=counter,
141
+ expires_at=expires_at,
142
+ hmac_tag=hmac_tag,
143
+ )
144
+ encoded = encode_routable_message(msg)
145
+
146
+ logger.debug(
147
+ "Encoded RoutableMessage for %s: %s",
148
+ vin,
149
+ encoded[:80] + "..." if len(encoded) > 80 else encoded,
150
+ )
151
+
152
+ # POST to signed_command endpoint
153
+ try:
154
+ data = await self._client.post(
155
+ f"/api/1/vehicles/{vin}/signed_command",
156
+ json={"routable_message": encoded},
157
+ )
158
+ except TeslaAPIError as exc:
159
+ # Check for key-not-enrolled error pattern
160
+ err_msg = str(exc).lower()
161
+ if "not enrolled" in err_msg or "unknown key" in err_msg:
162
+ raise KeyNotEnrolledError(
163
+ f"Key not enrolled on vehicle {vin}. "
164
+ "Enroll via 'tescmd key enroll' and approve in the Tesla app.",
165
+ status_code=422,
166
+ ) from exc
167
+ # Let domain-specific errors (VehicleAsleepError, RateLimitError,
168
+ # etc.) propagate so callers like auto_wake() can handle them.
169
+ raise
170
+
171
+ result = self._parse_signed_response(data, vin, command)
172
+ if result is not None:
173
+ return result
174
+
175
+ # Stale session — _parse_signed_response already invalidated the cache.
176
+ # Retry once with a fresh handshake.
177
+ if _retried:
178
+ raise SessionError(
179
+ f"Signed command '{command}' failed for {vin} after session refresh"
180
+ )
181
+ logger.debug(
182
+ "Stale session for %s (%s), retrying with fresh handshake",
183
+ vin,
184
+ command,
185
+ )
186
+ return await self._signed_command(vin, command, body, domain=domain, _retried=True)
187
+
188
+ # ------------------------------------------------------------------
189
+ # Response parsing
190
+ # ------------------------------------------------------------------
191
+
192
+ def _parse_signed_response(
193
+ self,
194
+ data: dict[str, Any],
195
+ vin: str,
196
+ command: str,
197
+ ) -> CommandResponse | None:
198
+ """Parse a signed_command endpoint response.
199
+
200
+ The endpoint returns ``{"response": "<base64-encoded RoutableMessage>"}``.
201
+ Decodes the protobuf, checks for vehicle-side fault codes, and returns
202
+ a ``CommandResponse`` on success.
203
+
204
+ Returns ``None`` if the fault indicates a stale session — the caller
205
+ should invalidate the session and retry with a fresh handshake.
206
+ """
207
+ response_b64: str = data.get("response", "")
208
+ if not response_b64:
209
+ raise SessionError(f"Empty signed_command response for {vin} ({command})")
210
+
211
+ try:
212
+ response_bytes = base64.b64decode(response_b64)
213
+ except Exception as exc:
214
+ raise SessionError(f"Failed to decode signed_command response: {exc}") from exc
215
+
216
+ msg = RoutableMessage.parse(response_bytes)
217
+ fault = msg.signed_message_fault
218
+
219
+ logger.debug(
220
+ "Signed command '%s' response for %s: fault=%s, has_protobuf_msg=%s, has_signature=%s",
221
+ command,
222
+ vin,
223
+ fault.name,
224
+ bool(msg.protobuf_message_as_bytes),
225
+ msg.signature_data is not None,
226
+ )
227
+
228
+ if fault != MessageFault.ERROR_NONE:
229
+ desc = FAULT_DESCRIPTIONS.get(fault, fault.name)
230
+
231
+ # Key not enrolled — permanent error, don't retry.
232
+ if fault in KEY_FAULTS:
233
+ raise KeyNotEnrolledError(
234
+ f"Vehicle rejected command '{command}': {desc}",
235
+ status_code=422,
236
+ )
237
+
238
+ # Stale session — invalidate and signal caller to retry.
239
+ if fault in _STALE_SESSION_FAULTS:
240
+ self._session_mgr.invalidate(vin)
241
+ logger.debug(
242
+ "Stale session fault %s for %s, invalidated cache",
243
+ fault.name,
244
+ vin,
245
+ )
246
+ return None
247
+
248
+ # All other faults — raise with descriptive message.
249
+ raise SessionError(f"Vehicle rejected command '{command}' for {vin}: {desc}")
250
+
251
+ logger.debug("Signed command '%s' succeeded for %s", command, vin)
252
+ return CommandResponse(response=CommandResult(result=True))
253
+
254
+ # ------------------------------------------------------------------
255
+ # Named method delegation
256
+ # ------------------------------------------------------------------
257
+
258
+ def __getattr__(self, name: str) -> Any:
259
+ """Route named command methods through the signed path.
260
+
261
+ ``execute_command()`` calls ``getattr(cmd_api, method_name)(vin, **body)``
262
+ — we create wrapper methods that route through ``self._command()``
263
+ which picks the signed or unsigned path based on the command registry.
264
+ """
265
+ spec = get_command_spec(name)
266
+ if spec is not None:
267
+ # Return a wrapper that routes through the signed/unsigned dispatcher
268
+ async def _dispatch(vin: str, **kwargs: Any) -> CommandResponse:
269
+ return await self._command(vin, name, kwargs or None)
270
+
271
+ return _dispatch
272
+
273
+ # Non-command attributes (e.g. _client) → fall back to unsigned API
274
+ attr = getattr(self._unsigned_api, name, None)
275
+ if attr is not None:
276
+ return attr
277
+ raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")
tescmd/api/user.py ADDED
@@ -0,0 +1,38 @@
1
+ """User account API — wraps /api/1/users endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from tescmd.models.user import FeatureConfig, UserInfo, UserRegion, VehicleOrder
8
+
9
+ if TYPE_CHECKING:
10
+ from tescmd.api.client import TeslaFleetClient
11
+
12
+
13
+ class UserAPI:
14
+ """User account operations (no VIN required)."""
15
+
16
+ def __init__(self, client: TeslaFleetClient) -> None:
17
+ self._client = client
18
+
19
+ async def me(self) -> UserInfo:
20
+ """Fetch the current user's profile."""
21
+ data = await self._client.get("/api/1/users/me")
22
+ return UserInfo.model_validate(data.get("response", {}))
23
+
24
+ async def region(self) -> UserRegion:
25
+ """Fetch the user's regional Fleet API endpoint."""
26
+ data = await self._client.get("/api/1/users/region")
27
+ return UserRegion.model_validate(data.get("response", {}))
28
+
29
+ async def orders(self) -> list[VehicleOrder]:
30
+ """Fetch the user's vehicle orders."""
31
+ data = await self._client.get("/api/1/users/orders")
32
+ raw_list: list[dict[str, Any]] = data.get("response", [])
33
+ return [VehicleOrder.model_validate(o) for o in raw_list]
34
+
35
+ async def feature_config(self) -> FeatureConfig:
36
+ """Fetch the user's feature flags."""
37
+ data = await self._client.get("/api/1/users/feature_config")
38
+ return FeatureConfig.model_validate(data.get("response", {}))
tescmd/api/vehicle.py ADDED
@@ -0,0 +1,150 @@
1
+ """High-level Vehicle API built on top of TeslaFleetClient."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from tescmd.models.sharing import ShareDriverInfo
8
+ from tescmd.models.vehicle import NearbyChargingSites, Vehicle, VehicleData
9
+
10
+ if TYPE_CHECKING:
11
+ from tescmd.api.client import TeslaFleetClient
12
+
13
+
14
+ class VehicleAPI:
15
+ """Vehicle-related API operations (composition over TeslaFleetClient)."""
16
+
17
+ def __init__(self, client: TeslaFleetClient) -> None:
18
+ self._client = client
19
+
20
+ async def list_vehicles(self) -> list[Vehicle]:
21
+ """Return all vehicles associated with the account."""
22
+ data = await self._client.get("/api/1/vehicles")
23
+ raw_list: list[dict[str, object]] = data.get("response", [])
24
+ return [Vehicle.model_validate(v) for v in raw_list]
25
+
26
+ async def get_vehicle(self, vin: str) -> Vehicle:
27
+ """Fetch a single vehicle by VIN."""
28
+ data = await self._client.get(f"/api/1/vehicles/{vin}")
29
+ return Vehicle.model_validate(data["response"])
30
+
31
+ async def get_vehicle_data(
32
+ self,
33
+ vin: str,
34
+ *,
35
+ endpoints: list[str] | None = None,
36
+ ) -> VehicleData:
37
+ """Fetch full vehicle data, optionally filtered to *endpoints*."""
38
+ path = f"/api/1/vehicles/{vin}/vehicle_data"
39
+ params: dict[str, str] = {}
40
+ if endpoints:
41
+ params["endpoints"] = ";".join(endpoints)
42
+ data = await self._client.get(path, params=params)
43
+ return VehicleData.model_validate(data["response"])
44
+
45
+ async def wake(self, vin: str) -> Vehicle:
46
+ """Send a wake-up command and return the vehicle state."""
47
+ data = await self._client.post(f"/api/1/vehicles/{vin}/wake_up")
48
+ return Vehicle.model_validate(data["response"])
49
+
50
+ async def mobile_enabled(self, vin: str) -> bool:
51
+ """Check if mobile access is enabled for the vehicle."""
52
+ data = await self._client.get(f"/api/1/vehicles/{vin}/mobile_enabled")
53
+ return bool(data.get("response", False))
54
+
55
+ async def nearby_charging_sites(self, vin: str) -> NearbyChargingSites:
56
+ """Fetch nearby Superchargers and destination chargers."""
57
+ data = await self._client.get(f"/api/1/vehicles/{vin}/nearby_charging_sites")
58
+ return NearbyChargingSites.model_validate(data.get("response", {}))
59
+
60
+ async def recent_alerts(self, vin: str) -> list[dict[str, Any]]:
61
+ """Fetch recent vehicle alerts."""
62
+ data = await self._client.get(f"/api/1/vehicles/{vin}/recent_alerts")
63
+ result: list[dict[str, Any]] = data.get("response", [])
64
+ return result
65
+
66
+ async def release_notes(self, vin: str) -> dict[str, Any]:
67
+ """Fetch firmware release notes."""
68
+ data = await self._client.get(f"/api/1/vehicles/{vin}/release_notes")
69
+ result: dict[str, Any] = data.get("response", {})
70
+ return result
71
+
72
+ async def service_data(self, vin: str) -> dict[str, Any]:
73
+ """Fetch vehicle service data."""
74
+ data = await self._client.get(f"/api/1/vehicles/{vin}/service_data")
75
+ result: dict[str, Any] = data.get("response", {})
76
+ return result
77
+
78
+ async def list_drivers(self, vin: str) -> list[ShareDriverInfo]:
79
+ """List drivers associated with the vehicle."""
80
+ data = await self._client.get(f"/api/1/vehicles/{vin}/drivers")
81
+ raw_list: list[dict[str, Any]] = data.get("response", [])
82
+ return [ShareDriverInfo.model_validate(d) for d in raw_list]
83
+
84
+ # ------------------------------------------------------------------
85
+ # Extended vehicle data endpoints
86
+ # ------------------------------------------------------------------
87
+
88
+ async def eligible_subscriptions(self, vin: str) -> dict[str, Any]:
89
+ """Check subscription eligibility for the vehicle."""
90
+ data = await self._client.get(
91
+ "/api/1/dx/vehicles/subscriptions/eligibility", params={"vin": vin}
92
+ )
93
+ result: dict[str, Any] = data.get("response", {})
94
+ return result
95
+
96
+ async def eligible_upgrades(self, vin: str) -> dict[str, Any]:
97
+ """Check upgrade eligibility for the vehicle."""
98
+ data = await self._client.get(
99
+ "/api/1/dx/vehicles/upgrades/eligibility", params={"vin": vin}
100
+ )
101
+ result: dict[str, Any] = data.get("response", {})
102
+ return result
103
+
104
+ async def options(self, vin: str) -> dict[str, Any]:
105
+ """Fetch vehicle option codes."""
106
+ data = await self._client.get("/api/1/dx/vehicles/options", params={"vin": vin})
107
+ result: dict[str, Any] = data.get("response", {})
108
+ return result
109
+
110
+ async def specs(self, vin: str) -> dict[str, Any]:
111
+ """Fetch vehicle specifications."""
112
+ data = await self._client.get(f"/api/1/vehicles/{vin}/specs")
113
+ result: dict[str, Any] = data.get("response", {})
114
+ return result
115
+
116
+ async def warranty_details(self) -> dict[str, Any]:
117
+ """Fetch warranty details."""
118
+ data = await self._client.get("/api/1/dx/warranty/details")
119
+ result: dict[str, Any] = data.get("response", {})
120
+ return result
121
+
122
+ async def fleet_status(self) -> dict[str, Any]:
123
+ """Fetch fleet status for all vehicles."""
124
+ data = await self._client.post("/api/1/vehicles/fleet_status")
125
+ result: dict[str, Any] = data.get("response", {})
126
+ return result
127
+
128
+ async def fleet_telemetry_config(self, vin: str) -> dict[str, Any]:
129
+ """Fetch fleet telemetry configuration for a vehicle."""
130
+ data = await self._client.get(f"/api/1/vehicles/{vin}/fleet_telemetry_config")
131
+ result: dict[str, Any] = data.get("response", {})
132
+ return result
133
+
134
+ async def fleet_telemetry_config_create(self, *, config: dict[str, Any]) -> dict[str, Any]:
135
+ """Create or update fleet telemetry server configuration."""
136
+ data = await self._client.post("/api/1/vehicles/fleet_telemetry_config", json=config)
137
+ result: dict[str, Any] = data.get("response", {})
138
+ return result
139
+
140
+ async def fleet_telemetry_config_delete(self, vin: str) -> dict[str, Any]:
141
+ """Remove fleet telemetry configuration from a vehicle."""
142
+ data = await self._client.delete(f"/api/1/vehicles/{vin}/fleet_telemetry_config")
143
+ result: dict[str, Any] = data.get("response", {})
144
+ return result
145
+
146
+ async def fleet_telemetry_errors(self, vin: str) -> dict[str, Any]:
147
+ """Fetch fleet telemetry errors."""
148
+ data = await self._client.get(f"/api/1/vehicles/{vin}/fleet_telemetry_errors")
149
+ result: dict[str, Any] = data.get("response", {})
150
+ return result
@@ -0,0 +1 @@
1
+ """Authentication: OAuth2 PKCE flow, token persistence, callback server."""