tescmd 0.2.0__py3-none-any.whl → 0.4.0__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 (65) 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 +15 -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 +255 -106
  13. tescmd/cli/energy.py +2 -0
  14. tescmd/cli/key.py +6 -7
  15. tescmd/cli/main.py +27 -8
  16. tescmd/cli/mcp_cmd.py +153 -0
  17. tescmd/cli/nav.py +3 -1
  18. tescmd/cli/openclaw.py +169 -0
  19. tescmd/cli/security.py +7 -1
  20. tescmd/cli/serve.py +923 -0
  21. tescmd/cli/setup.py +147 -58
  22. tescmd/cli/sharing.py +2 -0
  23. tescmd/cli/status.py +1 -1
  24. tescmd/cli/trunk.py +8 -17
  25. tescmd/cli/user.py +16 -1
  26. tescmd/cli/vehicle.py +135 -462
  27. tescmd/deploy/github_pages.py +21 -2
  28. tescmd/deploy/tailscale_serve.py +96 -8
  29. tescmd/mcp/__init__.py +7 -0
  30. tescmd/mcp/server.py +648 -0
  31. tescmd/models/auth.py +5 -2
  32. tescmd/openclaw/__init__.py +23 -0
  33. tescmd/openclaw/bridge.py +330 -0
  34. tescmd/openclaw/config.py +167 -0
  35. tescmd/openclaw/dispatcher.py +529 -0
  36. tescmd/openclaw/emitter.py +175 -0
  37. tescmd/openclaw/filters.py +123 -0
  38. tescmd/openclaw/gateway.py +700 -0
  39. tescmd/openclaw/telemetry_store.py +53 -0
  40. tescmd/output/rich_output.py +46 -14
  41. tescmd/protocol/commands.py +2 -2
  42. tescmd/protocol/encoder.py +16 -13
  43. tescmd/protocol/payloads.py +132 -11
  44. tescmd/protocol/session.py +8 -5
  45. tescmd/protocol/signer.py +3 -17
  46. tescmd/telemetry/__init__.py +9 -0
  47. tescmd/telemetry/cache_sink.py +154 -0
  48. tescmd/telemetry/csv_sink.py +180 -0
  49. tescmd/telemetry/dashboard.py +4 -4
  50. tescmd/telemetry/fanout.py +49 -0
  51. tescmd/telemetry/fields.py +308 -129
  52. tescmd/telemetry/mapper.py +239 -0
  53. tescmd/telemetry/server.py +26 -19
  54. tescmd/telemetry/setup.py +468 -0
  55. tescmd/telemetry/tailscale.py +78 -16
  56. tescmd/telemetry/tui.py +1716 -0
  57. tescmd/triggers/__init__.py +18 -0
  58. tescmd/triggers/manager.py +264 -0
  59. tescmd/triggers/models.py +93 -0
  60. tescmd-0.4.0.dist-info/METADATA +300 -0
  61. {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/RECORD +64 -42
  62. tescmd-0.2.0.dist-info/METADATA +0 -495
  63. {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/WHEEL +0 -0
  64. {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/entry_points.txt +0 -0
  65. {tescmd-0.2.0.dist-info → tescmd-0.4.0.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 = ""
@@ -0,0 +1,300 @@
1
+ Metadata-Version: 2.4
2
+ Name: tescmd
3
+ Version: 0.4.0
4
+ Summary: A Python CLI for querying and controlling Tesla vehicles via the Fleet API
5
+ Project-URL: Homepage, https://github.com/oceanswave/tescmd
6
+ Project-URL: Repository, https://github.com/oceanswave/tescmd
7
+ Project-URL: Issues, https://github.com/oceanswave/tescmd/issues
8
+ Project-URL: Documentation, https://github.com/oceanswave/tescmd#readme
9
+ Author: oceanswave
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: cli,ev,fleet-api,tesla,vehicle
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Libraries
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.11
25
+ Requires-Dist: click>=8.1
26
+ Requires-Dist: cryptography>=42.0
27
+ Requires-Dist: httpx>=0.27
28
+ Requires-Dist: keyring>=25.0
29
+ Requires-Dist: mcp>=1.0
30
+ Requires-Dist: protobuf>=5.29
31
+ Requires-Dist: pydantic-settings>=2.0
32
+ Requires-Dist: pydantic>=2.0
33
+ Requires-Dist: python-dotenv>=1.0
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
39
+ Provides-Extra: ble
40
+ Requires-Dist: bleak>=0.22; extra == 'ble'
41
+ Provides-Extra: dev
42
+ Requires-Dist: build>=1.0; extra == 'dev'
43
+ Requires-Dist: grpcio-tools>=1.68; extra == 'dev'
44
+ Requires-Dist: mypy-protobuf>=3.6; extra == 'dev'
45
+ Requires-Dist: mypy>=1.13; extra == 'dev'
46
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
47
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
48
+ Requires-Dist: pytest-httpx>=0.34; extra == 'dev'
49
+ Requires-Dist: pytest-xdist>=3.0; extra == 'dev'
50
+ Requires-Dist: pytest>=8.0; extra == 'dev'
51
+ Requires-Dist: ruff>=0.8; extra == 'dev'
52
+ Description-Content-Type: text/markdown
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
+
58
+ # tescmd
59
+
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>
67
+
68
+ <p align="center">
69
+ <strong>The complete Python CLI for Tesla's Fleet API — built for humans and AI agents alike.</strong>
70
+ </p>
71
+
72
+ <p align="center">
73
+ Check your battery. Lock your doors. Stream live telemetry. Let Claude control your car.<br>
74
+ Two commands to install. One wizard to set up. Every API endpoint at your fingertips.
75
+ </p>
76
+
77
+ ---
78
+
79
+ ## Quick Start
80
+
81
+ ```bash
82
+ pip install tescmd
83
+ tescmd setup
84
+ ```
85
+
86
+ The setup wizard handles everything — Tesla Developer app creation, key generation, public key hosting, Fleet API registration, OAuth2 authentication, and vehicle key enrollment. Then you're ready:
87
+
88
+ ```bash
89
+ tescmd charge status # Battery and charging state
90
+ tescmd climate on --wake # Turn on climate (wakes if asleep)
91
+ tescmd security lock --wake # Lock the car
92
+ tescmd nav waypoints "Home" "Work" # Multi-stop navigation
93
+ tescmd serve 5YJ3... # Launch the live dashboard
94
+ ```
95
+
96
+ ---
97
+
98
+ ## See It in Action
99
+
100
+ ### Live TUI Dashboard
101
+
102
+ `tescmd serve` launches a full-screen terminal dashboard with real-time telemetry, MCP server status, tunnel info, and connection metrics — powered by Textual.
103
+
104
+ <p align="center">
105
+ <img src="images/tescmd_serve.png" alt="tescmd serve — live TUI dashboard" width="700">
106
+ </p>
107
+
108
+ ### AI Agent Integration
109
+
110
+ Every command doubles as an MCP tool. Claude Desktop, Claude Code, and other agent frameworks can query your vehicle, send commands, and react to telemetry — all through structured JSON with built-in cost protection.
111
+
112
+ <p align="center">
113
+ <img src="images/tescmd_mcp.png" alt="tescmd MCP server — Claude Desktop integration" width="700">
114
+ </p>
115
+
116
+ ### Rich Terminal Output
117
+
118
+ Formatted tables in your terminal, structured JSON when piped — tescmd auto-detects the right output for the context.
119
+
120
+ <p align="center">
121
+ <img src="images/tescmd_waypoints.png" alt="tescmd nav waypoints" width="500">
122
+ </p>
123
+
124
+ ---
125
+
126
+ ## What You Get
127
+
128
+ ### Query & Control
129
+
130
+ Full read/write access to Tesla's Fleet API: battery, charge, climate, locks, trunks, windows, sentry, navigation, media, speed limits, PINs, Powerwalls, and more. Every read command is cached with smart TTLs — bots can call tescmd as often as they want and only pay for the first request.
131
+
132
+ ### Fleet Telemetry Streaming
133
+
134
+ Your vehicle pushes data directly to your machine via Tailscale Funnel — no polling, no per-request charges. Choose from field presets (`driving`, `charging`, `all`) or subscribe to 120+ individual fields. Sessions produce a wide-format CSV log by default.
135
+
136
+ ```bash
137
+ tescmd serve 5YJ3... --fields driving # Speed, location, power
138
+ tescmd serve 5YJ3... --fields all # Everything
139
+ ```
140
+
141
+ ### MCP Server for AI Agents
142
+
143
+ `tescmd serve` exposes every command as an MCP tool with OAuth 2.1 authentication. Agents get deterministic JSON output, meaningful exit codes, and a `--wake` opt-in flag so they never trigger billable wake calls by accident.
144
+
145
+ ### OpenClaw Bridge
146
+
147
+ Stream filtered telemetry to an [OpenClaw](https://openclaw.ai/) Gateway with per-field delta and throttle filtering. Bots on the gateway can send commands back — lock doors, start charging, set climate — through bidirectional dispatch.
148
+
149
+ ### Trigger Subscriptions
150
+
151
+ Register conditions on any telemetry field — battery below 20%, speed above 80, location enters a geofence — and get notified via OpenClaw push events or MCP polling. Supports one-shot and persistent modes with cooldown.
152
+
153
+ ### Signed Vehicle Commands
154
+
155
+ tescmd implements the [Vehicle Command Protocol](https://github.com/teslamotors/vehicle-command) with ECDH session management and HMAC-SHA256 signing. Once your key is enrolled, commands are signed transparently — no agent-side crypto needed.
156
+
157
+ ---
158
+
159
+ ## Cost Protection Built In
160
+
161
+ Tesla's Fleet API is pay-per-use. A naive polling script can generate hundreds of dollars in monthly charges from a single vehicle. tescmd implements four layers of defense:
162
+
163
+ | Layer | What it does |
164
+ |---|---|
165
+ | **Tiered caching** | Specs cached 1h, fleet lists 5m, standard queries 1m, location 30s |
166
+ | **Wake confirmation** | Prompts before billable wake calls; `--wake` flag for scripts |
167
+ | **Smart wake state** | Tracks recent wake confirmations, skips redundant attempts |
168
+ | **Write invalidation** | Write commands auto-invalidate the relevant cache scope |
169
+
170
+ Streaming telemetry via `tescmd serve` replaces polling entirely — flat cost regardless of data volume. See [API Costs](docs/api-costs.md) for the full breakdown.
171
+
172
+ ---
173
+
174
+ ## Commands
175
+
176
+ | Group | Description |
177
+ |---|---|
178
+ | `setup` | Interactive first-run wizard |
179
+ | `auth` | OAuth2 login, logout, token management, export/import |
180
+ | `vehicle` | State queries, wake, rename, telemetry streaming, fleet status |
181
+ | `charge` | Charge control, scheduling, departure, fleet management |
182
+ | `climate` | HVAC, seats, steering wheel, bioweapon defense, overheat protection |
183
+ | `security` | Lock/unlock, sentry, valet, PINs, speed limits, remote start |
184
+ | `trunk` | Trunk, frunk, windows, sunroof, tonneau |
185
+ | `media` | Playback control, volume, favorites |
186
+ | `nav` | Send destinations, GPS coordinates, multi-stop waypoints, HomeLink |
187
+ | `software` | Update status, scheduling, cancellation |
188
+ | `energy` | Powerwall status, backup reserve, storm mode, grid config, history |
189
+ | `billing` | Supercharger billing history and invoices |
190
+ | `user` | Account info, region, orders, feature flags |
191
+ | `sharing` | Driver management, vehicle sharing invites |
192
+ | `key` | Key generation, deployment, enrollment, validation |
193
+ | `serve` | Combined MCP + telemetry + OpenClaw TUI dashboard |
194
+ | `mcp` | Standalone MCP server |
195
+ | `openclaw` | Standalone OpenClaw bridge |
196
+ | `cache` | Cache status and management |
197
+ | `raw` | Direct Fleet API endpoint access |
198
+
199
+ Every command supports `--format json` for scripting and `--help` for detailed usage. See the [Command Reference](docs/commands.md) for the full list.
200
+
201
+ ---
202
+
203
+ ## Installation
204
+
205
+ ```bash
206
+ pip install tescmd
207
+ ```
208
+
209
+ **Requirements:** Python 3.11+ and a [Tesla account](https://www.tesla.com) with a linked vehicle or energy product.
210
+
211
+ **Recommended:** [GitHub CLI](https://cli.github.com) (`gh`) for automated key hosting via GitHub Pages, or [Tailscale](https://tailscale.com) for zero-config key hosting and telemetry streaming via Funnel.
212
+
213
+ <details>
214
+ <summary>Install from source</summary>
215
+
216
+ ```bash
217
+ git clone https://github.com/oceanswave/tescmd.git
218
+ cd tescmd
219
+ pip install -e ".[dev]"
220
+ ```
221
+
222
+ </details>
223
+
224
+ ---
225
+
226
+ ## Configuration
227
+
228
+ tescmd resolves settings from CLI flags, environment variables (`.env` files loaded automatically), and defaults — in that order.
229
+
230
+ <details>
231
+ <summary>Environment variables</summary>
232
+
233
+ ```dotenv
234
+ TESLA_CLIENT_ID=your-client-id
235
+ TESLA_CLIENT_SECRET=your-client-secret
236
+ TESLA_VIN=5YJ3E1EA1NF000000
237
+ TESLA_REGION=na # na, eu, cn
238
+
239
+ # Display units (optional — defaults to US)
240
+ TESLA_TEMP_UNIT=F # F or C
241
+ TESLA_DISTANCE_UNIT=mi # mi or km
242
+ TESLA_PRESSURE_UNIT=psi # psi or bar
243
+
244
+ # Or switch everything at once:
245
+ # tescmd --units metric charge status
246
+ ```
247
+
248
+ See [docs/commands.md](docs/commands.md) for the full environment variable reference.
249
+
250
+ </details>
251
+
252
+ <details>
253
+ <summary>Token storage</summary>
254
+
255
+ Tokens are stored in the OS keyring by default (macOS Keychain, GNOME Keyring, Windows Credential Manager). On headless systems, tescmd falls back to a file-based store with restricted permissions. Transfer tokens between machines with `tescmd auth export` and `tescmd auth import`.
256
+
257
+ </details>
258
+
259
+ ---
260
+
261
+ ## Documentation
262
+
263
+ | | |
264
+ |---|---|
265
+ | [Setup Guide](docs/setup.md) | Step-by-step walkthrough of `tescmd setup` |
266
+ | [Command Reference](docs/commands.md) | Detailed usage for every command |
267
+ | [API Costs](docs/api-costs.md) | Cost breakdown, savings calculations, streaming comparison |
268
+ | [Bot Integration](docs/bot-integration.md) | JSON schema, exit codes, headless auth |
269
+ | [OpenClaw Bridge](docs/openclaw.md) | Gateway protocol, bidirectional commands, triggers, geofencing |
270
+ | [MCP Server](docs/mcp.md) | Tool reference, OAuth 2.1, custom tools, trigger polling |
271
+ | [Vehicle Command Protocol](docs/vehicle-command-protocol.md) | ECDH sessions and signed commands |
272
+ | [Authentication](docs/authentication.md) | OAuth2 PKCE flow, token storage, scopes |
273
+ | [Architecture](docs/architecture.md) | Layered design, module responsibilities |
274
+ | [FAQ](docs/faq.md) | Common questions about costs, hosting, and configuration |
275
+ | [Development](docs/development.md) | Contributing, testing, linting |
276
+
277
+ ---
278
+
279
+ ## Development
280
+
281
+ ```bash
282
+ git clone https://github.com/oceanswave/tescmd.git && cd tescmd
283
+ pip install -e ".[dev]"
284
+ pytest # 1600+ tests
285
+ ruff check src/ tests/ && mypy src/
286
+ ```
287
+
288
+ ---
289
+
290
+ ## Changelog
291
+
292
+ See [CHANGELOG.md](CHANGELOG.md) for release history.
293
+
294
+ ## License
295
+
296
+ MIT
297
+
298
+ <p align="center">
299
+ <img src="images/tescmd_logo.jpeg" alt="tescmd logo" width="300">
300
+ </p>