tescmd 0.3.1__py3-none-any.whl → 0.5.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.
tescmd/openclaw/config.py CHANGED
@@ -12,8 +12,16 @@ from pydantic import BaseModel, Field
12
12
  class NodeCapabilities(BaseModel):
13
13
  """Advertised capabilities for the OpenClaw node role.
14
14
 
15
+ The node advertises only two commands to the gateway:
16
+
17
+ - ``location.get`` (read) — standard node location capability
18
+ - ``system.run`` (write) — single entry point; the gateway routes all
19
+ invocations through this method and the internal
20
+ :class:`~tescmd.openclaw.dispatcher.CommandDispatcher` fans out to
21
+ the full set of 34 handlers.
22
+
15
23
  Maps to the gateway connect schema fields:
16
- - ``caps``: broad capability categories (e.g. ``"location"``, ``"climate"``)
24
+ - ``caps``: broad capability categories (``"location"``, ``"system"``)
17
25
  - ``commands``: specific method names the node can handle
18
26
  - ``permissions``: per-command permission booleans
19
27
 
@@ -23,39 +31,8 @@ class NodeCapabilities(BaseModel):
23
31
 
24
32
  reads: list[str] = [
25
33
  "location.get",
26
- "battery.get",
27
- "temperature.get",
28
- "speed.get",
29
- "charge_state.get",
30
- "security.get",
31
- # Trigger reads
32
- "trigger.list",
33
- "trigger.poll",
34
34
  ]
35
35
  writes: list[str] = [
36
- "door.lock",
37
- "door.unlock",
38
- "climate.on",
39
- "climate.off",
40
- "climate.set_temp",
41
- "charge.start",
42
- "charge.stop",
43
- "charge.set_limit",
44
- "trunk.open",
45
- "frunk.open",
46
- "flash_lights",
47
- "honk_horn",
48
- "sentry.on",
49
- "sentry.off",
50
- # Trigger writes
51
- "trigger.create",
52
- "trigger.delete",
53
- # Convenience trigger aliases
54
- "cabin_temp.trigger",
55
- "outside_temp.trigger",
56
- "battery.trigger",
57
- "location.trigger",
58
- # Meta-dispatch
59
36
  "system.run",
60
37
  ]
61
38
 
@@ -424,13 +424,21 @@ class CommandDispatcher:
424
424
 
425
425
  Accepts both OpenClaw-style (``door.lock``) and API-style
426
426
  (``door_lock``) method names via :data:`_METHOD_ALIASES`.
427
+
428
+ The target method can be specified as ``method`` or ``command``
429
+ (the latter mirrors the gateway protocol's field name).
427
430
  """
428
- method = params.get("method", "")
431
+ raw = params.get("method", "") or params.get("command", "")
432
+ # Normalize: bots may send a list like ["door.lock"] instead of a string
433
+ if isinstance(raw, list):
434
+ raw = raw[0] if raw else ""
435
+ method = str(raw).strip() if raw else ""
429
436
  if not method:
430
- raise ValueError("system.run requires 'method' parameter")
437
+ raise ValueError("system.run requires 'method' (or 'command') parameter")
431
438
  resolved = _METHOD_ALIASES.get(method, method)
432
439
  if resolved == "system.run":
433
440
  raise ValueError("system.run cannot invoke itself")
441
+ logger.info("system.run → %s", resolved)
434
442
  inner_params = params.get("params", {})
435
443
  result = await self.dispatch({"method": resolved, "params": inner_params})
436
444
  if result is None:
@@ -229,6 +229,7 @@ class GatewayClient:
229
229
  model_identifier: str | None = None,
230
230
  capabilities: NodeCapabilities | None = None,
231
231
  on_request: Callable[[dict[str, Any]], Awaitable[dict[str, Any] | None]] | None = None,
232
+ on_reconnect: Callable[[], Awaitable[None]] | None = None,
232
233
  ) -> None:
233
234
  self._url = url
234
235
  self._token = token
