tescmd 0.2.0__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 (61) hide show
  1. tescmd/__init__.py +1 -1
  2. tescmd/api/client.py +41 -4
  3. tescmd/api/command.py +1 -1
  4. tescmd/api/errors.py +5 -0
  5. tescmd/api/signed_command.py +19 -14
  6. tescmd/auth/oauth.py +5 -1
  7. tescmd/auth/server.py +6 -1
  8. tescmd/auth/token_store.py +8 -1
  9. tescmd/cache/response_cache.py +8 -1
  10. tescmd/cli/_client.py +142 -20
  11. tescmd/cli/_options.py +2 -4
  12. tescmd/cli/auth.py +96 -14
  13. tescmd/cli/energy.py +2 -0
  14. tescmd/cli/main.py +27 -8
  15. tescmd/cli/mcp_cmd.py +153 -0
  16. tescmd/cli/nav.py +3 -1
  17. tescmd/cli/openclaw.py +169 -0
  18. tescmd/cli/security.py +7 -1
  19. tescmd/cli/serve.py +923 -0
  20. tescmd/cli/setup.py +18 -7
  21. tescmd/cli/sharing.py +2 -0
  22. tescmd/cli/status.py +1 -1
  23. tescmd/cli/trunk.py +8 -17
  24. tescmd/cli/user.py +16 -1
  25. tescmd/cli/vehicle.py +135 -462
  26. tescmd/deploy/github_pages.py +8 -0
  27. tescmd/mcp/__init__.py +7 -0
  28. tescmd/mcp/server.py +648 -0
  29. tescmd/models/auth.py +5 -2
  30. tescmd/openclaw/__init__.py +23 -0
  31. tescmd/openclaw/bridge.py +330 -0
  32. tescmd/openclaw/config.py +167 -0
  33. tescmd/openclaw/dispatcher.py +522 -0
  34. tescmd/openclaw/emitter.py +175 -0
  35. tescmd/openclaw/filters.py +123 -0
  36. tescmd/openclaw/gateway.py +687 -0
  37. tescmd/openclaw/telemetry_store.py +53 -0
  38. tescmd/output/rich_output.py +46 -14
  39. tescmd/protocol/commands.py +2 -2
  40. tescmd/protocol/encoder.py +16 -13
  41. tescmd/protocol/payloads.py +132 -11
  42. tescmd/protocol/session.py +8 -5
  43. tescmd/protocol/signer.py +3 -17
  44. tescmd/telemetry/__init__.py +9 -0
  45. tescmd/telemetry/cache_sink.py +154 -0
  46. tescmd/telemetry/csv_sink.py +180 -0
  47. tescmd/telemetry/dashboard.py +4 -4
  48. tescmd/telemetry/fanout.py +49 -0
  49. tescmd/telemetry/fields.py +308 -129
  50. tescmd/telemetry/mapper.py +239 -0
  51. tescmd/telemetry/server.py +26 -19
  52. tescmd/telemetry/setup.py +468 -0
  53. tescmd/telemetry/tui.py +1716 -0
  54. tescmd/triggers/__init__.py +18 -0
  55. tescmd/triggers/manager.py +264 -0
  56. tescmd/triggers/models.py +93 -0
  57. {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/METADATA +80 -32
  58. {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/RECORD +61 -39
  59. {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
  60. {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
  61. {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,239 @@
1
+ """Shared telemetry field -> VehicleData path mapping.
2
+
3
+ Translates Fleet Telemetry proto field names (e.g. ``"Soc"``, ``"Location"``)
4
+ into structured VehicleData paths (e.g. ``"charge_state.usable_battery_level"``).
5
+ Used by :class:`~tescmd.telemetry.cache_sink.CacheSink` for cache warming
6
+ and available for any consumer that needs to map telemetry into the
7
+ VehicleData JSON structure.
8
+
9
+ Keys match the ``vehicle_data.proto`` Field enum names exactly.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def _extract_lat(value: Any) -> float | None:
22
+ """Extract latitude from a Location value dict."""
23
+ try:
24
+ return float(value["latitude"])
25
+ except (TypeError, KeyError, ValueError):
26
+ return None
27
+
28
+
29
+ def _extract_lon(value: Any) -> float | None:
30
+ """Extract longitude from a Location value dict."""
31
+ try:
32
+ return float(value["longitude"])
33
+ except (TypeError, KeyError, ValueError):
34
+ return None
35
+
36
+
37
+ def _to_int(value: Any) -> int | None:
38
+ try:
39
+ return int(value)
40
+ except (TypeError, ValueError):
41
+ return None
42
+
43
+
44
+ def _to_float(value: Any) -> float | None:
45
+ try:
46
+ return float(value)
47
+ except (TypeError, ValueError):
48
+ return None
49
+
50
+
51
+ def _to_bool(value: Any) -> bool | None:
52
+ if isinstance(value, bool):
53
+ return value
54
+ if isinstance(value, (int, float)):
55
+ return bool(value)
56
+ if isinstance(value, str):
57
+ return value.lower() in ("true", "1", "yes")
58
+ return None
59
+
60
+
61
+ def _to_str(value: Any) -> str | None:
62
+ if value is None:
63
+ return None
64
+ return str(value)
65
+
66
+
67
+ def _gear_str(value: Any) -> str | None:
68
+ """Map gear enum values to the API's shift_state strings."""
69
+ s = str(value) if value is not None else ""
70
+ mapping = {
71
+ "P": "P",
72
+ "Park": "P",
73
+ "R": "R",
74
+ "Reverse": "R",
75
+ "N": "N",
76
+ "Neutral": "N",
77
+ "D": "D",
78
+ "Drive": "D",
79
+ "DriveSport": "D",
80
+ }
81
+ return mapping.get(s, s or None)
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class FieldMapping:
86
+ """Maps a telemetry field to a VehicleData JSON path."""
87
+
88
+ path: str
89
+ """Dotted path into VehicleData (e.g. ``"charge_state.battery_level"``)."""
90
+
91
+ transform: Any
92
+ """Callable ``(value) -> transformed_value``. Returns ``None`` to skip."""
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Master field map: proto field name -> list of VehicleData path mappings
97
+ #
98
+ # Keys are vehicle_data.proto Field enum names.
99
+ # ---------------------------------------------------------------------------
100
+
101
+ TELEMETRY_FIELD_MAP: dict[str, list[FieldMapping]] = {
102
+ # -- charge_state --
103
+ "Soc": [FieldMapping("charge_state.usable_battery_level", _to_int)],
104
+ "BatteryLevel": [FieldMapping("charge_state.battery_level", _to_int)],
105
+ "ChargeState": [FieldMapping("charge_state.charging_state", _to_str)],
106
+ "DetailedChargeState": [FieldMapping("charge_state.charge_port_latch", _to_str)],
107
+ "EstBatteryRange": [FieldMapping("charge_state.est_battery_range", _to_float)],
108
+ "IdealBatteryRange": [FieldMapping("charge_state.ideal_battery_range", _to_float)],
109
+ "RatedRange": [FieldMapping("charge_state.battery_range", _to_float)],
110
+ "ChargerVoltage": [FieldMapping("charge_state.charger_voltage", _to_int)],
111
+ "ChargeAmps": [FieldMapping("charge_state.charge_amps", _to_int)],
112
+ "ChargerPhases": [FieldMapping("charge_state.charger_phases", _to_int)],
113
+ "ChargeLimitSoc": [FieldMapping("charge_state.charge_limit_soc", _to_int)],
114
+ "ChargeCurrentRequest": [FieldMapping("charge_state.charge_current_request", _to_int)],
115
+ "ChargeCurrentRequestMax": [
116
+ FieldMapping("charge_state.charge_current_request_max", _to_int),
117
+ ],
118
+ "ChargePortDoorOpen": [FieldMapping("charge_state.charge_port_door_open", _to_bool)],
119
+ "ChargePortLatch": [FieldMapping("charge_state.charge_port_latch", _to_str)],
120
+ "TimeToFullCharge": [FieldMapping("charge_state.time_to_full_charge", _to_float)],
121
+ "ACChargingPower": [FieldMapping("charge_state.charger_power", _to_float)],
122
+ "ACChargingEnergyIn": [FieldMapping("charge_state.charge_energy_added", _to_float)],
123
+ "FastChargerPresent": [FieldMapping("charge_state.fast_charger_present", _to_bool)],
124
+ "ScheduledChargingMode": [
125
+ FieldMapping("charge_state.scheduled_charging_mode", _to_str),
126
+ ],
127
+ "ScheduledChargingPending": [
128
+ FieldMapping("charge_state.scheduled_charging_pending", _to_bool),
129
+ ],
130
+ "ScheduledChargingStartTime": [
131
+ FieldMapping("charge_state.scheduled_charging_start_time", _to_float),
132
+ ],
133
+ "ScheduledDepartureTime": [
134
+ FieldMapping("charge_state.scheduled_departure_time_minutes", _to_int),
135
+ ],
136
+ "EnergyRemaining": [FieldMapping("charge_state.energy_remaining", _to_float)],
137
+ "PackVoltage": [FieldMapping("charge_state.pack_voltage", _to_float)],
138
+ "PackCurrent": [FieldMapping("charge_state.pack_current", _to_float)],
139
+ "ChargingCableType": [FieldMapping("charge_state.conn_charge_cable", _to_str)],
140
+ # -- climate_state --
141
+ "InsideTemp": [FieldMapping("climate_state.inside_temp", _to_float)],
142
+ "OutsideTemp": [FieldMapping("climate_state.outside_temp", _to_float)],
143
+ "HvacLeftTemperatureRequest": [
144
+ FieldMapping("climate_state.driver_temp_setting", _to_float),
145
+ ],
146
+ "HvacRightTemperatureRequest": [
147
+ FieldMapping("climate_state.passenger_temp_setting", _to_float),
148
+ ],
149
+ "HvacPower": [FieldMapping("climate_state.is_climate_on", _to_bool)],
150
+ "HvacFanStatus": [FieldMapping("climate_state.fan_status", _to_int)],
151
+ "SeatHeaterLeft": [FieldMapping("climate_state.seat_heater_left", _to_int)],
152
+ "SeatHeaterRight": [FieldMapping("climate_state.seat_heater_right", _to_int)],
153
+ "SeatHeaterRearLeft": [FieldMapping("climate_state.seat_heater_rear_left", _to_int)],
154
+ "SeatHeaterRearCenter": [
155
+ FieldMapping("climate_state.seat_heater_rear_center", _to_int),
156
+ ],
157
+ "SeatHeaterRearRight": [FieldMapping("climate_state.seat_heater_rear_right", _to_int)],
158
+ "HvacSteeringWheelHeatLevel": [
159
+ FieldMapping("climate_state.steering_wheel_heater", _to_bool),
160
+ ],
161
+ "DefrostMode": [FieldMapping("climate_state.defrost_mode", _to_int)],
162
+ "CabinOverheatProtectionMode": [
163
+ FieldMapping("climate_state.cabin_overheat_protection", _to_str),
164
+ ],
165
+ "PreconditioningEnabled": [
166
+ FieldMapping("climate_state.is_preconditioning", _to_bool),
167
+ ],
168
+ # -- drive_state --
169
+ "Location": [
170
+ FieldMapping("drive_state.latitude", _extract_lat),
171
+ FieldMapping("drive_state.longitude", _extract_lon),
172
+ ],
173
+ "VehicleSpeed": [FieldMapping("drive_state.speed", _to_int)],
174
+ "GpsHeading": [FieldMapping("drive_state.heading", _to_int)],
175
+ "Gear": [FieldMapping("drive_state.shift_state", _gear_str)],
176
+ # -- vehicle_state --
177
+ "Locked": [FieldMapping("vehicle_state.locked", _to_bool)],
178
+ "SentryMode": [FieldMapping("vehicle_state.sentry_mode", _to_bool)],
179
+ "Odometer": [FieldMapping("vehicle_state.odometer", _to_float)],
180
+ "Version": [FieldMapping("vehicle_state.car_version", _to_str)],
181
+ "ValetModeEnabled": [FieldMapping("vehicle_state.valet_mode", _to_bool)],
182
+ "TpmsPressureFl": [FieldMapping("vehicle_state.tpms_pressure_fl", _to_float)],
183
+ "TpmsPressureFr": [FieldMapping("vehicle_state.tpms_pressure_fr", _to_float)],
184
+ "TpmsPressureRl": [FieldMapping("vehicle_state.tpms_pressure_rl", _to_float)],
185
+ "TpmsPressureRr": [FieldMapping("vehicle_state.tpms_pressure_rr", _to_float)],
186
+ "CenterDisplay": [FieldMapping("vehicle_state.center_display_state", _to_int)],
187
+ "HomelinkNearby": [FieldMapping("vehicle_state.homelink_nearby", _to_bool)],
188
+ "DriverSeatOccupied": [FieldMapping("vehicle_state.is_user_present", _to_bool)],
189
+ "RemoteStartEnabled": [FieldMapping("vehicle_state.remote_start", _to_bool)],
190
+ }
191
+
192
+
193
+ class TelemetryMapper:
194
+ """Maps telemetry field names to VehicleData paths.
195
+
196
+ Usage::
197
+
198
+ mapper = TelemetryMapper()
199
+ for path, value in mapper.map("Soc", 80):
200
+ # path = "charge_state.usable_battery_level", value = 80
201
+ ...
202
+ """
203
+
204
+ def __init__(
205
+ self,
206
+ field_map: dict[str, list[FieldMapping]] | None = None,
207
+ ) -> None:
208
+ self._field_map = field_map or TELEMETRY_FIELD_MAP
209
+
210
+ def map(self, field_name: str, value: Any) -> list[tuple[str, Any]]:
211
+ """Map a telemetry field to zero or more ``(path, transformed_value)`` pairs.
212
+
213
+ Returns an empty list if the field is unmapped or all transforms
214
+ return ``None``.
215
+ """
216
+ mappings = self._field_map.get(field_name)
217
+ if mappings is None:
218
+ return []
219
+
220
+ results: list[tuple[str, Any]] = []
221
+ for mapping in mappings:
222
+ try:
223
+ transformed = mapping.transform(value)
224
+ except Exception:
225
+ logger.debug(
226
+ "Transform failed for %s -> %s",
227
+ field_name,
228
+ mapping.path,
229
+ exc_info=True,
230
+ )
231
+ continue
232
+ if transformed is not None:
233
+ results.append((mapping.path, transformed))
234
+ return results
235
+
236
+ @property
237
+ def mapped_fields(self) -> frozenset[str]:
238
+ """Return the set of telemetry field names that have mappings."""
239
+ return frozenset(self._field_map.keys())
@@ -60,6 +60,8 @@ class TelemetryServer:
60
60
  *,
61
61
  public_key_pem: str | None = None,
62
62
  ) -> None:
63
+ if not (0 <= port <= 65534):
64
+ raise ValueError(f"Port must be between 0 and 65534, got {port}")
63
65
  self._port = port
64
66
  self._ws_port = port + 1
65
67
  self._decoder = decoder
@@ -73,29 +75,34 @@ class TelemetryServer:
73
75
 
74
76
  async def start(self) -> None:
75
77
  """Start the mux + WebSocket servers."""
76
- try:
77
- import websockets.asyncio.server as ws_server_mod
78
- except ImportError as exc:
79
- from tescmd.api.errors import ConfigError
80
-
81
- raise ConfigError(
82
- "websockets is required for telemetry streaming. "
83
- "Install with: pip install tescmd[telemetry]"
84
- ) from exc
78
+ import websockets.asyncio.server as ws_server_mod
85
79
 
86
80
  # Internal WS server — only reachable from localhost
87
- self._ws_server = await ws_server_mod.serve(
88
- self._ws_handler,
89
- host="127.0.0.1",
90
- port=self._ws_port,
91
- )
81
+ try:
82
+ self._ws_server = await ws_server_mod.serve(
83
+ self._ws_handler,
84
+ host="127.0.0.1",
85
+ port=self._ws_port,
86
+ )
87
+ except OSError as exc:
88
+ raise OSError(
89
+ f"Cannot bind internal WebSocket server to port {self._ws_port}: {exc}"
90
+ ) from exc
92
91
 
93
92
  # Public-facing TCP mux — bind to all interfaces (IPv4 + IPv6).
94
- self._mux_server = await asyncio.start_server(
95
- self._mux_handler,
96
- host=None,
97
- port=self._port,
98
- )
93
+ try:
94
+ self._mux_server = await asyncio.start_server(
95
+ self._mux_handler,
96
+ host=None,
97
+ port=self._port,
98
+ )
99
+ except OSError as exc:
100
+ # Clean up the already-started WS server before re-raising
101
+ if self._ws_server is not None:
102
+ self._ws_server.close()
103
+ await self._ws_server.wait_closed()
104
+ self._ws_server = None
105
+ raise OSError(f"Cannot bind mux server to port {self._port}: {exc}") from exc
99
106
 
100
107
  logger.info(
101
108
  "Telemetry server listening on 0.0.0.0:%d (ws internal :%d)",