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
tescmd/telemetry/tui.py
ADDED
|
@@ -0,0 +1,1716 @@
|
|
|
1
|
+
"""Full-screen Textual TUI dashboard for telemetry + server monitoring.
|
|
2
|
+
|
|
3
|
+
Multi-panel dashboard with categorized vehicle widgets, keybindings for
|
|
4
|
+
vehicle commands, and a help/info modal screen.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
15
|
+
|
|
16
|
+
from textual.app import App, ComposeResult, SystemCommand
|
|
17
|
+
from textual.binding import Binding
|
|
18
|
+
from textual.containers import Grid, Horizontal, Vertical, VerticalScroll
|
|
19
|
+
from textual.screen import ModalScreen
|
|
20
|
+
from textual.widgets import DataTable, Footer, Header, RichLog, Static
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Iterable
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from textual.screen import Screen
|
|
27
|
+
|
|
28
|
+
from tescmd.output.rich_output import DisplayUnits
|
|
29
|
+
from tescmd.telemetry.decoder import TelemetryFrame
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _make_cli_runner() -> Any:
|
|
35
|
+
"""Create a CliRunner with stderr separation.
|
|
36
|
+
|
|
37
|
+
Click 8.2 removed the ``mix_stderr`` parameter (stderr is always
|
|
38
|
+
separate). Click 8.1 defaults to ``mix_stderr=True``, so we pass
|
|
39
|
+
``False`` when supported. Routed through ``Any`` to avoid a
|
|
40
|
+
version-dependent ``type: ignore`` that fails strict mypy on one
|
|
41
|
+
version or the other.
|
|
42
|
+
"""
|
|
43
|
+
from click.testing import CliRunner as _Runner
|
|
44
|
+
|
|
45
|
+
_ctor: Any = _Runner
|
|
46
|
+
try:
|
|
47
|
+
return _ctor(mix_stderr=False)
|
|
48
|
+
except TypeError:
|
|
49
|
+
return _Runner()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Panel field mapping — declarative schema for the dashboard layout
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
PANEL_FIELDS: dict[str, tuple[str, list[str]]] = {
|
|
57
|
+
"battery": (
|
|
58
|
+
"Battery & Charging",
|
|
59
|
+
[
|
|
60
|
+
"Soc",
|
|
61
|
+
"BatteryLevel",
|
|
62
|
+
"EstBatteryRange",
|
|
63
|
+
"IdealBatteryRange",
|
|
64
|
+
"RatedRange",
|
|
65
|
+
"PackVoltage",
|
|
66
|
+
"PackCurrent",
|
|
67
|
+
"ChargeState",
|
|
68
|
+
"DetailedChargeState",
|
|
69
|
+
"ChargeLimitSoc",
|
|
70
|
+
"TimeToFullCharge",
|
|
71
|
+
"ACChargingPower",
|
|
72
|
+
"DCChargingPower",
|
|
73
|
+
"ChargerVoltage",
|
|
74
|
+
"ChargeAmps",
|
|
75
|
+
"ChargePortDoorOpen",
|
|
76
|
+
"ChargePortLatch",
|
|
77
|
+
"FastChargerPresent",
|
|
78
|
+
"FastChargerType",
|
|
79
|
+
"ChargingCableType",
|
|
80
|
+
"BatteryHeaterOn",
|
|
81
|
+
"EnergyRemaining",
|
|
82
|
+
"ChargeCurrentRequest",
|
|
83
|
+
"ChargeCurrentRequestMax",
|
|
84
|
+
"ChargeRateMilePerHour",
|
|
85
|
+
"EstimatedHoursToChargeTermination",
|
|
86
|
+
],
|
|
87
|
+
),
|
|
88
|
+
"climate": (
|
|
89
|
+
"Climate",
|
|
90
|
+
[
|
|
91
|
+
"InsideTemp",
|
|
92
|
+
"OutsideTemp",
|
|
93
|
+
"HvacLeftTemperatureRequest",
|
|
94
|
+
"HvacRightTemperatureRequest",
|
|
95
|
+
"HvacPower",
|
|
96
|
+
"HvacFanStatus",
|
|
97
|
+
"HvacFanSpeed",
|
|
98
|
+
"HvacACEnabled",
|
|
99
|
+
"HvacAutoMode",
|
|
100
|
+
"DefrostMode",
|
|
101
|
+
"DefrostForPreconditioning",
|
|
102
|
+
"SeatHeaterLeft",
|
|
103
|
+
"SeatHeaterRight",
|
|
104
|
+
"SeatHeaterRearLeft",
|
|
105
|
+
"SeatHeaterRearCenter",
|
|
106
|
+
"SeatHeaterRearRight",
|
|
107
|
+
"HvacSteeringWheelHeatLevel",
|
|
108
|
+
"HvacSteeringWheelHeatAuto",
|
|
109
|
+
"AutoSeatClimateLeft",
|
|
110
|
+
"AutoSeatClimateRight",
|
|
111
|
+
"ClimateSeatCoolingFrontLeft",
|
|
112
|
+
"ClimateSeatCoolingFrontRight",
|
|
113
|
+
"SeatVentEnabled",
|
|
114
|
+
"RearDisplayHvacEnabled",
|
|
115
|
+
"RearDefrostEnabled",
|
|
116
|
+
"ClimateKeeperMode",
|
|
117
|
+
"CabinOverheatProtectionMode",
|
|
118
|
+
"CabinOverheatProtectionTemperatureLimit",
|
|
119
|
+
"PreconditioningEnabled",
|
|
120
|
+
],
|
|
121
|
+
),
|
|
122
|
+
"driving": (
|
|
123
|
+
"Driving",
|
|
124
|
+
[
|
|
125
|
+
"VehicleSpeed",
|
|
126
|
+
"Location",
|
|
127
|
+
"Gear",
|
|
128
|
+
"GpsHeading",
|
|
129
|
+
"Odometer",
|
|
130
|
+
"CruiseSetSpeed",
|
|
131
|
+
"CruiseFollowDistance",
|
|
132
|
+
"CurrentLimitMph",
|
|
133
|
+
"SpeedLimitMode",
|
|
134
|
+
"SpeedLimitWarning",
|
|
135
|
+
"LateralAcceleration",
|
|
136
|
+
"LongitudinalAcceleration",
|
|
137
|
+
"PedalPosition",
|
|
138
|
+
"BrakePedalPos",
|
|
139
|
+
"LaneDepartureAvoidance",
|
|
140
|
+
"ForwardCollisionWarning",
|
|
141
|
+
],
|
|
142
|
+
),
|
|
143
|
+
"nav": (
|
|
144
|
+
"Navigation",
|
|
145
|
+
[
|
|
146
|
+
"MilesToArrival",
|
|
147
|
+
"MinutesToArrival",
|
|
148
|
+
"RouteTrafficMinutesDelay",
|
|
149
|
+
"DestinationName",
|
|
150
|
+
"DestinationLocation",
|
|
151
|
+
"OriginLocation",
|
|
152
|
+
"RouteLine",
|
|
153
|
+
"RouteLastUpdated",
|
|
154
|
+
"ExpectedEnergyPercentAtTripArrival",
|
|
155
|
+
"HomelinkNearby",
|
|
156
|
+
"HomelinkDeviceCount",
|
|
157
|
+
"LocatedAtHome",
|
|
158
|
+
"LocatedAtWork",
|
|
159
|
+
"LocatedAtFavorite",
|
|
160
|
+
],
|
|
161
|
+
),
|
|
162
|
+
"security": (
|
|
163
|
+
"Security",
|
|
164
|
+
[
|
|
165
|
+
"Locked",
|
|
166
|
+
"SentryMode",
|
|
167
|
+
"ValetModeEnabled",
|
|
168
|
+
"DoorState",
|
|
169
|
+
"FdWindow",
|
|
170
|
+
"FpWindow",
|
|
171
|
+
"RdWindow",
|
|
172
|
+
"RpWindow",
|
|
173
|
+
"DriverSeatOccupied",
|
|
174
|
+
"DriverSeatBelt",
|
|
175
|
+
"PassengerSeatBelt",
|
|
176
|
+
"CenterDisplay",
|
|
177
|
+
"RemoteStartEnabled",
|
|
178
|
+
"GuestModeEnabled",
|
|
179
|
+
"GuestModeMobileAccessState",
|
|
180
|
+
"PinToDriveEnabled",
|
|
181
|
+
],
|
|
182
|
+
),
|
|
183
|
+
"media": (
|
|
184
|
+
"Media",
|
|
185
|
+
[
|
|
186
|
+
"MediaPlaybackStatus",
|
|
187
|
+
"MediaPlaybackSource",
|
|
188
|
+
"MediaNowPlayingTitle",
|
|
189
|
+
"MediaNowPlayingArtist",
|
|
190
|
+
"MediaNowPlayingAlbum",
|
|
191
|
+
"MediaNowPlayingStation",
|
|
192
|
+
"MediaNowPlayingDuration",
|
|
193
|
+
"MediaNowPlayingElapsed",
|
|
194
|
+
"MediaAudioVolume",
|
|
195
|
+
"MediaAudioVolumeMax",
|
|
196
|
+
"MediaAudioVolumeIncrement",
|
|
197
|
+
],
|
|
198
|
+
),
|
|
199
|
+
"tires": (
|
|
200
|
+
"Tires",
|
|
201
|
+
[
|
|
202
|
+
"TpmsPressureFl",
|
|
203
|
+
"TpmsPressureFr",
|
|
204
|
+
"TpmsPressureRl",
|
|
205
|
+
"TpmsPressureRr",
|
|
206
|
+
"TpmsLastSeenPressureTimeFl",
|
|
207
|
+
"TpmsLastSeenPressureTimeFr",
|
|
208
|
+
"TpmsLastSeenPressureTimeRl",
|
|
209
|
+
"TpmsLastSeenPressureTimeRr",
|
|
210
|
+
"TpmsHardWarnings",
|
|
211
|
+
"TpmsSoftWarnings",
|
|
212
|
+
],
|
|
213
|
+
),
|
|
214
|
+
"diagnostics": (
|
|
215
|
+
"Diagnostics & Vehicle",
|
|
216
|
+
[
|
|
217
|
+
"ModuleTempMax",
|
|
218
|
+
"ModuleTempMin",
|
|
219
|
+
"BrickVoltageMax",
|
|
220
|
+
"BrickVoltageMin",
|
|
221
|
+
"NumBrickVoltageMax",
|
|
222
|
+
"NumBrickVoltageMin",
|
|
223
|
+
"NumModuleTempMax",
|
|
224
|
+
"NumModuleTempMin",
|
|
225
|
+
"BMSState",
|
|
226
|
+
"DriveRail",
|
|
227
|
+
"NotEnoughPowerToHeat",
|
|
228
|
+
"DCDCEnable",
|
|
229
|
+
"IsolationResistance",
|
|
230
|
+
"Hvil",
|
|
231
|
+
"Version",
|
|
232
|
+
"SoftwareUpdateVersion",
|
|
233
|
+
"SoftwareUpdateDownloadPercentComplete",
|
|
234
|
+
"SoftwareUpdateInstallationPercentComplete",
|
|
235
|
+
"CarType",
|
|
236
|
+
"WheelType",
|
|
237
|
+
"ServiceMode",
|
|
238
|
+
],
|
|
239
|
+
),
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
# Pre-compute field → panel_id lookup for O(1) routing.
|
|
243
|
+
_FIELD_TO_PANEL: dict[str, str] = {}
|
|
244
|
+
for _pid, (_title, _fields) in PANEL_FIELDS.items():
|
|
245
|
+
for _f in _fields:
|
|
246
|
+
_FIELD_TO_PANEL[_f] = _pid
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
# Activity sidebar — logging handler that funnels into an asyncio queue
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
# Logger-name prefix -> (short label, Rich color).
|
|
254
|
+
SOURCE_MAP: dict[str, tuple[str, str]] = {
|
|
255
|
+
"tescmd.tui.commands": ("CMD", "cyan"),
|
|
256
|
+
"tescmd.mcp.server": ("MCP", "magenta"),
|
|
257
|
+
"tescmd.cli.serve": ("HTTP", "blue"),
|
|
258
|
+
"tescmd.openclaw.gateway": ("CLAW", "green"),
|
|
259
|
+
"tescmd.openclaw.bridge": ("CLAW", "green"),
|
|
260
|
+
"tescmd.telemetry.server": ("TELEM", "yellow"),
|
|
261
|
+
"tescmd.telemetry.cache_sink": ("CACHE", "dim"),
|
|
262
|
+
"tescmd.api.client": ("API", "blue"),
|
|
263
|
+
"tescmd.api.signed_command": ("SIGN", "bright_red"),
|
|
264
|
+
"tescmd.protocol.session": ("PROTO", "bright_blue"),
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class ActivityLogHandler(logging.Handler):
|
|
269
|
+
"""Logging handler that enqueues messages for the TUI activity sidebar."""
|
|
270
|
+
|
|
271
|
+
def __init__(self, queue: asyncio.Queue[tuple[str, str, str]]) -> None:
|
|
272
|
+
super().__init__()
|
|
273
|
+
self._queue = queue
|
|
274
|
+
|
|
275
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
276
|
+
source = "LOG"
|
|
277
|
+
color = "white"
|
|
278
|
+
for prefix, (label, clr) in SOURCE_MAP.items():
|
|
279
|
+
if record.name.startswith(prefix):
|
|
280
|
+
source = label
|
|
281
|
+
color = clr
|
|
282
|
+
break
|
|
283
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
284
|
+
self._queue.put_nowait((source, color, self.format(record)))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _mask_vin(vin: str) -> str:
|
|
288
|
+
"""Mask VIN for display — last 4 characters replaced with XXXX."""
|
|
289
|
+
if len(vin) >= 4:
|
|
290
|
+
return vin[:-4] + "XXXX"
|
|
291
|
+
return vin
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
# Help / Info modal screen
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
_HELP_TEXT = (
|
|
299
|
+
"KEYBINDINGS\n"
|
|
300
|
+
"\n"
|
|
301
|
+
"General\n"
|
|
302
|
+
" q Quit\n"
|
|
303
|
+
" ? Toggle this help screen\n"
|
|
304
|
+
"\n"
|
|
305
|
+
"Security\n"
|
|
306
|
+
" l Lock doors\n"
|
|
307
|
+
" u Unlock doors\n"
|
|
308
|
+
" h Honk horn\n"
|
|
309
|
+
" f Flash lights\n"
|
|
310
|
+
" s/S Sentry on / off\n"
|
|
311
|
+
" r Remote start\n"
|
|
312
|
+
" v/V Valet on / off\n"
|
|
313
|
+
" g/G Guest mode on / off\n"
|
|
314
|
+
"\n"
|
|
315
|
+
"Charging\n"
|
|
316
|
+
" c/C Start / stop charging\n"
|
|
317
|
+
" p/P Port open / close\n"
|
|
318
|
+
" m Charge limit max\n"
|
|
319
|
+
" n Charge limit standard\n"
|
|
320
|
+
"\n"
|
|
321
|
+
"Climate\n"
|
|
322
|
+
" a/A Climate on / off\n"
|
|
323
|
+
" w/W Wheel heater on / off\n"
|
|
324
|
+
"\n"
|
|
325
|
+
"Trunk & Windows\n"
|
|
326
|
+
" t Trunk open/close\n"
|
|
327
|
+
" T Frunk open\n"
|
|
328
|
+
"\n"
|
|
329
|
+
"Media\n"
|
|
330
|
+
" space Play / Pause\n"
|
|
331
|
+
" \\] Next track\n"
|
|
332
|
+
" \\[ Previous track\n"
|
|
333
|
+
" = Volume up\n"
|
|
334
|
+
" - Volume down\n"
|
|
335
|
+
"\n"
|
|
336
|
+
"Vehicle\n"
|
|
337
|
+
" ctrl+w Wake vehicle\n"
|
|
338
|
+
"\n"
|
|
339
|
+
"Additional commands (no keybinding):\n"
|
|
340
|
+
" Auto-secure, Valet reset, PIN to drive,\n"
|
|
341
|
+
" Boombox, Precondition, Overheat protect,\n"
|
|
342
|
+
" Bioweapon defense, Auto wheel heater,\n"
|
|
343
|
+
" Defrost, Trunk close, Window vent/close,\n"
|
|
344
|
+
" Sunroof, Tonneau, Next/prev fav,\n"
|
|
345
|
+
" Supercharger nav, HomeLink, Software cancel,\n"
|
|
346
|
+
" Low power mode, Accessory power,\n"
|
|
347
|
+
" Charge schedule on/off\n"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class HelpScreen(ModalScreen[None]):
|
|
352
|
+
"""Modal help screen showing all keybindings and server info."""
|
|
353
|
+
|
|
354
|
+
BINDINGS: ClassVar[list[Binding]] = [ # type: ignore[assignment]
|
|
355
|
+
Binding("escape", "dismiss", "Close"),
|
|
356
|
+
Binding("question_mark", "dismiss", "Close"),
|
|
357
|
+
]
|
|
358
|
+
|
|
359
|
+
CSS = """
|
|
360
|
+
HelpScreen {
|
|
361
|
+
align: center middle;
|
|
362
|
+
}
|
|
363
|
+
#help-container {
|
|
364
|
+
width: 64;
|
|
365
|
+
max-height: 80%;
|
|
366
|
+
background: $surface;
|
|
367
|
+
border: thick $primary;
|
|
368
|
+
padding: 1 2;
|
|
369
|
+
}
|
|
370
|
+
#help-title {
|
|
371
|
+
text-align: center;
|
|
372
|
+
text-style: bold;
|
|
373
|
+
padding-bottom: 1;
|
|
374
|
+
}
|
|
375
|
+
#help-body {
|
|
376
|
+
height: auto;
|
|
377
|
+
}
|
|
378
|
+
#help-extra {
|
|
379
|
+
margin-top: 1;
|
|
380
|
+
color: $text-muted;
|
|
381
|
+
}
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
def __init__(self, log_path: str = "", server_info: str = "") -> None:
|
|
385
|
+
super().__init__()
|
|
386
|
+
self._log_path = log_path
|
|
387
|
+
self._server_info = server_info
|
|
388
|
+
|
|
389
|
+
def compose(self) -> ComposeResult:
|
|
390
|
+
with VerticalScroll(id="help-container"):
|
|
391
|
+
yield Static("tescmd Dashboard Help", id="help-title")
|
|
392
|
+
yield Static(_HELP_TEXT, id="help-body")
|
|
393
|
+
extra_parts: list[str] = []
|
|
394
|
+
if self._log_path:
|
|
395
|
+
extra_parts.append(f"Log: {self._log_path}")
|
|
396
|
+
if self._server_info:
|
|
397
|
+
extra_parts.append(self._server_info)
|
|
398
|
+
if extra_parts:
|
|
399
|
+
yield Static("\n".join(extra_parts), id="help-extra")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# ---------------------------------------------------------------------------
|
|
403
|
+
# Main TUI application
|
|
404
|
+
# ---------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class TelemetryTUI(App[None]):
|
|
408
|
+
"""Full-screen dashboard for tescmd telemetry and server monitoring.
|
|
409
|
+
|
|
410
|
+
**Frame ingestion** works via a bounded :class:`asyncio.Queue`:
|
|
411
|
+
|
|
412
|
+
- ``push_frame()`` enqueues frames from the telemetry fanout.
|
|
413
|
+
- A background worker pulls frames and updates internal state.
|
|
414
|
+
- A periodic timer (1 s) refreshes visible widgets from state.
|
|
415
|
+
|
|
416
|
+
This decouples the high-frequency telemetry stream from the render
|
|
417
|
+
cycle so the UI stays responsive.
|
|
418
|
+
"""
|
|
419
|
+
|
|
420
|
+
TITLE = "tescmd"
|
|
421
|
+
|
|
422
|
+
CSS = """
|
|
423
|
+
#main-area {
|
|
424
|
+
height: 1fr;
|
|
425
|
+
}
|
|
426
|
+
#panel-grid {
|
|
427
|
+
width: 3fr;
|
|
428
|
+
height: 1fr;
|
|
429
|
+
grid-size: 2;
|
|
430
|
+
grid-gutter: 0;
|
|
431
|
+
}
|
|
432
|
+
#activity-log {
|
|
433
|
+
width: 1fr;
|
|
434
|
+
min-width: 30;
|
|
435
|
+
height: 1fr;
|
|
436
|
+
border: solid $accent;
|
|
437
|
+
padding: 0 1;
|
|
438
|
+
}
|
|
439
|
+
.telemetry-panel {
|
|
440
|
+
height: 1fr;
|
|
441
|
+
border: solid $primary;
|
|
442
|
+
padding: 0;
|
|
443
|
+
}
|
|
444
|
+
.telemetry-panel DataTable {
|
|
445
|
+
height: 1fr;
|
|
446
|
+
}
|
|
447
|
+
#battery-panel { border: solid #f1c40f; }
|
|
448
|
+
#climate-panel { border: solid #3498db; }
|
|
449
|
+
#driving-panel { border: solid #2ecc71; }
|
|
450
|
+
#security-panel { border: solid #e74c3c; }
|
|
451
|
+
#tires-panel { border: solid #9b59b6; }
|
|
452
|
+
#diagnostics-panel { border: solid #95a5a6; }
|
|
453
|
+
#media-panel { border: solid #e67e22; }
|
|
454
|
+
#nav-panel { border: solid #1abc9c; }
|
|
455
|
+
#server-bar {
|
|
456
|
+
height: auto;
|
|
457
|
+
background: $panel;
|
|
458
|
+
padding: 0 1;
|
|
459
|
+
display: none;
|
|
460
|
+
}
|
|
461
|
+
#command-status {
|
|
462
|
+
height: auto;
|
|
463
|
+
background: $panel;
|
|
464
|
+
padding: 0 1;
|
|
465
|
+
display: none;
|
|
466
|
+
}
|
|
467
|
+
"""
|
|
468
|
+
|
|
469
|
+
BINDINGS: ClassVar[list[Binding]] = [ # type: ignore[assignment]
|
|
470
|
+
# -- General --
|
|
471
|
+
Binding("q", "quit", "Quit"),
|
|
472
|
+
Binding("question_mark", "help", "Help"),
|
|
473
|
+
# -- Security (shown in footer) --
|
|
474
|
+
Binding("l", "cmd_lock", "Lock"),
|
|
475
|
+
Binding("u", "cmd_unlock", "Unlock"),
|
|
476
|
+
Binding("h", "cmd_honk", "Honk"),
|
|
477
|
+
Binding("f", "cmd_flash", "Flash"),
|
|
478
|
+
Binding("s", "cmd_sentry_on", "Sentry On"),
|
|
479
|
+
Binding("S", "cmd_sentry_off", "Sentry Off", key_display="shift+s"),
|
|
480
|
+
# -- Charging (shown in footer) --
|
|
481
|
+
Binding("c", "cmd_charge_start", "Charge"),
|
|
482
|
+
Binding("C", "cmd_charge_stop", "Charge Stop", key_display="shift+c"),
|
|
483
|
+
# -- Security (hidden — accessible via palette / help) --
|
|
484
|
+
Binding("r", "cmd_remote_start", "Remote Start", show=False),
|
|
485
|
+
Binding("v", "cmd_valet_on", "Valet On", show=False),
|
|
486
|
+
Binding("V", "cmd_valet_off", "Valet Off", key_display="shift+v", show=False),
|
|
487
|
+
Binding("g", "cmd_guest_on", "Guest On", show=False),
|
|
488
|
+
Binding("G", "cmd_guest_off", "Guest Off", key_display="shift+g", show=False),
|
|
489
|
+
# -- Charging (hidden) --
|
|
490
|
+
Binding("p", "cmd_port_open", "Port Open", show=False),
|
|
491
|
+
Binding("P", "cmd_port_close", "Port Close", key_display="shift+p", show=False),
|
|
492
|
+
Binding("m", "cmd_charge_max", "Charge Max", show=False),
|
|
493
|
+
Binding("n", "cmd_charge_std", "Charge Std", show=False),
|
|
494
|
+
# -- Climate (hidden) --
|
|
495
|
+
Binding("a", "cmd_climate_on", "Climate On", show=False),
|
|
496
|
+
Binding("A", "cmd_climate_off", "Climate Off", key_display="shift+a", show=False),
|
|
497
|
+
Binding("w", "cmd_wheel_heater_on", "Wheel Heat On", show=False),
|
|
498
|
+
Binding("W", "cmd_wheel_heater_off", "Wheel Heat Off", key_display="shift+w", show=False),
|
|
499
|
+
# -- Trunk (hidden) --
|
|
500
|
+
Binding("t", "cmd_trunk", "Trunk", show=False),
|
|
501
|
+
Binding("T", "cmd_frunk", "Frunk", key_display="shift+t", show=False),
|
|
502
|
+
# -- Media --
|
|
503
|
+
Binding("space", "cmd_play_pause", "Play/Pause", show=False),
|
|
504
|
+
Binding("right_square_bracket", "cmd_next_track", "]Next", show=False),
|
|
505
|
+
Binding("left_square_bracket", "cmd_prev_track", "[Prev", show=False),
|
|
506
|
+
Binding("equals_sign", "cmd_vol_up", "Vol+", show=False),
|
|
507
|
+
Binding("minus", "cmd_vol_down", "Vol-", show=False),
|
|
508
|
+
# -- Vehicle --
|
|
509
|
+
Binding("ctrl+w", "cmd_wake", "Wake", show=False),
|
|
510
|
+
]
|
|
511
|
+
|
|
512
|
+
def __init__(
|
|
513
|
+
self,
|
|
514
|
+
units: DisplayUnits | None = None,
|
|
515
|
+
*,
|
|
516
|
+
vin: str = "",
|
|
517
|
+
telemetry_port: int | None = None,
|
|
518
|
+
) -> None:
|
|
519
|
+
super().__init__()
|
|
520
|
+
self._units = units
|
|
521
|
+
self._vin = vin
|
|
522
|
+
self._telemetry_port = telemetry_port
|
|
523
|
+
|
|
524
|
+
# Telemetry state.
|
|
525
|
+
self._state: dict[str, Any] = {}
|
|
526
|
+
self._timestamps: dict[str, datetime] = {}
|
|
527
|
+
self._frame_count: int = 0
|
|
528
|
+
self._started_at: datetime = datetime.now(tz=UTC)
|
|
529
|
+
self._connected: bool = False
|
|
530
|
+
|
|
531
|
+
# Server info (set by callers after resources are ready).
|
|
532
|
+
self._mcp_url: str = ""
|
|
533
|
+
self._tunnel_url: str = ""
|
|
534
|
+
self._sink_count: int = 0
|
|
535
|
+
self._openclaw_status: str = ""
|
|
536
|
+
self._cache_stats: str = ""
|
|
537
|
+
self._log_path: str = ""
|
|
538
|
+
|
|
539
|
+
# Bounded queue for frame ingestion.
|
|
540
|
+
self._queue: asyncio.Queue[TelemetryFrame] = asyncio.Queue(maxsize=100)
|
|
541
|
+
|
|
542
|
+
# Track which field names already have rows per panel.
|
|
543
|
+
self._panel_table_fields: dict[str, set[str]] = {pid: set() for pid in PANEL_FIELDS}
|
|
544
|
+
|
|
545
|
+
# Shutdown event — signalled when the TUI exits (e.g. user presses q).
|
|
546
|
+
# serve.py can await this to know when to tear down the MCP server.
|
|
547
|
+
self.shutdown_event: asyncio.Event = asyncio.Event()
|
|
548
|
+
|
|
549
|
+
# Activity sidebar.
|
|
550
|
+
self._activity_queue: asyncio.Queue[tuple[str, str, str]] = asyncio.Queue(maxsize=500)
|
|
551
|
+
self._activity_handler: ActivityLogHandler | None = None
|
|
552
|
+
self._last_frame_summary_count: int = 0
|
|
553
|
+
self._ui_tick: int = 0
|
|
554
|
+
self._original_propagate: dict[str, bool] = {}
|
|
555
|
+
self._saved_root_handlers: list[logging.Handler] = []
|
|
556
|
+
self._activity_file_handler: logging.FileHandler | None = None
|
|
557
|
+
self._activity_log_path: str = ""
|
|
558
|
+
|
|
559
|
+
# Debug logger for command attempts — always writes to a file.
|
|
560
|
+
self._cmd_logger = logging.getLogger("tescmd.tui.commands")
|
|
561
|
+
self._cmd_log_handler: logging.FileHandler | None = None
|
|
562
|
+
|
|
563
|
+
# -- Compose layout -------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
def compose(self) -> ComposeResult:
|
|
566
|
+
yield Header(show_clock=True)
|
|
567
|
+
with Horizontal(id="main-area"):
|
|
568
|
+
with Grid(id="panel-grid"):
|
|
569
|
+
for panel_id in PANEL_FIELDS:
|
|
570
|
+
with Vertical(id=f"{panel_id}-panel", classes="telemetry-panel"):
|
|
571
|
+
yield DataTable(
|
|
572
|
+
id=f"{panel_id}-table", cursor_type="none", zebra_stripes=True
|
|
573
|
+
)
|
|
574
|
+
yield RichLog(id="activity-log", wrap=True, markup=True)
|
|
575
|
+
yield Static(id="server-bar")
|
|
576
|
+
yield Static(id="command-status")
|
|
577
|
+
yield Footer()
|
|
578
|
+
|
|
579
|
+
def on_mount(self) -> None:
|
|
580
|
+
"""Set up DataTable columns per panel and start background processing."""
|
|
581
|
+
for panel_id, (title, _fields) in PANEL_FIELDS.items():
|
|
582
|
+
panel = self.query_one(f"#{panel_id}-panel", Vertical)
|
|
583
|
+
panel.border_title = title
|
|
584
|
+
|
|
585
|
+
table = self.query_one(f"#{panel_id}-table", DataTable)
|
|
586
|
+
table.add_column("Field", key="field", width=24)
|
|
587
|
+
table.add_column("Value", key="value", width=24)
|
|
588
|
+
|
|
589
|
+
# Activity sidebar title.
|
|
590
|
+
activity_log = self.query_one("#activity-log", RichLog)
|
|
591
|
+
activity_log.border_title = "Activity"
|
|
592
|
+
|
|
593
|
+
# Set up debug command log file.
|
|
594
|
+
self._setup_command_log()
|
|
595
|
+
|
|
596
|
+
# Attach activity handler to all monitored loggers.
|
|
597
|
+
self._setup_activity_handler()
|
|
598
|
+
|
|
599
|
+
# Periodic UI refresh.
|
|
600
|
+
self.set_interval(1.0, self._update_ui)
|
|
601
|
+
|
|
602
|
+
# Background worker to drain the frame queue.
|
|
603
|
+
self.run_worker(self._process_queue, exclusive=True, thread=False) # type: ignore[arg-type]
|
|
604
|
+
|
|
605
|
+
# Background worker to drain the activity queue into the RichLog.
|
|
606
|
+
self.run_worker(self._process_activity_queue, exclusive=False, thread=False) # type: ignore[arg-type]
|
|
607
|
+
|
|
608
|
+
def _setup_command_log(self) -> None:
|
|
609
|
+
"""Set up a file-based debug logger for command attempts."""
|
|
610
|
+
from pathlib import Path
|
|
611
|
+
|
|
612
|
+
# Place log next to the CSV log if set, otherwise default config dir.
|
|
613
|
+
if self._log_path:
|
|
614
|
+
log_dir = Path(self._log_path).parent
|
|
615
|
+
else:
|
|
616
|
+
log_dir = Path("~/.config/tescmd").expanduser()
|
|
617
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
618
|
+
|
|
619
|
+
log_file = log_dir / "tui-commands.log"
|
|
620
|
+
handler = logging.FileHandler(log_file, encoding="utf-8")
|
|
621
|
+
handler.setLevel(logging.DEBUG)
|
|
622
|
+
fmt = "%(asctime)s %(levelname)-5s %(message)s"
|
|
623
|
+
handler.setFormatter(logging.Formatter(fmt, datefmt="%Y-%m-%dT%H:%M:%S"))
|
|
624
|
+
self._cmd_logger.addHandler(handler)
|
|
625
|
+
self._cmd_logger.setLevel(logging.DEBUG)
|
|
626
|
+
self._cmd_log_handler = handler
|
|
627
|
+
self._cmd_log_path = str(log_file)
|
|
628
|
+
self._cmd_logger.info("TUI started — VIN=%s", self._vin or "(none)")
|
|
629
|
+
|
|
630
|
+
# -- Activity sidebar -------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
def _setup_activity_handler(self) -> None:
|
|
633
|
+
"""Attach a logging handler to all monitored loggers.
|
|
634
|
+
|
|
635
|
+
Disables propagation so records go ONLY to the activity sidebar,
|
|
636
|
+
not to the console (which would corrupt the Textual TUI).
|
|
637
|
+
"""
|
|
638
|
+
handler = ActivityLogHandler(self._activity_queue)
|
|
639
|
+
handler.setLevel(logging.INFO)
|
|
640
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
641
|
+
self._activity_handler = handler
|
|
642
|
+
|
|
643
|
+
# File handler — mirrors activity to a log file for post-mortem.
|
|
644
|
+
from pathlib import Path
|
|
645
|
+
|
|
646
|
+
if self._log_path:
|
|
647
|
+
log_dir = Path(self._log_path).parent
|
|
648
|
+
else:
|
|
649
|
+
log_dir = Path("~/.config/tescmd").expanduser()
|
|
650
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
651
|
+
activity_file = log_dir / "tui-activity.log"
|
|
652
|
+
file_handler = logging.FileHandler(activity_file, encoding="utf-8")
|
|
653
|
+
file_handler.setLevel(logging.DEBUG)
|
|
654
|
+
file_handler.setFormatter(
|
|
655
|
+
logging.Formatter(
|
|
656
|
+
"%(asctime)s %(levelname)-5s [%(name)s] %(message)s",
|
|
657
|
+
datefmt="%Y-%m-%dT%H:%M:%S",
|
|
658
|
+
)
|
|
659
|
+
)
|
|
660
|
+
self._activity_file_handler = file_handler
|
|
661
|
+
self._activity_log_path = str(activity_file)
|
|
662
|
+
|
|
663
|
+
# Attach handlers and disable console propagation for monitored loggers.
|
|
664
|
+
for logger_name in SOURCE_MAP:
|
|
665
|
+
log = logging.getLogger(logger_name)
|
|
666
|
+
log.addHandler(handler)
|
|
667
|
+
log.addHandler(file_handler)
|
|
668
|
+
log.setLevel(min(log.level or logging.DEBUG, logging.DEBUG))
|
|
669
|
+
self._original_propagate[logger_name] = log.propagate
|
|
670
|
+
log.propagate = False
|
|
671
|
+
|
|
672
|
+
# Suppress noisy library loggers that would corrupt the terminal.
|
|
673
|
+
for name in (
|
|
674
|
+
"uvicorn",
|
|
675
|
+
"uvicorn.error",
|
|
676
|
+
"uvicorn.access",
|
|
677
|
+
"httpx",
|
|
678
|
+
"httpcore",
|
|
679
|
+
"websockets",
|
|
680
|
+
"mcp",
|
|
681
|
+
):
|
|
682
|
+
log = logging.getLogger(name)
|
|
683
|
+
self._original_propagate[name] = log.propagate
|
|
684
|
+
log.propagate = False
|
|
685
|
+
|
|
686
|
+
# Remove root logger's stream handlers to prevent ANY stray
|
|
687
|
+
# console output while the TUI owns the terminal.
|
|
688
|
+
self._saved_root_handlers = logging.root.handlers[:]
|
|
689
|
+
logging.root.handlers = [
|
|
690
|
+
h
|
|
691
|
+
for h in logging.root.handlers
|
|
692
|
+
if not isinstance(h, logging.StreamHandler) or isinstance(h, logging.FileHandler)
|
|
693
|
+
]
|
|
694
|
+
|
|
695
|
+
async def _process_activity_queue(self) -> None:
|
|
696
|
+
"""Background worker: drain activity queue into the RichLog widget."""
|
|
697
|
+
while True:
|
|
698
|
+
try:
|
|
699
|
+
source, color, message = await asyncio.wait_for(
|
|
700
|
+
self._activity_queue.get(), timeout=0.5
|
|
701
|
+
)
|
|
702
|
+
except TimeoutError:
|
|
703
|
+
continue
|
|
704
|
+
|
|
705
|
+
ts = datetime.now(tz=UTC).strftime("%H:%M:%S")
|
|
706
|
+
rich_log = self.query_one("#activity-log", RichLog)
|
|
707
|
+
rich_log.write(f"[{color}]{ts} {source}[/{color}] {message}")
|
|
708
|
+
|
|
709
|
+
def _maybe_log_frame_summary(self) -> None:
|
|
710
|
+
"""Every 5 UI ticks, log a frame summary if new frames arrived."""
|
|
711
|
+
self._ui_tick += 1
|
|
712
|
+
if self._ui_tick % 5 != 0:
|
|
713
|
+
return
|
|
714
|
+
current = self._frame_count
|
|
715
|
+
delta = current - self._last_frame_summary_count
|
|
716
|
+
if delta > 0:
|
|
717
|
+
self._last_frame_summary_count = current
|
|
718
|
+
ts = datetime.now(tz=UTC).strftime("%H:%M:%S")
|
|
719
|
+
with contextlib.suppress(Exception):
|
|
720
|
+
rich_log = self.query_one("#activity-log", RichLog)
|
|
721
|
+
rich_log.write(f"[yellow]{ts} TELEM[/yellow] +{delta} frames ({current} total)")
|
|
722
|
+
|
|
723
|
+
def _cleanup_activity_handler(self) -> None:
|
|
724
|
+
"""Remove activity handler and restore console logging."""
|
|
725
|
+
handler = self._activity_handler
|
|
726
|
+
if handler is None:
|
|
727
|
+
return
|
|
728
|
+
for logger_name in SOURCE_MAP:
|
|
729
|
+
log = logging.getLogger(logger_name)
|
|
730
|
+
log.removeHandler(handler)
|
|
731
|
+
if self._activity_file_handler is not None:
|
|
732
|
+
log.removeHandler(self._activity_file_handler)
|
|
733
|
+
|
|
734
|
+
if self._activity_file_handler is not None:
|
|
735
|
+
self._activity_file_handler.close()
|
|
736
|
+
self._activity_file_handler = None
|
|
737
|
+
|
|
738
|
+
# Restore propagation settings.
|
|
739
|
+
for logger_name, propagate in self._original_propagate.items():
|
|
740
|
+
logging.getLogger(logger_name).propagate = propagate
|
|
741
|
+
self._original_propagate.clear()
|
|
742
|
+
|
|
743
|
+
# Restore root logger handlers.
|
|
744
|
+
logging.root.handlers = self._saved_root_handlers
|
|
745
|
+
self._saved_root_handlers = []
|
|
746
|
+
|
|
747
|
+
self._activity_handler = None
|
|
748
|
+
|
|
749
|
+
# -- Frame ingestion (called from fanout) ---------------------------------
|
|
750
|
+
|
|
751
|
+
async def push_frame(self, frame: TelemetryFrame) -> None:
|
|
752
|
+
"""Enqueue a telemetry frame for processing.
|
|
753
|
+
|
|
754
|
+
Matches the :class:`~tescmd.telemetry.fanout.FrameFanout` callback
|
|
755
|
+
signature. If the queue is full, the frame is silently dropped
|
|
756
|
+
to prevent memory growth.
|
|
757
|
+
"""
|
|
758
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
759
|
+
self._queue.put_nowait(frame)
|
|
760
|
+
|
|
761
|
+
async def _process_queue(self) -> None:
|
|
762
|
+
"""Background worker: drain the queue and update state."""
|
|
763
|
+
while True:
|
|
764
|
+
try:
|
|
765
|
+
frame = await asyncio.wait_for(self._queue.get(), timeout=0.5)
|
|
766
|
+
except TimeoutError:
|
|
767
|
+
continue
|
|
768
|
+
|
|
769
|
+
self._frame_count += 1
|
|
770
|
+
self._connected = True
|
|
771
|
+
if frame.vin:
|
|
772
|
+
self._vin = frame.vin
|
|
773
|
+
|
|
774
|
+
for datum in frame.data:
|
|
775
|
+
self._state[datum.field_name] = datum.value
|
|
776
|
+
self._timestamps[datum.field_name] = frame.created_at
|
|
777
|
+
|
|
778
|
+
# -- Server info setters (called from serve.py) ---------------------------
|
|
779
|
+
|
|
780
|
+
def set_mcp_url(self, url: str) -> None:
|
|
781
|
+
self._mcp_url = url
|
|
782
|
+
|
|
783
|
+
def set_tunnel_url(self, url: str) -> None:
|
|
784
|
+
self._tunnel_url = url
|
|
785
|
+
|
|
786
|
+
def set_sink_count(self, n: int) -> None:
|
|
787
|
+
self._sink_count = n
|
|
788
|
+
|
|
789
|
+
def set_openclaw_status(self, connected: bool, send_count: int, event_count: int) -> None:
|
|
790
|
+
status = "Connected" if connected else "Disconnected"
|
|
791
|
+
self._openclaw_status = f"{status} (sent={send_count}, events={event_count})"
|
|
792
|
+
|
|
793
|
+
def set_cache_stats(self, frames: int, fields: int, pending: int) -> None:
|
|
794
|
+
self._cache_stats = f"{frames} frames, {fields} fields, {pending} pending"
|
|
795
|
+
|
|
796
|
+
def set_log_path(self, path: Path | str) -> None:
|
|
797
|
+
self._log_path = str(path)
|
|
798
|
+
|
|
799
|
+
# -- UI update (runs every 1 second) --------------------------------------
|
|
800
|
+
|
|
801
|
+
def _update_ui(self) -> None:
|
|
802
|
+
"""Refresh all visible widgets from current state."""
|
|
803
|
+
self._update_header()
|
|
804
|
+
self._update_panels()
|
|
805
|
+
self._update_server_info()
|
|
806
|
+
self._maybe_log_frame_summary()
|
|
807
|
+
|
|
808
|
+
def _update_header(self) -> None:
|
|
809
|
+
now = datetime.now(tz=UTC)
|
|
810
|
+
uptime = now - self._started_at
|
|
811
|
+
hours, remainder = divmod(int(uptime.total_seconds()), 3600)
|
|
812
|
+
minutes, seconds = divmod(remainder, 60)
|
|
813
|
+
|
|
814
|
+
# Title: VIN + connection status.
|
|
815
|
+
vin_display = _mask_vin(self._vin) if self._vin else "(waiting)"
|
|
816
|
+
status = "Connected" if self._connected else "Waiting"
|
|
817
|
+
self.title = f"tescmd {vin_display} [{status}]"
|
|
818
|
+
|
|
819
|
+
# Subtitle: frames + uptime (renders right-aligned beside clock).
|
|
820
|
+
self.sub_title = (
|
|
821
|
+
f"Frames: {self._frame_count:,} Up: {hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
def _update_panels(self) -> None:
|
|
825
|
+
"""Route each field to its categorized panel DataTable."""
|
|
826
|
+
for field_name in sorted(self._state.keys()):
|
|
827
|
+
panel_id = _FIELD_TO_PANEL.get(field_name, "diagnostics")
|
|
828
|
+
value = self._state[field_name]
|
|
829
|
+
display_value = _format_value(field_name, value, self._units)
|
|
830
|
+
tracked = self._panel_table_fields[panel_id]
|
|
831
|
+
|
|
832
|
+
table = self.query_one(f"#{panel_id}-table", DataTable)
|
|
833
|
+
if field_name in tracked:
|
|
834
|
+
table.update_cell(field_name, "value", display_value)
|
|
835
|
+
else:
|
|
836
|
+
table.add_row(field_name, display_value, key=field_name)
|
|
837
|
+
tracked.add(field_name)
|
|
838
|
+
|
|
839
|
+
def _update_server_info(self) -> None:
|
|
840
|
+
parts: list[str] = []
|
|
841
|
+
# Tunnel first — it's the public entry point.
|
|
842
|
+
if self._tunnel_url:
|
|
843
|
+
parts.append(f"Tunnel: {self._tunnel_url}")
|
|
844
|
+
# MCP as relative path (port/path) when tunnel is present.
|
|
845
|
+
if self._mcp_url:
|
|
846
|
+
parts.append(f"MCP: {_relative_path(self._mcp_url, self._tunnel_url)}")
|
|
847
|
+
# Telemetry WS port.
|
|
848
|
+
if self._telemetry_port is not None:
|
|
849
|
+
parts.append(f"WS: :{self._telemetry_port}")
|
|
850
|
+
if self._sink_count:
|
|
851
|
+
parts.append(f"Sinks: {self._sink_count}")
|
|
852
|
+
if self._cache_stats:
|
|
853
|
+
parts.append(f"Cache: {self._cache_stats}")
|
|
854
|
+
if self._openclaw_status:
|
|
855
|
+
parts.append(f"OpenClaw: {self._openclaw_status}")
|
|
856
|
+
|
|
857
|
+
info_widget = self.query_one("#server-bar", Static)
|
|
858
|
+
if parts:
|
|
859
|
+
info_widget.update(" | ".join(parts))
|
|
860
|
+
info_widget.display = True
|
|
861
|
+
else:
|
|
862
|
+
info_widget.update("")
|
|
863
|
+
info_widget.display = False
|
|
864
|
+
|
|
865
|
+
# -- Command execution (keybinding actions) --------------------------------
|
|
866
|
+
|
|
867
|
+
def _show_command_status(self, text: str) -> None:
|
|
868
|
+
widget = self.query_one("#command-status", Static)
|
|
869
|
+
widget.update(text)
|
|
870
|
+
widget.display = True
|
|
871
|
+
|
|
872
|
+
def _hide_command_status(self) -> None:
|
|
873
|
+
widget = self.query_one("#command-status", Static)
|
|
874
|
+
widget.display = False
|
|
875
|
+
|
|
876
|
+
def _run_command(self, cli_args: list[str], description: str) -> None:
|
|
877
|
+
"""Run a CLI command in a background thread worker."""
|
|
878
|
+
self._show_command_status(f"Sending: {description}...")
|
|
879
|
+
self._cmd_logger.info("%s args=%s", description, cli_args)
|
|
880
|
+
|
|
881
|
+
async def _invoke() -> None:
|
|
882
|
+
from tescmd.cli.main import cli
|
|
883
|
+
|
|
884
|
+
# Suppress httpx/httpcore logging during invocation — their INFO
|
|
885
|
+
# lines write directly to the terminal and corrupt the TUI display.
|
|
886
|
+
_noisy_loggers = ("httpx", "httpcore")
|
|
887
|
+
saved_levels = {n: logging.getLogger(n).level for n in _noisy_loggers}
|
|
888
|
+
for n in _noisy_loggers:
|
|
889
|
+
logging.getLogger(n).setLevel(logging.WARNING)
|
|
890
|
+
|
|
891
|
+
runner = _make_cli_runner()
|
|
892
|
+
env = os.environ.copy()
|
|
893
|
+
if self._vin:
|
|
894
|
+
env["TESLA_VIN"] = self._vin
|
|
895
|
+
try:
|
|
896
|
+
result = runner.invoke(cli, ["--format", "json", "--wake", *cli_args], env=env)
|
|
897
|
+
finally:
|
|
898
|
+
for n in _noisy_loggers:
|
|
899
|
+
logging.getLogger(n).setLevel(saved_levels[n])
|
|
900
|
+
|
|
901
|
+
if result.exit_code == 0:
|
|
902
|
+
self._cmd_logger.info("OK %s", description)
|
|
903
|
+
self.call_from_thread(self._show_command_status, f"OK: {description}")
|
|
904
|
+
else:
|
|
905
|
+
stderr = (result.stderr or "").strip()
|
|
906
|
+
stdout = (result.output or "").strip()
|
|
907
|
+
exc_msg = str(result.exception) if result.exception else ""
|
|
908
|
+
err = _extract_error_message(stderr, stdout, exc_msg)
|
|
909
|
+
self._cmd_logger.error("FAIL %s — %s", description, err)
|
|
910
|
+
self.call_from_thread(self._show_command_status, f"FAIL: {description} — {err}")
|
|
911
|
+
|
|
912
|
+
await asyncio.sleep(5)
|
|
913
|
+
self.call_from_thread(self._hide_command_status)
|
|
914
|
+
|
|
915
|
+
self.run_worker(_invoke, thread=True, exclusive=False) # type: ignore[arg-type]
|
|
916
|
+
|
|
917
|
+
# -- Quit (signals shutdown to serve.py) -----------------------------------
|
|
918
|
+
|
|
919
|
+
async def action_quit(self) -> None:
|
|
920
|
+
"""Quit the TUI and signal the serve loop to shut down."""
|
|
921
|
+
self._cmd_logger.info("QUIT user requested shutdown")
|
|
922
|
+
self._cleanup_activity_handler()
|
|
923
|
+
self._show_command_status("Shutting down...")
|
|
924
|
+
self.shutdown_event.set()
|
|
925
|
+
self.exit()
|
|
926
|
+
|
|
927
|
+
# -- Help screen -----------------------------------------------------------
|
|
928
|
+
|
|
929
|
+
def action_help(self) -> None:
|
|
930
|
+
server_parts: list[str] = []
|
|
931
|
+
if self._mcp_url:
|
|
932
|
+
server_parts.append(f"MCP: {self._mcp_url}")
|
|
933
|
+
if self._tunnel_url:
|
|
934
|
+
server_parts.append(f"Tunnel: {self._tunnel_url}")
|
|
935
|
+
log_paths = ""
|
|
936
|
+
if self._log_path:
|
|
937
|
+
log_paths += f"CSV log: {self._log_path}\n"
|
|
938
|
+
cmd_log = getattr(self, "_cmd_log_path", "")
|
|
939
|
+
if cmd_log:
|
|
940
|
+
log_paths += f"Command log: {cmd_log}\n"
|
|
941
|
+
if self._activity_log_path:
|
|
942
|
+
log_paths += f"Activity log: {self._activity_log_path}\n"
|
|
943
|
+
self.push_screen(
|
|
944
|
+
HelpScreen(log_path=log_paths.rstrip(), server_info=" ".join(server_parts))
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
# -- Command palette (Ctrl+P) -----------------------------------------------
|
|
948
|
+
|
|
949
|
+
def get_system_commands(self, screen: Screen[Any]) -> Iterable[SystemCommand]:
|
|
950
|
+
"""Populate the command palette with all vehicle commands."""
|
|
951
|
+
yield from super().get_system_commands(screen)
|
|
952
|
+
|
|
953
|
+
# --- Security ---
|
|
954
|
+
yield SystemCommand("Lock doors", "Lock all doors", self.action_cmd_lock)
|
|
955
|
+
yield SystemCommand("Unlock doors", "Unlock all doors", self.action_cmd_unlock)
|
|
956
|
+
yield SystemCommand("Honk horn", "Honk the horn", self.action_cmd_honk)
|
|
957
|
+
yield SystemCommand("Flash lights", "Flash headlights", self.action_cmd_flash)
|
|
958
|
+
yield SystemCommand("Sentry mode on", "Enable sentry mode", self.action_cmd_sentry_on)
|
|
959
|
+
yield SystemCommand("Sentry mode off", "Disable sentry mode", self.action_cmd_sentry_off)
|
|
960
|
+
yield SystemCommand("Remote start", "Enable keyless driving", self.action_cmd_remote_start)
|
|
961
|
+
yield SystemCommand("Valet mode on", "Enable valet mode", self.action_cmd_valet_on)
|
|
962
|
+
yield SystemCommand("Valet mode off", "Disable valet mode", self.action_cmd_valet_off)
|
|
963
|
+
yield SystemCommand("Valet PIN reset", "Reset valet mode PIN", self.action_cmd_valet_reset)
|
|
964
|
+
yield SystemCommand(
|
|
965
|
+
"Auto-secure", "Auto-lock and close windows", self.action_cmd_auto_secure
|
|
966
|
+
)
|
|
967
|
+
yield SystemCommand(
|
|
968
|
+
"PIN to Drive on", "Enable PIN to Drive", self.action_cmd_pin_to_drive_on
|
|
969
|
+
)
|
|
970
|
+
yield SystemCommand(
|
|
971
|
+
"PIN to Drive off", "Disable PIN to Drive", self.action_cmd_pin_to_drive_off
|
|
972
|
+
)
|
|
973
|
+
yield SystemCommand("Guest mode on", "Enable guest mode", self.action_cmd_guest_on)
|
|
974
|
+
yield SystemCommand("Guest mode off", "Disable guest mode", self.action_cmd_guest_off)
|
|
975
|
+
yield SystemCommand(
|
|
976
|
+
"Boombox", "Play boombox sound", self.action_cmd_boombox, discover=False
|
|
977
|
+
)
|
|
978
|
+
yield SystemCommand(
|
|
979
|
+
"Speed limit clear (admin)",
|
|
980
|
+
"Admin clear speed limit",
|
|
981
|
+
self.action_cmd_speed_clear_admin,
|
|
982
|
+
discover=False,
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
# --- Charging ---
|
|
986
|
+
yield SystemCommand(
|
|
987
|
+
"Start charging", "Begin charging session", self.action_cmd_charge_start
|
|
988
|
+
)
|
|
989
|
+
yield SystemCommand("Stop charging", "Stop charging session", self.action_cmd_charge_stop)
|
|
990
|
+
yield SystemCommand(
|
|
991
|
+
"Open charge port", "Open the charge port door", self.action_cmd_port_open
|
|
992
|
+
)
|
|
993
|
+
yield SystemCommand(
|
|
994
|
+
"Close charge port", "Close the charge port door", self.action_cmd_port_close
|
|
995
|
+
)
|
|
996
|
+
yield SystemCommand(
|
|
997
|
+
"Charge limit: max range",
|
|
998
|
+
"Set charge limit to 100%",
|
|
999
|
+
self.action_cmd_charge_max,
|
|
1000
|
+
)
|
|
1001
|
+
yield SystemCommand(
|
|
1002
|
+
"Charge limit: standard",
|
|
1003
|
+
"Set charge limit to standard (80%)",
|
|
1004
|
+
self.action_cmd_charge_std,
|
|
1005
|
+
)
|
|
1006
|
+
yield SystemCommand(
|
|
1007
|
+
"Charge limit: 50%",
|
|
1008
|
+
"Set charge limit to 50%",
|
|
1009
|
+
lambda: self._run_command(["charge", "limit", "50"], "Charge limit 50%"),
|
|
1010
|
+
)
|
|
1011
|
+
yield SystemCommand(
|
|
1012
|
+
"Charge limit: 60%",
|
|
1013
|
+
"Set charge limit to 60%",
|
|
1014
|
+
lambda: self._run_command(["charge", "limit", "60"], "Charge limit 60%"),
|
|
1015
|
+
)
|
|
1016
|
+
yield SystemCommand(
|
|
1017
|
+
"Charge limit: 70%",
|
|
1018
|
+
"Set charge limit to 70%",
|
|
1019
|
+
lambda: self._run_command(["charge", "limit", "70"], "Charge limit 70%"),
|
|
1020
|
+
)
|
|
1021
|
+
yield SystemCommand(
|
|
1022
|
+
"Charge limit: 80%",
|
|
1023
|
+
"Set charge limit to 80%",
|
|
1024
|
+
lambda: self._run_command(["charge", "limit", "80"], "Charge limit 80%"),
|
|
1025
|
+
)
|
|
1026
|
+
yield SystemCommand(
|
|
1027
|
+
"Charge limit: 90%",
|
|
1028
|
+
"Set charge limit to 90%",
|
|
1029
|
+
lambda: self._run_command(["charge", "limit", "90"], "Charge limit 90%"),
|
|
1030
|
+
)
|
|
1031
|
+
yield SystemCommand(
|
|
1032
|
+
"Charge amps: 8A",
|
|
1033
|
+
"Set charge amps to 8",
|
|
1034
|
+
lambda: self._run_command(["charge", "amps", "8"], "Charge amps 8A"),
|
|
1035
|
+
)
|
|
1036
|
+
yield SystemCommand(
|
|
1037
|
+
"Charge amps: 16A",
|
|
1038
|
+
"Set charge amps to 16",
|
|
1039
|
+
lambda: self._run_command(["charge", "amps", "16"], "Charge amps 16A"),
|
|
1040
|
+
)
|
|
1041
|
+
yield SystemCommand(
|
|
1042
|
+
"Charge amps: 24A",
|
|
1043
|
+
"Set charge amps to 24",
|
|
1044
|
+
lambda: self._run_command(["charge", "amps", "24"], "Charge amps 24A"),
|
|
1045
|
+
)
|
|
1046
|
+
yield SystemCommand(
|
|
1047
|
+
"Charge amps: 32A",
|
|
1048
|
+
"Set charge amps to 32",
|
|
1049
|
+
lambda: self._run_command(["charge", "amps", "32"], "Charge amps 32A"),
|
|
1050
|
+
)
|
|
1051
|
+
yield SystemCommand(
|
|
1052
|
+
"Charge amps: 48A",
|
|
1053
|
+
"Set charge amps to 48",
|
|
1054
|
+
lambda: self._run_command(["charge", "amps", "48"], "Charge amps 48A"),
|
|
1055
|
+
)
|
|
1056
|
+
yield SystemCommand(
|
|
1057
|
+
"Scheduled charging on",
|
|
1058
|
+
"Enable scheduled charging",
|
|
1059
|
+
self.action_cmd_charge_schedule_on,
|
|
1060
|
+
)
|
|
1061
|
+
yield SystemCommand(
|
|
1062
|
+
"Scheduled charging off",
|
|
1063
|
+
"Disable scheduled charging",
|
|
1064
|
+
self.action_cmd_charge_schedule_off,
|
|
1065
|
+
)
|
|
1066
|
+
|
|
1067
|
+
# --- Climate ---
|
|
1068
|
+
yield SystemCommand("Climate on", "Turn on climate control", self.action_cmd_climate_on)
|
|
1069
|
+
yield SystemCommand("Climate off", "Turn off climate control", self.action_cmd_climate_off)
|
|
1070
|
+
yield SystemCommand(
|
|
1071
|
+
"Steering wheel heater on",
|
|
1072
|
+
"Enable steering wheel heater",
|
|
1073
|
+
self.action_cmd_wheel_heater_on,
|
|
1074
|
+
)
|
|
1075
|
+
yield SystemCommand(
|
|
1076
|
+
"Steering wheel heater off",
|
|
1077
|
+
"Disable steering wheel heater",
|
|
1078
|
+
self.action_cmd_wheel_heater_off,
|
|
1079
|
+
)
|
|
1080
|
+
yield SystemCommand(
|
|
1081
|
+
"Preconditioning on",
|
|
1082
|
+
"Enable battery preconditioning",
|
|
1083
|
+
self.action_cmd_precondition_on,
|
|
1084
|
+
)
|
|
1085
|
+
yield SystemCommand(
|
|
1086
|
+
"Preconditioning off",
|
|
1087
|
+
"Disable battery preconditioning",
|
|
1088
|
+
self.action_cmd_precondition_off,
|
|
1089
|
+
)
|
|
1090
|
+
yield SystemCommand(
|
|
1091
|
+
"Cabin overheat protection on",
|
|
1092
|
+
"Enable cabin overheat protection",
|
|
1093
|
+
self.action_cmd_overheat_on,
|
|
1094
|
+
)
|
|
1095
|
+
yield SystemCommand(
|
|
1096
|
+
"Cabin overheat protection off",
|
|
1097
|
+
"Disable cabin overheat protection",
|
|
1098
|
+
self.action_cmd_overheat_off,
|
|
1099
|
+
)
|
|
1100
|
+
yield SystemCommand(
|
|
1101
|
+
"Bioweapon defense on",
|
|
1102
|
+
"Enable bioweapon defense mode",
|
|
1103
|
+
self.action_cmd_bioweapon_on,
|
|
1104
|
+
)
|
|
1105
|
+
yield SystemCommand(
|
|
1106
|
+
"Bioweapon defense off",
|
|
1107
|
+
"Disable bioweapon defense mode",
|
|
1108
|
+
self.action_cmd_bioweapon_off,
|
|
1109
|
+
)
|
|
1110
|
+
yield SystemCommand(
|
|
1111
|
+
"Auto steering wheel heater on",
|
|
1112
|
+
"Enable auto steering wheel heater",
|
|
1113
|
+
self.action_cmd_auto_wheel_on,
|
|
1114
|
+
)
|
|
1115
|
+
yield SystemCommand(
|
|
1116
|
+
"Auto steering wheel heater off",
|
|
1117
|
+
"Disable auto steering wheel heater",
|
|
1118
|
+
self.action_cmd_auto_wheel_off,
|
|
1119
|
+
)
|
|
1120
|
+
yield SystemCommand("Defrost on", "Enable max defrost", self.action_cmd_defrost_on)
|
|
1121
|
+
yield SystemCommand("Defrost off", "Disable max defrost", self.action_cmd_defrost_off)
|
|
1122
|
+
yield SystemCommand(
|
|
1123
|
+
"Rear defrost on",
|
|
1124
|
+
"Enable rear window defrost",
|
|
1125
|
+
lambda: self._run_command(["climate", "set", "--rear-defrost-on"], "Rear defrost on"),
|
|
1126
|
+
)
|
|
1127
|
+
yield SystemCommand(
|
|
1128
|
+
"Rear defrost off",
|
|
1129
|
+
"Disable rear window defrost",
|
|
1130
|
+
lambda: self._run_command(
|
|
1131
|
+
["climate", "set", "--rear-defrost-off"], "Rear defrost off"
|
|
1132
|
+
),
|
|
1133
|
+
)
|
|
1134
|
+
yield SystemCommand(
|
|
1135
|
+
"Set cabin temp 68°F / 20°C",
|
|
1136
|
+
"Set driver and passenger temp to 68°F",
|
|
1137
|
+
lambda: self._run_command(
|
|
1138
|
+
["climate", "set", "--driver-temp", "20", "--passenger-temp", "20"],
|
|
1139
|
+
"Cabin temp 20°C",
|
|
1140
|
+
),
|
|
1141
|
+
)
|
|
1142
|
+
yield SystemCommand(
|
|
1143
|
+
"Set cabin temp 72°F / 22°C",
|
|
1144
|
+
"Set driver and passenger temp to 72°F",
|
|
1145
|
+
lambda: self._run_command(
|
|
1146
|
+
["climate", "set", "--driver-temp", "22", "--passenger-temp", "22"],
|
|
1147
|
+
"Cabin temp 22°C",
|
|
1148
|
+
),
|
|
1149
|
+
)
|
|
1150
|
+
yield SystemCommand(
|
|
1151
|
+
"Set cabin temp 75°F / 24°C",
|
|
1152
|
+
"Set driver and passenger temp to 75°F",
|
|
1153
|
+
lambda: self._run_command(
|
|
1154
|
+
["climate", "set", "--driver-temp", "24", "--passenger-temp", "24"],
|
|
1155
|
+
"Cabin temp 24°C",
|
|
1156
|
+
),
|
|
1157
|
+
)
|
|
1158
|
+
yield SystemCommand(
|
|
1159
|
+
"Seat heater: driver high",
|
|
1160
|
+
"Set driver seat heater to high",
|
|
1161
|
+
lambda: self._run_command(
|
|
1162
|
+
["climate", "seat", "driver", "3"], "Driver seat heater high"
|
|
1163
|
+
),
|
|
1164
|
+
)
|
|
1165
|
+
yield SystemCommand(
|
|
1166
|
+
"Seat heater: driver off",
|
|
1167
|
+
"Turn off driver seat heater",
|
|
1168
|
+
lambda: self._run_command(
|
|
1169
|
+
["climate", "seat", "driver", "0"], "Driver seat heater off"
|
|
1170
|
+
),
|
|
1171
|
+
)
|
|
1172
|
+
yield SystemCommand(
|
|
1173
|
+
"Seat heater: passenger high",
|
|
1174
|
+
"Set passenger seat heater to high",
|
|
1175
|
+
lambda: self._run_command(
|
|
1176
|
+
["climate", "seat", "passenger", "3"],
|
|
1177
|
+
"Passenger seat heater high",
|
|
1178
|
+
),
|
|
1179
|
+
)
|
|
1180
|
+
yield SystemCommand(
|
|
1181
|
+
"Seat heater: passenger off",
|
|
1182
|
+
"Turn off passenger seat heater",
|
|
1183
|
+
lambda: self._run_command(
|
|
1184
|
+
["climate", "seat", "passenger", "0"], "Passenger seat heater off"
|
|
1185
|
+
),
|
|
1186
|
+
)
|
|
1187
|
+
yield SystemCommand(
|
|
1188
|
+
"Auto seat climate: driver on",
|
|
1189
|
+
"Enable driver auto seat climate",
|
|
1190
|
+
lambda: self._run_command(
|
|
1191
|
+
["climate", "auto-seat", "driver", "--on"], "Auto seat driver on"
|
|
1192
|
+
),
|
|
1193
|
+
)
|
|
1194
|
+
yield SystemCommand(
|
|
1195
|
+
"Auto seat climate: driver off",
|
|
1196
|
+
"Disable driver auto seat climate",
|
|
1197
|
+
lambda: self._run_command(
|
|
1198
|
+
["climate", "auto-seat", "driver", "--off"], "Auto seat driver off"
|
|
1199
|
+
),
|
|
1200
|
+
)
|
|
1201
|
+
yield SystemCommand(
|
|
1202
|
+
"Climate keeper: Dog mode",
|
|
1203
|
+
"Set climate keeper to Dog mode",
|
|
1204
|
+
lambda: self._run_command(["climate", "keeper", "dog"], "Climate keeper: Dog"),
|
|
1205
|
+
)
|
|
1206
|
+
yield SystemCommand(
|
|
1207
|
+
"Climate keeper: Camp mode",
|
|
1208
|
+
"Set climate keeper to Camp mode",
|
|
1209
|
+
lambda: self._run_command(["climate", "keeper", "camp"], "Climate keeper: Camp"),
|
|
1210
|
+
)
|
|
1211
|
+
yield SystemCommand(
|
|
1212
|
+
"Climate keeper: off",
|
|
1213
|
+
"Turn off climate keeper mode",
|
|
1214
|
+
lambda: self._run_command(["climate", "keeper", "off"], "Climate keeper: off"),
|
|
1215
|
+
)
|
|
1216
|
+
yield SystemCommand(
|
|
1217
|
+
"Cabin overheat temp: low",
|
|
1218
|
+
"Set overheat protection temp to low (90°F/32°C)",
|
|
1219
|
+
lambda: self._run_command(["climate", "cop-temp", "low"], "Overheat temp: low"),
|
|
1220
|
+
)
|
|
1221
|
+
yield SystemCommand(
|
|
1222
|
+
"Cabin overheat temp: medium",
|
|
1223
|
+
"Set overheat protection temp to medium (100°F/38°C)",
|
|
1224
|
+
lambda: self._run_command(["climate", "cop-temp", "medium"], "Overheat temp: medium"),
|
|
1225
|
+
)
|
|
1226
|
+
yield SystemCommand(
|
|
1227
|
+
"Cabin overheat temp: high",
|
|
1228
|
+
"Set overheat protection temp to high (110°F/43°C)",
|
|
1229
|
+
lambda: self._run_command(["climate", "cop-temp", "high"], "Overheat temp: high"),
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
# --- Trunk & Windows ---
|
|
1233
|
+
yield SystemCommand("Open trunk", "Open/close rear trunk", self.action_cmd_trunk)
|
|
1234
|
+
yield SystemCommand("Close trunk", "Close rear trunk", self.action_cmd_trunk_close)
|
|
1235
|
+
yield SystemCommand("Open frunk", "Open front trunk", self.action_cmd_frunk)
|
|
1236
|
+
yield SystemCommand("Vent windows", "Vent all windows", self.action_cmd_window_vent)
|
|
1237
|
+
yield SystemCommand("Close windows", "Close all windows", self.action_cmd_window_close)
|
|
1238
|
+
yield SystemCommand("Vent sunroof", "Vent the sunroof", self.action_cmd_sunroof_vent)
|
|
1239
|
+
yield SystemCommand("Close sunroof", "Close the sunroof", self.action_cmd_sunroof_close)
|
|
1240
|
+
yield SystemCommand("Open tonneau", "Open the tonneau cover", self.action_cmd_tonneau_open)
|
|
1241
|
+
yield SystemCommand(
|
|
1242
|
+
"Close tonneau", "Close the tonneau cover", self.action_cmd_tonneau_close
|
|
1243
|
+
)
|
|
1244
|
+
yield SystemCommand("Stop tonneau", "Stop tonneau movement", self.action_cmd_tonneau_stop)
|
|
1245
|
+
|
|
1246
|
+
# --- Media ---
|
|
1247
|
+
yield SystemCommand("Play / Pause", "Toggle media playback", self.action_cmd_play_pause)
|
|
1248
|
+
yield SystemCommand("Next track", "Skip to next track", self.action_cmd_next_track)
|
|
1249
|
+
yield SystemCommand("Previous track", "Go to previous track", self.action_cmd_prev_track)
|
|
1250
|
+
yield SystemCommand("Next favorite", "Skip to next favorite", self.action_cmd_next_fav)
|
|
1251
|
+
yield SystemCommand(
|
|
1252
|
+
"Previous favorite", "Go to previous favorite", self.action_cmd_prev_fav
|
|
1253
|
+
)
|
|
1254
|
+
yield SystemCommand("Volume up", "Increase volume", self.action_cmd_vol_up)
|
|
1255
|
+
yield SystemCommand("Volume down", "Decrease volume", self.action_cmd_vol_down)
|
|
1256
|
+
yield SystemCommand(
|
|
1257
|
+
"Volume: mute (0)",
|
|
1258
|
+
"Set volume to 0",
|
|
1259
|
+
lambda: self._run_command(["media", "adjust-volume", "0"], "Volume 0"),
|
|
1260
|
+
)
|
|
1261
|
+
yield SystemCommand(
|
|
1262
|
+
"Volume: 25%",
|
|
1263
|
+
"Set volume to 25%",
|
|
1264
|
+
lambda: self._run_command(["media", "adjust-volume", "2.75"], "Volume 25%"),
|
|
1265
|
+
)
|
|
1266
|
+
yield SystemCommand(
|
|
1267
|
+
"Volume: 50%",
|
|
1268
|
+
"Set volume to 50%",
|
|
1269
|
+
lambda: self._run_command(["media", "adjust-volume", "5.5"], "Volume 50%"),
|
|
1270
|
+
)
|
|
1271
|
+
|
|
1272
|
+
# --- Navigation ---
|
|
1273
|
+
yield SystemCommand(
|
|
1274
|
+
"Navigate to Supercharger",
|
|
1275
|
+
"Navigate to nearest Supercharger",
|
|
1276
|
+
self.action_cmd_nav_supercharger,
|
|
1277
|
+
)
|
|
1278
|
+
yield SystemCommand(
|
|
1279
|
+
"Trigger HomeLink",
|
|
1280
|
+
"Trigger HomeLink (garage door)",
|
|
1281
|
+
self.action_cmd_nav_homelink,
|
|
1282
|
+
)
|
|
1283
|
+
yield SystemCommand(
|
|
1284
|
+
"Navigation: get GPS position",
|
|
1285
|
+
"Read current vehicle GPS coordinates",
|
|
1286
|
+
lambda: self._run_command(["nav", "gps"], "GPS position"),
|
|
1287
|
+
)
|
|
1288
|
+
|
|
1289
|
+
# --- Software ---
|
|
1290
|
+
yield SystemCommand(
|
|
1291
|
+
"Cancel software update",
|
|
1292
|
+
"Cancel a pending software update",
|
|
1293
|
+
self.action_cmd_software_cancel,
|
|
1294
|
+
)
|
|
1295
|
+
yield SystemCommand(
|
|
1296
|
+
"Schedule software update now",
|
|
1297
|
+
"Schedule software update to install in 60s",
|
|
1298
|
+
lambda: self._run_command(["software", "schedule", "60"], "Schedule software update"),
|
|
1299
|
+
)
|
|
1300
|
+
|
|
1301
|
+
# --- Vehicle ---
|
|
1302
|
+
yield SystemCommand("Wake vehicle", "Wake the vehicle from sleep", self.action_cmd_wake)
|
|
1303
|
+
yield SystemCommand(
|
|
1304
|
+
"Low power mode on",
|
|
1305
|
+
"Enable low power consumption mode",
|
|
1306
|
+
self.action_cmd_low_power_on,
|
|
1307
|
+
)
|
|
1308
|
+
yield SystemCommand(
|
|
1309
|
+
"Low power mode off",
|
|
1310
|
+
"Disable low power consumption mode",
|
|
1311
|
+
self.action_cmd_low_power_off,
|
|
1312
|
+
)
|
|
1313
|
+
yield SystemCommand(
|
|
1314
|
+
"Accessory power on",
|
|
1315
|
+
"Enable accessory power mode",
|
|
1316
|
+
self.action_cmd_accessory_power_on,
|
|
1317
|
+
)
|
|
1318
|
+
yield SystemCommand(
|
|
1319
|
+
"Accessory power off",
|
|
1320
|
+
"Disable accessory power mode",
|
|
1321
|
+
self.action_cmd_accessory_power_off,
|
|
1322
|
+
)
|
|
1323
|
+
yield SystemCommand(
|
|
1324
|
+
"Mobile access on",
|
|
1325
|
+
"Enable mobile app access",
|
|
1326
|
+
lambda: self._run_command(["vehicle", "mobile-access", "--on"], "Mobile access on"),
|
|
1327
|
+
)
|
|
1328
|
+
yield SystemCommand(
|
|
1329
|
+
"Mobile access off",
|
|
1330
|
+
"Disable mobile app access",
|
|
1331
|
+
lambda: self._run_command(["vehicle", "mobile-access", "--off"], "Mobile access off"),
|
|
1332
|
+
)
|
|
1333
|
+
|
|
1334
|
+
# --- Sharing ---
|
|
1335
|
+
yield SystemCommand(
|
|
1336
|
+
"List driver invites",
|
|
1337
|
+
"List all sharing invitations",
|
|
1338
|
+
lambda: self._run_command(["sharing", "list-invites"], "List invites"),
|
|
1339
|
+
)
|
|
1340
|
+
|
|
1341
|
+
# -- Security actions ------------------------------------------------------
|
|
1342
|
+
|
|
1343
|
+
def action_cmd_lock(self) -> None:
|
|
1344
|
+
self._run_command(["security", "lock"], "Lock doors")
|
|
1345
|
+
|
|
1346
|
+
def action_cmd_unlock(self) -> None:
|
|
1347
|
+
self._run_command(["security", "unlock"], "Unlock doors")
|
|
1348
|
+
|
|
1349
|
+
def action_cmd_honk(self) -> None:
|
|
1350
|
+
self._run_command(["security", "honk"], "Honk horn")
|
|
1351
|
+
|
|
1352
|
+
def action_cmd_flash(self) -> None:
|
|
1353
|
+
self._run_command(["security", "flash"], "Flash lights")
|
|
1354
|
+
|
|
1355
|
+
def action_cmd_sentry_on(self) -> None:
|
|
1356
|
+
self._run_command(["security", "sentry", "--on"], "Sentry mode on")
|
|
1357
|
+
|
|
1358
|
+
def action_cmd_sentry_off(self) -> None:
|
|
1359
|
+
self._run_command(["security", "sentry", "--off"], "Sentry mode off")
|
|
1360
|
+
|
|
1361
|
+
def action_cmd_remote_start(self) -> None:
|
|
1362
|
+
self._run_command(["security", "remote-start"], "Remote start")
|
|
1363
|
+
|
|
1364
|
+
def action_cmd_valet_on(self) -> None:
|
|
1365
|
+
self._run_command(["security", "valet", "--on"], "Valet mode on")
|
|
1366
|
+
|
|
1367
|
+
def action_cmd_valet_off(self) -> None:
|
|
1368
|
+
self._run_command(["security", "valet", "--off"], "Valet mode off")
|
|
1369
|
+
|
|
1370
|
+
def action_cmd_valet_reset(self) -> None:
|
|
1371
|
+
self._run_command(["security", "valet-reset"], "Valet pin reset")
|
|
1372
|
+
|
|
1373
|
+
def action_cmd_auto_secure(self) -> None:
|
|
1374
|
+
self._run_command(["security", "auto-secure"], "Auto secure")
|
|
1375
|
+
|
|
1376
|
+
def action_cmd_pin_to_drive_on(self) -> None:
|
|
1377
|
+
self._run_command(["security", "pin-to-drive", "--on"], "PIN to drive on")
|
|
1378
|
+
|
|
1379
|
+
def action_cmd_pin_to_drive_off(self) -> None:
|
|
1380
|
+
self._run_command(["security", "pin-to-drive", "--off"], "PIN to drive off")
|
|
1381
|
+
|
|
1382
|
+
def action_cmd_guest_on(self) -> None:
|
|
1383
|
+
self._run_command(["security", "guest-mode", "--on"], "Guest mode on")
|
|
1384
|
+
|
|
1385
|
+
def action_cmd_guest_off(self) -> None:
|
|
1386
|
+
self._run_command(["security", "guest-mode", "--off"], "Guest mode off")
|
|
1387
|
+
|
|
1388
|
+
def action_cmd_boombox(self) -> None:
|
|
1389
|
+
self._run_command(["security", "boombox"], "Boombox")
|
|
1390
|
+
|
|
1391
|
+
def action_cmd_speed_clear_admin(self) -> None:
|
|
1392
|
+
self._run_command(["security", "speed-clear-admin"], "Speed limit clear (admin)")
|
|
1393
|
+
|
|
1394
|
+
# -- Charging actions ------------------------------------------------------
|
|
1395
|
+
|
|
1396
|
+
def action_cmd_charge_start(self) -> None:
|
|
1397
|
+
self._run_command(["charge", "start"], "Start charging")
|
|
1398
|
+
|
|
1399
|
+
def action_cmd_charge_stop(self) -> None:
|
|
1400
|
+
self._run_command(["charge", "stop"], "Stop charging")
|
|
1401
|
+
|
|
1402
|
+
def action_cmd_port_open(self) -> None:
|
|
1403
|
+
self._run_command(["charge", "port-open"], "Open charge port")
|
|
1404
|
+
|
|
1405
|
+
def action_cmd_port_close(self) -> None:
|
|
1406
|
+
self._run_command(["charge", "port-close"], "Close charge port")
|
|
1407
|
+
|
|
1408
|
+
def action_cmd_charge_max(self) -> None:
|
|
1409
|
+
self._run_command(["charge", "limit-max"], "Charge limit max")
|
|
1410
|
+
|
|
1411
|
+
def action_cmd_charge_std(self) -> None:
|
|
1412
|
+
self._run_command(["charge", "limit-std"], "Charge limit standard")
|
|
1413
|
+
|
|
1414
|
+
def action_cmd_charge_schedule_on(self) -> None:
|
|
1415
|
+
self._run_command(["charge", "schedule", "--enable"], "Charge schedule on")
|
|
1416
|
+
|
|
1417
|
+
def action_cmd_charge_schedule_off(self) -> None:
|
|
1418
|
+
self._run_command(["charge", "schedule", "--disable"], "Charge schedule off")
|
|
1419
|
+
|
|
1420
|
+
# -- Climate actions -------------------------------------------------------
|
|
1421
|
+
|
|
1422
|
+
def action_cmd_climate_on(self) -> None:
|
|
1423
|
+
self._run_command(["climate", "on"], "Climate on")
|
|
1424
|
+
|
|
1425
|
+
def action_cmd_climate_off(self) -> None:
|
|
1426
|
+
self._run_command(["climate", "off"], "Climate off")
|
|
1427
|
+
|
|
1428
|
+
def action_cmd_wheel_heater_on(self) -> None:
|
|
1429
|
+
self._run_command(["climate", "wheel-heater", "--on"], "Wheel heater on")
|
|
1430
|
+
|
|
1431
|
+
def action_cmd_wheel_heater_off(self) -> None:
|
|
1432
|
+
self._run_command(["climate", "wheel-heater", "--off"], "Wheel heater off")
|
|
1433
|
+
|
|
1434
|
+
def action_cmd_precondition_on(self) -> None:
|
|
1435
|
+
self._run_command(["climate", "precondition", "--on"], "Precondition on")
|
|
1436
|
+
|
|
1437
|
+
def action_cmd_precondition_off(self) -> None:
|
|
1438
|
+
self._run_command(["climate", "precondition", "--off"], "Precondition off")
|
|
1439
|
+
|
|
1440
|
+
def action_cmd_overheat_on(self) -> None:
|
|
1441
|
+
self._run_command(["climate", "overheat", "--on"], "Overheat protection on")
|
|
1442
|
+
|
|
1443
|
+
def action_cmd_overheat_off(self) -> None:
|
|
1444
|
+
self._run_command(["climate", "overheat", "--off"], "Overheat protection off")
|
|
1445
|
+
|
|
1446
|
+
def action_cmd_bioweapon_on(self) -> None:
|
|
1447
|
+
self._run_command(["climate", "bioweapon", "--on"], "Bioweapon defense on")
|
|
1448
|
+
|
|
1449
|
+
def action_cmd_bioweapon_off(self) -> None:
|
|
1450
|
+
self._run_command(["climate", "bioweapon", "--off"], "Bioweapon defense off")
|
|
1451
|
+
|
|
1452
|
+
def action_cmd_auto_wheel_on(self) -> None:
|
|
1453
|
+
self._run_command(["climate", "auto-wheel", "--on"], "Auto wheel heater on")
|
|
1454
|
+
|
|
1455
|
+
def action_cmd_auto_wheel_off(self) -> None:
|
|
1456
|
+
self._run_command(["climate", "auto-wheel", "--off"], "Auto wheel heater off")
|
|
1457
|
+
|
|
1458
|
+
def action_cmd_defrost_on(self) -> None:
|
|
1459
|
+
self._run_command(["climate", "set", "--defrost-on"], "Defrost on")
|
|
1460
|
+
|
|
1461
|
+
def action_cmd_defrost_off(self) -> None:
|
|
1462
|
+
self._run_command(["climate", "set", "--defrost-off"], "Defrost off")
|
|
1463
|
+
|
|
1464
|
+
# -- Trunk actions ---------------------------------------------------------
|
|
1465
|
+
|
|
1466
|
+
def action_cmd_trunk(self) -> None:
|
|
1467
|
+
self._run_command(["trunk", "open"], "Trunk open/close")
|
|
1468
|
+
|
|
1469
|
+
def action_cmd_trunk_close(self) -> None:
|
|
1470
|
+
self._run_command(["trunk", "close"], "Trunk close")
|
|
1471
|
+
|
|
1472
|
+
def action_cmd_frunk(self) -> None:
|
|
1473
|
+
self._run_command(["trunk", "frunk"], "Frunk open")
|
|
1474
|
+
|
|
1475
|
+
def action_cmd_window_vent(self) -> None:
|
|
1476
|
+
self._run_command(["trunk", "window", "--vent"], "Windows vent")
|
|
1477
|
+
|
|
1478
|
+
def action_cmd_window_close(self) -> None:
|
|
1479
|
+
self._run_command(["trunk", "window", "--close"], "Windows close")
|
|
1480
|
+
|
|
1481
|
+
def action_cmd_sunroof_vent(self) -> None:
|
|
1482
|
+
self._run_command(["trunk", "sunroof", "--state", "vent"], "Sunroof vent")
|
|
1483
|
+
|
|
1484
|
+
def action_cmd_sunroof_close(self) -> None:
|
|
1485
|
+
self._run_command(["trunk", "sunroof", "--state", "close"], "Sunroof close")
|
|
1486
|
+
|
|
1487
|
+
def action_cmd_tonneau_open(self) -> None:
|
|
1488
|
+
self._run_command(["trunk", "tonneau-open"], "Tonneau open")
|
|
1489
|
+
|
|
1490
|
+
def action_cmd_tonneau_close(self) -> None:
|
|
1491
|
+
self._run_command(["trunk", "tonneau-close"], "Tonneau close")
|
|
1492
|
+
|
|
1493
|
+
def action_cmd_tonneau_stop(self) -> None:
|
|
1494
|
+
self._run_command(["trunk", "tonneau-stop"], "Tonneau stop")
|
|
1495
|
+
|
|
1496
|
+
# -- Media actions ---------------------------------------------------------
|
|
1497
|
+
|
|
1498
|
+
def action_cmd_play_pause(self) -> None:
|
|
1499
|
+
self._run_command(["media", "play-pause"], "Play/Pause")
|
|
1500
|
+
|
|
1501
|
+
def action_cmd_next_track(self) -> None:
|
|
1502
|
+
self._run_command(["media", "next-track"], "Next track")
|
|
1503
|
+
|
|
1504
|
+
def action_cmd_prev_track(self) -> None:
|
|
1505
|
+
self._run_command(["media", "prev-track"], "Previous track")
|
|
1506
|
+
|
|
1507
|
+
def action_cmd_next_fav(self) -> None:
|
|
1508
|
+
self._run_command(["media", "next-fav"], "Next favorite")
|
|
1509
|
+
|
|
1510
|
+
def action_cmd_prev_fav(self) -> None:
|
|
1511
|
+
self._run_command(["media", "prev-fav"], "Previous favorite")
|
|
1512
|
+
|
|
1513
|
+
def action_cmd_vol_up(self) -> None:
|
|
1514
|
+
self._run_command(["media", "volume-up"], "Volume up")
|
|
1515
|
+
|
|
1516
|
+
def action_cmd_vol_down(self) -> None:
|
|
1517
|
+
self._run_command(["media", "volume-down"], "Volume down")
|
|
1518
|
+
|
|
1519
|
+
# -- Navigation actions ----------------------------------------------------
|
|
1520
|
+
|
|
1521
|
+
def action_cmd_nav_supercharger(self) -> None:
|
|
1522
|
+
self._run_command(["nav", "supercharger"], "Navigate to Supercharger")
|
|
1523
|
+
|
|
1524
|
+
def action_cmd_nav_homelink(self) -> None:
|
|
1525
|
+
self._run_command(["nav", "homelink"], "HomeLink trigger")
|
|
1526
|
+
|
|
1527
|
+
# -- Software actions ------------------------------------------------------
|
|
1528
|
+
|
|
1529
|
+
def action_cmd_software_cancel(self) -> None:
|
|
1530
|
+
self._run_command(["software", "cancel"], "Cancel software update")
|
|
1531
|
+
|
|
1532
|
+
# -- Vehicle actions -------------------------------------------------------
|
|
1533
|
+
|
|
1534
|
+
def action_cmd_wake(self) -> None:
|
|
1535
|
+
self._run_command(["vehicle", "wake", "--wait"], "Wake vehicle")
|
|
1536
|
+
|
|
1537
|
+
def action_cmd_low_power_on(self) -> None:
|
|
1538
|
+
self._run_command(["vehicle", "low-power", "--on"], "Low power mode on")
|
|
1539
|
+
|
|
1540
|
+
def action_cmd_low_power_off(self) -> None:
|
|
1541
|
+
self._run_command(["vehicle", "low-power", "--off"], "Low power mode off")
|
|
1542
|
+
|
|
1543
|
+
def action_cmd_accessory_power_on(self) -> None:
|
|
1544
|
+
self._run_command(["vehicle", "accessory-power", "--on"], "Accessory power on")
|
|
1545
|
+
|
|
1546
|
+
def action_cmd_accessory_power_off(self) -> None:
|
|
1547
|
+
self._run_command(["vehicle", "accessory-power", "--off"], "Accessory power off")
|
|
1548
|
+
|
|
1549
|
+
|
|
1550
|
+
# ---------------------------------------------------------------------------
|
|
1551
|
+
# Value formatting (ported from dashboard.py)
|
|
1552
|
+
# ---------------------------------------------------------------------------
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
def _relative_path(url: str, tunnel_url: str) -> str:
|
|
1556
|
+
"""Reduce a full URL to a relative port/path when a tunnel is active.
|
|
1557
|
+
|
|
1558
|
+
If the URL shares the same scheme+host as *tunnel_url*, show only the
|
|
1559
|
+
path portion. Otherwise fall back to ``:port/path`` for localhost URLs,
|
|
1560
|
+
or the full URL as-is.
|
|
1561
|
+
"""
|
|
1562
|
+
from urllib.parse import urlparse
|
|
1563
|
+
|
|
1564
|
+
parsed = urlparse(url)
|
|
1565
|
+
if tunnel_url:
|
|
1566
|
+
tunnel_parsed = urlparse(tunnel_url)
|
|
1567
|
+
if parsed.hostname == tunnel_parsed.hostname:
|
|
1568
|
+
return parsed.path or "/"
|
|
1569
|
+
# Localhost — show :port/path.
|
|
1570
|
+
if parsed.hostname in ("127.0.0.1", "localhost", "::1"):
|
|
1571
|
+
return f":{parsed.port}{parsed.path}" if parsed.port else parsed.path or "/"
|
|
1572
|
+
return url
|
|
1573
|
+
|
|
1574
|
+
|
|
1575
|
+
def _extract_error_message(stderr: str, stdout: str, exc_msg: str = "") -> str:
|
|
1576
|
+
"""Extract a human-readable error from CLI output.
|
|
1577
|
+
|
|
1578
|
+
When the TUI runs commands via CliRunner, exceptions are caught by Click
|
|
1579
|
+
and stored in ``result.exception`` rather than being formatted by
|
|
1580
|
+
``main()``'s error handler. The *exc_msg* parameter (from
|
|
1581
|
+
``str(result.exception)``) is the most reliable error source.
|
|
1582
|
+
|
|
1583
|
+
Falls back to parsing JSON error envelopes from stderr, then the last
|
|
1584
|
+
non-httpx line, then raw stderr/stdout.
|
|
1585
|
+
"""
|
|
1586
|
+
import json
|
|
1587
|
+
|
|
1588
|
+
# Best source: the exception message from Click's runner.
|
|
1589
|
+
if exc_msg:
|
|
1590
|
+
return exc_msg[:120]
|
|
1591
|
+
|
|
1592
|
+
# Try each line of stderr for a JSON error envelope.
|
|
1593
|
+
for line in stderr.splitlines():
|
|
1594
|
+
line = line.strip()
|
|
1595
|
+
if line.startswith("{"):
|
|
1596
|
+
try:
|
|
1597
|
+
data = json.loads(line)
|
|
1598
|
+
err = data.get("error", {})
|
|
1599
|
+
msg = err.get("message", "") if isinstance(err, dict) else str(err)
|
|
1600
|
+
if msg:
|
|
1601
|
+
return msg[:120]
|
|
1602
|
+
except json.JSONDecodeError:
|
|
1603
|
+
continue
|
|
1604
|
+
|
|
1605
|
+
# Fall back: last non-empty line of stderr, filtering out httpx INFO lines.
|
|
1606
|
+
for line in reversed(stderr.splitlines()):
|
|
1607
|
+
line = line.strip()
|
|
1608
|
+
if line and "HTTP Request:" not in line and "HTTP/1.1" not in line:
|
|
1609
|
+
return line[:120]
|
|
1610
|
+
|
|
1611
|
+
return (stderr or stdout)[:80]
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
def _format_value(
|
|
1615
|
+
field_name: str,
|
|
1616
|
+
value: Any,
|
|
1617
|
+
units: DisplayUnits | None,
|
|
1618
|
+
) -> str:
|
|
1619
|
+
"""Format a telemetry value with optional unit conversion."""
|
|
1620
|
+
if value is None:
|
|
1621
|
+
return "—"
|
|
1622
|
+
|
|
1623
|
+
if isinstance(value, dict):
|
|
1624
|
+
lat = value.get("latitude", 0.0)
|
|
1625
|
+
lng = value.get("longitude", 0.0)
|
|
1626
|
+
return f"{lat:.6f}, {lng:.6f}"
|
|
1627
|
+
|
|
1628
|
+
if isinstance(value, bool):
|
|
1629
|
+
return "Yes" if value else "No"
|
|
1630
|
+
|
|
1631
|
+
if units is not None:
|
|
1632
|
+
return _format_with_units(field_name, value, units)
|
|
1633
|
+
|
|
1634
|
+
# No unit preferences — raw value.
|
|
1635
|
+
if isinstance(value, float):
|
|
1636
|
+
return f"{value:.2f}"
|
|
1637
|
+
return str(value)
|
|
1638
|
+
|
|
1639
|
+
|
|
1640
|
+
_TEMP_FIELDS = frozenset(
|
|
1641
|
+
{
|
|
1642
|
+
"InsideTemp",
|
|
1643
|
+
"OutsideTemp",
|
|
1644
|
+
"HvacLeftTemperatureRequest",
|
|
1645
|
+
"HvacRightTemperatureRequest",
|
|
1646
|
+
"ModuleTempMax",
|
|
1647
|
+
"ModuleTempMin",
|
|
1648
|
+
}
|
|
1649
|
+
)
|
|
1650
|
+
_DISTANCE_FIELDS = frozenset(
|
|
1651
|
+
{
|
|
1652
|
+
"Odometer",
|
|
1653
|
+
"EstBatteryRange",
|
|
1654
|
+
"IdealBatteryRange",
|
|
1655
|
+
"RatedRange",
|
|
1656
|
+
"MilesToArrival",
|
|
1657
|
+
}
|
|
1658
|
+
)
|
|
1659
|
+
_SPEED_FIELDS = frozenset({"VehicleSpeed", "CruiseSetSpeed", "CurrentLimitMph"})
|
|
1660
|
+
_PRESSURE_FIELDS = frozenset(
|
|
1661
|
+
{
|
|
1662
|
+
"TpmsPressureFl",
|
|
1663
|
+
"TpmsPressureFr",
|
|
1664
|
+
"TpmsPressureRl",
|
|
1665
|
+
"TpmsPressureRr",
|
|
1666
|
+
}
|
|
1667
|
+
)
|
|
1668
|
+
_PCT_FIELDS = frozenset({"Soc", "BatteryLevel", "ChargeLimitSoc"})
|
|
1669
|
+
|
|
1670
|
+
|
|
1671
|
+
def _format_with_units(field_name: str, value: Any, units: DisplayUnits) -> str:
|
|
1672
|
+
"""Apply unit conversion based on user preferences."""
|
|
1673
|
+
from tescmd.output.rich_output import DistanceUnit, PressureUnit, TempUnit
|
|
1674
|
+
|
|
1675
|
+
if field_name in _TEMP_FIELDS and isinstance(value, (int, float)):
|
|
1676
|
+
if units.temp == TempUnit.F:
|
|
1677
|
+
return f"{value * 9 / 5 + 32:.1f}\u00b0F"
|
|
1678
|
+
return f"{value:.1f}\u00b0C"
|
|
1679
|
+
|
|
1680
|
+
if field_name in _DISTANCE_FIELDS and isinstance(value, (int, float)):
|
|
1681
|
+
if units.distance == DistanceUnit.KM:
|
|
1682
|
+
return f"{value * 1.60934:.1f} km"
|
|
1683
|
+
return f"{value:.1f} mi"
|
|
1684
|
+
|
|
1685
|
+
if field_name in _SPEED_FIELDS and isinstance(value, (int, float)):
|
|
1686
|
+
if units.distance == DistanceUnit.KM:
|
|
1687
|
+
return f"{value * 1.60934:.0f} km/h"
|
|
1688
|
+
return f"{value:.0f} mph"
|
|
1689
|
+
|
|
1690
|
+
if field_name in _PRESSURE_FIELDS and isinstance(value, (int, float)):
|
|
1691
|
+
if units.pressure == PressureUnit.PSI:
|
|
1692
|
+
return f"{value * 14.5038:.1f} psi"
|
|
1693
|
+
return f"{value:.2f} bar"
|
|
1694
|
+
|
|
1695
|
+
if field_name in _PCT_FIELDS and isinstance(value, (int, float)):
|
|
1696
|
+
return f"{value}%"
|
|
1697
|
+
|
|
1698
|
+
# Voltage / current.
|
|
1699
|
+
if "Voltage" in field_name and isinstance(value, (int, float)):
|
|
1700
|
+
return f"{value:.1f} V"
|
|
1701
|
+
if ("Current" in field_name or "Amps" in field_name) and isinstance(value, (int, float)):
|
|
1702
|
+
return f"{value:.1f} A"
|
|
1703
|
+
|
|
1704
|
+
# Power.
|
|
1705
|
+
if "Power" in field_name and isinstance(value, (int, float)):
|
|
1706
|
+
return f"{value:.2f} kW"
|
|
1707
|
+
|
|
1708
|
+
# Time-to-full.
|
|
1709
|
+
if field_name == "TimeToFullCharge" and isinstance(value, (int, float)):
|
|
1710
|
+
hours = int(value)
|
|
1711
|
+
mins = int((value - hours) * 60)
|
|
1712
|
+
return f"{hours}h {mins}m" if hours else f"{mins}m"
|
|
1713
|
+
|
|
1714
|
+
if isinstance(value, float):
|
|
1715
|
+
return f"{value:.2f}"
|
|
1716
|
+
return str(value)
|