@@ -243,6 +244,7 @@ class GatewayClient:
243
244
  self._model_identifier = model_identifier or "tescmd"
244
245
  self._capabilities = capabilities
245
246
  self._on_request = on_request
247
+ self._on_reconnect = on_reconnect
246
248
  self._ws: ClientConnection | None = None
247
249
  self._connected = False
248
250
  self._send_count = 0
@@ -280,8 +282,21 @@ class GatewayClient:
280
282
  so gateways that enforce authentication at the transport layer
281
283
  accept the connection before the OpenClaw handshake begins.
282
284
 
285
+ If a receive loop is already running (e.g. from a previous
286
+ connection or a concurrent reconnect attempt), it is cancelled
287
+ before establishing the new connection to prevent duplicate
288
+ ``recv()`` calls on the same WebSocket.
289
+
283
290
  Raises :class:`GatewayConnectionError` on failure.
284
291
  """
292
+ import contextlib
293
+
294
+ if self._recv_task is not None and not self._recv_task.done():
295
+ self._recv_task.cancel()
296
+ with contextlib.suppress(asyncio.CancelledError):
297
+ await self._recv_task
298
+ self._recv_task = None
299
+
285
300
  await self._establish_connection()
286
301
 
287
302
  if self._on_request is not None:
@@ -510,6 +525,12 @@ class GatewayClient:
510
525
  logger.error("Reconnection failed — receive loop exiting")
511
526
  break
512
527
 
528
+ if self._on_reconnect is not None:
529
+ try:
530
+ await self._on_reconnect()
531
+ except Exception:
532
+ logger.warning("on_reconnect callback failed", exc_info=True)
533
+
513
534
  async def _try_reconnect(self) -> bool:
514
535
  """Attempt to re-establish the gateway connection with exponential backoff.
515
536
 
@@ -531,7 +552,6 @@ class GatewayClient:
531
552
  invoke_id = payload.get("id", "")
532
553
  command = payload.get("command", "")
533
554
  params_json = payload.get("paramsJSON", "{}")
534
- logger.info("Invoke request: id=%s command=%s", invoke_id, command)
535
555
 
536
556
  if not self._on_request:
537
557
  await self._send_invoke_result(invoke_id, ok=False, error="no handler configured")
@@ -550,6 +570,18 @@ class GatewayClient:
550
570
  )
551
571
  params = {}
552
572
 
573
+ # Log with the real command name — for system.run, peek at the
574
+ # inner method so the activity log shows what's actually invoked.
575
+ if command == "system.run":
576
+ inner = params.get("method", "") or params.get("command", "")
577
+ if isinstance(inner, list):
578
+ inner = inner[0] if inner else ""
579
+ logger.info(
580
+ "Invoke request: id=%s command=%s (via system.run)", invoke_id, inner or "?"
581
+ )
582
+ else:
583
+ logger.info("Invoke request: id=%s command=%s", invoke_id, command)
584
+
553
585
  # Build the message dict the dispatcher expects
