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.
Files changed (90) hide show
  1. tescmd/__init__.py +1 -1
  2. tescmd/api/client.py +49 -5
  3. tescmd/api/command.py +1 -1
  4. tescmd/api/errors.py +13 -0
  5. tescmd/api/signed_command.py +19 -14
  6. tescmd/api/vehicle.py +19 -1
  7. tescmd/auth/oauth.py +5 -1
  8. tescmd/auth/server.py +6 -1
  9. tescmd/auth/token_store.py +8 -1
  10. tescmd/cache/response_cache.py +11 -3
  11. tescmd/cli/_client.py +142 -20
  12. tescmd/cli/_options.py +2 -4
  13. tescmd/cli/auth.py +121 -11
  14. tescmd/cli/energy.py +2 -0
  15. tescmd/cli/key.py +149 -14
  16. tescmd/cli/main.py +70 -7
  17. tescmd/cli/mcp_cmd.py +153 -0
  18. tescmd/cli/nav.py +3 -1
  19. tescmd/cli/openclaw.py +169 -0
  20. tescmd/cli/security.py +7 -1
  21. tescmd/cli/serve.py +923 -0
  22. tescmd/cli/setup.py +244 -25
  23. tescmd/cli/sharing.py +2 -0
  24. tescmd/cli/status.py +1 -1
  25. tescmd/cli/trunk.py +8 -17
  26. tescmd/cli/user.py +16 -1
  27. tescmd/cli/vehicle.py +156 -20
  28. tescmd/crypto/__init__.py +3 -1
  29. tescmd/crypto/ecdh.py +9 -0
  30. tescmd/crypto/schnorr.py +191 -0
  31. tescmd/deploy/github_pages.py +8 -0
  32. tescmd/deploy/tailscale_serve.py +154 -0
  33. tescmd/mcp/__init__.py +7 -0
  34. tescmd/mcp/server.py +648 -0
  35. tescmd/models/__init__.py +0 -2
  36. tescmd/models/auth.py +24 -2
  37. tescmd/models/config.py +1 -0
  38. tescmd/models/energy.py +0 -9
  39. tescmd/openclaw/__init__.py +23 -0
  40. tescmd/openclaw/bridge.py +330 -0
  41. tescmd/openclaw/config.py +167 -0
  42. tescmd/openclaw/dispatcher.py +522 -0
  43. tescmd/openclaw/emitter.py +175 -0
  44. tescmd/openclaw/filters.py +123 -0
  45. tescmd/openclaw/gateway.py +687 -0
  46. tescmd/openclaw/telemetry_store.py +53 -0
  47. tescmd/output/rich_output.py +46 -14
  48. tescmd/protocol/commands.py +2 -2
  49. tescmd/protocol/encoder.py +16 -13
  50. tescmd/protocol/payloads.py +132 -11
  51. tescmd/protocol/session.py +18 -8
  52. tescmd/protocol/signer.py +3 -17
  53. tescmd/telemetry/__init__.py +28 -0
  54. tescmd/telemetry/cache_sink.py +154 -0
  55. tescmd/telemetry/csv_sink.py +180 -0
  56. tescmd/telemetry/dashboard.py +227 -0
  57. tescmd/telemetry/decoder.py +284 -0
  58. tescmd/telemetry/fanout.py +49 -0
  59. tescmd/telemetry/fields.py +427 -0
  60. tescmd/telemetry/flatbuf.py +162 -0
  61. tescmd/telemetry/mapper.py +239 -0
  62. tescmd/telemetry/protos/__init__.py +4 -0
  63. tescmd/telemetry/protos/vehicle_alert.proto +31 -0
  64. tescmd/telemetry/protos/vehicle_alert_pb2.py +42 -0
  65. tescmd/telemetry/protos/vehicle_alert_pb2.pyi +44 -0
  66. tescmd/telemetry/protos/vehicle_connectivity.proto +23 -0
  67. tescmd/telemetry/protos/vehicle_connectivity_pb2.py +40 -0
  68. tescmd/telemetry/protos/vehicle_connectivity_pb2.pyi +33 -0
  69. tescmd/telemetry/protos/vehicle_data.proto +768 -0
  70. tescmd/telemetry/protos/vehicle_data_pb2.py +136 -0
  71. tescmd/telemetry/protos/vehicle_data_pb2.pyi +1336 -0
  72. tescmd/telemetry/protos/vehicle_error.proto +23 -0
  73. tescmd/telemetry/protos/vehicle_error_pb2.py +44 -0
  74. tescmd/telemetry/protos/vehicle_error_pb2.pyi +39 -0
  75. tescmd/telemetry/protos/vehicle_metric.proto +22 -0
  76. tescmd/telemetry/protos/vehicle_metric_pb2.py +44 -0
  77. tescmd/telemetry/protos/vehicle_metric_pb2.pyi +37 -0
  78. tescmd/telemetry/server.py +300 -0
  79. tescmd/telemetry/setup.py +468 -0
  80. tescmd/telemetry/tailscale.py +300 -0
  81. tescmd/telemetry/tui.py +1716 -0
  82. tescmd/triggers/__init__.py +18 -0
  83. tescmd/triggers/manager.py +264 -0
  84. tescmd/triggers/models.py +93 -0
  85. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/METADATA +125 -40
  86. tescmd-0.3.1.dist-info/RECORD +128 -0
  87. tescmd-0.1.2.dist-info/RECORD +0 -81
  88. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
  89. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
  90. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -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)