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,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.2.0
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
@@ -44,23 +49,33 @@ Requires-Dist: pytest-httpx>=0.34; extra == 'dev'
44
49
  Requires-Dist: pytest-xdist>=3.0; extra == 'dev'
45
50
  Requires-Dist: pytest>=8.0; extra == 'dev'
46
51
  Requires-Dist: ruff>=0.8; extra == 'dev'
47
- Provides-Extra: telemetry
48
- Requires-Dist: websockets>=14.0; extra == 'telemetry'
49
52
  Description-Content-Type: text/markdown
50
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
+
51
58
  # tescmd
52
59
 
53
- [![PyPI](https://img.shields.io/pypi/v/tescmd)](https://pypi.org/project/tescmd/)
54
- [![Python](https://img.shields.io/pypi/pyversions/tescmd)](https://pypi.org/project/tescmd/)
55
- [![Build](https://img.shields.io/github/actions/workflow/status/oceanswave/tescmd/test.yml?branch=main&label=build)](https://github.com/oceanswave/tescmd/actions/workflows/test.yml)
56
- [![License](https://img.shields.io/github/license/oceanswave/tescmd)](LICENSE)
57
- [![GitHub Release](https://img.shields.io/github/v/release/oceanswave/tescmd)](https://github.com/oceanswave/tescmd/releases)
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>
58
67
 
59
68
  A Python CLI for querying and controlling Tesla vehicles via the Fleet API — built for both human operators and AI agents.
60
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
+
61
74
  ## Why tescmd?
62
75
 
63
- 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. 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.
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.
64
79
 
65
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.
66
81
 
@@ -73,7 +88,11 @@ tescmd is designed to work as a tool that AI agents can invoke directly. Platfor
73
88
  - **Tier enforcement** — readonly tier blocks write commands with clear guidance to upgrade
74
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
75
90
  - **User & sharing** — account info, region, orders, feature flags, driver management, vehicle sharing invites
76
- - **Fleet Telemetry streaming** — `tescmd vehicle telemetry stream` starts a real-time dashboard with push-based data from your vehicle via Tailscale Funnelno polling, 99%+ cost reduction
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
77
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
78
97
  - **Cost-aware wake** — prompts before sending billable wake API calls; `--wake` flag for scripts that accept the cost
79
98
  - **Guided OAuth2 setup** — `tescmd auth login` walks you through browser-based authentication with PKCE
@@ -100,24 +119,35 @@ tescmd charge status # Check battery and charging state
100
119
  tescmd vehicle info # Full vehicle data snapshot
101
120
  tescmd climate on --wake # Turn on climate (wakes vehicle if asleep)
102
121
  tescmd security lock --wake # Lock the car
103
- tescmd vehicle telemetry stream # Real-time telemetry dashboard
122
+ tescmd serve 5YJ3... # MCP + telemetry TUI dashboard + CSV log
123
+ tescmd serve --no-mcp # Telemetry-only TUI dashboard
104
124
  ```
105
125
 
106
126
  Every read command is cached — repeat calls within the TTL window are instant and free.
107
127
 
108
128
  ## Prerequisites
109
129
 
110
- The following tools should be installed and authenticated before running `tescmd setup`:
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 |
138
+
139
+ ### Self-Hosting with Tailscale (No Domain Required)
140
+
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.
111
142
 
112
- | Tool | Required | Purpose | Auth |
113
- |------|----------|---------|------|
114
- | **Git** | Yes | Version control, repo management | N/A |
115
- | **GitHub CLI** (`gh`) | Recommended | Auto-creates `*.github.io` domain for key hosting | `gh auth login` |
116
- | **Tailscale** | Optional | Key hosting via Funnel + Fleet Telemetry streaming | `tailscale login` |
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
146
+ ```
117
147
 
118
- Without the GitHub CLI, `tescmd setup` will try Tailscale Funnel for key hosting (requires Funnel enabled in your tailnet ACL). Without either, you'll need to manually host your public key at the Tesla-required `.well-known` path on your own domain.
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.
119
149
 
120
- For telemetry streaming, you need **Tailscale** with Funnel enabled.
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.
121
151
 
122
152
  ## Installation
123
153
 
@@ -214,6 +244,9 @@ Check which backend is active with `tescmd status` — the output includes a `To
214
244
  | `sharing` | `add-driver`, `remove-driver`, `create-invite`, `redeem-invite`, `revoke-invite`, `list-invites` | Vehicle sharing and driver management |
215
245
  | `key` | `generate`, `deploy`, `validate`, `show`, `enroll`, `unenroll` | Key management and enrollment |
216
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 |
217
250
  | `cache` | `status`, `clear` | Response cache management |
218
251
  | `raw` | `get`, `post` | Arbitrary Fleet API endpoint access |
219
252
 
@@ -295,7 +328,7 @@ A naive script that polls `vehicle_data` every 5 minutes generates **4-5 billabl
295
328
 
296
329
  | | Without tescmd | With tescmd |
297
330
  |---|---|---|
298
- | Vehicle asleep, check battery | 408 error (billable) + wake (billable) + poll (billable) + data (billable) = **4+ requests** | Cache miss → prompt user → user wakes via Tesla app (free) → retry → data (billable) = **1 request** |
331
+ | Vehicle asleep, check battery | 408 error (billable) + wake (billable) + poll (billable) + data (billable) = **4+ requests** | Data attempt408 (billable) → prompt user → user wakes via Tesla app (free) → retry → data (billable) = **2 requests** |
299
332
  | Check battery again 30s later | Another 4+ requests | **0 requests** (cache hit) |
300
333
  | 10 checks in 1 minute | **40+ billable requests** | **1 billable request** + 9 cache hits |
301
334
 
@@ -350,26 +383,35 @@ Configure via environment variables:
350
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:
351
384
 
352
385
  ```bash
353
- # Install telemetry dependencies
354
- pip install tescmd[telemetry]
386
+ # Full-screen TUI with live telemetry + MCP server
387
+ tescmd serve 5YJ3...
355
388
 
356
- # Stream real-time data (Rich dashboard in TTY, JSONL when piped)
357
- tescmd vehicle telemetry stream
389
+ # Telemetry-only mode (full-screen TUI, no MCP)
390
+ tescmd serve 5YJ3... --no-mcp
358
391
 
359
392
  # Select field presets
360
- tescmd vehicle telemetry stream --fields driving # Speed, location, power
361
- tescmd vehicle telemetry stream --fields charging # Battery, voltage, current
362
- tescmd vehicle telemetry stream --fields climate # Temps, HVAC state
363
- tescmd vehicle telemetry stream --fields all # Everything (120+ fields)
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)
364
396
 
365
397
  # Override polling interval
366
- tescmd vehicle telemetry stream --interval 5 # Every 5 seconds
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
367
405
 
368
- # JSONL output for scripting
369
- tescmd vehicle telemetry stream --format json | jq .
406
+ # Legacy Rich Live dashboard
407
+ tescmd serve 5YJ3... --legacy-dashboard
370
408
  ```
371
409
 
372
- **Requires Tailscale** with Funnel enabled. The stream command starts a local WebSocket server, exposes it via Tailscale Funnel (handles TLS + NAT traversal), configures Tesla to push data to it, and renders an interactive dashboard with live uptime counter, unit conversion, and connection status. Press `q` to stop — cleanup messages show each step (removing telemetry config, restoring partner domain, stopping tunnel).
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`.
373
415
 
374
416
  ### Telemetry vs Polling Costs
375
417
 
@@ -481,6 +523,8 @@ See [docs/development.md](docs/development.md) for detailed contribution guideli
481
523
  - [Command Reference](docs/commands.md) — detailed usage for every command
482
524
  - [API Costs](docs/api-costs.md) — detailed cost breakdown and savings calculations
483
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
484
528
  - [Architecture](docs/architecture.md) — layered design, module responsibilities, design decisions
485
529
  - [Vehicle Command Protocol](docs/vehicle-command-protocol.md) — ECDH sessions and signed commands
486
530
  - [Authentication](docs/authentication.md) — OAuth2 PKCE flow, token storage, scopes
@@ -493,3 +537,7 @@ See [CHANGELOG.md](CHANGELOG.md) for release history.
493
537
  ## License
494
538
 
495
539
  MIT
540
+
541
+ <p align="center">
542
+ <img src="images/tescmd_logo.jpeg" alt="tescmd logo" width="180">
543
+ </p>