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/cache/keys.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Cache key generation for response cache entries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def cache_key(vin: str, endpoints: list[str] | None = None) -> str:
|
|
9
|
+
"""Return a filesystem-safe cache key for *vin* and optional *endpoints*.
|
|
10
|
+
|
|
11
|
+
* No endpoints → ``"{vin}_all"``
|
|
12
|
+
* With endpoints → ``"{vin}_{sha256(sorted_semicolon_joined)[:12]}"``
|
|
13
|
+
|
|
14
|
+
Sorting ensures order-independence: ``["a","b"]`` and ``["b","a"]``
|
|
15
|
+
produce the same key.
|
|
16
|
+
"""
|
|
17
|
+
if not endpoints:
|
|
18
|
+
return f"{vin}_all"
|
|
19
|
+
joined = ";".join(sorted(endpoints))
|
|
20
|
+
digest = hashlib.sha256(joined.encode()).hexdigest()[:12]
|
|
21
|
+
return f"{vin}_{digest}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def generic_cache_key(
|
|
25
|
+
scope: str,
|
|
26
|
+
identifier: str,
|
|
27
|
+
endpoint: str,
|
|
28
|
+
params: dict[str, str] | None = None,
|
|
29
|
+
) -> str:
|
|
30
|
+
"""Return a filesystem-safe cache key for any API endpoint.
|
|
31
|
+
|
|
32
|
+
Key format: ``{scope}_{identifier}_{sha256(endpoint+sorted_params)[:12]}``
|
|
33
|
+
|
|
34
|
+
*scope* categorises the entry (``"vin"``, ``"site"``, ``"account"``,
|
|
35
|
+
``"partner"``). *identifier* disambiguates within the scope (a VIN,
|
|
36
|
+
site-id, or ``"global"``). *endpoint* names the API call. Optional
|
|
37
|
+
*params* are hashed into the key so different query parameters produce
|
|
38
|
+
different cache entries.
|
|
39
|
+
|
|
40
|
+
Examples::
|
|
41
|
+
|
|
42
|
+
generic_cache_key("account", "global", "vehicle.list")
|
|
43
|
+
generic_cache_key("vin", "5YJ3E...", "nearby_chargers")
|
|
44
|
+
generic_cache_key("site", "12345", "site_info")
|
|
45
|
+
"""
|
|
46
|
+
parts = endpoint
|
|
47
|
+
if params:
|
|
48
|
+
sorted_params = ";".join(f"{k}={v}" for k, v in sorted(params.items()))
|
|
49
|
+
parts = f"{endpoint};{sorted_params}"
|
|
50
|
+
digest = hashlib.sha256(parts.encode()).hexdigest()[:12]
|
|
51
|
+
return f"{scope}_{identifier}_{digest}"
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""File-based response cache with per-entry TTL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from tescmd.cache.keys import cache_key
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class CacheResult:
|
|
18
|
+
"""Wrapper around cached data that includes freshness metadata."""
|
|
19
|
+
|
|
20
|
+
data: dict[str, Any]
|
|
21
|
+
created_at: float
|
|
22
|
+
expires_at: float
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def age_seconds(self) -> int:
|
|
26
|
+
"""Seconds since the data was cached."""
|
|
27
|
+
return int(time.time() - self.created_at)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def ttl_seconds(self) -> int:
|
|
31
|
+
"""Total TTL that was assigned to this cache entry."""
|
|
32
|
+
return int(self.expires_at - self.created_at)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ResponseCache:
|
|
36
|
+
"""Sync, file-based cache. Each entry is a JSON file under *cache_dir*.
|
|
37
|
+
|
|
38
|
+
Cache files are named ``{vin}_{endpoint_hash}.json`` (data) or
|
|
39
|
+
``{vin}_wake.json`` (wake state). The VIN prefix enables per-vehicle
|
|
40
|
+
clearing via glob.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
cache_dir: Path,
|
|
46
|
+
default_ttl: int = 60,
|
|
47
|
+
enabled: bool = True,
|
|
48
|
+
) -> None:
|
|
49
|
+
self._cache_dir = cache_dir
|
|
50
|
+
self._default_ttl = default_ttl
|
|
51
|
+
self._enabled = enabled
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
# Vehicle data cache
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def get(self, vin: str, endpoints: list[str] | None = None) -> CacheResult | None:
|
|
58
|
+
"""Return cached data with metadata, or ``None`` on miss/expiry."""
|
|
59
|
+
if not self._enabled:
|
|
60
|
+
return None
|
|
61
|
+
path = self._data_path(vin, endpoints)
|
|
62
|
+
return self._read_entry(path)
|
|
63
|
+
|
|
64
|
+
def put(
|
|
65
|
+
self,
|
|
66
|
+
vin: str,
|
|
67
|
+
data: dict[str, Any],
|
|
68
|
+
endpoints: list[str] | None = None,
|
|
69
|
+
ttl: int | None = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Store *data* for *vin*/*endpoints* with the given *ttl*."""
|
|
72
|
+
if not self._enabled:
|
|
73
|
+
return
|
|
74
|
+
path = self._data_path(vin, endpoints)
|
|
75
|
+
self._write_entry(path, data, ttl or self._default_ttl)
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
# Wake state cache
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def get_wake_state(self, vin: str) -> bool:
|
|
82
|
+
"""Return ``True`` if the vehicle was recently confirmed online."""
|
|
83
|
+
if not self._enabled:
|
|
84
|
+
return False
|
|
85
|
+
path = self._wake_path(vin)
|
|
86
|
+
result = self._read_entry(path)
|
|
87
|
+
if result is None:
|
|
88
|
+
return False
|
|
89
|
+
return result.data.get("state") == "online"
|
|
90
|
+
|
|
91
|
+
def put_wake_state(self, vin: str, state: str, ttl: int = 30) -> None:
|
|
92
|
+
"""Cache the vehicle's wake *state* (e.g. ``"online"``)."""
|
|
93
|
+
if not self._enabled:
|
|
94
|
+
return
|
|
95
|
+
path = self._wake_path(vin)
|
|
96
|
+
self._write_entry(path, {"state": state}, ttl)
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# Generic cache (any scope / endpoint)
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def get_generic(self, key: str) -> CacheResult | None:
|
|
103
|
+
"""Return cached data for a pre-computed *key*, or ``None`` on miss/expiry."""
|
|
104
|
+
if not self._enabled:
|
|
105
|
+
return None
|
|
106
|
+
path = self._cache_dir / f"{key}.json"
|
|
107
|
+
return self._read_entry(path)
|
|
108
|
+
|
|
109
|
+
def put_generic(self, key: str, data: dict[str, Any], ttl: int | None = None) -> None:
|
|
110
|
+
"""Store *data* under a pre-computed *key* with the given *ttl*."""
|
|
111
|
+
if not self._enabled:
|
|
112
|
+
return
|
|
113
|
+
path = self._cache_dir / f"{key}.json"
|
|
114
|
+
self._write_entry(path, data, ttl or self._default_ttl)
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
# Cache management
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def clear(self, vin: str | None = None) -> int:
|
|
121
|
+
"""Delete cache entries. If *vin* is given, only that vehicle's files.
|
|
122
|
+
|
|
123
|
+
Returns the number of files removed.
|
|
124
|
+
"""
|
|
125
|
+
if not self._cache_dir.is_dir():
|
|
126
|
+
return 0
|
|
127
|
+
pattern = f"{vin}_*.json" if vin else "*.json"
|
|
128
|
+
removed = 0
|
|
129
|
+
for path in self._cache_dir.glob(pattern):
|
|
130
|
+
path.unlink(missing_ok=True)
|
|
131
|
+
removed += 1
|
|
132
|
+
return removed
|
|
133
|
+
|
|
134
|
+
def clear_by_prefix(self, prefix: str) -> int:
|
|
135
|
+
"""Delete cache entries whose filename starts with *prefix*.
|
|
136
|
+
|
|
137
|
+
Returns the number of files removed. Used for scope-based
|
|
138
|
+
invalidation (e.g. ``clear_by_prefix("site_12345_")``).
|
|
139
|
+
"""
|
|
140
|
+
if not self._cache_dir.is_dir():
|
|
141
|
+
return 0
|
|
142
|
+
removed = 0
|
|
143
|
+
for path in self._cache_dir.glob(f"{prefix}*.json"):
|
|
144
|
+
path.unlink(missing_ok=True)
|
|
145
|
+
removed += 1
|
|
146
|
+
return removed
|
|
147
|
+
|
|
148
|
+
def status(self) -> dict[str, Any]:
|
|
149
|
+
"""Return cache statistics: enabled, dir, ttl, counts, disk usage."""
|
|
150
|
+
result: dict[str, Any] = {
|
|
151
|
+
"enabled": self._enabled,
|
|
152
|
+
"cache_dir": str(self._cache_dir),
|
|
153
|
+
"default_ttl": self._default_ttl,
|
|
154
|
+
"total": 0,
|
|
155
|
+
"fresh": 0,
|
|
156
|
+
"stale": 0,
|
|
157
|
+
"disk_bytes": 0,
|
|
158
|
+
}
|
|
159
|
+
if not self._cache_dir.is_dir():
|
|
160
|
+
return result
|
|
161
|
+
now = time.time()
|
|
162
|
+
for path in self._cache_dir.glob("*.json"):
|
|
163
|
+
result["total"] += 1
|
|
164
|
+
result["disk_bytes"] += path.stat().st_size
|
|
165
|
+
entry = self._read_json(path)
|
|
166
|
+
if entry and entry.get("expires_at", 0) > now:
|
|
167
|
+
result["fresh"] += 1
|
|
168
|
+
else:
|
|
169
|
+
result["stale"] += 1
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
# Internal helpers
|
|
174
|
+
# ------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def _data_path(self, vin: str, endpoints: list[str] | None) -> Path:
|
|
177
|
+
return self._cache_dir / f"{cache_key(vin, endpoints)}.json"
|
|
178
|
+
|
|
179
|
+
def _wake_path(self, vin: str) -> Path:
|
|
180
|
+
return self._cache_dir / f"{vin}_wake.json"
|
|
181
|
+
|
|
182
|
+
def _read_entry(self, path: Path) -> CacheResult | None:
|
|
183
|
+
"""Read and validate a cache entry. Returns ``None`` if missing or expired."""
|
|
184
|
+
entry = self._read_json(path)
|
|
185
|
+
if entry is None:
|
|
186
|
+
return None
|
|
187
|
+
expires_at = entry.get("expires_at", 0)
|
|
188
|
+
if expires_at <= time.time():
|
|
189
|
+
path.unlink(missing_ok=True)
|
|
190
|
+
return None
|
|
191
|
+
return CacheResult(
|
|
192
|
+
data=entry.get("data", {}),
|
|
193
|
+
created_at=entry.get("created_at", 0.0),
|
|
194
|
+
expires_at=expires_at,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def _write_entry(self, path: Path, data: dict[str, Any], ttl: int) -> None:
|
|
198
|
+
self._cache_dir.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
entry = {
|
|
200
|
+
"data": data,
|
|
201
|
+
"created_at": time.time(),
|
|
202
|
+
"expires_at": time.time() + ttl,
|
|
203
|
+
}
|
|
204
|
+
path.write_text(json.dumps(entry), encoding="utf-8")
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def _read_json(path: Path) -> dict[str, Any] | None:
|
|
208
|
+
if not path.is_file():
|
|
209
|
+
return None
|
|
210
|
+
try:
|
|
211
|
+
return json.loads(path.read_text(encoding="utf-8")) # type: ignore[no-any-return]
|
|
212
|
+
except (json.JSONDecodeError, OSError):
|
|
213
|
+
return None
|
tescmd/cli/__init__.py
ADDED
|
File without changes
|