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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """tescmd — A Python CLI for querying and controlling Tesla vehicles via the Fleet API."""
2
+
3
+ __version__ = "0.1.2"
tescmd/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m tescmd"""
2
+
3
+ from tescmd.cli.main import main
4
+
5
+ main()
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
+ )
@@ -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