tescmd 0.1.2__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 (81) hide show
  1. tescmd/__init__.py +3 -0
  2. tescmd/__main__.py +5 -0
  3. tescmd/_internal/__init__.py +0 -0
  4. tescmd/_internal/async_utils.py +25 -0
  5. tescmd/_internal/permissions.py +43 -0
  6. tescmd/_internal/vin.py +44 -0
  7. tescmd/api/__init__.py +1 -0
  8. tescmd/api/charging.py +102 -0
  9. tescmd/api/client.py +189 -0
  10. tescmd/api/command.py +540 -0
  11. tescmd/api/energy.py +146 -0
  12. tescmd/api/errors.py +76 -0
  13. tescmd/api/partner.py +40 -0
  14. tescmd/api/sharing.py +65 -0
  15. tescmd/api/signed_command.py +277 -0
  16. tescmd/api/user.py +38 -0
  17. tescmd/api/vehicle.py +150 -0
  18. tescmd/auth/__init__.py +1 -0
  19. tescmd/auth/oauth.py +312 -0
  20. tescmd/auth/server.py +108 -0
  21. tescmd/auth/token_store.py +273 -0
  22. tescmd/ble/__init__.py +0 -0
  23. tescmd/cache/__init__.py +6 -0
  24. tescmd/cache/keys.py +51 -0
  25. tescmd/cache/response_cache.py +213 -0
  26. tescmd/cli/__init__.py +0 -0
  27. tescmd/cli/_client.py +603 -0
  28. tescmd/cli/_options.py +126 -0
  29. tescmd/cli/auth.py +682 -0
  30. tescmd/cli/billing.py +240 -0
  31. tescmd/cli/cache.py +85 -0
  32. tescmd/cli/charge.py +610 -0
  33. tescmd/cli/climate.py +501 -0
  34. tescmd/cli/energy.py +385 -0
  35. tescmd/cli/key.py +611 -0
  36. tescmd/cli/main.py +601 -0
  37. tescmd/cli/media.py +146 -0
  38. tescmd/cli/nav.py +242 -0
  39. tescmd/cli/partner.py +112 -0
  40. tescmd/cli/raw.py +75 -0
  41. tescmd/cli/security.py +495 -0
  42. tescmd/cli/setup.py +786 -0
  43. tescmd/cli/sharing.py +188 -0
  44. tescmd/cli/software.py +81 -0
  45. tescmd/cli/status.py +106 -0
  46. tescmd/cli/trunk.py +240 -0
  47. tescmd/cli/user.py +145 -0
  48. tescmd/cli/vehicle.py +837 -0
  49. tescmd/config/__init__.py +0 -0
  50. tescmd/crypto/__init__.py +19 -0
  51. tescmd/crypto/ecdh.py +46 -0
  52. tescmd/crypto/keys.py +122 -0
  53. tescmd/deploy/__init__.py +0 -0
  54. tescmd/deploy/github_pages.py +268 -0
  55. tescmd/models/__init__.py +85 -0
  56. tescmd/models/auth.py +108 -0
  57. tescmd/models/command.py +18 -0
  58. tescmd/models/config.py +63 -0
  59. tescmd/models/energy.py +56 -0
  60. tescmd/models/sharing.py +26 -0
  61. tescmd/models/user.py +37 -0
  62. tescmd/models/vehicle.py +185 -0
  63. tescmd/output/__init__.py +5 -0
  64. tescmd/output/formatter.py +132 -0
  65. tescmd/output/json_output.py +83 -0
  66. tescmd/output/rich_output.py +809 -0
  67. tescmd/protocol/__init__.py +23 -0
  68. tescmd/protocol/commands.py +175 -0
  69. tescmd/protocol/encoder.py +122 -0
  70. tescmd/protocol/metadata.py +116 -0
  71. tescmd/protocol/payloads.py +621 -0
  72. tescmd/protocol/protobuf/__init__.py +6 -0
  73. tescmd/protocol/protobuf/messages.py +564 -0
  74. tescmd/protocol/session.py +318 -0
  75. tescmd/protocol/signer.py +84 -0
  76. tescmd/py.typed +0 -0
  77. tescmd-0.1.2.dist-info/METADATA +458 -0
  78. tescmd-0.1.2.dist-info/RECORD +81 -0
  79. tescmd-0.1.2.dist-info/WHEEL +4 -0
  80. tescmd-0.1.2.dist-info/entry_points.txt +2 -0
  81. tescmd-0.1.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,809 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from enum import StrEnum
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from rich.panel import Panel
9
+ from rich.table import Table
10
+
11
+ if TYPE_CHECKING:
12
+ from rich.console import Console
13
+
14
+ from tescmd.models.energy import LiveStatus, SiteInfo
15
+ from tescmd.models.user import UserInfo, UserRegion
16
+ from tescmd.models.vehicle import (
17
+ ChargeState,
18
+ ClimateState,
19
+ DriveState,
20
+ GuiSettings,
21
+ NearbyChargingSites,
22
+ Vehicle,
23
+ VehicleConfig,
24
+ VehicleData,
25
+ VehicleState,
26
+ )
27
+
28
+
29
+ # -- Unit types --------------------------------------------------------
30
+
31
+
32
+ class PressureUnit(StrEnum):
33
+ PSI = "psi"
34
+ BAR = "bar"
35
+
36
+
37
+ class TempUnit(StrEnum):
38
+ F = "F"
39
+ C = "C"
40
+
41
+
42
+ class DistanceUnit(StrEnum):
43
+ MI = "mi"
44
+ KM = "km"
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class DisplayUnits:
49
+ """Controls how values are displayed. Defaults to US units."""
50
+
51
+ pressure: PressureUnit = PressureUnit.PSI
52
+ temp: TempUnit = TempUnit.F
53
+ distance: DistanceUnit = DistanceUnit.MI
54
+
55
+
56
+ # -- Conversion constants ----------------------------------------------
57
+
58
+ _BAR_TO_PSI = 14.5038
59
+ _MI_TO_KM = 1.60934
60
+
61
+
62
+ class RichOutput:
63
+ """Rich-based terminal output helpers for *tescmd*."""
64
+
65
+ def __init__(self, console: Console, units: DisplayUnits | None = None) -> None:
66
+ self._con = console
67
+ self._units = units or DisplayUnits()
68
+
69
+ # ------------------------------------------------------------------
70
+ # Unit formatting helpers
71
+ # ------------------------------------------------------------------
72
+
73
+ def _fmt_temp(self, celsius: float) -> str:
74
+ if self._units.temp == TempUnit.F:
75
+ return f"{celsius * 9.0 / 5.0 + 32.0:.1f}\u00b0F"
76
+ return f"{celsius:.1f}\u00b0C"
77
+
78
+ def _fmt_dist(self, miles: float) -> str:
79
+ if self._units.distance == DistanceUnit.KM:
80
+ return f"{miles * _MI_TO_KM:.1f} km"
81
+ return f"{miles} mi"
82
+
83
+ def _fmt_odometer(self, miles: float) -> str:
84
+ if self._units.distance == DistanceUnit.KM:
85
+ return f"{miles * _MI_TO_KM:,.1f} km"
86
+ return f"{miles:,.1f} mi"
87
+
88
+ def _fmt_speed(self, mph: int) -> str:
89
+ if self._units.distance == DistanceUnit.KM:
90
+ return f"{mph * _MI_TO_KM:.0f} km/h"
91
+ return f"{mph} mph"
92
+
93
+ def _fmt_rate(self, mi_per_hr: float) -> str:
94
+ if self._units.distance == DistanceUnit.KM:
95
+ return f"{mi_per_hr * _MI_TO_KM:.1f} km/hr"
96
+ return f"{mi_per_hr} mi/hr"
97
+
98
+ def _fmt_pressure(self, bar: float) -> str:
99
+ if self._units.pressure == PressureUnit.PSI:
100
+ return f"{bar * _BAR_TO_PSI:.1f}"
101
+ return f"{bar:.2f}"
102
+
103
+ # ------------------------------------------------------------------
104
+ # Generic dict → table helper
105
+ # ------------------------------------------------------------------
106
+
107
+ def _dict_table(self, title: str, data: dict[str, Any], *, empty_msg: str = "") -> None:
108
+ """Render an arbitrary dict as a 2-column Field / Value table.
109
+
110
+ * Nested dicts are flattened one level deep with dot notation.
111
+ * Lists of scalars are comma-joined; lists of dicts show ``[N items]``.
112
+ * ``None`` values are skipped.
113
+ """
114
+ if not data:
115
+ self._con.print(empty_msg or "[dim]No data.[/dim]")
116
+ return
117
+
118
+ table = Table(title=title)
119
+ table.add_column("Field", style="bold")
120
+ table.add_column("Value")
121
+
122
+ for key, val in data.items():
123
+ if val is None:
124
+ continue
125
+ if isinstance(val, dict):
126
+ for sub_key, sub_val in val.items():
127
+ if sub_val is not None:
128
+ table.add_row(f"{key}.{sub_key}", str(sub_val))
129
+ elif isinstance(val, list):
130
+ if not val:
131
+ table.add_row(key, "[]")
132
+ elif isinstance(val[0], dict):
133
+ table.add_row(key, f"[{len(val)} items]")
134
+ else:
135
+ table.add_row(key, ", ".join(str(v) for v in val))
136
+ else:
137
+ table.add_row(key, str(val))
138
+
139
+ self._con.print(table)
140
+
141
+ # -- Thin wrappers for specific dict-based endpoints ----------------
142
+
143
+ def vehicle_subscriptions(self, data: dict[str, Any]) -> None:
144
+ """Display subscription eligibility data."""
145
+ self._dict_table(
146
+ "Subscription Eligibility",
147
+ data,
148
+ empty_msg="[dim]No subscription eligibility data.[/dim]",
149
+ )
150
+
151
+ def vehicle_upgrades(self, data: dict[str, Any]) -> None:
152
+ """Display upgrade eligibility data."""
153
+ self._dict_table(
154
+ "Upgrade Eligibility",
155
+ data,
156
+ empty_msg="[dim]No upgrade eligibility data.[/dim]",
157
+ )
158
+
159
+ def vehicle_options(self, data: dict[str, Any]) -> None:
160
+ """Display vehicle option codes."""
161
+ self._dict_table(
162
+ "Vehicle Options",
163
+ data,
164
+ empty_msg="[dim]No option data available.[/dim]",
165
+ )
166
+
167
+ def vehicle_specs(self, data: dict[str, Any]) -> None:
168
+ """Display vehicle specifications."""
169
+ self._dict_table(
170
+ "Vehicle Specifications",
171
+ data,
172
+ empty_msg="[dim]No spec data available.[/dim]",
173
+ )
174
+
175
+ def vehicle_warranty(self, data: dict[str, Any]) -> None:
176
+ """Display warranty details."""
177
+ self._dict_table(
178
+ "Warranty Details",
179
+ data,
180
+ empty_msg="[dim]No warranty data available.[/dim]",
181
+ )
182
+
183
+ def fleet_status(self, data: dict[str, Any]) -> None:
184
+ """Display fleet status."""
185
+ self._dict_table(
186
+ "Fleet Status",
187
+ data,
188
+ empty_msg="[dim]No fleet status data.[/dim]",
189
+ )
190
+
191
+ def telemetry_config(self, data: dict[str, Any]) -> None:
192
+ """Display fleet telemetry configuration."""
193
+ self._dict_table(
194
+ "Fleet Telemetry Config",
195
+ data,
196
+ empty_msg="[dim]No telemetry config found.[/dim]",
197
+ )
198
+
199
+ def telemetry_errors(self, data: dict[str, Any]) -> None:
200
+ """Display fleet telemetry errors."""
201
+ self._dict_table(
202
+ "Fleet Telemetry Errors",
203
+ data,
204
+ empty_msg="[dim]No telemetry errors found.[/dim]",
205
+ )
206
+
207
+ def vehicle_service(self, data: dict[str, Any]) -> None:
208
+ """Display vehicle service data."""
209
+ self._dict_table(
210
+ "Service Data",
211
+ data,
212
+ empty_msg="[dim]No service data available.[/dim]",
213
+ )
214
+
215
+ def vehicle_release_notes(self, data: dict[str, Any]) -> None:
216
+ """Display firmware release notes."""
217
+ self._dict_table(
218
+ "Release Notes",
219
+ data,
220
+ empty_msg="[dim]No release notes available.[/dim]",
221
+ )
222
+
223
+ # ------------------------------------------------------------------
224
+ # Vehicle list
225
+ # ------------------------------------------------------------------
226
+
227
+ def vehicle_list(self, vehicles: list[Vehicle]) -> None:
228
+ """Print a table of vehicles."""
229
+ table = Table(title="Vehicles")
230
+ table.add_column("VIN", style="cyan")
231
+ table.add_column("Name")
232
+ table.add_column("State")
233
+ table.add_column("ID", justify="right")
234
+
235
+ for v in vehicles:
236
+ state_style = "green" if v.state == "online" else "yellow"
237
+ table.add_row(
238
+ v.vin,
239
+ v.display_name or "",
240
+ f"[{state_style}]{v.state}[/{state_style}]",
241
+ str(v.vehicle_id) if v.vehicle_id is not None else "",
242
+ )
243
+
244
+ self._con.print(table)
245
+
246
+ # ------------------------------------------------------------------
247
+ # Full vehicle data
248
+ # ------------------------------------------------------------------
249
+
250
+ def vehicle_data(self, data: VehicleData) -> None:
251
+ """Print a panel containing all available vehicle data sections."""
252
+ title = data.display_name or data.vin
253
+ self._con.print(Panel(f"[bold]{title}[/bold]", expand=False))
254
+
255
+ if data.vehicle_state is not None:
256
+ self.vehicle_status(data.vehicle_state)
257
+ if data.charge_state is not None:
258
+ self.charge_status(data.charge_state)
259
+ if data.climate_state is not None:
260
+ self.climate_status(data.climate_state)
261
+ if data.drive_state is not None:
262
+ self.location(data.drive_state)
263
+ elif data.state == "online":
264
+ self.info("[dim]Location unavailable — vehicle command key not enrolled.[/dim]")
265
+ if data.vehicle_config is not None:
266
+ self.vehicle_config(data.vehicle_config)
267
+ if data.gui_settings is not None:
268
+ self.gui_settings(data.gui_settings)
269
+
270
+ # ------------------------------------------------------------------
271
+ # Charge status
272
+ # ------------------------------------------------------------------
273
+
274
+ def charge_status(self, cs: ChargeState) -> None:
275
+ """Print a table of charge-related fields (non-None only)."""
276
+ table = Table(title="Charge Status")
277
+ table.add_column("Field", style="bold")
278
+ table.add_column("Value")
279
+
280
+ rows: list[tuple[str, str]] = []
281
+ if cs.battery_level is not None:
282
+ rows.append(("Battery %", f"{cs.battery_level}%"))
283
+ if cs.battery_range is not None:
284
+ rows.append(("Range", self._fmt_dist(cs.battery_range)))
285
+ if cs.charging_state is not None:
286
+ state = cs.charging_state
287
+ style = {
288
+ "Charging": "green",
289
+ "Complete": "cyan",
290
+ "Disconnected": "dim",
291
+ "Stopped": "yellow",
292
+ }.get(state, "")
293
+ label = f"[{style}]{state}[/{style}]" if style else state
294
+ rows.append(("Status", label))
295
+ if cs.charge_limit_soc is not None:
296
+ rows.append(("Limit", f"{cs.charge_limit_soc}%"))
297
+ if cs.charge_rate is not None and cs.charge_rate > 0:
298
+ rows.append(("Rate", self._fmt_rate(cs.charge_rate)))
299
+ if cs.charger_voltage is not None and cs.charger_voltage > 0:
300
+ rows.append(("Voltage", f"{cs.charger_voltage} V"))
301
+ if cs.charger_actual_current is not None and cs.charger_actual_current > 0:
302
+ rows.append(("Current", f"{cs.charger_actual_current} A"))
303
+ if cs.charger_type:
304
+ rows.append(("Charger", cs.charger_type))
305
+ if cs.minutes_to_full_charge is not None and cs.minutes_to_full_charge > 0:
306
+ hours, mins = divmod(cs.minutes_to_full_charge, 60)
307
+ if hours:
308
+ rows.append(("Time remaining", f"{hours}h {mins}m"))
309
+ else:
310
+ rows.append(("Time remaining", f"{mins} min"))
311
+ if cs.charge_port_door_open is not None:
312
+ rows.append(("Port door", "open" if cs.charge_port_door_open else "closed"))
313
+ if cs.ideal_battery_range is not None:
314
+ rows.append(("Ideal range", self._fmt_dist(cs.ideal_battery_range)))
315
+ if cs.usable_battery_level is not None and cs.usable_battery_level != cs.battery_level:
316
+ rows.append(("Usable %", f"{cs.usable_battery_level}%"))
317
+ if cs.charge_energy_added is not None and cs.charge_energy_added > 0:
318
+ rows.append(("Energy added", f"{cs.charge_energy_added} kWh"))
319
+ if cs.charge_miles_added_rated is not None and cs.charge_miles_added_rated > 0:
320
+ rows.append(("Range added", self._fmt_dist(cs.charge_miles_added_rated)))
321
+ if cs.charger_power is not None and cs.charger_power > 0:
322
+ rows.append(("Charger power", f"{cs.charger_power} kW"))
323
+ if cs.conn_charge_cable and cs.conn_charge_cable != "<invalid>":
324
+ rows.append(("Cable", cs.conn_charge_cable))
325
+ if cs.charge_port_latch:
326
+ rows.append(("Port latch", cs.charge_port_latch))
327
+ if cs.scheduled_charging_mode and cs.scheduled_charging_mode != "Off":
328
+ rows.append(("Scheduled", cs.scheduled_charging_mode))
329
+ if cs.battery_heater_on is True:
330
+ rows.append(("Battery heater", "[green]on[/green]"))
331
+ if cs.preconditioning_enabled is True:
332
+ rows.append(("Preconditioning", "[green]on[/green]"))
333
+ if cs.est_battery_range is not None:
334
+ rows.append(("Est. range", self._fmt_dist(cs.est_battery_range)))
335
+ if (
336
+ cs.time_to_full_charge is not None
337
+ and cs.time_to_full_charge > 0
338
+ and cs.minutes_to_full_charge is None
339
+ ):
340
+ rows.append(("Time to full", f"{cs.time_to_full_charge:.1f}h"))
341
+ if cs.scheduled_charging_start_time is not None:
342
+ rows.append(
343
+ (
344
+ "Scheduled start",
345
+ datetime.fromtimestamp(cs.scheduled_charging_start_time).strftime("%I:%M %p"),
346
+ )
347
+ )
348
+ if cs.scheduled_departure_time_minutes is not None:
349
+ h, m = divmod(cs.scheduled_departure_time_minutes, 60)
350
+ rows.append(("Scheduled departure", f"{h:02d}:{m:02d}"))
351
+
352
+ for field, value in rows:
353
+ table.add_row(field, value)
354
+
355
+ self._con.print(table)
356
+
357
+ # ------------------------------------------------------------------
358
+ # Climate status
359
+ # ------------------------------------------------------------------
360
+
361
+ def climate_status(self, cs: ClimateState) -> None:
362
+ """Print a table of climate-related fields."""
363
+ table = Table(title="Climate Status")
364
+ table.add_column("Field", style="bold")
365
+ table.add_column("Value")
366
+
367
+ if cs.inside_temp is not None:
368
+ table.add_row("Inside temp", self._fmt_temp(cs.inside_temp))
369
+ if cs.outside_temp is not None:
370
+ table.add_row("Outside temp", self._fmt_temp(cs.outside_temp))
371
+ if cs.driver_temp_setting is not None:
372
+ table.add_row("Driver temp", self._fmt_temp(cs.driver_temp_setting))
373
+ if cs.passenger_temp_setting is not None:
374
+ table.add_row("Passenger temp", self._fmt_temp(cs.passenger_temp_setting))
375
+ if cs.is_climate_on is not None:
376
+ on = cs.is_climate_on
377
+ table.add_row("HVAC", "[green]on[/green]" if on else "off")
378
+ if cs.fan_status is not None and cs.fan_status > 0:
379
+ table.add_row("Fan speed", str(cs.fan_status))
380
+ if cs.defrost_mode is not None and cs.defrost_mode > 0:
381
+ table.add_row("Defrost", "on")
382
+ if cs.seat_heater_left is not None and cs.seat_heater_left > 0:
383
+ table.add_row("Seat heater L", _heat_level(cs.seat_heater_left))
384
+ if cs.seat_heater_right is not None and cs.seat_heater_right > 0:
385
+ table.add_row("Seat heater R", _heat_level(cs.seat_heater_right))
386
+ if cs.steering_wheel_heater is True:
387
+ table.add_row("Wheel heater", "on")
388
+ if cs.cabin_overheat_protection:
389
+ cop = cs.cabin_overheat_protection
390
+ if cs.cabin_overheat_protection_actively_cooling is True:
391
+ cop = f"[green]{cop}[/green]"
392
+ table.add_row("Cabin overheat", cop)
393
+ if cs.is_auto_conditioning_on is True:
394
+ table.add_row("Auto conditioning", "on")
395
+ if cs.is_preconditioning is True:
396
+ table.add_row("Preconditioning", "on")
397
+ if cs.seat_heater_rear_left is not None and cs.seat_heater_rear_left > 0:
398
+ table.add_row("Seat heater RL", _heat_level(cs.seat_heater_rear_left))
399
+ if cs.seat_heater_rear_center is not None and cs.seat_heater_rear_center > 0:
400
+ table.add_row("Seat heater RC", _heat_level(cs.seat_heater_rear_center))
401
+ if cs.seat_heater_rear_right is not None and cs.seat_heater_rear_right > 0:
402
+ table.add_row("Seat heater RR", _heat_level(cs.seat_heater_rear_right))
403
+ if cs.bioweapon_defense_mode is True:
404
+ table.add_row("Bio-defense", "[green]on[/green]")
405
+
406
+ self._con.print(table)
407
+
408
+ # ------------------------------------------------------------------
409
+ # Location / drive state
410
+ # ------------------------------------------------------------------
411
+
412
+ def location(self, ds: DriveState) -> None:
413
+ """Print a table of drive-state / location fields."""
414
+ table = Table(title="Location")
415
+ table.add_column("Field", style="bold")
416
+ table.add_column("Value")
417
+
418
+ if ds.latitude is not None and ds.longitude is not None:
419
+ table.add_row("Coordinates", f"{ds.latitude}, {ds.longitude}")
420
+ if ds.heading is not None:
421
+ table.add_row("Heading", f"{ds.heading}\u00b0")
422
+ if ds.shift_state:
423
+ table.add_row("Gear", ds.shift_state)
424
+ if ds.speed is not None:
425
+ table.add_row("Speed", self._fmt_speed(ds.speed))
426
+ if ds.power is not None:
427
+ table.add_row("Power", f"{ds.power} kW")
428
+ if ds.timestamp is not None:
429
+ table.add_row(
430
+ "Updated",
431
+ datetime.fromtimestamp(ds.timestamp / 1000).strftime("%Y-%m-%d %H:%M:%S"),
432
+ )
433
+
434
+ self._con.print(table)
435
+
436
+ # ------------------------------------------------------------------
437
+ # Vehicle state
438
+ # ------------------------------------------------------------------
439
+
440
+ def vehicle_status(self, vs: VehicleState) -> None:
441
+ """Print a table of vehicle state fields."""
442
+ table = Table(title="Vehicle Status")
443
+ table.add_column("Field", style="bold")
444
+ table.add_column("Value")
445
+
446
+ if vs.locked is not None:
447
+ locked = vs.locked
448
+ table.add_row(
449
+ "Locked",
450
+ "[green]yes[/green]" if locked else "[yellow]no[/yellow]",
451
+ )
452
+ if vs.sentry_mode is not None:
453
+ on = vs.sentry_mode
454
+ table.add_row("Sentry mode", "[green]on[/green]" if on else "off")
455
+ if vs.odometer is not None:
456
+ table.add_row("Odometer", self._fmt_odometer(vs.odometer))
457
+ if vs.car_version:
458
+ table.add_row("Software", vs.car_version)
459
+
460
+ # Doors (only show if any data present)
461
+ doors = _door_summary(vs)
462
+ if doors:
463
+ table.add_row("Doors", doors)
464
+
465
+ # Windows (only show if any data present)
466
+ windows = _window_summary(vs)
467
+ if windows:
468
+ table.add_row("Windows", windows)
469
+
470
+ # Trunks
471
+ if vs.ft is not None:
472
+ table.add_row("Frunk", "[yellow]open[/yellow]" if vs.ft else "closed")
473
+ if vs.rt is not None:
474
+ table.add_row("Trunk", "[yellow]open[/yellow]" if vs.rt else "closed")
475
+
476
+ # Additional state
477
+ if vs.center_display_state is not None:
478
+ table.add_row("Center display", "on" if vs.center_display_state >= 2 else "off")
479
+ if vs.dashcam_state:
480
+ table.add_row("Dashcam", vs.dashcam_state)
481
+ if vs.remote_start_enabled is True:
482
+ table.add_row("Remote start", "[green]enabled[/green]")
483
+ if vs.is_user_present is True:
484
+ table.add_row("User present", "yes")
485
+ if vs.homelink_nearby is True:
486
+ table.add_row("Homelink", "nearby")
487
+
488
+ # Tire pressure (API returns bar)
489
+ tpms = [vs.tpms_pressure_fl, vs.tpms_pressure_fr, vs.tpms_pressure_rl, vs.tpms_pressure_rr]
490
+ if any(p is not None for p in tpms):
491
+ parts: list[str] = []
492
+ for label, val in [
493
+ ("FL", vs.tpms_pressure_fl),
494
+ ("FR", vs.tpms_pressure_fr),
495
+ ("RL", vs.tpms_pressure_rl),
496
+ ("RR", vs.tpms_pressure_rr),
497
+ ]:
498
+ if val is not None:
499
+ parts.append(f"{label}: {self._fmt_pressure(val)}")
500
+ else:
501
+ parts.append(f"{label}: --")
502
+ table.add_row(f"Tire pressure ({self._units.pressure})", ", ".join(parts))
503
+
504
+ self._con.print(table)
505
+
506
+ # ------------------------------------------------------------------
507
+ # Vehicle config
508
+ # ------------------------------------------------------------------
509
+
510
+ def vehicle_config(self, vc: VehicleConfig) -> None:
511
+ """Print a table of vehicle configuration fields."""
512
+ table = Table(title="Vehicle Config")
513
+ table.add_column("Field", style="bold")
514
+ table.add_column("Value")
515
+
516
+ if vc.car_type:
517
+ table.add_row("Model", vc.car_type)
518
+ if vc.trim_badging:
519
+ table.add_row("Trim", vc.trim_badging)
520
+ if vc.exterior_color:
521
+ table.add_row("Color", vc.exterior_color)
522
+ if vc.wheel_type:
523
+ table.add_row("Wheels", vc.wheel_type)
524
+ if vc.roof_color:
525
+ table.add_row("Roof", vc.roof_color)
526
+ if vc.can_accept_navigation_requests is True:
527
+ table.add_row("Navigation", "yes")
528
+ if vc.can_actuate_trunks is True:
529
+ table.add_row("Trunk actuation", "yes")
530
+ if vc.has_seat_cooling is True:
531
+ table.add_row("Seat cooling", "yes")
532
+ if vc.motorized_charge_port is True:
533
+ table.add_row("Motorized port", "yes")
534
+ if vc.plg is True:
535
+ table.add_row("Power liftgate", "yes")
536
+ if vc.eu_vehicle is True:
537
+ table.add_row("EU vehicle", "yes")
538
+
539
+ self._con.print(table)
540
+
541
+ # ------------------------------------------------------------------
542
+ # GUI settings
543
+ # ------------------------------------------------------------------
544
+
545
+ def gui_settings(self, gs: GuiSettings) -> None:
546
+ """Print a table of display / GUI settings."""
547
+ table = Table(title="Display Settings")
548
+ table.add_column("Field", style="bold")
549
+ table.add_column("Value")
550
+
551
+ if gs.gui_distance_units:
552
+ table.add_row("Distance units", gs.gui_distance_units)
553
+ if gs.gui_temperature_units:
554
+ table.add_row("Temperature units", gs.gui_temperature_units)
555
+ if gs.gui_charge_rate_units:
556
+ table.add_row("Charge rate units", gs.gui_charge_rate_units)
557
+
558
+ self._con.print(table)
559
+
560
+ # ------------------------------------------------------------------
561
+ # Command result helpers
562
+ # ------------------------------------------------------------------
563
+
564
+ # ------------------------------------------------------------------
565
+ # Software status
566
+ # ------------------------------------------------------------------
567
+
568
+ def software_status(self, vs: VehicleState) -> None:
569
+ """Print a table of software version and update status."""
570
+ table = Table(title="Software Status")
571
+ table.add_column("Field", style="bold")
572
+ table.add_column("Value")
573
+
574
+ if vs.car_version:
575
+ table.add_row("Current version", vs.car_version)
576
+
577
+ su = vs.software_update
578
+ if su:
579
+ if su.status:
580
+ style = {"available": "yellow", "installing": "green", "scheduled": "cyan"}.get(
581
+ su.status, ""
582
+ )
583
+ label = f"[{style}]{su.status}[/{style}]" if style else su.status
584
+ table.add_row("Update status", label)
585
+ if su.version:
586
+ table.add_row("Available version", su.version)
587
+ if su.install_perc is not None:
588
+ table.add_row("Install progress", f"{su.install_perc}%")
589
+ if su.expected_duration_sec is not None:
590
+ table.add_row("Expected duration", f"{su.expected_duration_sec // 60}m")
591
+ else:
592
+ table.add_row("Update status", "[dim]up to date[/dim]")
593
+
594
+ self._con.print(table)
595
+
596
+ # ------------------------------------------------------------------
597
+ # Nearby chargers
598
+ # ------------------------------------------------------------------
599
+
600
+ def nearby_chargers(self, data: NearbyChargingSites) -> None:
601
+ """Print nearby charging sites."""
602
+ if data.superchargers:
603
+ table = Table(title="Nearby Superchargers")
604
+ table.add_column("Name")
605
+ table.add_column("Distance", justify="right")
606
+ table.add_column("Stalls", justify="right")
607
+ table.add_column("Available", justify="right")
608
+ for sc in data.superchargers[:10]:
609
+ table.add_row(
610
+ sc.name or "",
611
+ f"{sc.distance_miles:.1f} mi" if sc.distance_miles is not None else "",
612
+ str(sc.total_stalls) if sc.total_stalls is not None else "",
613
+ str(sc.available_stalls) if sc.available_stalls is not None else "",
614
+ )
615
+ self._con.print(table)
616
+
617
+ if data.destination_charging:
618
+ table = Table(title="Nearby Destination Chargers")
619
+ table.add_column("Name")
620
+ table.add_column("Distance", justify="right")
621
+ for dc in data.destination_charging[:10]:
622
+ table.add_row(
623
+ dc.name or "",
624
+ f"{dc.distance_miles:.1f} mi" if dc.distance_miles is not None else "",
625
+ )
626
+ self._con.print(table)
627
+
628
+ if not data.superchargers and not data.destination_charging:
629
+ self.info("[dim]No nearby charging sites found.[/dim]")
630
+
631
+ # ------------------------------------------------------------------
632
+ # Energy site display
633
+ # ------------------------------------------------------------------
634
+
635
+ def energy_site_list(self, sites: list[dict[str, Any]]) -> None:
636
+ """Print a table of energy products (Powerwall, Solar, etc.)."""
637
+ table = Table(title="Energy Products")
638
+ table.add_column("Site ID", style="cyan")
639
+ table.add_column("Name")
640
+ table.add_column("Type")
641
+ for s in sites:
642
+ table.add_row(
643
+ str(s.get("energy_site_id", "")),
644
+ s.get("site_name", ""),
645
+ s.get("resource_type", ""),
646
+ )
647
+ self._con.print(table)
648
+
649
+ def energy_live_status(self, data: LiveStatus) -> None:
650
+ """Print real-time power flow for an energy site."""
651
+ table = Table(title="Energy Live Status")
652
+ table.add_column("Field", style="bold")
653
+ table.add_column("Value")
654
+
655
+ for val, label in [
656
+ (data.solar_power, "Solar"),
657
+ (data.battery_power, "Battery"),
658
+ (data.grid_power, "Grid"),
659
+ (data.load_power, "Home"),
660
+ ]:
661
+ if val is not None:
662
+ table.add_row(label, f"{val / 1000:.2f} kW")
663
+
664
+ if data.grid_status:
665
+ table.add_row("Grid status", data.grid_status)
666
+
667
+ battery_pct = data.percentage_charged or data.battery_level
668
+ if battery_pct is not None:
669
+ table.add_row("Battery %", f"{battery_pct:.0f}%")
670
+
671
+ self._con.print(table)
672
+
673
+ def energy_site_info(self, data: SiteInfo) -> None:
674
+ """Print energy site configuration."""
675
+ table = Table(title="Energy Site Info")
676
+ table.add_column("Field", style="bold")
677
+ table.add_column("Value")
678
+
679
+ if data.site_name is not None:
680
+ table.add_row("Name", data.site_name)
681
+ if data.energy_site_id is not None:
682
+ table.add_row("Site ID", str(data.energy_site_id))
683
+ if data.resource_type is not None:
684
+ table.add_row("Type", data.resource_type)
685
+ if data.backup_reserve_percent is not None:
686
+ table.add_row("Backup reserve", f"{data.backup_reserve_percent}%")
687
+ if data.default_real_mode is not None:
688
+ table.add_row("Operation mode", data.default_real_mode)
689
+ if data.storm_mode_enabled is not None:
690
+ table.add_row(
691
+ "Storm watch",
692
+ "[green]on[/green]" if data.storm_mode_enabled else "off",
693
+ )
694
+
695
+ self._con.print(table)
696
+
697
+ # ------------------------------------------------------------------
698
+ # User info display
699
+ # ------------------------------------------------------------------
700
+
701
+ def user_info(self, data: UserInfo) -> None:
702
+ """Print user account information."""
703
+ table = Table(title="User Info")
704
+ table.add_column("Field", style="bold")
705
+ table.add_column("Value")
706
+
707
+ if data.email:
708
+ table.add_row("Email", data.email)
709
+ if data.full_name:
710
+ table.add_row("Name", data.full_name)
711
+ if data.profile_image_url:
712
+ table.add_row("Avatar", data.profile_image_url)
713
+
714
+ self._con.print(table)
715
+
716
+ def user_region(self, data: UserRegion) -> None:
717
+ """Print user's regional endpoint."""
718
+ table = Table(title="Region")
719
+ table.add_column("Field", style="bold")
720
+ table.add_column("Value")
721
+
722
+ if data.region:
723
+ table.add_row("Region", data.region)
724
+ if data.fleet_api_base_url:
725
+ table.add_row("Fleet API URL", data.fleet_api_base_url)
726
+
727
+ self._con.print(table)
728
+
729
+ # ------------------------------------------------------------------
730
+ # Command result helpers
731
+ # ------------------------------------------------------------------
732
+
733
+ def command_result(self, success: bool, message: str = "") -> None:
734
+ """Print a coloured OK / FAILED indicator."""
735
+ text = "[green]OK[/green]" if success else "[red]FAILED[/red]"
736
+ if message:
737
+ text += f" {message}"
738
+ self._con.print(text)
739
+
740
+ def error(self, message: str) -> None:
741
+ """Print a bold red error line."""
742
+ from rich.markup import escape
743
+
744
+ self._con.print(f"[bold red]Error:[/bold red] {escape(message)}")
745
+
746
+ def info(self, message: str) -> None:
747
+ """Print an informational message (plain)."""
748
+ self._con.print(message)
749
+
750
+
751
+ # ----------------------------------------------------------------------
752
+ # Helpers (module-level, used by RichOutput methods)
753
+ # ----------------------------------------------------------------------
754
+
755
+
756
+ def _heat_level(level: int) -> str:
757
+ """Convert a seat/wheel heater level (0-3) to a readable label."""
758
+ return {1: "low", 2: "med", 3: "high"}.get(level, str(level))
759
+
760
+
761
+ def _door_summary(vs: VehicleState) -> str:
762
+ """Return a compact summary of open doors, or empty string if all closed."""
763
+ open_doors: list[str] = []
764
+ if vs.door_driver_front:
765
+ open_doors.append("FL")
766
+ if vs.door_driver_rear:
767
+ open_doors.append("RL")
768
+ if vs.door_passenger_front:
769
+ open_doors.append("FR")
770
+ if vs.door_passenger_rear:
771
+ open_doors.append("RR")
772
+ if open_doors:
773
+ return f"[yellow]{', '.join(open_doors)} open[/yellow]"
774
+ # Only show "all closed" if we actually have door data
775
+ has_data = any(
776
+ getattr(vs, f) is not None
777
+ for f in (
778
+ "door_driver_front",
779
+ "door_driver_rear",
780
+ "door_passenger_front",
781
+ "door_passenger_rear",
782
+ )
783
+ )
784
+ return "all closed" if has_data else ""
785
+
786
+
787
+ def _window_summary(vs: VehicleState) -> str:
788
+ """Return a compact summary of open windows, or empty string if all closed."""
789
+ open_wins: list[str] = []
790
+ if vs.window_driver_front:
791
+ open_wins.append("FL")
792
+ if vs.window_driver_rear:
793
+ open_wins.append("RL")
794
+ if vs.window_passenger_front:
795
+ open_wins.append("FR")
796
+ if vs.window_passenger_rear:
797
+ open_wins.append("RR")
798
+ if open_wins:
799
+ return f"[yellow]{', '.join(open_wins)} open[/yellow]"
800
+ has_data = any(
801
+ getattr(vs, f) is not None
802
+ for f in (
803
+ "window_driver_front",
804
+ "window_driver_rear",
805
+ "window_passenger_front",
806
+ "window_passenger_rear",
807
+ )
808
+ )
809
+ return "all closed" if has_data else ""