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.
- tescmd/__init__.py +1 -1
- tescmd/api/client.py +41 -4
- tescmd/api/command.py +1 -1
- tescmd/api/errors.py +5 -0
- tescmd/api/signed_command.py +19 -14
- tescmd/auth/oauth.py +5 -1
- tescmd/auth/server.py +6 -1
- tescmd/auth/token_store.py +8 -1
- tescmd/cache/response_cache.py +8 -1
- tescmd/cli/_client.py +142 -20
- tescmd/cli/_options.py +2 -4
- tescmd/cli/auth.py +96 -14
- tescmd/cli/energy.py +2 -0
- tescmd/cli/main.py +27 -8
- tescmd/cli/mcp_cmd.py +153 -0
- tescmd/cli/nav.py +3 -1
- tescmd/cli/openclaw.py +169 -0
- tescmd/cli/security.py +7 -1
- tescmd/cli/serve.py +923 -0
- tescmd/cli/setup.py +18 -7
- tescmd/cli/sharing.py +2 -0
- tescmd/cli/status.py +1 -1
- tescmd/cli/trunk.py +8 -17
- tescmd/cli/user.py +16 -1
- tescmd/cli/vehicle.py +135 -462
- tescmd/deploy/github_pages.py +8 -0
- tescmd/mcp/__init__.py +7 -0
- tescmd/mcp/server.py +648 -0
- tescmd/models/auth.py +5 -2
- tescmd/openclaw/__init__.py +23 -0
- tescmd/openclaw/bridge.py +330 -0
- tescmd/openclaw/config.py +167 -0
- tescmd/openclaw/dispatcher.py +522 -0
- tescmd/openclaw/emitter.py +175 -0
- tescmd/openclaw/filters.py +123 -0
- tescmd/openclaw/gateway.py +687 -0
- tescmd/openclaw/telemetry_store.py +53 -0
- tescmd/output/rich_output.py +46 -14
- tescmd/protocol/commands.py +2 -2
- tescmd/protocol/encoder.py +16 -13
- tescmd/protocol/payloads.py +132 -11
- tescmd/protocol/session.py +8 -5
- tescmd/protocol/signer.py +3 -17
- tescmd/telemetry/__init__.py +9 -0
- tescmd/telemetry/cache_sink.py +154 -0
- tescmd/telemetry/csv_sink.py +180 -0
- tescmd/telemetry/dashboard.py +4 -4
- tescmd/telemetry/fanout.py +49 -0
- tescmd/telemetry/fields.py +308 -129
- tescmd/telemetry/mapper.py +239 -0
- tescmd/telemetry/server.py +26 -19
- tescmd/telemetry/setup.py +468 -0
- tescmd/telemetry/tui.py +1716 -0
- tescmd/triggers/__init__.py +18 -0
- tescmd/triggers/manager.py +264 -0
- tescmd/triggers/models.py +93 -0
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/METADATA +80 -32
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/RECORD +61 -39
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
- {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())
|
tescmd/telemetry/server.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
88
|
-
self.
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
95
|
-
self.
|
|
96
|
-
|
|
97
|
-
|
|
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)",
|