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/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