tescmd 0.1.2__py3-none-any.whl → 0.3.1__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 +49 -5
- tescmd/api/command.py +1 -1
- tescmd/api/errors.py +13 -0
- tescmd/api/signed_command.py +19 -14
- tescmd/api/vehicle.py +19 -1
- tescmd/auth/oauth.py +5 -1
- tescmd/auth/server.py +6 -1
- tescmd/auth/token_store.py +8 -1
- tescmd/cache/response_cache.py +11 -3
- tescmd/cli/_client.py +142 -20
- tescmd/cli/_options.py +2 -4
- tescmd/cli/auth.py +121 -11
- tescmd/cli/energy.py +2 -0
- tescmd/cli/key.py +149 -14
- tescmd/cli/main.py +70 -7
- tescmd/cli/mcp_cmd.py +153 -0
- tescmd/cli/nav.py +3 -1
- tescmd/cli/openclaw.py +169 -0
- tescmd/cli/security.py +7 -1
- tescmd/cli/serve.py +923 -0
- tescmd/cli/setup.py +244 -25
- tescmd/cli/sharing.py +2 -0
- tescmd/cli/status.py +1 -1
- tescmd/cli/trunk.py +8 -17
- tescmd/cli/user.py +16 -1
- tescmd/cli/vehicle.py +156 -20
- tescmd/crypto/__init__.py +3 -1
- tescmd/crypto/ecdh.py +9 -0
- tescmd/crypto/schnorr.py +191 -0
- tescmd/deploy/github_pages.py +8 -0
- tescmd/deploy/tailscale_serve.py +154 -0
- tescmd/mcp/__init__.py +7 -0
- tescmd/mcp/server.py +648 -0
- tescmd/models/__init__.py +0 -2
- tescmd/models/auth.py +24 -2
- tescmd/models/config.py +1 -0
- tescmd/models/energy.py +0 -9
- tescmd/openclaw/__init__.py +23 -0
- tescmd/openclaw/bridge.py +330 -0
- tescmd/openclaw/config.py +167 -0
- tescmd/openclaw/dispatcher.py +522 -0
- tescmd/openclaw/emitter.py +175 -0
- tescmd/openclaw/filters.py +123 -0
- tescmd/openclaw/gateway.py +687 -0
- tescmd/openclaw/telemetry_store.py +53 -0
- tescmd/output/rich_output.py +46 -14
- tescmd/protocol/commands.py +2 -2
- tescmd/protocol/encoder.py +16 -13
- tescmd/protocol/payloads.py +132 -11
- tescmd/protocol/session.py +18 -8
- tescmd/protocol/signer.py +3 -17
- tescmd/telemetry/__init__.py +28 -0
- tescmd/telemetry/cache_sink.py +154 -0
- tescmd/telemetry/csv_sink.py +180 -0
- tescmd/telemetry/dashboard.py +227 -0
- tescmd/telemetry/decoder.py +284 -0
- tescmd/telemetry/fanout.py +49 -0
- tescmd/telemetry/fields.py +427 -0
- tescmd/telemetry/flatbuf.py +162 -0
- tescmd/telemetry/mapper.py +239 -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 +300 -0
- tescmd/telemetry/setup.py +468 -0
- tescmd/telemetry/tailscale.py +300 -0
- tescmd/telemetry/tui.py +1716 -0
- tescmd/triggers/__init__.py +18 -0
- tescmd/triggers/manager.py +264 -0
- tescmd/triggers/models.py +93 -0
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/METADATA +125 -40
- tescmd-0.3.1.dist-info/RECORD +128 -0
- tescmd-0.1.2.dist-info/RECORD +0 -81
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Cache-warming telemetry sink.
|
|
2
|
+
|
|
3
|
+
Receives decoded telemetry frames, translates field values via
|
|
4
|
+
:class:`~tescmd.telemetry.mapper.TelemetryMapper`, and merges them into
|
|
5
|
+
a :class:`~tescmd.cache.response_cache.ResponseCache`. This keeps the
|
|
6
|
+
MCP tool cache warm so read operations are free while telemetry is active.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from tescmd.cache.response_cache import ResponseCache
|
|
17
|
+
from tescmd.telemetry.decoder import TelemetryFrame
|
|
18
|
+
from tescmd.telemetry.mapper import TelemetryMapper
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _deep_set(target: dict[str, Any], dotted_path: str, value: Any) -> None:
|
|
24
|
+
"""Set a value in a nested dict using a dotted path.
|
|
25
|
+
|
|
26
|
+
Creates intermediate dicts as needed.
|
|
27
|
+
|
|
28
|
+
>>> d: dict[str, Any] = {}
|
|
29
|
+
>>> _deep_set(d, "charge_state.battery_level", 80)
|
|
30
|
+
>>> d
|
|
31
|
+
{'charge_state': {'battery_level': 80}}
|
|
32
|
+
"""
|
|
33
|
+
keys = dotted_path.split(".")
|
|
34
|
+
for key in keys[:-1]:
|
|
35
|
+
if key not in target or not isinstance(target[key], dict):
|
|
36
|
+
target[key] = {}
|
|
37
|
+
target = target[key]
|
|
38
|
+
target[keys[-1]] = value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> None:
|
|
42
|
+
"""Recursively merge *overlay* into *base* (mutates *base*).
|
|
43
|
+
|
|
44
|
+
Dict values are merged recursively; all other values are overwritten.
|
|
45
|
+
"""
|
|
46
|
+
for key, value in overlay.items():
|
|
47
|
+
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
|
|
48
|
+
_deep_merge(base[key], value)
|
|
49
|
+
else:
|
|
50
|
+
base[key] = value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CacheSink:
|
|
54
|
+
"""Telemetry sink that warms the response cache.
|
|
55
|
+
|
|
56
|
+
Accumulates telemetry updates in a buffer and flushes them to the
|
|
57
|
+
:class:`ResponseCache` at a configurable interval. While active,
|
|
58
|
+
cached data is given a generous TTL so MCP reads don't trigger API
|
|
59
|
+
requests.
|
|
60
|
+
|
|
61
|
+
Parameters:
|
|
62
|
+
cache: The file-based response cache to write into.
|
|
63
|
+
mapper: Field mapper for telemetry → VehicleData translation.
|
|
64
|
+
vin: Vehicle VIN to cache data for.
|
|
65
|
+
flush_interval: Minimum seconds between disk flushes.
|
|
66
|
+
telemetry_ttl: TTL in seconds for cache entries while streaming.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
cache: ResponseCache,
|
|
72
|
+
mapper: TelemetryMapper,
|
|
73
|
+
vin: str,
|
|
74
|
+
*,
|
|
75
|
+
flush_interval: float = 1.0,
|
|
76
|
+
telemetry_ttl: int = 120,
|
|
77
|
+
) -> None:
|
|
78
|
+
self._cache = cache
|
|
79
|
+
self._mapper = mapper
|
|
80
|
+
self._vin = vin
|
|
81
|
+
self._flush_interval = flush_interval
|
|
82
|
+
self._telemetry_ttl = telemetry_ttl
|
|
83
|
+
self._pending: dict[str, Any] = {}
|
|
84
|
+
self._last_flush: float = float("-inf")
|
|
85
|
+
self._frame_count: int = 0
|
|
86
|
+
self._field_count: int = 0
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def frame_count(self) -> int:
|
|
90
|
+
"""Total frames processed."""
|
|
91
|
+
return self._frame_count
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def field_count(self) -> int:
|
|
95
|
+
"""Total field updates applied to cache."""
|
|
96
|
+
return self._field_count
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def pending_count(self) -> int:
|
|
100
|
+
"""Number of buffered updates not yet flushed."""
|
|
101
|
+
return self._count_leaves(self._pending)
|
|
102
|
+
|
|
103
|
+
async def on_frame(self, frame: TelemetryFrame) -> None:
|
|
104
|
+
"""Process a decoded telemetry frame into the cache buffer.
|
|
105
|
+
|
|
106
|
+
Skips frames for other VINs. Maps each datum through the
|
|
107
|
+
:class:`TelemetryMapper` and buffers the results for the next
|
|
108
|
+
flush cycle.
|
|
109
|
+
"""
|
|
110
|
+
if frame.vin != self._vin:
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
self._frame_count += 1
|
|
114
|
+
|
|
115
|
+
for datum in frame.data:
|
|
116
|
+
for path, value in self._mapper.map(datum.field_name, datum.value):
|
|
117
|
+
_deep_set(self._pending, path, value)
|
|
118
|
+
self._field_count += 1
|
|
119
|
+
|
|
120
|
+
now = time.monotonic()
|
|
121
|
+
if now - self._last_flush >= self._flush_interval:
|
|
122
|
+
self.flush()
|
|
123
|
+
self._last_flush = now
|
|
124
|
+
|
|
125
|
+
def flush(self) -> None:
|
|
126
|
+
"""Merge buffered updates into the response cache immediately.
|
|
127
|
+
|
|
128
|
+
Called automatically during ``on_frame`` when the flush interval
|
|
129
|
+
has elapsed, or manually for cleanup / testing.
|
|
130
|
+
"""
|
|
131
|
+
if not self._pending:
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
# Read-modify-write: merge into existing cached data
|
|
135
|
+
existing = self._cache.get(self._vin)
|
|
136
|
+
blob: dict[str, Any] = existing.data if existing else {"vin": self._vin, "state": "online"}
|
|
137
|
+
|
|
138
|
+
_deep_merge(blob, self._pending)
|
|
139
|
+
self._cache.put(self._vin, blob, ttl=self._telemetry_ttl)
|
|
140
|
+
self._cache.put_wake_state(self._vin, "online", ttl=self._telemetry_ttl)
|
|
141
|
+
self._pending.clear()
|
|
142
|
+
|
|
143
|
+
logger.debug("Cache flushed for %s (%d fields)", self._vin, self._field_count)
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _count_leaves(d: dict[str, Any]) -> int:
|
|
147
|
+
"""Count leaf (non-dict) values in a nested dict."""
|
|
148
|
+
count = 0
|
|
149
|
+
for v in d.values():
|
|
150
|
+
if isinstance(v, dict):
|
|
151
|
+
count += CacheSink._count_leaves(v)
|
|
152
|
+
else:
|
|
153
|
+
count += 1
|
|
154
|
+
return count
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Wide-format CSV log sink for vehicle telemetry.
|
|
2
|
+
|
|
3
|
+
Writes one row per telemetry frame with one column per subscribed field.
|
|
4
|
+
The header extends dynamically as new fields are discovered.
|
|
5
|
+
|
|
6
|
+
Output format::
|
|
7
|
+
|
|
8
|
+
timestamp,vin,BatteryLevel,ChargeLimitSoc,InsideTemp,...
|
|
9
|
+
2026-02-01T12:34:55Z,5YJ3E...,80,90,22.5,...
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import csv
|
|
15
|
+
import logging
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import IO, TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from tescmd.telemetry.decoder import TelemetryFrame
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# Fixed columns that always appear first.
|
|
26
|
+
_FIXED_COLUMNS = ("timestamp", "vin")
|
|
27
|
+
|
|
28
|
+
# Flush to disk every N frames for crash safety.
|
|
29
|
+
_FLUSH_INTERVAL = 10
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_log_path(vin: str, config_dir: Path | None = None) -> Path:
|
|
33
|
+
"""Build a timestamped CSV log path under the config directory.
|
|
34
|
+
|
|
35
|
+
Returns a path like ``~/.config/tescmd/logs/serve-{VIN}-{YYYYMMDD-HHMMSS}.csv``.
|
|
36
|
+
Creates the ``logs/`` subdirectory if it does not exist.
|
|
37
|
+
"""
|
|
38
|
+
if config_dir is None:
|
|
39
|
+
config_dir = Path.home() / ".config" / "tescmd"
|
|
40
|
+
log_dir = config_dir / "logs"
|
|
41
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
|
|
43
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
44
|
+
return log_dir / f"serve-{vin}-{stamp}.csv"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CSVLogSink:
|
|
48
|
+
"""Telemetry sink that writes wide-format CSV.
|
|
49
|
+
|
|
50
|
+
Raw API values are written without unit conversion (Celsius, miles, bar)
|
|
51
|
+
so the CSV is a faithful record of what the vehicle reported.
|
|
52
|
+
|
|
53
|
+
Parameters:
|
|
54
|
+
path: Destination CSV file path.
|
|
55
|
+
vin: Only log frames matching this VIN (``None`` = log all).
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, path: Path, vin: str | None = None) -> None:
|
|
59
|
+
import atexit
|
|
60
|
+
|
|
61
|
+
self._path = path
|
|
62
|
+
self._vin = vin
|
|
63
|
+
self._fh: IO[str] | None = None
|
|
64
|
+
self._writer: csv.DictWriter[str] | None = None
|
|
65
|
+
self._fieldnames: list[str] = list(_FIXED_COLUMNS)
|
|
66
|
+
self._frame_count: int = 0
|
|
67
|
+
self._since_flush: int = 0
|
|
68
|
+
atexit.register(self.close)
|
|
69
|
+
|
|
70
|
+
# -- Properties -----------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def log_path(self) -> Path:
|
|
74
|
+
"""The CSV file path."""
|
|
75
|
+
return self._path
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def frame_count(self) -> int:
|
|
79
|
+
"""Total frames written."""
|
|
80
|
+
return self._frame_count
|
|
81
|
+
|
|
82
|
+
# -- Sink callback --------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
async def on_frame(self, frame: TelemetryFrame) -> None:
|
|
85
|
+
"""Write a single telemetry frame as a CSV row.
|
|
86
|
+
|
|
87
|
+
Called by :class:`~tescmd.telemetry.fanout.FrameFanout`.
|
|
88
|
+
Frames for other VINs are silently skipped.
|
|
89
|
+
"""
|
|
90
|
+
if self._vin is not None and frame.vin != self._vin:
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
# Build the row dict from frame data.
|
|
94
|
+
row: dict[str, Any] = {
|
|
95
|
+
"timestamp": frame.created_at.isoformat(),
|
|
96
|
+
"vin": frame.vin,
|
|
97
|
+
}
|
|
98
|
+
for datum in frame.data:
|
|
99
|
+
value = datum.value
|
|
100
|
+
# Flatten location dicts to a string representation.
|
|
101
|
+
if isinstance(value, dict):
|
|
102
|
+
value = ";".join(f"{k}={v}" for k, v in value.items())
|
|
103
|
+
row[datum.field_name] = value
|
|
104
|
+
|
|
105
|
+
# Discover new fields and rewrite the header if needed.
|
|
106
|
+
new_fields = [f for f in row if f not in self._fieldnames]
|
|
107
|
+
if new_fields:
|
|
108
|
+
self._fieldnames.extend(new_fields)
|
|
109
|
+
if self._fh is not None:
|
|
110
|
+
self._rewrite_header()
|
|
111
|
+
|
|
112
|
+
# Lazily open the file on the first frame.
|
|
113
|
+
if self._fh is None:
|
|
114
|
+
self._open()
|
|
115
|
+
|
|
116
|
+
assert self._writer is not None
|
|
117
|
+
self._writer.writerow(row)
|
|
118
|
+
self._frame_count += 1
|
|
119
|
+
self._since_flush += 1
|
|
120
|
+
|
|
121
|
+
if self._since_flush >= _FLUSH_INTERVAL:
|
|
122
|
+
self._flush()
|
|
123
|
+
|
|
124
|
+
# -- Lifecycle ------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def close(self) -> None:
|
|
127
|
+
"""Flush and close the CSV file."""
|
|
128
|
+
if self._fh is not None:
|
|
129
|
+
self._flush()
|
|
130
|
+
self._fh.close()
|
|
131
|
+
self._fh = None
|
|
132
|
+
self._writer = None
|
|
133
|
+
|
|
134
|
+
# -- Internals ------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def _open(self) -> None:
|
|
137
|
+
"""Open the CSV file and write the initial header."""
|
|
138
|
+
self._fh = open(self._path, "w", newline="", encoding="utf-8") # noqa: SIM115
|
|
139
|
+
self._writer = csv.DictWriter(
|
|
140
|
+
self._fh,
|
|
141
|
+
fieldnames=self._fieldnames,
|
|
142
|
+
extrasaction="ignore",
|
|
143
|
+
)
|
|
144
|
+
self._writer.writeheader()
|
|
145
|
+
|
|
146
|
+
def _rewrite_header(self) -> None:
|
|
147
|
+
"""Rewrite the file with the expanded header, preserving existing rows.
|
|
148
|
+
|
|
149
|
+
This is called when a new telemetry field is discovered mid-stream.
|
|
150
|
+
The approach is: read existing content, rewrite with new fieldnames.
|
|
151
|
+
"""
|
|
152
|
+
if self._fh is None:
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
self._fh.flush()
|
|
156
|
+
self._fh.close()
|
|
157
|
+
|
|
158
|
+
# Read existing rows.
|
|
159
|
+
rows: list[dict[str, str]] = []
|
|
160
|
+
with open(self._path, newline="", encoding="utf-8") as f:
|
|
161
|
+
reader = csv.DictReader(f)
|
|
162
|
+
for r in reader:
|
|
163
|
+
rows.append(r)
|
|
164
|
+
|
|
165
|
+
# Rewrite with expanded header.
|
|
166
|
+
self._fh = open(self._path, "w", newline="", encoding="utf-8") # noqa: SIM115
|
|
167
|
+
self._writer = csv.DictWriter(
|
|
168
|
+
self._fh,
|
|
169
|
+
fieldnames=self._fieldnames,
|
|
170
|
+
extrasaction="ignore",
|
|
171
|
+
)
|
|
172
|
+
self._writer.writeheader()
|
|
173
|
+
for r in rows:
|
|
174
|
+
self._writer.writerow(r)
|
|
175
|
+
|
|
176
|
+
def _flush(self) -> None:
|
|
177
|
+
"""Flush the file handle to disk."""
|
|
178
|
+
if self._fh is not None:
|
|
179
|
+
self._fh.flush()
|
|
180
|
+
self._since_flush = 0
|
|
@@ -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
|
+
"HvacLeftTemperatureRequest",
|
|
162
|
+
"HvacRightTemperatureRequest",
|
|
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
|
+
"RatedRange",
|
|
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", "CurrentLimitMph"}
|
|
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)
|