tescmd 0.1.2__py3-none-any.whl → 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tescmd/__init__.py +1 -1
- tescmd/api/client.py +49 -5
- tescmd/api/command.py +1 -1
- tescmd/api/errors.py +13 -0
- tescmd/api/signed_command.py +19 -14
- tescmd/api/vehicle.py +19 -1
- tescmd/auth/oauth.py +5 -1
- tescmd/auth/server.py +6 -1
- tescmd/auth/token_store.py +8 -1
- tescmd/cache/response_cache.py +11 -3
- tescmd/cli/_client.py +142 -20
- tescmd/cli/_options.py +2 -4
- tescmd/cli/auth.py +121 -11
- tescmd/cli/energy.py +2 -0
- tescmd/cli/key.py +149 -14
- tescmd/cli/main.py +70 -7
- tescmd/cli/mcp_cmd.py +153 -0
- tescmd/cli/nav.py +3 -1
- tescmd/cli/openclaw.py +169 -0
- tescmd/cli/security.py +7 -1
- tescmd/cli/serve.py +923 -0
- tescmd/cli/setup.py +244 -25
- tescmd/cli/sharing.py +2 -0
- tescmd/cli/status.py +1 -1
- tescmd/cli/trunk.py +8 -17
- tescmd/cli/user.py +16 -1
- tescmd/cli/vehicle.py +156 -20
- tescmd/crypto/__init__.py +3 -1
- tescmd/crypto/ecdh.py +9 -0
- tescmd/crypto/schnorr.py +191 -0
- tescmd/deploy/github_pages.py +8 -0
- tescmd/deploy/tailscale_serve.py +154 -0
- tescmd/mcp/__init__.py +7 -0
- tescmd/mcp/server.py +648 -0
- tescmd/models/__init__.py +0 -2
- tescmd/models/auth.py +24 -2
- tescmd/models/config.py +1 -0
- tescmd/models/energy.py +0 -9
- tescmd/openclaw/__init__.py +23 -0
- tescmd/openclaw/bridge.py +330 -0
- tescmd/openclaw/config.py +167 -0
- tescmd/openclaw/dispatcher.py +522 -0
- tescmd/openclaw/emitter.py +175 -0
- tescmd/openclaw/filters.py +123 -0
- tescmd/openclaw/gateway.py +687 -0
- tescmd/openclaw/telemetry_store.py +53 -0
- tescmd/output/rich_output.py +46 -14
- tescmd/protocol/commands.py +2 -2
- tescmd/protocol/encoder.py +16 -13
- tescmd/protocol/payloads.py +132 -11
- tescmd/protocol/session.py +18 -8
- tescmd/protocol/signer.py +3 -17
- tescmd/telemetry/__init__.py +28 -0
- tescmd/telemetry/cache_sink.py +154 -0
- tescmd/telemetry/csv_sink.py +180 -0
- tescmd/telemetry/dashboard.py +227 -0
- tescmd/telemetry/decoder.py +284 -0
- tescmd/telemetry/fanout.py +49 -0
- tescmd/telemetry/fields.py +427 -0
- tescmd/telemetry/flatbuf.py +162 -0
- tescmd/telemetry/mapper.py +239 -0
- tescmd/telemetry/protos/__init__.py +4 -0
- tescmd/telemetry/protos/vehicle_alert.proto +31 -0
- tescmd/telemetry/protos/vehicle_alert_pb2.py +42 -0
- tescmd/telemetry/protos/vehicle_alert_pb2.pyi +44 -0
- tescmd/telemetry/protos/vehicle_connectivity.proto +23 -0
- tescmd/telemetry/protos/vehicle_connectivity_pb2.py +40 -0
- tescmd/telemetry/protos/vehicle_connectivity_pb2.pyi +33 -0
- tescmd/telemetry/protos/vehicle_data.proto +768 -0
- tescmd/telemetry/protos/vehicle_data_pb2.py +136 -0
- tescmd/telemetry/protos/vehicle_data_pb2.pyi +1336 -0
- tescmd/telemetry/protos/vehicle_error.proto +23 -0
- tescmd/telemetry/protos/vehicle_error_pb2.py +44 -0
- tescmd/telemetry/protos/vehicle_error_pb2.pyi +39 -0
- tescmd/telemetry/protos/vehicle_metric.proto +22 -0
- tescmd/telemetry/protos/vehicle_metric_pb2.py +44 -0
- tescmd/telemetry/protos/vehicle_metric_pb2.pyi +37 -0
- tescmd/telemetry/server.py +300 -0
- tescmd/telemetry/setup.py +468 -0
- tescmd/telemetry/tailscale.py +300 -0
- tescmd/telemetry/tui.py +1716 -0
- tescmd/triggers/__init__.py +18 -0
- tescmd/triggers/manager.py +264 -0
- tescmd/triggers/models.py +93 -0
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/METADATA +125 -40
- tescmd-0.3.1.dist-info/RECORD +128 -0
- tescmd-0.1.2.dist-info/RECORD +0 -81
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Trigger/subscription system for telemetry-driven notifications."""
|
|
2
|
+
|
|
3
|
+
from tescmd.triggers.manager import TriggerLimitError, TriggerManager
|
|
4
|
+
from tescmd.triggers.models import (
|
|
5
|
+
TriggerCondition,
|
|
6
|
+
TriggerDefinition,
|
|
7
|
+
TriggerNotification,
|
|
8
|
+
TriggerOperator,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"TriggerCondition",
|
|
13
|
+
"TriggerDefinition",
|
|
14
|
+
"TriggerLimitError",
|
|
15
|
+
"TriggerManager",
|
|
16
|
+
"TriggerNotification",
|
|
17
|
+
"TriggerOperator",
|
|
18
|
+
]
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Trigger evaluation engine.
|
|
2
|
+
|
|
3
|
+
Evaluates registered triggers against incoming telemetry values,
|
|
4
|
+
manages cooldowns, fires callbacks, and queues notifications.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
from collections import defaultdict, deque
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from tescmd.triggers.models import (
|
|
15
|
+
TriggerCondition,
|
|
16
|
+
TriggerDefinition,
|
|
17
|
+
TriggerNotification,
|
|
18
|
+
TriggerOperator,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Awaitable, Callable
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
MAX_TRIGGERS = 100
|
|
28
|
+
MAX_PENDING = 500
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TriggerLimitError(Exception):
|
|
32
|
+
"""Raised when the maximum number of triggers is exceeded."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TriggerManager:
|
|
36
|
+
"""Manages trigger lifecycle and evaluation.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
vin:
|
|
41
|
+
Vehicle Identification Number — included in notifications.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, vin: str) -> None:
|
|
45
|
+
self._vin = vin
|
|
46
|
+
self._triggers: dict[str, TriggerDefinition] = {}
|
|
47
|
+
self._field_index: dict[str, set[str]] = defaultdict(set)
|
|
48
|
+
self._last_fire_times: dict[str, float] = {}
|
|
49
|
+
self._pending: deque[TriggerNotification] = deque(maxlen=MAX_PENDING)
|
|
50
|
+
self._on_fire_callbacks: list[Callable[[TriggerNotification], Awaitable[None]]] = []
|
|
51
|
+
|
|
52
|
+
def create(self, trigger: TriggerDefinition) -> TriggerDefinition:
|
|
53
|
+
"""Register a trigger. Returns the trigger with its assigned ID.
|
|
54
|
+
|
|
55
|
+
Raises :class:`TriggerLimitError` if the limit is reached.
|
|
56
|
+
"""
|
|
57
|
+
if len(self._triggers) >= MAX_TRIGGERS:
|
|
58
|
+
raise TriggerLimitError(
|
|
59
|
+
f"Maximum of {MAX_TRIGGERS} triggers reached. "
|
|
60
|
+
"Delete some before creating new ones."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
cond = trigger.condition
|
|
64
|
+
self._triggers[trigger.id] = trigger
|
|
65
|
+
self._field_index[cond.field].add(trigger.id)
|
|
66
|
+
logger.info(
|
|
67
|
+
"Created trigger %s: %s %s %s",
|
|
68
|
+
trigger.id,
|
|
69
|
+
cond.field,
|
|
70
|
+
cond.operator.value,
|
|
71
|
+
cond.value,
|
|
72
|
+
)
|
|
73
|
+
return trigger
|
|
74
|
+
|
|
75
|
+
def delete(self, trigger_id: str) -> bool:
|
|
76
|
+
"""Remove a trigger by ID. Returns ``True`` if it existed."""
|
|
77
|
+
trigger = self._triggers.pop(trigger_id, None)
|
|
78
|
+
if trigger is None:
|
|
79
|
+
return False
|
|
80
|
+
field = trigger.condition.field
|
|
81
|
+
ids = self._field_index.get(field)
|
|
82
|
+
if ids is not None:
|
|
83
|
+
ids.discard(trigger_id)
|
|
84
|
+
if not ids:
|
|
85
|
+
del self._field_index[field]
|
|
86
|
+
self._last_fire_times.pop(trigger_id, None)
|
|
87
|
+
logger.info("Deleted trigger %s", trigger_id)
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
def list_all(self) -> list[TriggerDefinition]:
|
|
91
|
+
"""Return all registered triggers."""
|
|
92
|
+
return list(self._triggers.values())
|
|
93
|
+
|
|
94
|
+
def drain_pending(self) -> list[TriggerNotification]:
|
|
95
|
+
"""Return and clear all pending notifications (for MCP polling)."""
|
|
96
|
+
result = list(self._pending)
|
|
97
|
+
self._pending.clear()
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
def add_on_fire(self, callback: Callable[[TriggerNotification], Awaitable[None]]) -> None:
|
|
101
|
+
"""Register an async callback invoked when a trigger fires."""
|
|
102
|
+
self._on_fire_callbacks.append(callback)
|
|
103
|
+
|
|
104
|
+
async def evaluate(
|
|
105
|
+
self,
|
|
106
|
+
field: str,
|
|
107
|
+
value: Any,
|
|
108
|
+
previous_value: Any,
|
|
109
|
+
timestamp: datetime,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Evaluate all triggers registered for *field*.
|
|
112
|
+
|
|
113
|
+
Called by the bridge after capturing the previous value and
|
|
114
|
+
before updating the telemetry store.
|
|
115
|
+
"""
|
|
116
|
+
trigger_ids = self._field_index.get(field)
|
|
117
|
+
if not trigger_ids:
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
now = time.monotonic()
|
|
121
|
+
# Iterate over a copy — one-shot triggers mutate the set
|
|
122
|
+
for tid in list(trigger_ids):
|
|
123
|
+
trigger = self._triggers.get(tid)
|
|
124
|
+
if trigger is None:
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
# Cooldown check for persistent triggers
|
|
128
|
+
if not trigger.once:
|
|
129
|
+
last_fire = self._last_fire_times.get(tid)
|
|
130
|
+
if last_fire is not None and (now - last_fire) < trigger.cooldown_seconds:
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
if not _matches(trigger.condition, value, previous_value):
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
# Fire!
|
|
137
|
+
self._last_fire_times[tid] = now
|
|
138
|
+
notification = TriggerNotification(
|
|
139
|
+
trigger_id=tid,
|
|
140
|
+
field=field,
|
|
141
|
+
operator=trigger.condition.operator,
|
|
142
|
+
threshold=trigger.condition.value,
|
|
143
|
+
value=value,
|
|
144
|
+
previous_value=previous_value,
|
|
145
|
+
fired_at=timestamp,
|
|
146
|
+
vin=self._vin,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
logger.info(
|
|
150
|
+
"Trigger %s fired: %s %s %s (value=%s prev=%s)",
|
|
151
|
+
tid,
|
|
152
|
+
field,
|
|
153
|
+
trigger.condition.operator.value,
|
|
154
|
+
trigger.condition.value,
|
|
155
|
+
value,
|
|
156
|
+
previous_value,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
self._pending.append(notification)
|
|
160
|
+
|
|
161
|
+
for callback in self._on_fire_callbacks:
|
|
162
|
+
try:
|
|
163
|
+
await callback(notification)
|
|
164
|
+
except Exception:
|
|
165
|
+
logger.warning("Trigger fire callback failed for %s", tid, exc_info=True)
|
|
166
|
+
|
|
167
|
+
# Auto-delete one-shot triggers
|
|
168
|
+
if trigger.once:
|
|
169
|
+
self.delete(tid)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _matches(condition: TriggerCondition, value: Any, previous_value: Any) -> bool:
|
|
173
|
+
"""Check whether a value satisfies a trigger condition."""
|
|
174
|
+
op = condition.operator
|
|
175
|
+
|
|
176
|
+
if op == TriggerOperator.CHANGED:
|
|
177
|
+
return bool(value != previous_value)
|
|
178
|
+
|
|
179
|
+
if op == TriggerOperator.EQ:
|
|
180
|
+
return bool(value == condition.value)
|
|
181
|
+
|
|
182
|
+
if op == TriggerOperator.NEQ:
|
|
183
|
+
return bool(value != condition.value)
|
|
184
|
+
|
|
185
|
+
if op in (TriggerOperator.ENTER, TriggerOperator.LEAVE):
|
|
186
|
+
return _matches_geofence(condition, value, previous_value)
|
|
187
|
+
|
|
188
|
+
# Numeric comparisons
|
|
189
|
+
try:
|
|
190
|
+
fval = float(value)
|
|
191
|
+
fthresh = float(condition.value)
|
|
192
|
+
except (TypeError, ValueError):
|
|
193
|
+
logger.debug(
|
|
194
|
+
"Numeric coercion failed for %s %s (value=%r, threshold=%r)",
|
|
195
|
+
condition.field,
|
|
196
|
+
op.value,
|
|
197
|
+
value,
|
|
198
|
+
condition.value,
|
|
199
|
+
)
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
if op == TriggerOperator.LT:
|
|
203
|
+
return fval < fthresh
|
|
204
|
+
if op == TriggerOperator.GT:
|
|
205
|
+
return fval > fthresh
|
|
206
|
+
if op == TriggerOperator.LTE:
|
|
207
|
+
return fval <= fthresh
|
|
208
|
+
if op == TriggerOperator.GTE:
|
|
209
|
+
return fval >= fthresh
|
|
210
|
+
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _matches_geofence(condition: TriggerCondition, value: Any, previous_value: Any) -> bool:
|
|
215
|
+
"""Evaluate geofence enter/leave conditions.
|
|
216
|
+
|
|
217
|
+
Requires a boundary crossing — being "already inside" doesn't fire
|
|
218
|
+
an ``enter`` trigger. ``previous_value`` of ``None`` never fires.
|
|
219
|
+
"""
|
|
220
|
+
from tescmd.openclaw.filters import haversine
|
|
221
|
+
|
|
222
|
+
if previous_value is None:
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
geo = condition.value
|
|
226
|
+
if not isinstance(geo, dict):
|
|
227
|
+
logger.warning("Geofence trigger on %s has non-dict value: %r", condition.field, geo)
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
center_lat = float(geo["latitude"])
|
|
232
|
+
center_lon = float(geo["longitude"])
|
|
233
|
+
radius = float(geo["radius_m"])
|
|
234
|
+
except (KeyError, TypeError, ValueError):
|
|
235
|
+
logger.warning(
|
|
236
|
+
"Geofence trigger on %s has invalid config (need latitude, longitude, radius_m): %r",
|
|
237
|
+
condition.field,
|
|
238
|
+
geo,
|
|
239
|
+
)
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
cur_lat = float(value["latitude"])
|
|
244
|
+
cur_lon = float(value["longitude"])
|
|
245
|
+
prev_lat = float(previous_value["latitude"])
|
|
246
|
+
prev_lon = float(previous_value["longitude"])
|
|
247
|
+
except (KeyError, TypeError, ValueError):
|
|
248
|
+
logger.debug(
|
|
249
|
+
"Geofence data missing coordinates for %s (value=%r, prev=%r)",
|
|
250
|
+
condition.field,
|
|
251
|
+
value,
|
|
252
|
+
previous_value,
|
|
253
|
+
)
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
cur_dist = haversine(cur_lat, cur_lon, center_lat, center_lon)
|
|
257
|
+
prev_dist = haversine(prev_lat, prev_lon, center_lat, center_lon)
|
|
258
|
+
|
|
259
|
+
if condition.operator == TriggerOperator.ENTER:
|
|
260
|
+
return cur_dist <= radius and prev_dist > radius
|
|
261
|
+
if condition.operator == TriggerOperator.LEAVE:
|
|
262
|
+
return cur_dist > radius and prev_dist <= radius
|
|
263
|
+
|
|
264
|
+
return False
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Pydantic v2 models for the trigger/subscription system.
|
|
2
|
+
|
|
3
|
+
Triggers let OpenClaw bots and MCP clients register conditions on
|
|
4
|
+
telemetry fields and receive notifications when they fire.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field, model_validator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TriggerOperator(str, Enum):
|
|
18
|
+
"""Supported comparison operators for trigger conditions."""
|
|
19
|
+
|
|
20
|
+
LT = "lt"
|
|
21
|
+
GT = "gt"
|
|
22
|
+
LTE = "lte"
|
|
23
|
+
GTE = "gte"
|
|
24
|
+
EQ = "eq"
|
|
25
|
+
NEQ = "neq"
|
|
26
|
+
CHANGED = "changed"
|
|
27
|
+
ENTER = "enter"
|
|
28
|
+
LEAVE = "leave"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Operators that require no threshold value
|
|
32
|
+
_NO_VALUE_OPS = frozenset({TriggerOperator.CHANGED})
|
|
33
|
+
|
|
34
|
+
# Geofence operators require a dict with lat/lon/radius
|
|
35
|
+
_GEOFENCE_OPS = frozenset({TriggerOperator.ENTER, TriggerOperator.LEAVE})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TriggerCondition(BaseModel):
|
|
39
|
+
"""A single condition that a trigger evaluates.
|
|
40
|
+
|
|
41
|
+
For most operators, ``value`` is a numeric or string threshold.
|
|
42
|
+
For ``changed``, ``value`` is not required.
|
|
43
|
+
For ``enter``/``leave``, ``value`` is a dict with
|
|
44
|
+
``latitude``, ``longitude``, and ``radius_m``.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
field: str
|
|
48
|
+
operator: TriggerOperator
|
|
49
|
+
value: Any = None
|
|
50
|
+
|
|
51
|
+
@model_validator(mode="after")
|
|
52
|
+
def _validate_value_for_operator(self) -> TriggerCondition:
|
|
53
|
+
op = self.operator
|
|
54
|
+
if op in _GEOFENCE_OPS:
|
|
55
|
+
if not isinstance(self.value, dict):
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"Operator '{op.value}' requires a dict value "
|
|
58
|
+
"with latitude, longitude, radius_m"
|
|
59
|
+
)
|
|
60
|
+
missing = {"latitude", "longitude", "radius_m"} - set(self.value.keys())
|
|
61
|
+
if missing:
|
|
62
|
+
raise ValueError(f"Geofence value missing keys: {', '.join(sorted(missing))}")
|
|
63
|
+
elif op not in _NO_VALUE_OPS and self.value is None:
|
|
64
|
+
raise ValueError(f"Operator '{op.value}' requires a 'value' parameter")
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _make_trigger_id() -> str:
|
|
69
|
+
"""Generate a short hex ID (12 chars from a UUID4)."""
|
|
70
|
+
return uuid.uuid4().hex[:12]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TriggerDefinition(BaseModel):
|
|
74
|
+
"""A registered trigger with its condition and firing configuration."""
|
|
75
|
+
|
|
76
|
+
id: str = Field(default_factory=_make_trigger_id)
|
|
77
|
+
condition: TriggerCondition
|
|
78
|
+
once: bool = False
|
|
79
|
+
cooldown_seconds: float = Field(default=60.0, ge=0)
|
|
80
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TriggerNotification(BaseModel):
|
|
84
|
+
"""Notification emitted when a trigger fires."""
|
|
85
|
+
|
|
86
|
+
trigger_id: str
|
|
87
|
+
field: str
|
|
88
|
+
operator: TriggerOperator
|
|
89
|
+
threshold: Any = None
|
|
90
|
+
value: Any = None
|
|
91
|
+
previous_value: Any = None
|
|
92
|
+
fired_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
93
|
+
vin: str = ""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tescmd
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A Python CLI for querying and controlling Tesla vehicles via the Fleet API
|
|
5
5
|
Project-URL: Homepage, https://github.com/oceanswave/tescmd
|
|
6
6
|
Project-URL: Repository, https://github.com/oceanswave/tescmd
|
|
@@ -26,11 +26,16 @@ Requires-Dist: click>=8.1
|
|
|
26
26
|
Requires-Dist: cryptography>=42.0
|
|
27
27
|
Requires-Dist: httpx>=0.27
|
|
28
28
|
Requires-Dist: keyring>=25.0
|
|
29
|
+
Requires-Dist: mcp>=1.0
|
|
29
30
|
Requires-Dist: protobuf>=5.29
|
|
30
31
|
Requires-Dist: pydantic-settings>=2.0
|
|
31
32
|
Requires-Dist: pydantic>=2.0
|
|
32
33
|
Requires-Dist: python-dotenv>=1.0
|
|
33
34
|
Requires-Dist: rich>=13.0
|
|
35
|
+
Requires-Dist: starlette>=0.37
|
|
36
|
+
Requires-Dist: textual>=1.0
|
|
37
|
+
Requires-Dist: uvicorn>=0.30
|
|
38
|
+
Requires-Dist: websockets>=14.0
|
|
34
39
|
Provides-Extra: ble
|
|
35
40
|
Requires-Dist: bleak>=0.22; extra == 'ble'
|
|
36
41
|
Provides-Extra: dev
|
|
@@ -41,21 +46,36 @@ Requires-Dist: mypy>=1.13; extra == 'dev'
|
|
|
41
46
|
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
42
47
|
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
43
48
|
Requires-Dist: pytest-httpx>=0.34; extra == 'dev'
|
|
49
|
+
Requires-Dist: pytest-xdist>=3.0; extra == 'dev'
|
|
44
50
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
45
51
|
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
46
52
|
Description-Content-Type: text/markdown
|
|
47
53
|
|
|
54
|
+
<p align="center">
|
|
55
|
+
<img src="images/tescmd_header.jpeg" alt="tescmd — Python CLI for Tesla Fleet API" width="100%">
|
|
56
|
+
</p>
|
|
57
|
+
|
|
48
58
|
# tescmd
|
|
49
59
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
60
|
+
<p align="center">
|
|
61
|
+
<a href="https://pypi.org/project/tescmd/"><img src="https://img.shields.io/pypi/v/tescmd" alt="PyPI"></a>
|
|
62
|
+
<a href="https://pypi.org/project/tescmd/"><img src="https://img.shields.io/pypi/pyversions/tescmd" alt="Python"></a>
|
|
63
|
+
<a href="https://github.com/oceanswave/tescmd/actions/workflows/test.yml"><img src="https://img.shields.io/github/actions/workflow/status/oceanswave/tescmd/test.yml?branch=main&label=build" alt="Build"></a>
|
|
64
|
+
<a href="LICENSE"><img src="https://img.shields.io/github/license/oceanswave/tescmd" alt="License"></a>
|
|
65
|
+
<a href="https://github.com/oceanswave/tescmd/releases"><img src="https://img.shields.io/github/v/release/oceanswave/tescmd" alt="GitHub Release"></a>
|
|
66
|
+
</p>
|
|
53
67
|
|
|
54
68
|
A Python CLI for querying and controlling Tesla vehicles via the Fleet API — built for both human operators and AI agents.
|
|
55
69
|
|
|
70
|
+
## What It Does
|
|
71
|
+
|
|
72
|
+
tescmd gives you full command-line access to Tesla's Fleet API: check battery and charge status, lock or unlock doors, control climate, open trunks, send navigation waypoints, manage Powerwalls, stream live telemetry, and more. It handles OAuth2 authentication, token refresh, key enrollment, command signing, and response caching so you don't have to. Every command works in both interactive (Rich tables) and scripted (JSON) modes, and an MCP server lets AI agents call any command as a tool.
|
|
73
|
+
|
|
56
74
|
## Why tescmd?
|
|
57
75
|
|
|
58
|
-
Tesla's Fleet API gives developers full access to vehicle data and commands, but working with it directly means juggling OAuth2 PKCE flows, token refresh, regional endpoints, key enrollment, and raw JSON responses.
|
|
76
|
+
Tesla's Fleet API gives developers full access to vehicle data and commands, but working with it directly means juggling OAuth2 PKCE flows, token refresh, regional endpoints, key enrollment, and raw JSON responses.
|
|
77
|
+
|
|
78
|
+
tescmd wraps all of that into a single command-line tool that handles authentication, token management, and output formatting so you can focus on what you actually want to do — check your battery, find your car, or control your vehicle.
|
|
59
79
|
|
|
60
80
|
tescmd is designed to work as a tool that AI agents can invoke directly. Platforms like [OpenClaw](https://openclaw.ai/), [Claude Desktop](https://claude.ai), and other agent frameworks can call tescmd commands, parse the structured JSON output, and take actions on your behalf — "lock my car", "what's my battery at?", "start climate control". The deterministic JSON output, meaningful exit codes, cost-aware wake confirmation, and `--wake` opt-in flag make it safe for autonomous agent use without surprise billing.
|
|
61
81
|
|
|
@@ -68,7 +88,11 @@ tescmd is designed to work as a tool that AI agents can invoke directly. Platfor
|
|
|
68
88
|
- **Tier enforcement** — readonly tier blocks write commands with clear guidance to upgrade
|
|
69
89
|
- **Energy products** — Powerwall live status, site info, backup reserve, operation mode, storm mode, time-of-use settings, charging history, calendar history, grid import/export
|
|
70
90
|
- **User & sharing** — account info, region, orders, feature flags, driver management, vehicle sharing invites
|
|
71
|
-
- **
|
|
91
|
+
- **Live Dashboard** — `tescmd serve` launches a full-screen TUI showing live telemetry data, MCP server info, tunnel URL, sink count, and cache stats — all in a scrollable, interactive terminal UI powered by Textual
|
|
92
|
+
- **Fleet Telemetry streaming** — `tescmd serve` (or `tescmd vehicle telemetry stream`) receives push-based data from your vehicle via Tailscale Funnel — no polling, 99%+ cost reduction. Telemetry sessions produce a wide-format CSV log by default
|
|
93
|
+
- **OpenClaw Bridge** — `tescmd serve --openclaw ws://...` streams filtered telemetry to an OpenClaw Gateway with configurable delta+throttle filtering per field; supports bidirectional command dispatch so bots can send vehicle commands back through the gateway
|
|
94
|
+
- **Trigger subscriptions** — register conditions on any telemetry field (battery < 20%, speed > 80, location enters geofence) and get notified via OpenClaw push events or MCP polling; supports one-shot and persistent modes with cooldown
|
|
95
|
+
- **MCP Server** — `tescmd serve` (or `tescmd mcp serve`) exposes all commands as MCP tools for Claude.ai, Claude Desktop, Claude Code, and other agent frameworks via OAuth 2.1
|
|
72
96
|
- **Universal response caching** — all read commands are cached with tiered TTLs (1h for specs/warranty, 5m for fleet lists, 1m standard, 30s for location-dependent); bots can call tescmd as often as needed — within the TTL window, responses are instant and free
|
|
73
97
|
- **Cost-aware wake** — prompts before sending billable wake API calls; `--wake` flag for scripts that accept the cost
|
|
74
98
|
- **Guided OAuth2 setup** — `tescmd auth login` walks you through browser-based authentication with PKCE
|
|
@@ -83,51 +107,47 @@ tescmd is designed to work as a tool that AI agents can invoke directly. Platfor
|
|
|
83
107
|
|
|
84
108
|
```bash
|
|
85
109
|
pip install tescmd
|
|
86
|
-
|
|
87
|
-
# First-time setup (interactive wizard)
|
|
88
110
|
tescmd setup
|
|
111
|
+
```
|
|
89
112
|
|
|
90
|
-
|
|
91
|
-
tescmd auth login
|
|
113
|
+
That's it. The interactive setup wizard walks you through everything: creating a Tesla Developer app, generating an EC key pair, hosting the public key (via GitHub Pages or Tailscale Funnel), registering with the Fleet API, authenticating via OAuth2, and enrolling your key on a vehicle. Each step checks prerequisites and offers remediation if something is missing.
|
|
92
114
|
|
|
93
|
-
|
|
94
|
-
tescmd vehicle list
|
|
115
|
+
After setup completes, you can start using commands:
|
|
95
116
|
|
|
96
|
-
|
|
97
|
-
tescmd
|
|
117
|
+
```bash
|
|
118
|
+
tescmd charge status # Check battery and charging state
|
|
119
|
+
tescmd vehicle info # Full vehicle data snapshot
|
|
120
|
+
tescmd climate on --wake # Turn on climate (wakes vehicle if asleep)
|
|
121
|
+
tescmd security lock --wake # Lock the car
|
|
122
|
+
tescmd serve 5YJ3... # MCP + telemetry TUI dashboard + CSV log
|
|
123
|
+
tescmd serve --no-mcp # Telemetry-only TUI dashboard
|
|
124
|
+
```
|
|
98
125
|
|
|
99
|
-
|
|
100
|
-
tescmd charge status
|
|
126
|
+
Every read command is cached — repeat calls within the TTL window are instant and free.
|
|
101
127
|
|
|
102
|
-
|
|
103
|
-
tescmd charge start --wake
|
|
128
|
+
## Prerequisites
|
|
104
129
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
tescmd
|
|
130
|
+
| Requirement | Required | What it is | Why tescmd needs it |
|
|
131
|
+
|---|---|---|---|
|
|
132
|
+
| **Python 3.11+** | Yes | The programming language runtime that runs tescmd | tescmd is a Python package — you need Python installed to use it |
|
|
133
|
+
| **pip** | Yes | Python's package installer (ships with Python) | Used to install tescmd and its dependencies via `pip install tescmd` |
|
|
134
|
+
| **Tesla account** | Yes | A [tesla.com](https://www.tesla.com) account linked to a vehicle or energy product | tescmd authenticates via OAuth2 against your Tesla account to access the Fleet API |
|
|
135
|
+
| **Git** | Yes | Version control tool ([git-scm.com](https://git-scm.com)) | Used during setup for key hosting via GitHub Pages |
|
|
136
|
+
| **GitHub CLI** (`gh`) | Recommended | GitHub's command-line tool ([cli.github.com](https://cli.github.com)) — authenticate with `gh auth login` | Auto-creates a `*.github.io` site to host your public key at the `.well-known` path Tesla requires |
|
|
137
|
+
| **Tailscale** | Recommended | Mesh VPN with public tunneling ([tailscale.com](https://tailscale.com)) — authenticate with `tailscale login` | Provides a public HTTPS URL for key hosting and Fleet Telemetry streaming with zero infrastructure setup |
|
|
108
138
|
|
|
109
|
-
|
|
110
|
-
tescmd security lock --wake
|
|
139
|
+
### Self-Hosting with Tailscale (No Domain Required)
|
|
111
140
|
|
|
112
|
-
|
|
113
|
-
tescmd key enroll 5YJ3E1EA1NF000000
|
|
141
|
+
If you have **Tailscale** installed with Funnel enabled, you don't need a custom domain or GitHub Pages at all. Tailscale Funnel gives you a public HTTPS URL (`<machine>.tailnet.ts.net`) that serves both your public key and (optionally) Fleet Telemetry streaming — all from your local machine with zero infrastructure setup.
|
|
114
142
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
tescmd
|
|
143
|
+
```bash
|
|
144
|
+
# Install Tailscale, enable Funnel in your tailnet ACL, then:
|
|
145
|
+
tescmd setup # wizard auto-detects Tailscale and offers it as the hosting method
|
|
118
146
|
```
|
|
119
147
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
The following tools should be installed and authenticated before running `tescmd setup`:
|
|
123
|
-
|
|
124
|
-
| Tool | Required | Purpose | Auth |
|
|
125
|
-
|------|----------|---------|------|
|
|
126
|
-
| **Git** | Yes | Version control, repo management | N/A |
|
|
127
|
-
| **GitHub CLI** (`gh`) | Recommended | Auto-creates `*.github.io` domain for key hosting | `gh auth login` |
|
|
128
|
-
| **Tailscale** | Optional | Secure remote access to vehicles via Fleet Telemetry | `tailscale login` |
|
|
148
|
+
This is the fastest path to a working setup: Tailscale handles TLS certificates, NAT traversal, and public DNS automatically. The tradeoff is that your machine needs to be running for Tesla to reach your key and for telemetry streaming to work. For always-on key hosting with offline machines, use GitHub Pages instead.
|
|
129
149
|
|
|
130
|
-
Without
|
|
150
|
+
Without either GitHub CLI or Tailscale, you'll need to manually host your public key at the Tesla-required `.well-known` path on your own domain.
|
|
131
151
|
|
|
132
152
|
## Installation
|
|
133
153
|
|
|
@@ -210,7 +230,7 @@ Check which backend is active with `tescmd status` — the output includes a `To
|
|
|
210
230
|
|---|---|---|
|
|
211
231
|
| `setup` | *(interactive wizard)* | First-run configuration: client ID, secret, region, domain, key enrollment |
|
|
212
232
|
| `auth` | `login`, `logout`, `status`, `refresh`, `register`, `export`, `import` | OAuth2 authentication lifecycle |
|
|
213
|
-
| `vehicle` | `list`, `get`, `info`, `data`, `location`, `wake`, `rename`, `mobile-access`, `nearby-chargers`, `alerts`, `release-notes`, `service`, `drivers`, `calendar`, `subscriptions`, `upgrades`, `options`, `specs`, `warranty`, `fleet-status`, `low-power`, `accessory-power`, `telemetry {config,create,delete,errors}` | Vehicle discovery, state queries, fleet telemetry, power management |
|
|
233
|
+
| `vehicle` | `list`, `get`, `info`, `data`, `location`, `wake`, `rename`, `mobile-access`, `nearby-chargers`, `alerts`, `release-notes`, `service`, `drivers`, `calendar`, `subscriptions`, `upgrades`, `options`, `specs`, `warranty`, `fleet-status`, `low-power`, `accessory-power`, `telemetry {config,create,delete,errors,stream}` | Vehicle discovery, state queries, fleet telemetry streaming, power management |
|
|
214
234
|
| `charge` | `status`, `start`, `stop`, `limit`, `limit-max`, `limit-std`, `amps`, `port-open`, `port-close`, `schedule`, `departure`, `precondition-add`, `precondition-remove`, `add-schedule`, `remove-schedule`, `clear-schedules`, `clear-preconditions`, `managed-amps`, `managed-location`, `managed-schedule` | Charge queries, control, scheduling, and fleet management |
|
|
215
235
|
| `billing` | `history`, `sessions`, `invoice` | Supercharger billing history and invoices |
|
|
216
236
|
| `climate` | `status`, `on`, `off`, `set`, `precondition`, `seat`, `seat-cool`, `wheel-heater`, `overheat`, `bioweapon`, `keeper`, `cop-temp`, `auto-seat`, `auto-wheel`, `wheel-level` | Climate, seat, and steering wheel control |
|
|
@@ -224,6 +244,9 @@ Check which backend is active with `tescmd status` — the output includes a `To
|
|
|
224
244
|
| `sharing` | `add-driver`, `remove-driver`, `create-invite`, `redeem-invite`, `revoke-invite`, `list-invites` | Vehicle sharing and driver management |
|
|
225
245
|
| `key` | `generate`, `deploy`, `validate`, `show`, `enroll`, `unenroll` | Key management and enrollment |
|
|
226
246
|
| `partner` | `public-key`, `telemetry-error-vins`, `telemetry-errors` | Partner account endpoints (require client credentials) |
|
|
247
|
+
| `serve` | *(unified server)* | Combined MCP + telemetry + OpenClaw TUI dashboard with trigger subscriptions |
|
|
248
|
+
| `openclaw` | `bridge` | Standalone OpenClaw bridge with bidirectional command dispatch |
|
|
249
|
+
| `mcp` | `serve` | Standalone MCP server exposing all commands as agent tools |
|
|
227
250
|
| `cache` | `status`, `clear` | Response cache management |
|
|
228
251
|
| `raw` | `get`, `post` | Arbitrary Fleet API endpoint access |
|
|
229
252
|
|
|
@@ -305,7 +328,7 @@ A naive script that polls `vehicle_data` every 5 minutes generates **4-5 billabl
|
|
|
305
328
|
|
|
306
329
|
| | Without tescmd | With tescmd |
|
|
307
330
|
|---|---|---|
|
|
308
|
-
| Vehicle asleep, check battery | 408 error (billable) + wake (billable) + poll (billable) + data (billable) = **4+ requests** |
|
|
331
|
+
| Vehicle asleep, check battery | 408 error (billable) + wake (billable) + poll (billable) + data (billable) = **4+ requests** | Data attempt → 408 (billable) → prompt user → user wakes via Tesla app (free) → retry → data (billable) = **2 requests** |
|
|
309
332
|
| Check battery again 30s later | Another 4+ requests | **0 requests** (cache hit) |
|
|
310
333
|
| 10 checks in 1 minute | **40+ billable requests** | **1 billable request** + 9 cache hits |
|
|
311
334
|
|
|
@@ -355,6 +378,50 @@ Configure via environment variables:
|
|
|
355
378
|
| `TESLA_CACHE_TTL` | `60` | Time-to-live in seconds |
|
|
356
379
|
| `TESLA_CACHE_DIR` | `~/.cache/tescmd` | Cache directory path |
|
|
357
380
|
|
|
381
|
+
## Fleet Telemetry Streaming
|
|
382
|
+
|
|
383
|
+
Tesla's Fleet Telemetry lets your vehicle push real-time data directly to your server — no polling, no per-request charges. tescmd handles all the setup:
|
|
384
|
+
|
|
385
|
+
```bash
|
|
386
|
+
# Full-screen TUI with live telemetry + MCP server
|
|
387
|
+
tescmd serve 5YJ3...
|
|
388
|
+
|
|
389
|
+
# Telemetry-only mode (full-screen TUI, no MCP)
|
|
390
|
+
tescmd serve 5YJ3... --no-mcp
|
|
391
|
+
|
|
392
|
+
# Select field presets
|
|
393
|
+
tescmd serve 5YJ3... --fields driving # Speed, location, power
|
|
394
|
+
tescmd serve 5YJ3... --fields charging # Battery, voltage, current
|
|
395
|
+
tescmd serve 5YJ3... --fields all # Everything (120+ fields)
|
|
396
|
+
|
|
397
|
+
# Override polling interval
|
|
398
|
+
tescmd serve 5YJ3... --interval 5 # Every 5 seconds
|
|
399
|
+
|
|
400
|
+
# JSONL output for scripting (non-TTY / piped)
|
|
401
|
+
tescmd serve 5YJ3... --no-mcp --format json | jq .
|
|
402
|
+
|
|
403
|
+
# Disable CSV log
|
|
404
|
+
tescmd serve 5YJ3... --no-log
|
|
405
|
+
|
|
406
|
+
# Legacy Rich Live dashboard
|
|
407
|
+
tescmd serve 5YJ3... --legacy-dashboard
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
**Requires Tailscale** with Funnel enabled. The serve command starts a local WebSocket server, exposes it via Tailscale Funnel (handles TLS + NAT traversal), configures Tesla to push data to it, and renders a full-screen TUI with live telemetry data, server info (MCP URL, tunnel, sinks), unit conversion, and connection status. Press `q` to quit.
|
|
411
|
+
|
|
412
|
+
By default, telemetry sessions write a wide-format CSV log to `~/.config/tescmd/logs/` with one row per frame and one column per subscribed field. Disable with `--no-log`.
|
|
413
|
+
|
|
414
|
+
`tescmd vehicle telemetry stream` is an alias for `tescmd serve --no-mcp`.
|
|
415
|
+
|
|
416
|
+
### Telemetry vs Polling Costs
|
|
417
|
+
|
|
418
|
+
| Approach | 1 vehicle, 5-second interval, 24 hours | Monthly cost estimate |
|
|
419
|
+
|---|---|---|
|
|
420
|
+
| **Polling `vehicle_data`** | ~17,280 requests × $0.001 = **$17/day** | **$500+/month** |
|
|
421
|
+
| **Fleet Telemetry streaming** | 1 config create + 1 config delete = **2 requests** | **< $0.01/month** |
|
|
422
|
+
|
|
423
|
+
Fleet Telemetry streaming is a flat-cost alternative: you pay only for the initial config setup and teardown, regardless of how much data flows. The tradeoff is that you need Tailscale running on a machine to receive the push.
|
|
424
|
+
|
|
358
425
|
## Key Enrollment & Vehicle Command Protocol
|
|
359
426
|
|
|
360
427
|
Newer Tesla vehicles require commands to be signed using the [Vehicle Command Protocol](https://github.com/teslamotors/vehicle-command). tescmd handles this transparently:
|
|
@@ -449,6 +516,20 @@ Run this periodically or after modifying API methods to catch drift.
|
|
|
449
516
|
|
|
450
517
|
See [docs/development.md](docs/development.md) for detailed contribution guidelines.
|
|
451
518
|
|
|
519
|
+
## Documentation
|
|
520
|
+
|
|
521
|
+
- [Setup Guide](docs/setup.md) — step-by-step walkthrough of `tescmd setup`
|
|
522
|
+
- [FAQ](docs/faq.md) — common questions about tescmd, costs, hosting, and configuration
|
|
523
|
+
- [Command Reference](docs/commands.md) — detailed usage for every command
|
|
524
|
+
- [API Costs](docs/api-costs.md) — detailed cost breakdown and savings calculations
|
|
525
|
+
- [Bot Integration](docs/bot-integration.md) — JSON schema, exit codes, telemetry streaming, headless auth
|
|
526
|
+
- [OpenClaw Bridge](docs/openclaw.md) — gateway protocol, bidirectional commands, trigger subscriptions, geofencing
|
|
527
|
+
- [MCP Server](docs/mcp.md) — tool reference, authentication, custom tools, trigger polling
|
|
528
|
+
- [Architecture](docs/architecture.md) — layered design, module responsibilities, design decisions
|
|
529
|
+
- [Vehicle Command Protocol](docs/vehicle-command-protocol.md) — ECDH sessions and signed commands
|
|
530
|
+
- [Authentication](docs/authentication.md) — OAuth2 PKCE flow, token storage, scopes
|
|
531
|
+
- [Development](docs/development.md) — contribution guidelines, testing, linting
|
|
532
|
+
|
|
452
533
|
## Changelog
|
|
453
534
|
|
|
454
535
|
See [CHANGELOG.md](CHANGELOG.md) for release history.
|
|
@@ -456,3 +537,7 @@ See [CHANGELOG.md](CHANGELOG.md) for release history.
|
|
|
456
537
|
## License
|
|
457
538
|
|
|
458
539
|
MIT
|
|
540
|
+
|
|
541
|
+
<p align="center">
|
|
542
|
+
<img src="images/tescmd_logo.jpeg" alt="tescmd logo" width="180">
|
|
543
|
+
</p>
|