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.
- tescmd/__init__.py +3 -0
- tescmd/__main__.py +5 -0
- tescmd/_internal/__init__.py +0 -0
- tescmd/_internal/async_utils.py +25 -0
- tescmd/_internal/permissions.py +43 -0
- tescmd/_internal/vin.py +44 -0
- tescmd/api/__init__.py +1 -0
- tescmd/api/charging.py +102 -0
- tescmd/api/client.py +189 -0
- tescmd/api/command.py +540 -0
- tescmd/api/energy.py +146 -0
- tescmd/api/errors.py +76 -0
- tescmd/api/partner.py +40 -0
- tescmd/api/sharing.py +65 -0
- tescmd/api/signed_command.py +277 -0
- tescmd/api/user.py +38 -0
- tescmd/api/vehicle.py +150 -0
- tescmd/auth/__init__.py +1 -0
- tescmd/auth/oauth.py +312 -0
- tescmd/auth/server.py +108 -0
- tescmd/auth/token_store.py +273 -0
- tescmd/ble/__init__.py +0 -0
- tescmd/cache/__init__.py +6 -0
- tescmd/cache/keys.py +51 -0
- tescmd/cache/response_cache.py +213 -0
- tescmd/cli/__init__.py +0 -0
- tescmd/cli/_client.py +603 -0
- tescmd/cli/_options.py +126 -0
- tescmd/cli/auth.py +682 -0
- tescmd/cli/billing.py +240 -0
- tescmd/cli/cache.py +85 -0
- tescmd/cli/charge.py +610 -0
- tescmd/cli/climate.py +501 -0
- tescmd/cli/energy.py +385 -0
- tescmd/cli/key.py +611 -0
- tescmd/cli/main.py +601 -0
- tescmd/cli/media.py +146 -0
- tescmd/cli/nav.py +242 -0
- tescmd/cli/partner.py +112 -0
- tescmd/cli/raw.py +75 -0
- tescmd/cli/security.py +495 -0
- tescmd/cli/setup.py +786 -0
- tescmd/cli/sharing.py +188 -0
- tescmd/cli/software.py +81 -0
- tescmd/cli/status.py +106 -0
- tescmd/cli/trunk.py +240 -0
- tescmd/cli/user.py +145 -0
- tescmd/cli/vehicle.py +837 -0
- tescmd/config/__init__.py +0 -0
- tescmd/crypto/__init__.py +19 -0
- tescmd/crypto/ecdh.py +46 -0
- tescmd/crypto/keys.py +122 -0
- tescmd/deploy/__init__.py +0 -0
- tescmd/deploy/github_pages.py +268 -0
- tescmd/models/__init__.py +85 -0
- tescmd/models/auth.py +108 -0
- tescmd/models/command.py +18 -0
- tescmd/models/config.py +63 -0
- tescmd/models/energy.py +56 -0
- tescmd/models/sharing.py +26 -0
- tescmd/models/user.py +37 -0
- tescmd/models/vehicle.py +185 -0
- tescmd/output/__init__.py +5 -0
- tescmd/output/formatter.py +132 -0
- tescmd/output/json_output.py +83 -0
- tescmd/output/rich_output.py +809 -0
- tescmd/protocol/__init__.py +23 -0
- tescmd/protocol/commands.py +175 -0
- tescmd/protocol/encoder.py +122 -0
- tescmd/protocol/metadata.py +116 -0
- tescmd/protocol/payloads.py +621 -0
- tescmd/protocol/protobuf/__init__.py +6 -0
- tescmd/protocol/protobuf/messages.py +564 -0
- tescmd/protocol/session.py +318 -0
- tescmd/protocol/signer.py +84 -0
- tescmd/py.typed +0 -0
- tescmd-0.1.2.dist-info/METADATA +458 -0
- tescmd-0.1.2.dist-info/RECORD +81 -0
- tescmd-0.1.2.dist-info/WHEEL +4 -0
- tescmd-0.1.2.dist-info/entry_points.txt +2 -0
- tescmd-0.1.2.dist-info/licenses/LICENSE +21 -0
tescmd/__init__.py
ADDED
tescmd/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Asyncio utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Coroutine
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run_async(coro: Coroutine[Any, Any, Any]) -> Any:
|
|
13
|
+
"""Run an async coroutine from synchronous code."""
|
|
14
|
+
try:
|
|
15
|
+
loop = asyncio.get_running_loop()
|
|
16
|
+
except RuntimeError:
|
|
17
|
+
loop = None
|
|
18
|
+
|
|
19
|
+
if loop and loop.is_running():
|
|
20
|
+
import concurrent.futures
|
|
21
|
+
|
|
22
|
+
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
23
|
+
return pool.submit(asyncio.run, coro).result()
|
|
24
|
+
else:
|
|
25
|
+
return asyncio.run(coro)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Cross-platform file-permission helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def secure_file(path: Path) -> None:
|
|
16
|
+
"""Set *path* to owner-only read/write (best-effort).
|
|
17
|
+
|
|
18
|
+
* **Unix** — ``chmod 0600``
|
|
19
|
+
* **Windows** — ``icacls`` grant to current user, remove inherited ACLs
|
|
20
|
+
|
|
21
|
+
Failures are silently ignored so callers never crash on permission
|
|
22
|
+
quirks (Docker volumes, network mounts, etc.).
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
if sys.platform == "win32":
|
|
26
|
+
_secure_file_windows(path)
|
|
27
|
+
else:
|
|
28
|
+
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
29
|
+
except OSError:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _secure_file_windows(path: Path) -> None:
|
|
34
|
+
"""Restrict *path* to the current user via ``icacls``."""
|
|
35
|
+
username = os.environ.get("USERNAME", "")
|
|
36
|
+
if not username:
|
|
37
|
+
return
|
|
38
|
+
# Remove inherited permissions, then grant owner full control
|
|
39
|
+
subprocess.run(
|
|
40
|
+
["icacls", str(path), "/inheritance:r", "/grant:r", f"{username}:(R,W)"],
|
|
41
|
+
capture_output=True,
|
|
42
|
+
check=False,
|
|
43
|
+
)
|
tescmd/_internal/vin.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Smart VIN resolution and validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
# ISO 3779: VINs are 17 alphanumeric characters, excluding I, O, Q.
|
|
9
|
+
_VIN_PATTERN = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InvalidVINError(ValueError):
|
|
13
|
+
"""Raised when a VIN fails format validation."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_vin(vin: str) -> str:
|
|
17
|
+
"""Validate that *vin* matches the ISO 3779 VIN format.
|
|
18
|
+
|
|
19
|
+
Returns the uppercased VIN on success; raises :class:`InvalidVINError`
|
|
20
|
+
on failure.
|
|
21
|
+
"""
|
|
22
|
+
upper = vin.upper()
|
|
23
|
+
if not _VIN_PATTERN.match(upper):
|
|
24
|
+
raise InvalidVINError(
|
|
25
|
+
f"Invalid VIN {vin!r}: must be 17 alphanumeric characters "
|
|
26
|
+
"(excluding I, O, Q per ISO 3779)."
|
|
27
|
+
)
|
|
28
|
+
return upper
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_vin(
|
|
32
|
+
*,
|
|
33
|
+
vin_positional: str | None = None,
|
|
34
|
+
vin_flag: str | None = None,
|
|
35
|
+
) -> str | None:
|
|
36
|
+
"""Resolve VIN from multiple sources in priority order.
|
|
37
|
+
|
|
38
|
+
Resolution: positional arg > --vin flag > TESLA_VIN env > None.
|
|
39
|
+
"""
|
|
40
|
+
if vin_positional:
|
|
41
|
+
return vin_positional
|
|
42
|
+
if vin_flag:
|
|
43
|
+
return vin_flag
|
|
44
|
+
return os.environ.get("TESLA_VIN")
|
tescmd/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tesla Fleet API client layer."""
|
tescmd/api/charging.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Charging history API — wraps /api/1/dx/charging endpoints."""
|
|
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 ChargingAPI:
|
|
12
|
+
"""Charging history and invoice operations (composition over TeslaFleetClient)."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, client: TeslaFleetClient) -> None:
|
|
15
|
+
self._client = client
|
|
16
|
+
|
|
17
|
+
async def charging_history(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
vin: str | None = None,
|
|
21
|
+
start_time: str | None = None,
|
|
22
|
+
end_time: str | None = None,
|
|
23
|
+
page_no: int | None = None,
|
|
24
|
+
page_size: int | None = None,
|
|
25
|
+
sort_by: str | None = None,
|
|
26
|
+
sort_order: str | None = None,
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
"""Fetch paginated Supercharger charging history.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
vin: Filter by vehicle VIN.
|
|
32
|
+
start_time: ISO-8601 start time filter.
|
|
33
|
+
end_time: ISO-8601 end time filter.
|
|
34
|
+
page_no: Page number (0-based).
|
|
35
|
+
page_size: Results per page.
|
|
36
|
+
sort_by: Field to sort by.
|
|
37
|
+
sort_order: Sort order (ASC or DESC).
|
|
38
|
+
"""
|
|
39
|
+
params: dict[str, str] = {}
|
|
40
|
+
if vin is not None:
|
|
41
|
+
params["vin"] = vin
|
|
42
|
+
if start_time is not None:
|
|
43
|
+
params["startTime"] = start_time
|
|
44
|
+
if end_time is not None:
|
|
45
|
+
params["endTime"] = end_time
|
|
46
|
+
if page_no is not None:
|
|
47
|
+
params["pageNo"] = str(page_no)
|
|
48
|
+
if page_size is not None:
|
|
49
|
+
params["pageSize"] = str(page_size)
|
|
50
|
+
if sort_by is not None:
|
|
51
|
+
params["sortBy"] = sort_by
|
|
52
|
+
if sort_order is not None:
|
|
53
|
+
params["sortOrder"] = sort_order
|
|
54
|
+
data = await self._client.get("/api/1/dx/charging/history", params=params)
|
|
55
|
+
result: dict[str, Any] = data.get("response", {})
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
async def charging_sessions(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
vin: str | None = None,
|
|
62
|
+
date_from: str | None = None,
|
|
63
|
+
date_to: str | None = None,
|
|
64
|
+
limit: int | None = None,
|
|
65
|
+
offset: int | None = None,
|
|
66
|
+
) -> dict[str, Any]:
|
|
67
|
+
"""Fetch charging sessions (business accounts only).
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
vin: Filter by vehicle VIN.
|
|
71
|
+
date_from: ISO-8601 start date.
|
|
72
|
+
date_to: ISO-8601 end date.
|
|
73
|
+
limit: Max results to return.
|
|
74
|
+
offset: Pagination offset.
|
|
75
|
+
"""
|
|
76
|
+
params: dict[str, str] = {}
|
|
77
|
+
if vin is not None:
|
|
78
|
+
params["vin"] = vin
|
|
79
|
+
if date_from is not None:
|
|
80
|
+
params["date_from"] = date_from
|
|
81
|
+
if date_to is not None:
|
|
82
|
+
params["date_to"] = date_to
|
|
83
|
+
if limit is not None:
|
|
84
|
+
params["limit"] = str(limit)
|
|
85
|
+
if offset is not None:
|
|
86
|
+
params["offset"] = str(offset)
|
|
87
|
+
data = await self._client.get("/api/1/dx/charging/sessions", params=params)
|
|
88
|
+
result: dict[str, Any] = data.get("response", {})
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
async def charging_invoice(self, invoice_id: str) -> dict[str, Any]:
|
|
92
|
+
"""Download a charging invoice.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
invoice_id: The invoice identifier.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Invoice response data (may contain a download URL or inline content).
|
|
99
|
+
"""
|
|
100
|
+
data = await self._client.get(f"/api/1/dx/charging/invoice/{invoice_id}")
|
|
101
|
+
result: dict[str, Any] = data.get("response", data) if isinstance(data, dict) else {}
|
|
102
|
+
return result
|
tescmd/api/client.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Async HTTP client for the Tesla Fleet API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
|
|
13
|
+
from tescmd.api.errors import (
|
|
14
|
+
AuthError,
|
|
15
|
+
MissingScopesError,
|
|
16
|
+
NetworkError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
RegistrationRequiredError,
|
|
19
|
+
TeslaAPIError,
|
|
20
|
+
VehicleAsleepError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Region -> base URL mapping
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
REGION_BASE_URLS: dict[str, str] = {
|
|
28
|
+
"na": "https://fleet-api.prd.na.vn.cloud.tesla.com",
|
|
29
|
+
"eu": "https://fleet-api.prd.eu.vn.cloud.tesla.com",
|
|
30
|
+
"cn": "https://fleet-api.prd.cn.vn.cloud.tesla.com",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Tesla Fleet API documented rate limits (per device, per account).
|
|
35
|
+
# See: https://developer.tesla.com/docs/fleet-api/billing-and-limits
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
RATE_LIMITS = {
|
|
39
|
+
"data": 60, # Realtime data: 60 req/min
|
|
40
|
+
"commands": 30, # Device commands: 30 req/min
|
|
41
|
+
"wakes": 3, # Wakes: 3 req/min
|
|
42
|
+
"auth": 20, # Auth: 20 req/sec
|
|
43
|
+
}
|
|
44
|
+
_RATE_LIMIT_MAX_RETRIES = 3
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TeslaFleetClient:
|
|
48
|
+
"""Low-level async HTTP client for Tesla Fleet API endpoints."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
access_token: str,
|
|
53
|
+
region: str = "na",
|
|
54
|
+
timeout: float = 30.0,
|
|
55
|
+
on_token_refresh: Callable[[], Awaitable[str | None]] | None = None,
|
|
56
|
+
on_rate_limit_wait: Callable[[int, int, int], Awaitable[None]] | None = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
base_url = REGION_BASE_URLS.get(region)
|
|
59
|
+
if base_url is None:
|
|
60
|
+
msg = f"Unknown region {region!r}; expected one of {sorted(REGION_BASE_URLS)}"
|
|
61
|
+
raise ValueError(msg)
|
|
62
|
+
|
|
63
|
+
self._access_token = access_token
|
|
64
|
+
self._on_token_refresh = on_token_refresh
|
|
65
|
+
self._on_rate_limit_wait = on_rate_limit_wait
|
|
66
|
+
self._client = httpx.AsyncClient(
|
|
67
|
+
base_url=base_url,
|
|
68
|
+
timeout=timeout,
|
|
69
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# -- public helpers -----------------------------------------------------
|
|
73
|
+
|
|
74
|
+
async def get(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
75
|
+
"""Issue a GET request and return the parsed JSON body."""
|
|
76
|
+
return await self._request("GET", path, **kwargs)
|
|
77
|
+
|
|
78
|
+
async def post(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
79
|
+
"""Issue a POST request and return the parsed JSON body."""
|
|
80
|
+
return await self._request("POST", path, **kwargs)
|
|
81
|
+
|
|
82
|
+
async def delete(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
83
|
+
"""Issue a DELETE request and return the parsed JSON body."""
|
|
84
|
+
return await self._request("DELETE", path, **kwargs)
|
|
85
|
+
|
|
86
|
+
def update_token(self, access_token: str) -> None:
|
|
87
|
+
"""Replace the current access token and update the header."""
|
|
88
|
+
self._access_token = access_token
|
|
89
|
+
self._client.headers["Authorization"] = f"Bearer {access_token}"
|
|
90
|
+
|
|
91
|
+
async def close(self) -> None:
|
|
92
|
+
"""Close the underlying HTTP client."""
|
|
93
|
+
await self._client.aclose()
|
|
94
|
+
|
|
95
|
+
# -- async context manager -----------------------------------------------
|
|
96
|
+
|
|
97
|
+
async def __aenter__(self) -> TeslaFleetClient:
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
async def __aexit__(
|
|
101
|
+
self,
|
|
102
|
+
exc_type: type[BaseException] | None,
|
|
103
|
+
exc_val: BaseException | None,
|
|
104
|
+
exc_tb: Any,
|
|
105
|
+
) -> None:
|
|
106
|
+
await self.close()
|
|
107
|
+
|
|
108
|
+
# -- internal ------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
async def _request(
|
|
111
|
+
self,
|
|
112
|
+
method: str,
|
|
113
|
+
path: str,
|
|
114
|
+
**kwargs: Any,
|
|
115
|
+
) -> dict[str, Any]:
|
|
116
|
+
"""Issue an HTTP request with automatic retry on 429 rate-limit responses."""
|
|
117
|
+
for attempt in range(_RATE_LIMIT_MAX_RETRIES + 1):
|
|
118
|
+
try:
|
|
119
|
+
return await self._send(method, path, **kwargs)
|
|
120
|
+
except RateLimitError as exc:
|
|
121
|
+
if attempt >= _RATE_LIMIT_MAX_RETRIES:
|
|
122
|
+
raise
|
|
123
|
+
wait = exc.retry_after or min(2 ** (attempt + 1), 30)
|
|
124
|
+
if self._on_rate_limit_wait:
|
|
125
|
+
await self._on_rate_limit_wait(wait, attempt + 1, _RATE_LIMIT_MAX_RETRIES)
|
|
126
|
+
else:
|
|
127
|
+
await asyncio.sleep(wait)
|
|
128
|
+
raise RateLimitError() # unreachable, satisfies type checker
|
|
129
|
+
|
|
130
|
+
async def _send(
|
|
131
|
+
self,
|
|
132
|
+
method: str,
|
|
133
|
+
path: str,
|
|
134
|
+
*,
|
|
135
|
+
_retried: bool = False,
|
|
136
|
+
**kwargs: Any,
|
|
137
|
+
) -> dict[str, Any]:
|
|
138
|
+
"""Send a single HTTP request, handling auth refresh on 401."""
|
|
139
|
+
try:
|
|
140
|
+
response = await self._client.request(method, path, **kwargs)
|
|
141
|
+
except httpx.TimeoutException as exc:
|
|
142
|
+
raise NetworkError(f"Request timed out: {exc}") from exc
|
|
143
|
+
except httpx.ConnectError as exc:
|
|
144
|
+
raise NetworkError(f"Connection error: {exc}") from exc
|
|
145
|
+
|
|
146
|
+
# Handle 401 with optional token refresh
|
|
147
|
+
if response.status_code == 401 and not _retried:
|
|
148
|
+
if self._on_token_refresh is not None:
|
|
149
|
+
new_token = await self._on_token_refresh()
|
|
150
|
+
if new_token is not None:
|
|
151
|
+
self.update_token(new_token)
|
|
152
|
+
return await self._send(method, path, _retried=True, **kwargs)
|
|
153
|
+
raise AuthError("Authentication failed", status_code=401)
|
|
154
|
+
|
|
155
|
+
return self._parse_response(response)
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _parse_response(response: httpx.Response) -> dict[str, Any]:
|
|
159
|
+
"""Translate HTTP status codes to domain exceptions and return JSON."""
|
|
160
|
+
if response.status_code == 429:
|
|
161
|
+
raw = response.headers.get("retry-after")
|
|
162
|
+
retry_after: int | None = int(raw) if raw is not None else None
|
|
163
|
+
raise RateLimitError(retry_after=retry_after)
|
|
164
|
+
|
|
165
|
+
if response.status_code == 408:
|
|
166
|
+
raise VehicleAsleepError("Vehicle is asleep", status_code=408)
|
|
167
|
+
|
|
168
|
+
if response.status_code == 412:
|
|
169
|
+
raise RegistrationRequiredError(
|
|
170
|
+
"Your application is not registered with the Tesla Fleet API "
|
|
171
|
+
"for this region. Run 'tescmd auth register' to fix this.",
|
|
172
|
+
status_code=412,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if response.status_code == 403:
|
|
176
|
+
text = response.text[:200]
|
|
177
|
+
if "missing scopes" in text.lower():
|
|
178
|
+
raise MissingScopesError(text, status_code=403)
|
|
179
|
+
raise AuthError(f"HTTP 403: {text}", status_code=403)
|
|
180
|
+
|
|
181
|
+
if response.status_code >= 400:
|
|
182
|
+
text = response.text[:200]
|
|
183
|
+
raise TeslaAPIError(
|
|
184
|
+
f"HTTP {response.status_code}: {text}",
|
|
185
|
+
status_code=response.status_code,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
result: dict[str, Any] = response.json()
|
|
189
|
+
return result
|