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.
Files changed (47) hide show
  1. tescmd/__init__.py +1 -1
  2. tescmd/api/client.py +8 -1
  3. tescmd/api/errors.py +8 -0
  4. tescmd/api/vehicle.py +19 -1
  5. tescmd/cache/response_cache.py +3 -2
  6. tescmd/cli/auth.py +30 -2
  7. tescmd/cli/key.py +149 -14
  8. tescmd/cli/main.py +44 -0
  9. tescmd/cli/setup.py +230 -22
  10. tescmd/cli/vehicle.py +464 -1
  11. tescmd/crypto/__init__.py +3 -1
  12. tescmd/crypto/ecdh.py +9 -0
  13. tescmd/crypto/schnorr.py +191 -0
  14. tescmd/deploy/tailscale_serve.py +154 -0
  15. tescmd/models/__init__.py +0 -2
  16. tescmd/models/auth.py +19 -0
  17. tescmd/models/config.py +1 -0
  18. tescmd/models/energy.py +0 -9
  19. tescmd/protocol/session.py +10 -3
  20. tescmd/telemetry/__init__.py +19 -0
  21. tescmd/telemetry/dashboard.py +227 -0
  22. tescmd/telemetry/decoder.py +284 -0
  23. tescmd/telemetry/fields.py +248 -0
  24. tescmd/telemetry/flatbuf.py +162 -0
  25. tescmd/telemetry/protos/__init__.py +4 -0
  26. tescmd/telemetry/protos/vehicle_alert.proto +31 -0
  27. tescmd/telemetry/protos/vehicle_alert_pb2.py +42 -0
  28. tescmd/telemetry/protos/vehicle_alert_pb2.pyi +44 -0
  29. tescmd/telemetry/protos/vehicle_connectivity.proto +23 -0
  30. tescmd/telemetry/protos/vehicle_connectivity_pb2.py +40 -0
  31. tescmd/telemetry/protos/vehicle_connectivity_pb2.pyi +33 -0
  32. tescmd/telemetry/protos/vehicle_data.proto +768 -0
  33. tescmd/telemetry/protos/vehicle_data_pb2.py +136 -0
  34. tescmd/telemetry/protos/vehicle_data_pb2.pyi +1336 -0
  35. tescmd/telemetry/protos/vehicle_error.proto +23 -0
  36. tescmd/telemetry/protos/vehicle_error_pb2.py +44 -0
  37. tescmd/telemetry/protos/vehicle_error_pb2.pyi +39 -0
  38. tescmd/telemetry/protos/vehicle_metric.proto +22 -0
  39. tescmd/telemetry/protos/vehicle_metric_pb2.py +44 -0
  40. tescmd/telemetry/protos/vehicle_metric_pb2.pyi +37 -0
  41. tescmd/telemetry/server.py +293 -0
  42. tescmd/telemetry/tailscale.py +300 -0
  43. {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/METADATA +72 -35
  44. {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/RECORD +47 -22
  45. {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/WHEEL +0 -0
  46. {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/entry_points.txt +0 -0
  47. {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -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
 
@@ -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 and not session.is_expired:
109
- return session
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)