tescmd 0.1.2__py3-none-any.whl → 0.2.0__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 +1 -1
- tescmd/api/client.py +8 -1
- tescmd/api/errors.py +8 -0
- tescmd/api/vehicle.py +19 -1
- tescmd/cache/response_cache.py +3 -2
- tescmd/cli/auth.py +30 -2
- tescmd/cli/key.py +149 -14
- tescmd/cli/main.py +44 -0
- tescmd/cli/setup.py +230 -22
- tescmd/cli/vehicle.py +464 -1
- tescmd/crypto/__init__.py +3 -1
- tescmd/crypto/ecdh.py +9 -0
- tescmd/crypto/schnorr.py +191 -0
- tescmd/deploy/tailscale_serve.py +154 -0
- tescmd/models/__init__.py +0 -2
- tescmd/models/auth.py +19 -0
- tescmd/models/config.py +1 -0
- tescmd/models/energy.py +0 -9
- tescmd/protocol/session.py +10 -3
- tescmd/telemetry/__init__.py +19 -0
- tescmd/telemetry/dashboard.py +227 -0
- tescmd/telemetry/decoder.py +284 -0
- tescmd/telemetry/fields.py +248 -0
- tescmd/telemetry/flatbuf.py +162 -0
- tescmd/telemetry/protos/__init__.py +4 -0
- tescmd/telemetry/protos/vehicle_alert.proto +31 -0
- tescmd/telemetry/protos/vehicle_alert_pb2.py +42 -0
- tescmd/telemetry/protos/vehicle_alert_pb2.pyi +44 -0
- tescmd/telemetry/protos/vehicle_connectivity.proto +23 -0
- tescmd/telemetry/protos/vehicle_connectivity_pb2.py +40 -0
- tescmd/telemetry/protos/vehicle_connectivity_pb2.pyi +33 -0
- tescmd/telemetry/protos/vehicle_data.proto +768 -0
- tescmd/telemetry/protos/vehicle_data_pb2.py +136 -0
- tescmd/telemetry/protos/vehicle_data_pb2.pyi +1336 -0
- tescmd/telemetry/protos/vehicle_error.proto +23 -0
- tescmd/telemetry/protos/vehicle_error_pb2.py +44 -0
- tescmd/telemetry/protos/vehicle_error_pb2.pyi +39 -0
- tescmd/telemetry/protos/vehicle_metric.proto +22 -0
- tescmd/telemetry/protos/vehicle_metric_pb2.py +44 -0
- tescmd/telemetry/protos/vehicle_metric_pb2.pyi +37 -0
- tescmd/telemetry/server.py +293 -0
- tescmd/telemetry/tailscale.py +300 -0
- {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/METADATA +72 -35
- {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/RECORD +47 -22
- {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/WHEEL +0 -0
- {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/entry_points.txt +0 -0
- {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/licenses/LICENSE +0 -0
tescmd/crypto/schnorr.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Schnorr signatures over NIST P-256 for Tesla Fleet Telemetry JWS.
|
|
2
|
+
|
|
3
|
+
Implements the ``Tesla.SS256`` signing algorithm used by the Vehicle
|
|
4
|
+
Command HTTP Proxy to sign fleet telemetry configurations. This lets
|
|
5
|
+
tescmd call the ``fleet_telemetry_config_jws`` endpoint directly,
|
|
6
|
+
without requiring the Go-based ``tesla-http-proxy`` binary.
|
|
7
|
+
|
|
8
|
+
Algorithm reference: github.com/teslamotors/vehicle-command
|
|
9
|
+
internal/schnorr/sign.go — Sign()
|
|
10
|
+
internal/schnorr/schnorr.go — challenge(), Verify()
|
|
11
|
+
internal/authentication/jwt.go — SignMessage()
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import hashlib
|
|
18
|
+
import hmac
|
|
19
|
+
import json
|
|
20
|
+
import struct
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
24
|
+
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# P-256 curve constants
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
P256_ORDER = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
|
|
31
|
+
|
|
32
|
+
_P256_GX = 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296
|
|
33
|
+
_P256_GY = 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5
|
|
34
|
+
|
|
35
|
+
# Generator point G in uncompressed X9.62 form (04 || X || Y).
|
|
36
|
+
P256_GENERATOR_BYTES = b"\x04" + _P256_GX.to_bytes(32, "big") + _P256_GY.to_bytes(32, "big")
|
|
37
|
+
|
|
38
|
+
_SCALAR_LEN = 32
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Low-level Schnorr primitives
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _deterministic_nonce(scalar: bytes, message_hash: bytes) -> bytes:
|
|
47
|
+
"""RFC 6979 deterministic nonce generation for P-256 / SHA-256.
|
|
48
|
+
|
|
49
|
+
Mirrors ``DeterministicNonce`` in the Go SDK's ``internal/schnorr/sign.go``.
|
|
50
|
+
"""
|
|
51
|
+
# Reduce message hash mod n
|
|
52
|
+
h1_int = int.from_bytes(message_hash, "big") % P256_ORDER
|
|
53
|
+
h1 = h1_int.to_bytes(32, "big")
|
|
54
|
+
|
|
55
|
+
# HMAC-DRBG initialisation (RFC 6979 S3.2 steps a-d)
|
|
56
|
+
k_mac = b"\x00" * 32
|
|
57
|
+
v = b"\x01" * 32
|
|
58
|
+
|
|
59
|
+
# Step d
|
|
60
|
+
k_mac = hmac.new(k_mac, v + b"\x00" + scalar + h1, hashlib.sha256).digest()
|
|
61
|
+
# Step e
|
|
62
|
+
v = hmac.new(k_mac, v, hashlib.sha256).digest()
|
|
63
|
+
# Step f
|
|
64
|
+
k_mac = hmac.new(k_mac, v + b"\x01" + scalar + h1, hashlib.sha256).digest()
|
|
65
|
+
# Step g
|
|
66
|
+
v = hmac.new(k_mac, v, hashlib.sha256).digest()
|
|
67
|
+
|
|
68
|
+
# Step h — generate candidates
|
|
69
|
+
while True:
|
|
70
|
+
v = hmac.new(k_mac, v, hashlib.sha256).digest()
|
|
71
|
+
nonce_int = int.from_bytes(v, "big")
|
|
72
|
+
if 0 < nonce_int < P256_ORDER:
|
|
73
|
+
return v
|
|
74
|
+
|
|
75
|
+
# Retry per spec
|
|
76
|
+
k_mac = hmac.new(k_mac, v + b"\x00", hashlib.sha256).digest()
|
|
77
|
+
v = hmac.new(k_mac, v, hashlib.sha256).digest()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _write_length_value(h: Any, data: bytes) -> None:
|
|
81
|
+
"""Write a 4-byte big-endian length prefix then the data into *h*."""
|
|
82
|
+
h.update(struct.pack(">I", len(data)))
|
|
83
|
+
h.update(data)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _challenge(
|
|
87
|
+
public_nonce_uncompressed: bytes,
|
|
88
|
+
sender_public_uncompressed: bytes,
|
|
89
|
+
message: bytes,
|
|
90
|
+
) -> bytes:
|
|
91
|
+
"""Compute the Schnorr challenge hash.
|
|
92
|
+
|
|
93
|
+
``SHA-256(len(G) || G || len(R) || R || len(P) || P || len(m) || m)``
|
|
94
|
+
|
|
95
|
+
Mirrors ``challenge()`` in ``internal/schnorr/schnorr.go``.
|
|
96
|
+
"""
|
|
97
|
+
h = hashlib.sha256()
|
|
98
|
+
_write_length_value(h, P256_GENERATOR_BYTES)
|
|
99
|
+
_write_length_value(h, public_nonce_uncompressed)
|
|
100
|
+
_write_length_value(h, sender_public_uncompressed)
|
|
101
|
+
_write_length_value(h, message)
|
|
102
|
+
return h.digest()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def schnorr_sign(
|
|
106
|
+
private_key: ec.EllipticCurvePrivateKey,
|
|
107
|
+
message: bytes,
|
|
108
|
+
) -> bytes:
|
|
109
|
+
"""Produce a 96-byte Tesla Schnorr/P-256 signature.
|
|
110
|
+
|
|
111
|
+
Returns ``nonce_X (32) || nonce_Y (32) || r (32)``.
|
|
112
|
+
"""
|
|
113
|
+
# Private scalar bytes (big-endian, zero-padded to 32 bytes)
|
|
114
|
+
priv_numbers = private_key.private_numbers()
|
|
115
|
+
scalar = priv_numbers.private_value.to_bytes(_SCALAR_LEN, "big")
|
|
116
|
+
|
|
117
|
+
# Public key (uncompressed X9.62)
|
|
118
|
+
sender_public = private_key.public_key().public_bytes(
|
|
119
|
+
Encoding.X962, PublicFormat.UncompressedPoint
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# 1. Deterministic nonce
|
|
123
|
+
digest = hashlib.sha256(message).digest()
|
|
124
|
+
nonce_bytes = _deterministic_nonce(scalar, digest)
|
|
125
|
+
nonce_int = int.from_bytes(nonce_bytes, "big")
|
|
126
|
+
|
|
127
|
+
# 2. Nonce public key (k·G)
|
|
128
|
+
nonce_key = ec.derive_private_key(nonce_int, ec.SECP256R1())
|
|
129
|
+
nonce_public = nonce_key.public_key().public_bytes(
|
|
130
|
+
Encoding.X962, PublicFormat.UncompressedPoint
|
|
131
|
+
) # 65 bytes: 04 || X || Y
|
|
132
|
+
|
|
133
|
+
# 3. Challenge c = H(G, R, P, m)
|
|
134
|
+
c_bytes = _challenge(nonce_public, sender_public, message)
|
|
135
|
+
c_int = int.from_bytes(c_bytes, "big")
|
|
136
|
+
|
|
137
|
+
# 4. Response r = k - x*c (mod n)
|
|
138
|
+
x_int = priv_numbers.private_value
|
|
139
|
+
r_int = (nonce_int - x_int * c_int) % P256_ORDER
|
|
140
|
+
|
|
141
|
+
# 5. Signature = nonce_X || nonce_Y || r (strip 0x04 prefix)
|
|
142
|
+
sig = nonce_public[1:] + r_int.to_bytes(_SCALAR_LEN, "big")
|
|
143
|
+
assert len(sig) == 3 * _SCALAR_LEN
|
|
144
|
+
return sig
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# JWS token construction (Tesla.SS256)
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
_JWS_HEADER = {"alg": "Tesla.SS256", "typ": "JWT"}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _b64url(data: bytes) -> str:
|
|
155
|
+
"""Base64url-encode without padding (per RFC 7515)."""
|
|
156
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def sign_fleet_telemetry_config(
|
|
160
|
+
private_key: ec.EllipticCurvePrivateKey,
|
|
161
|
+
config: dict[str, Any],
|
|
162
|
+
) -> str:
|
|
163
|
+
"""Create a JWS token for a fleet telemetry config payload.
|
|
164
|
+
|
|
165
|
+
The *config* dict (hostname, ca, fields, alert_types) is signed with the
|
|
166
|
+
Tesla.SS256 algorithm. ``iss`` and ``aud`` claims are set automatically.
|
|
167
|
+
|
|
168
|
+
Returns the compact JWS string for the ``fleet_telemetry_config_jws``
|
|
169
|
+
endpoint's ``token`` field.
|
|
170
|
+
"""
|
|
171
|
+
# Build claims — shallow copy so we don't mutate the caller's dict
|
|
172
|
+
claims: dict[str, Any] = dict(config)
|
|
173
|
+
|
|
174
|
+
# iss = base64(uncompressed public key bytes)
|
|
175
|
+
pub_bytes = private_key.public_key().public_bytes(
|
|
176
|
+
Encoding.X962, PublicFormat.UncompressedPoint
|
|
177
|
+
)
|
|
178
|
+
claims["iss"] = base64.standard_b64encode(pub_bytes).decode("ascii")
|
|
179
|
+
claims["aud"] = "com.tesla.fleet.TelemetryClient"
|
|
180
|
+
|
|
181
|
+
# Serialise header & payload
|
|
182
|
+
header_b64 = _b64url(json.dumps(_JWS_HEADER, separators=(",", ":")).encode())
|
|
183
|
+
payload_b64 = _b64url(json.dumps(claims, separators=(",", ":")).encode())
|
|
184
|
+
|
|
185
|
+
signing_input = f"{header_b64}.{payload_b64}".encode("ascii")
|
|
186
|
+
|
|
187
|
+
# Sign
|
|
188
|
+
sig = schnorr_sign(private_key, signing_input)
|
|
189
|
+
sig_b64 = _b64url(sig)
|
|
190
|
+
|
|
191
|
+
return f"{header_b64}.{payload_b64}.{sig_b64}"
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Tailscale Funnel deployment helpers for Tesla Fleet API public keys.
|
|
2
|
+
|
|
3
|
+
Serves the public key at the Tesla-required ``.well-known`` path via
|
|
4
|
+
Tailscale's ``serve`` + ``funnel`` commands. All Tailscale interaction
|
|
5
|
+
goes through :class:`~tescmd.telemetry.tailscale.TailscaleManager`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from tescmd.api.errors import TailscaleError
|
|
18
|
+
from tescmd.telemetry.tailscale import TailscaleManager
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
WELL_KNOWN_PATH = ".well-known/appspecific/com.tesla.3p.public-key.pem"
|
|
23
|
+
DEFAULT_SERVE_DIR = Path("~/.config/tescmd/serve")
|
|
24
|
+
|
|
25
|
+
# Polling for deployment validation
|
|
26
|
+
DEFAULT_DEPLOY_TIMEOUT = 60 # seconds (faster than GitHub Pages)
|
|
27
|
+
POLL_INTERVAL = 3 # seconds
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Key file management
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def deploy_public_key_tailscale(
|
|
36
|
+
public_key_pem: str,
|
|
37
|
+
serve_dir: Path | None = None,
|
|
38
|
+
) -> Path:
|
|
39
|
+
"""Write the PEM key into the serve directory structure.
|
|
40
|
+
|
|
41
|
+
Creates ``<serve_dir>/.well-known/appspecific/com.tesla.3p.public-key.pem``.
|
|
42
|
+
|
|
43
|
+
Returns the path to the written key file.
|
|
44
|
+
"""
|
|
45
|
+
base = (serve_dir or DEFAULT_SERVE_DIR).expanduser()
|
|
46
|
+
key_path = base / WELL_KNOWN_PATH
|
|
47
|
+
key_path.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
key_path.write_text(public_key_pem)
|
|
49
|
+
logger.info("Public key written to %s", key_path)
|
|
50
|
+
return key_path
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Serve / Funnel lifecycle
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def start_key_serving(serve_dir: Path | None = None) -> str:
|
|
59
|
+
"""Start ``tailscale serve`` for ``.well-known`` and enable Funnel.
|
|
60
|
+
|
|
61
|
+
Returns the public hostname (e.g. ``machine.tailnet.ts.net``).
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
TailscaleError: If Tailscale is not ready or Funnel cannot start.
|
|
65
|
+
"""
|
|
66
|
+
base = (serve_dir or DEFAULT_SERVE_DIR).expanduser()
|
|
67
|
+
well_known_dir = base / ".well-known"
|
|
68
|
+
if not well_known_dir.exists():
|
|
69
|
+
raise TailscaleError(
|
|
70
|
+
f"Serve directory not found: {well_known_dir}. "
|
|
71
|
+
"Run deploy_public_key_tailscale() first."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
ts = TailscaleManager()
|
|
75
|
+
await ts.check_available()
|
|
76
|
+
hostname = await ts.get_hostname()
|
|
77
|
+
|
|
78
|
+
# Serve the .well-known directory at /.well-known/
|
|
79
|
+
await ts.start_serve("/.well-known/", str(well_known_dir))
|
|
80
|
+
|
|
81
|
+
# Enable Funnel to make it publicly accessible
|
|
82
|
+
await ts.enable_funnel()
|
|
83
|
+
|
|
84
|
+
logger.info("Key serving started at https://%s/%s", hostname, WELL_KNOWN_PATH)
|
|
85
|
+
return hostname
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def stop_key_serving() -> None:
|
|
89
|
+
"""Remove the ``.well-known`` serve handler."""
|
|
90
|
+
ts = TailscaleManager()
|
|
91
|
+
await ts.stop_serve("/.well-known/")
|
|
92
|
+
logger.info("Key serving stopped")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Readiness check
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def is_tailscale_serve_ready() -> bool:
|
|
101
|
+
"""Quick check: CLI on PATH + daemon running + Funnel available.
|
|
102
|
+
|
|
103
|
+
Returns bool, never raises.
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
ts = TailscaleManager()
|
|
107
|
+
await ts.check_available()
|
|
108
|
+
await ts.check_running()
|
|
109
|
+
return await ts.check_funnel_available()
|
|
110
|
+
except (TailscaleError, Exception):
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# URL helpers and validation
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def get_key_url(hostname: str) -> str:
|
|
120
|
+
"""Return full URL to the public key."""
|
|
121
|
+
return f"https://{hostname}/{WELL_KNOWN_PATH}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def validate_tailscale_key_url(hostname: str) -> bool:
|
|
125
|
+
"""HTTP GET to verify key is accessible.
|
|
126
|
+
|
|
127
|
+
Returns True if the key is reachable and contains PEM content.
|
|
128
|
+
"""
|
|
129
|
+
url = get_key_url(hostname)
|
|
130
|
+
try:
|
|
131
|
+
async with httpx.AsyncClient() as client:
|
|
132
|
+
resp = await client.get(url, follow_redirects=True, timeout=10)
|
|
133
|
+
return resp.status_code == 200 and "BEGIN PUBLIC KEY" in resp.text
|
|
134
|
+
except httpx.HTTPError:
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def wait_for_tailscale_deployment(
|
|
139
|
+
hostname: str,
|
|
140
|
+
*,
|
|
141
|
+
timeout: int = DEFAULT_DEPLOY_TIMEOUT,
|
|
142
|
+
) -> bool:
|
|
143
|
+
"""Poll key URL until accessible or *timeout* elapses.
|
|
144
|
+
|
|
145
|
+
Returns True if the key became accessible, False on timeout.
|
|
146
|
+
"""
|
|
147
|
+
deadline = time.monotonic() + timeout
|
|
148
|
+
|
|
149
|
+
while time.monotonic() < deadline:
|
|
150
|
+
if await validate_tailscale_key_url(hostname):
|
|
151
|
+
return True
|
|
152
|
+
await asyncio.sleep(POLL_INTERVAL)
|
|
153
|
+
|
|
154
|
+
return False
|
tescmd/models/__init__.py
CHANGED
|
@@ -19,7 +19,6 @@ from tescmd.models.command import CommandResponse, CommandResult
|
|
|
19
19
|
from tescmd.models.config import AppSettings, Profile
|
|
20
20
|
from tescmd.models.energy import (
|
|
21
21
|
CalendarHistory,
|
|
22
|
-
EnergySite,
|
|
23
22
|
GridImportExportConfig,
|
|
24
23
|
LiveStatus,
|
|
25
24
|
SiteInfo,
|
|
@@ -61,7 +60,6 @@ __all__ = [
|
|
|
61
60
|
"CommandResult",
|
|
62
61
|
"DestChargerInfo",
|
|
63
62
|
"DriveState",
|
|
64
|
-
"EnergySite",
|
|
65
63
|
"FeatureConfig",
|
|
66
64
|
"GridImportExportConfig",
|
|
67
65
|
"GuiSettings",
|
tescmd/models/auth.py
CHANGED
|
@@ -17,6 +17,7 @@ VEHICLE_SCOPES: list[str] = [
|
|
|
17
17
|
"vehicle_device_data",
|
|
18
18
|
"vehicle_cmds",
|
|
19
19
|
"vehicle_charging_cmds",
|
|
20
|
+
"vehicle_location",
|
|
20
21
|
]
|
|
21
22
|
|
|
22
23
|
ENERGY_SCOPES: list[str] = [
|
|
@@ -99,6 +100,24 @@ def decode_jwt_scopes(token: str) -> list[str] | None:
|
|
|
99
100
|
return None
|
|
100
101
|
|
|
101
102
|
|
|
103
|
+
def decode_jwt_payload(token: str) -> dict[str, Any] | None:
|
|
104
|
+
"""Decode the full JWT payload without verifying the signature.
|
|
105
|
+
|
|
106
|
+
Returns the payload dict, or ``None`` if the token isn't a valid JWT.
|
|
107
|
+
"""
|
|
108
|
+
parts = token.split(".")
|
|
109
|
+
if len(parts) != 3:
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
|
|
113
|
+
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
|
114
|
+
payload: dict[str, Any] = json.loads(payload_bytes)
|
|
115
|
+
except (ValueError, json.JSONDecodeError):
|
|
116
|
+
logger.debug("Failed to decode JWT payload")
|
|
117
|
+
return None
|
|
118
|
+
return payload
|
|
119
|
+
|
|
120
|
+
|
|
102
121
|
class AuthConfig(BaseModel):
|
|
103
122
|
"""Configuration needed to start an OAuth flow."""
|
|
104
123
|
|
tescmd/models/config.py
CHANGED
|
@@ -43,6 +43,7 @@ class AppSettings(BaseSettings):
|
|
|
43
43
|
profile: str = "default"
|
|
44
44
|
setup_tier: str | None = None
|
|
45
45
|
github_repo: str | None = None
|
|
46
|
+
hosting_method: str | None = None # "github" | "tailscale" | None (manual)
|
|
46
47
|
access_token: str | None = None
|
|
47
48
|
refresh_token: str | None = None
|
|
48
49
|
|
tescmd/models/energy.py
CHANGED
|
@@ -9,15 +9,6 @@ from pydantic import BaseModel, ConfigDict
|
|
|
9
9
|
_EXTRA_ALLOW = ConfigDict(extra="allow")
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
class EnergySite(BaseModel):
|
|
13
|
-
model_config = _EXTRA_ALLOW
|
|
14
|
-
|
|
15
|
-
energy_site_id: int
|
|
16
|
-
resource_type: str | None = None
|
|
17
|
-
site_name: str | None = None
|
|
18
|
-
gateway_id: str | None = None
|
|
19
|
-
|
|
20
|
-
|
|
21
12
|
class LiveStatus(BaseModel):
|
|
22
13
|
model_config = _EXTRA_ALLOW
|
|
23
14
|
|
tescmd/protocol/session.py
CHANGED
|
@@ -72,7 +72,12 @@ class Session:
|
|
|
72
72
|
return time.monotonic() - self.created_at > self.ttl
|
|
73
73
|
|
|
74
74
|
def next_counter(self) -> int:
|
|
75
|
-
"""Return and increment the anti-replay counter.
|
|
75
|
+
"""Return and increment the anti-replay counter.
|
|
76
|
+
|
|
77
|
+
This is safe because all callers run on the same asyncio event
|
|
78
|
+
loop and ``next_counter`` is synchronous — no ``await`` between
|
|
79
|
+
the read and the increment means no interleaving is possible.
|
|
80
|
+
"""
|
|
76
81
|
self.counter += 1
|
|
77
82
|
return self.counter
|
|
78
83
|
|
|
@@ -105,8 +110,10 @@ class SessionManager:
|
|
|
105
110
|
"""Return a valid session, performing a handshake if needed."""
|
|
106
111
|
key = (vin, domain)
|
|
107
112
|
session = self._sessions.get(key)
|
|
108
|
-
if session is not None
|
|
109
|
-
|
|
113
|
+
if session is not None:
|
|
114
|
+
if not session.is_expired:
|
|
115
|
+
return session
|
|
116
|
+
del self._sessions[key]
|
|
110
117
|
|
|
111
118
|
session = await self._handshake(vin, domain)
|
|
112
119
|
self._sessions[key] = session
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Fleet Telemetry streaming — WebSocket server, decoder, and dashboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from tescmd.telemetry.decoder import TelemetryDatum, TelemetryDecoder, TelemetryFrame
|
|
6
|
+
from tescmd.telemetry.fields import FIELD_NAMES, PRESETS, resolve_fields
|
|
7
|
+
from tescmd.telemetry.server import TelemetryServer
|
|
8
|
+
from tescmd.telemetry.tailscale import TailscaleManager
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"FIELD_NAMES",
|
|
12
|
+
"PRESETS",
|
|
13
|
+
"TailscaleManager",
|
|
14
|
+
"TelemetryDatum",
|
|
15
|
+
"TelemetryDecoder",
|
|
16
|
+
"TelemetryFrame",
|
|
17
|
+
"TelemetryServer",
|
|
18
|
+
"resolve_fields",
|
|
19
|
+
]
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Rich Live TUI dashboard for real-time telemetry display."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from rich.console import Console, RenderableType
|
|
14
|
+
from rich.live import Live
|
|
15
|
+
|
|
16
|
+
from tescmd.output.rich_output import DisplayUnits
|
|
17
|
+
from tescmd.telemetry.decoder import TelemetryFrame
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TelemetryDashboard:
|
|
21
|
+
"""Rich Live TUI renderer for streaming telemetry data."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, console: Console, units: DisplayUnits) -> None:
|
|
24
|
+
self._console = console
|
|
25
|
+
self._units = units
|
|
26
|
+
self._state: dict[str, Any] = {}
|
|
27
|
+
self._timestamps: dict[str, datetime] = {}
|
|
28
|
+
self._frame_count: int = 0
|
|
29
|
+
self._vin: str = ""
|
|
30
|
+
self._started_at: datetime = datetime.now(tz=UTC)
|
|
31
|
+
self._live: Live | None = None
|
|
32
|
+
self._connected: bool = False
|
|
33
|
+
self._tunnel_url: str = ""
|
|
34
|
+
|
|
35
|
+
def update(self, frame: TelemetryFrame) -> None:
|
|
36
|
+
"""Ingest a telemetry frame and refresh the display."""
|
|
37
|
+
self._frame_count += 1
|
|
38
|
+
self._connected = True
|
|
39
|
+
if frame.vin:
|
|
40
|
+
self._vin = frame.vin
|
|
41
|
+
|
|
42
|
+
for datum in frame.data:
|
|
43
|
+
self._state[datum.field_name] = datum.value
|
|
44
|
+
self._timestamps[datum.field_name] = frame.created_at
|
|
45
|
+
|
|
46
|
+
# Trigger an immediate refresh so data appears without waiting for the
|
|
47
|
+
# next auto-refresh tick. We do NOT call live.update(self.render())
|
|
48
|
+
# because that would replace the Live renderable with a static snapshot,
|
|
49
|
+
# breaking the uptime counter between frames. The Live object already
|
|
50
|
+
# holds a reference to `self` (via __rich__), so refresh() re-renders
|
|
51
|
+
# the dashboard with the freshly updated state.
|
|
52
|
+
if self._live is not None:
|
|
53
|
+
self._live.refresh()
|
|
54
|
+
|
|
55
|
+
def set_live(self, live: Live) -> None:
|
|
56
|
+
"""Attach a Rich Live instance for auto-refresh."""
|
|
57
|
+
self._live = live
|
|
58
|
+
|
|
59
|
+
def set_tunnel_url(self, url: str) -> None:
|
|
60
|
+
"""Set the tunnel URL for display."""
|
|
61
|
+
self._tunnel_url = url
|
|
62
|
+
|
|
63
|
+
def __rich__(self) -> RenderableType:
|
|
64
|
+
"""Allow Rich Live to call render() on every refresh tick."""
|
|
65
|
+
return self.render()
|
|
66
|
+
|
|
67
|
+
def render(self) -> RenderableType:
|
|
68
|
+
"""Build the full dashboard renderable."""
|
|
69
|
+
from rich.console import Group
|
|
70
|
+
|
|
71
|
+
parts: list[RenderableType] = []
|
|
72
|
+
|
|
73
|
+
# Header panel
|
|
74
|
+
parts.append(self._render_header())
|
|
75
|
+
|
|
76
|
+
# Data table
|
|
77
|
+
if self._state:
|
|
78
|
+
parts.append(self._render_data_table())
|
|
79
|
+
else:
|
|
80
|
+
parts.append(
|
|
81
|
+
Panel(
|
|
82
|
+
"[dim]Waiting for telemetry data...[/dim]",
|
|
83
|
+
title="Telemetry Data",
|
|
84
|
+
border_style="dim",
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Footer
|
|
89
|
+
parts.append(self._render_footer())
|
|
90
|
+
|
|
91
|
+
return Group(*parts)
|
|
92
|
+
|
|
93
|
+
def _render_header(self) -> Panel:
|
|
94
|
+
"""Render the status header panel."""
|
|
95
|
+
now = datetime.now(tz=UTC)
|
|
96
|
+
uptime = now - self._started_at
|
|
97
|
+
hours, remainder = divmod(int(uptime.total_seconds()), 3600)
|
|
98
|
+
minutes, seconds = divmod(remainder, 60)
|
|
99
|
+
|
|
100
|
+
status_style = "green" if self._connected else "yellow"
|
|
101
|
+
status_text = "Connected" if self._connected else "Waiting"
|
|
102
|
+
|
|
103
|
+
header = Text()
|
|
104
|
+
header.append("VIN: ", style="bold")
|
|
105
|
+
header.append(self._vin or "(waiting)", style="cyan")
|
|
106
|
+
header.append(" | Status: ", style="bold")
|
|
107
|
+
header.append(status_text, style=status_style)
|
|
108
|
+
header.append(" | Frames: ", style="bold")
|
|
109
|
+
header.append(str(self._frame_count), style="cyan")
|
|
110
|
+
header.append(" | Uptime: ", style="bold")
|
|
111
|
+
header.append(f"{hours:02d}:{minutes:02d}:{seconds:02d}", style="cyan")
|
|
112
|
+
|
|
113
|
+
return Panel(header, title="Fleet Telemetry Stream", border_style="blue")
|
|
114
|
+
|
|
115
|
+
def _render_data_table(self) -> Table:
|
|
116
|
+
"""Render the telemetry data table."""
|
|
117
|
+
table = Table(title="Telemetry Data", expand=True)
|
|
118
|
+
table.add_column("Field", style="bold", no_wrap=True)
|
|
119
|
+
table.add_column("Value", style="cyan")
|
|
120
|
+
table.add_column("Last Update", style="dim", no_wrap=True)
|
|
121
|
+
|
|
122
|
+
for field_name in sorted(self._state.keys()):
|
|
123
|
+
value = self._state[field_name]
|
|
124
|
+
display_value = self._format_value(field_name, value)
|
|
125
|
+
ts = self._timestamps.get(field_name)
|
|
126
|
+
ts_str = ts.strftime("%H:%M:%S") if ts else ""
|
|
127
|
+
table.add_row(field_name, display_value, ts_str)
|
|
128
|
+
|
|
129
|
+
return table
|
|
130
|
+
|
|
131
|
+
def _render_footer(self) -> Text:
|
|
132
|
+
"""Render the footer with stream URL and exit hint."""
|
|
133
|
+
footer = Text()
|
|
134
|
+
if self._tunnel_url:
|
|
135
|
+
footer.append(" Stream: ", style="dim")
|
|
136
|
+
footer.append(self._tunnel_url, style="dim cyan")
|
|
137
|
+
footer.append(" | ", style="dim")
|
|
138
|
+
footer.append("Press q or Ctrl+C to stop", style="dim")
|
|
139
|
+
return footer
|
|
140
|
+
|
|
141
|
+
def _format_value(self, field_name: str, value: Any) -> str:
|
|
142
|
+
"""Format a telemetry value with unit conversion where applicable."""
|
|
143
|
+
from tescmd.output.rich_output import DistanceUnit, PressureUnit, TempUnit
|
|
144
|
+
|
|
145
|
+
if value is None:
|
|
146
|
+
return "—"
|
|
147
|
+
|
|
148
|
+
if isinstance(value, dict):
|
|
149
|
+
# Location
|
|
150
|
+
lat = value.get("latitude", 0.0)
|
|
151
|
+
lng = value.get("longitude", 0.0)
|
|
152
|
+
return f"{lat:.6f}, {lng:.6f}"
|
|
153
|
+
|
|
154
|
+
if isinstance(value, bool):
|
|
155
|
+
return "Yes" if value else "No"
|
|
156
|
+
|
|
157
|
+
# Temperature fields (API returns Celsius)
|
|
158
|
+
temp_fields = {
|
|
159
|
+
"InsideTemp",
|
|
160
|
+
"OutsideTemp",
|
|
161
|
+
"DriverTempSetting",
|
|
162
|
+
"PassengerTempSetting",
|
|
163
|
+
"ModuleTempMax",
|
|
164
|
+
"ModuleTempMin",
|
|
165
|
+
}
|
|
166
|
+
if field_name in temp_fields and isinstance(value, (int, float)):
|
|
167
|
+
if self._units.temp == TempUnit.F:
|
|
168
|
+
return f"{value * 9 / 5 + 32:.1f}°F"
|
|
169
|
+
return f"{value:.1f}°C"
|
|
170
|
+
|
|
171
|
+
# Distance fields (API returns miles)
|
|
172
|
+
distance_fields = {
|
|
173
|
+
"Odometer",
|
|
174
|
+
"EstBatteryRange",
|
|
175
|
+
"IdealBatteryRange",
|
|
176
|
+
"RatedBatteryRange",
|
|
177
|
+
"MilesToArrival",
|
|
178
|
+
}
|
|
179
|
+
if field_name in distance_fields and isinstance(value, (int, float)):
|
|
180
|
+
if self._units.distance == DistanceUnit.KM:
|
|
181
|
+
return f"{value * 1.60934:.1f} km"
|
|
182
|
+
return f"{value:.1f} mi"
|
|
183
|
+
|
|
184
|
+
# Speed fields (API returns mph)
|
|
185
|
+
speed_fields = {"VehicleSpeed", "CruiseSetSpeed", "MaxSpeedLimit"}
|
|
186
|
+
if field_name in speed_fields and isinstance(value, (int, float)):
|
|
187
|
+
if self._units.distance == DistanceUnit.KM:
|
|
188
|
+
return f"{value * 1.60934:.0f} km/h"
|
|
189
|
+
return f"{value:.0f} mph"
|
|
190
|
+
|
|
191
|
+
# Pressure fields (API returns bar)
|
|
192
|
+
pressure_fields = {
|
|
193
|
+
"TpmsPressureFl",
|
|
194
|
+
"TpmsPressureFr",
|
|
195
|
+
"TpmsPressureRl",
|
|
196
|
+
"TpmsPressureRr",
|
|
197
|
+
}
|
|
198
|
+
if field_name in pressure_fields and isinstance(value, (int, float)):
|
|
199
|
+
if self._units.pressure == PressureUnit.PSI:
|
|
200
|
+
return f"{value * 14.5038:.1f} psi"
|
|
201
|
+
return f"{value:.2f} bar"
|
|
202
|
+
|
|
203
|
+
# Percentage fields
|
|
204
|
+
pct_fields = {"Soc", "BatteryLevel", "ChargeLimitSoc"}
|
|
205
|
+
if field_name in pct_fields and isinstance(value, (int, float)):
|
|
206
|
+
return f"{value}%"
|
|
207
|
+
|
|
208
|
+
# Voltage / current
|
|
209
|
+
if "Voltage" in field_name and isinstance(value, (int, float)):
|
|
210
|
+
return f"{value:.1f} V"
|
|
211
|
+
if ("Current" in field_name or "Amps" in field_name) and isinstance(value, (int, float)):
|
|
212
|
+
return f"{value:.1f} A"
|
|
213
|
+
|
|
214
|
+
# Power
|
|
215
|
+
if "Power" in field_name and isinstance(value, (int, float)):
|
|
216
|
+
return f"{value:.2f} kW"
|
|
217
|
+
|
|
218
|
+
# Time-to-full
|
|
219
|
+
if field_name == "TimeToFullCharge" and isinstance(value, (int, float)):
|
|
220
|
+
hours = int(value)
|
|
221
|
+
mins = int((value - hours) * 60)
|
|
222
|
+
return f"{hours}h {mins}m" if hours else f"{mins}m"
|
|
223
|
+
|
|
224
|
+
if isinstance(value, float):
|
|
225
|
+
return f"{value:.2f}"
|
|
226
|
+
|
|
227
|
+
return str(value)
|