554
586
  dispatch_msg: dict[str, Any] = {
555
587
  "method": command,
@@ -112,27 +112,86 @@ class TailscaleManager:
112
112
  # Serve management (static file hosting)
113
113
  # ------------------------------------------------------------------
114
114
 
115
- async def start_serve(self, path: str, target: str | Path) -> None:
115
+ async def start_serve(
116
+ self,
117
+ path: str,
118
+ target: str | Path,
119
+ *,
120
+ port: int = 443,
121
+ funnel: bool = False,
122
+ ) -> None:
116
123
  """Serve a local directory at a URL path prefix.
117
124
 
118
- Runs: ``tailscale serve --bg --set-path <path> <target>``
125
+ Runs: ``tailscale serve --bg [--https=<port>] [--funnel] --set-path <path> <target>``
126
+
127
+ When *port* differs from 443 the ``--https=<port>`` flag is added so
128
+ Tailscale listens on the requested HTTPS port.
119
129
 
120
130
  Args:
121
131
  path: URL path prefix (e.g. ``/.well-known/``).
122
132
  target: Local directory to serve.
133
+ port: HTTPS port to serve on (default ``443``).
134
+ funnel: Also enable Funnel (public access) for this handler.
123
135
  """
124
- returncode, stdout, stderr = await self._run(
125
- "tailscale",
126
- "serve",
127
- "--bg",
128
- "--set-path",
129
- path,
130
- str(target),
131
- )
136
+ cmd: list[str] = ["tailscale", "serve", "--bg"]
137
+ if port != 443:
138
+ cmd.append(f"--https={port}")
139
+ if funnel:
140
+ cmd.append("--funnel")
141
+ cmd.extend(["--set-path", path, str(target)])
142
+
143
+ returncode, stdout, stderr = await self._run(*cmd)
132
144
  if returncode != 0:
133
145
  msg = stderr.strip() or stdout.strip()
134
146
  raise TailscaleError(f"Failed to start Tailscale serve: {msg}")
135
- logger.info("Tailscale serve started: %s -> %s", path, target)
147
+ logger.info("Tailscale serve started: %s -> %s (port %d)", path, target, port)
148
+
149
+ async def start_proxy(self, local_port: int, *, https_port: int = 443) -> None:
150
+ """Reverse-proxy an HTTPS port to a local HTTP server.
151
+
152
+ Runs: ``tailscale serve --bg [--https=<https_port>] http://127.0.0.1:<local_port>``
153
+
154
+ Unlike :meth:`start_serve` (which serves static files via
155
+ ``--set-path``), this sets up a reverse proxy so Tailscale
156
+ forwards traffic to a local HTTP server.
157
+
158
+ Args:
159
+ local_port: Port of the local HTTP server to proxy to.
160
+ https_port: Public-facing HTTPS port (default ``443``).
161
+ """
162
+ cmd: list[str] = ["tailscale", "serve", "--bg"]
163
+ if https_port != 443:
164
+ cmd.append(f"--https={https_port}")
165
+ cmd.append(f"http://127.0.0.1:{local_port}")
166
+
167
+ returncode, stdout, stderr = await self._run(*cmd)
168
+ if returncode != 0:
169
+ msg = stderr.strip() or stdout.strip()
170
+ raise TailscaleError(f"Failed to start Tailscale proxy: {msg}")
171
+ logger.info(
172
+ "Tailscale proxy started: https port %d -> http://127.0.0.1:%d",
173
+ https_port,
174
+ local_port,
175
+ )
176
+
177
+ async def stop_proxy(self, *, https_port: int = 443) -> None:
178
+ """Remove a reverse-proxy serve configuration for an HTTPS port.
179
+
180
+ Runs: ``tailscale serve --bg [--https=<https_port>] off``
181
+ """
182
+ cmd: list[str] = ["tailscale", "serve", "--bg"]
183
+ if https_port != 443:
184
+ cmd.append(f"--https={https_port}")
185
+ cmd.append("off")
186
+
187
+ returncode, stdout, stderr = await self._run(*cmd)
188
+ if returncode != 0:
189
+ logger.warning(
190
+ "Failed to stop Tailscale proxy on port %d (may already be stopped): %s",
191
+ https_port,
192
+ stderr.strip() or stdout.strip(),
193
+ )
194
+ logger.info("Tailscale proxy stopped for HTTPS port %d", https_port)
136
195
 
137
196
  async def stop_serve(self, path: str) -> None:
138
197
  """Remove a serve handler for a path.
@@ -155,21 +214,24 @@ class TailscaleManager:
155
214
  )
156
215
  logger.info("Tailscale serve stopped for path: %s", path)
157
216
 
158
- async def enable_funnel(self) -> None:
159
- """Enable Funnel on port 443 (expose all serve handlers publicly).
217
+ async def enable_funnel(self, port: int = 443) -> None:
218
+ """Enable Funnel on the given *port* (expose all serve handlers publicly).
160
219
 
161
- Runs: ``tailscale funnel --bg 443``
220
+ Runs: ``tailscale funnel --bg <port>``
221
+
222
+ Args:
223
+ port: HTTPS port to expose via Funnel (default ``443``).
162
224
  """
163
225
  returncode, stdout, stderr = await self._run(
164
226
  "tailscale",
165
227
  "funnel",
166
228
  "--bg",
167
- "443",
229
+ str(port),
168
230
  )
169
231
  if returncode != 0:
170
232
  msg = stderr.strip() or stdout.strip()
171
233
  raise TailscaleError(f"Failed to enable Tailscale Funnel: {msg}")
172
- logger.info("Tailscale Funnel enabled on port 443")
234
+ logger.info("Tailscale Funnel enabled on port %d", port)
173
235
 
174
236
  # ------------------------------------------------------------------
175
237
  # Funnel management (port proxying for telemetry)
tescmd/triggers/models.py CHANGED
@@ -8,13 +8,13 @@ from __future__ import annotations
8
8
 
9
9
  import uuid
10
10
  from datetime import UTC, datetime
11
- from enum import Enum
11
+ from enum import StrEnum
12
12
  from typing import Any
13
13
 
14
14
  from pydantic import BaseModel, Field, model_validator
15
15
 
16
16
 
17
- class TriggerOperator(str, Enum):
17
+ class TriggerOperator(StrEnum):
18
18
  """Supported comparison operators for trigger conditions."""
19
19
 
20
20
  LT = "lt"
@@ -0,0 +1,301 @@
1
+ Metadata-Version: 2.4
2
+ Name: tescmd
3
+ Version: 0.5.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-timeout>=2.3; extra == 'dev'
50
+ Requires-Dist: pytest-xdist>=3.0; extra == 'dev'
51
+ Requires-Dist: pytest>=8.0; extra == 'dev'
52
+ Requires-Dist: ruff>=0.8; extra == 'dev'
53
+ Description-Content-Type: text/markdown
54
+
55
+ <p align="center">
56
+ <img src="images/tescmd_header.jpeg" alt="tescmd — Python CLI for Tesla Fleet API" width="100%">
57
+ </p>
58
+
59
+ # tescmd
60
+
61
+ <p align="center">
62
+ <a href="https://pypi.org/project/tescmd/"><img src="https://img.shields.io/pypi/v/tescmd" alt="PyPI"></a>
63
+ <a href="https://pypi.org/project/tescmd/"><img src="https://img.shields.io/pypi/pyversions/tescmd" alt="Python"></a>
64
+ <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>
65
+ <a href="LICENSE"><img src="https://img.shields.io/github/license/oceanswave/tescmd" alt="License"></a>
66
+ <a href="https://github.com/oceanswave/tescmd/releases"><img src="https://img.shields.io/github/v/release/oceanswave/tescmd" alt="GitHub Release"></a>
67
+ </p>
68
+
69
+ <p align="center">
70
+ <strong>The complete Python CLI for Tesla's Fleet API — built for humans and AI agents alike.</strong>
71
+ </p>
72
+
73
+ <p align="center">
74
+ Check your battery. Lock your doors. Stream live telemetry. Let Claude control your car.<br>
75
+ Two commands to install. One wizard to set up. Every API endpoint at your fingertips.
76
+ </p>
77
+
78
+ ---
79
+
80
+ ## Quick Start
81
+
82
+ ```bash
83
+ pip install tescmd
84
+ tescmd setup
85
+ ```
86
+
87
+ 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:
88
+
89
+ ```bash
90
+ tescmd charge status # Battery and charging state
91
+ tescmd climate on --wake # Turn on climate (wakes if asleep)
92
+ tescmd security lock --wake # Lock the car
93
+ tescmd nav waypoints "Home" "Work" # Multi-stop navigation
94
+ tescmd serve 5YJ3... # Launch the live dashboard
95
+ ```
96
+
97
+ ---
98
+
99
+ ## See It in Action
100
+
101
+ ### Live TUI Dashboard
102
+
103
+ `tescmd serve` launches a full-screen terminal dashboard with real-time telemetry, MCP server status, tunnel info, and connection metrics — powered by Textual.
104
+
105
+ <p align="center">
106
+ <img src="images/tescmd_serve.png" alt="tescmd serve — live TUI dashboard" width="700">
107
+ </p>
108
+
109
+ ### AI Agent Integration
110
+
111
+ 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.
112
+
113
+ <p align="center">
114
+ <img src="images/tescmd_mcp.png" alt="tescmd MCP server — Claude Desktop integration" width="700">
115
+ </p>
116
+
117
+ ### Rich Terminal Output
118
+
119
+ Formatted tables in your terminal, structured JSON when piped — tescmd auto-detects the right output for the context.
120
+
121
+ <p align="center">
122
+ <img src="images/tescmd_waypoints.png" alt="tescmd nav waypoints" width="500">
123
+ </p>
124
+
125
+ ---
126
+
127
+ ## What You Get
128
+
129
+ ### Query & Control
130
+
131
+ 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.
132
+
133
+ ### Fleet Telemetry Streaming
134
+
135
+ 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.
136
+
137
+ ```bash
138
+ tescmd serve 5YJ3... --fields driving # Speed, location, power
139
+ tescmd serve 5YJ3... --fields all # Everything
140
+ ```
141
+
142
+ ### MCP Server for AI Agents
143
+
144
+ `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.
145
+
146
+ ### OpenClaw Bridge
147
+
148
+ 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.
149
+
150
+ ### Trigger Subscriptions
151
+
152
+ 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.
153
+
154
+ ### Signed Vehicle Commands
155
+
156
+ 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.
157
+
158
+ ---
159
+
160
+ ## Cost Protection Built In
161
+
162
+ 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:
163
+
164
+ | Layer | What it does |
165
+ |---|---|
166
+ | **Tiered caching** | Specs cached 1h, fleet lists 5m, standard queries 1m, location 30s |
167
+ | **Wake confirmation** | Prompts before billable wake calls; `--wake` flag for scripts |
168
+ | **Smart wake state** | Tracks recent wake confirmations, skips redundant attempts |
169
+ | **Write invalidation** | Write commands auto-invalidate the relevant cache scope |
170
+
171
+ 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.
172
+
173
+ ---
174
+
175
+ ## Commands
176
+
177
+ | Group | Description |
178
+ |---|---|
179
+ | `setup` | Interactive first-run wizard |
180
+ | `auth` | OAuth2 login, logout, token management, export/import |
181
+ | `vehicle` | State queries, wake, rename, telemetry streaming, fleet status |
182
+ | `charge` | Charge control, scheduling, departure, fleet management |
183
+ | `climate` | HVAC, seats, steering wheel, bioweapon defense, overheat protection |
184
+ | `security` | Lock/unlock, sentry, valet, PINs, speed limits, remote start |
185
+ | `trunk` | Trunk, frunk, windows, sunroof, tonneau |
186
+ | `media` | Playback control, volume, favorites |
187
+ | `nav` | Send destinations, GPS coordinates, multi-stop waypoints, HomeLink |
188
+ | `software` | Update status, scheduling, cancellation |
189
+ | `energy` | Powerwall status, backup reserve, storm mode, grid config, history |
190
+ | `billing` | Supercharger billing history and invoices |
191
+ | `user` | Account info, region, orders, feature flags |
192
+ | `sharing` | Driver management, vehicle sharing invites |
193
+ | `key` | Key generation, deployment, enrollment, validation |
194
+ | `serve` | Combined MCP + telemetry + OpenClaw TUI dashboard |
195
+ | `mcp` | Standalone MCP server |
196
+ | `openclaw` | Standalone OpenClaw bridge |
197
+ | `cache` | Cache status and management |
198
+ | `raw` | Direct Fleet API endpoint access |
199
+
200
+ Every command supports `--format json` for scripting and `--help` for detailed usage. See the [Command Reference](docs/commands.md) for the full list.
201
+
202
+ ---
203
+
204
+ ## Installation
205
+
206
+ ```bash
207
+ pip install tescmd
208
+ ```
209
+
210
+ **Requirements:** Python 3.11+ and a [Tesla account](https://www.tesla.com) with a linked vehicle or energy product.
211
+
212
+ **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.
213
+
214
+ <details>
215
+ <summary>Install from source</summary>
216
+
217
+ ```bash
218
+ git clone https://github.com/oceanswave/tescmd.git
219
+ cd tescmd
220
+ pip install -e ".[dev]"
221
+ ```
222
+
223
+ </details>
224
+
225
+ ---
226
+
227
+ ## Configuration
228
+
229
+ tescmd resolves settings from CLI flags, environment variables (`.env` files loaded automatically), and defaults — in that order.
230
+
231
+ <details>
232
+ <summary>Environment variables</summary>
233
+
234
+ ```dotenv
235
+ TESLA_CLIENT_ID=your-client-id
236
+ TESLA_CLIENT_SECRET=your-client-secret
237
+ TESLA_VIN=5YJ3E1EA1NF000000
238
+ TESLA_REGION=na # na, eu, cn
239
+
240
+ # Display units (optional — defaults to US)
241
+ TESLA_TEMP_UNIT=F # F or C
242
+ TESLA_DISTANCE_UNIT=mi # mi or km
243
+ TESLA_PRESSURE_UNIT=psi # psi or bar
244
+
245
+ # Or switch everything at once:
246
+ # tescmd --units metric charge status
247
+ ```
248
+
249
+ See [docs/commands.md](docs/commands.md) for the full environment variable reference.
250
+
251
+ </details>
252
+
253
+ <details>
254
+ <summary>Token storage</summary>
255
+
256
+ 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`.
257
+
258
+ </details>
259
+
260
+ ---
261
+
262
+ ## Documentation
263
+
264
+ | | |
265
+ |---|---|
266
+ | [Setup Guide](docs/setup.md) | Step-by-step walkthrough of `tescmd setup` |
267
+ | [Command Reference](docs/commands.md) | Detailed usage for every command |
268
+ | [API Costs](docs/api-costs.md) | Cost breakdown, savings calculations, streaming comparison |
269
+ | [Bot Integration](docs/bot-integration.md) | JSON schema, exit codes, headless auth |
270
+ | [OpenClaw Bridge](docs/openclaw.md) | Gateway protocol, bidirectional commands, triggers, geofencing |
271
+ | [MCP Server](docs/mcp.md) | Tool reference, OAuth 2.1, custom tools, trigger polling |
272
+ | [Vehicle Command Protocol](docs/vehicle-command-protocol.md) | ECDH sessions and signed commands |
273
+ | [Authentication](docs/authentication.md) | OAuth2 PKCE flow, token storage, scopes |
274
+ | [Architecture](docs/architecture.md) | Layered design, module responsibilities |
275
+ | [FAQ](docs/faq.md) | Common questions about costs, hosting, and configuration |
276
+ | [Development](docs/development.md) | Contributing, testing, linting |
277
+
278
+ ---
279
+
280
+ ## Development
281
+
282
+ ```bash
283
+ git clone https://github.com/oceanswave/tescmd.git && cd tescmd
284
+ pip install -e ".[dev]"
285
+ pytest # 1600+ tests
286
+ ruff check src/ tests/ && mypy src/
287
+ ```
288
+
289
+ ---
290
+
291
+ ## Changelog
292
+
293
+ See [CHANGELOG.md](CHANGELOG.md) for release history.
294
+
295
+ ## License
296
+
297
+ MIT
298
+
299
+ <p align="center">
300
+ <img src="images/tescmd_logo.jpeg" alt="tescmd logo" width="300">
301
+ </p>