tesla-cli 4.8.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.
- tesla_cli/__init__.py +3 -0
- tesla_cli/__main__.py +5 -0
- tesla_cli/api/__init__.py +0 -0
- tesla_cli/api/app.py +594 -0
- tesla_cli/api/auth.py +50 -0
- tesla_cli/api/routes/__init__.py +1 -0
- tesla_cli/api/routes/auth.py +389 -0
- tesla_cli/api/routes/charge.py +178 -0
- tesla_cli/api/routes/climate.py +69 -0
- tesla_cli/api/routes/colombia.py +138 -0
- tesla_cli/api/routes/dossier.py +140 -0
- tesla_cli/api/routes/geofence.py +135 -0
- tesla_cli/api/routes/notify.py +86 -0
- tesla_cli/api/routes/order.py +25 -0
- tesla_cli/api/routes/security.py +119 -0
- tesla_cli/api/routes/sources.py +107 -0
- tesla_cli/api/routes/teslaMate.py +344 -0
- tesla_cli/api/routes/vehicle.py +322 -0
- tesla_cli/cli/__init__.py +0 -0
- tesla_cli/cli/app.py +255 -0
- tesla_cli/cli/commands/__init__.py +0 -0
- tesla_cli/cli/commands/abrp.py +278 -0
- tesla_cli/cli/commands/automations.py +772 -0
- tesla_cli/cli/commands/ble.py +235 -0
- tesla_cli/cli/commands/charge.py +1213 -0
- tesla_cli/cli/commands/climate.py +316 -0
- tesla_cli/cli/commands/config_cmd.py +1158 -0
- tesla_cli/cli/commands/data_cmd.py +584 -0
- tesla_cli/cli/commands/dossier.py +2208 -0
- tesla_cli/cli/commands/energy.py +298 -0
- tesla_cli/cli/commands/geofence.py +252 -0
- tesla_cli/cli/commands/ha.py +316 -0
- tesla_cli/cli/commands/media.py +161 -0
- tesla_cli/cli/commands/mqtt_cmd.py +413 -0
- tesla_cli/cli/commands/notify.py +249 -0
- tesla_cli/cli/commands/order.py +1352 -0
- tesla_cli/cli/commands/providers_cmd.py +194 -0
- tesla_cli/cli/commands/scenes.py +299 -0
- tesla_cli/cli/commands/security.py +158 -0
- tesla_cli/cli/commands/serve.py +475 -0
- tesla_cli/cli/commands/setup.py +886 -0
- tesla_cli/cli/commands/telemetry.py +323 -0
- tesla_cli/cli/commands/teslaMate.py +2411 -0
- tesla_cli/cli/commands/vehicle.py +3000 -0
- tesla_cli/cli/i18n.py +604 -0
- tesla_cli/cli/output.py +156 -0
- tesla_cli/core/__init__.py +0 -0
- tesla_cli/core/auth/__init__.py +0 -0
- tesla_cli/core/auth/browser_login.py +233 -0
- tesla_cli/core/auth/encryption.py +70 -0
- tesla_cli/core/auth/oauth.py +275 -0
- tesla_cli/core/auth/portal_scrape.py +113 -0
- tesla_cli/core/auth/tessie.py +12 -0
- tesla_cli/core/auth/tokens.py +41 -0
- tesla_cli/core/automation.py +370 -0
- tesla_cli/core/backends/__init__.py +54 -0
- tesla_cli/core/backends/base.py +116 -0
- tesla_cli/core/backends/dossier.py +1073 -0
- tesla_cli/core/backends/energy.py +111 -0
- tesla_cli/core/backends/fleet.py +343 -0
- tesla_cli/core/backends/fleet_signed.py +445 -0
- tesla_cli/core/backends/fleet_telemetry.py +150 -0
- tesla_cli/core/backends/http.py +82 -0
- tesla_cli/core/backends/order.py +487 -0
- tesla_cli/core/backends/owner.py +213 -0
- tesla_cli/core/backends/runt.py +73 -0
- tesla_cli/core/backends/simit.py +65 -0
- tesla_cli/core/backends/teslaMate.py +557 -0
- tesla_cli/core/backends/tessie.py +141 -0
- tesla_cli/core/config.py +185 -0
- tesla_cli/core/exceptions.py +104 -0
- tesla_cli/core/models/__init__.py +0 -0
- tesla_cli/core/models/automation.py +39 -0
- tesla_cli/core/models/charge.py +152 -0
- tesla_cli/core/models/climate.py +23 -0
- tesla_cli/core/models/dossier.py +450 -0
- tesla_cli/core/models/drive.py +33 -0
- tesla_cli/core/models/energy.py +35 -0
- tesla_cli/core/models/order.py +88 -0
- tesla_cli/core/models/vehicle.py +26 -0
- tesla_cli/core/providers/__init__.py +52 -0
- tesla_cli/core/providers/base.py +180 -0
- tesla_cli/core/providers/impl/__init__.py +1 -0
- tesla_cli/core/providers/impl/abrp.py +93 -0
- tesla_cli/core/providers/impl/apprise_notify.py +93 -0
- tesla_cli/core/providers/impl/ble.py +113 -0
- tesla_cli/core/providers/impl/ha.py +137 -0
- tesla_cli/core/providers/impl/mqtt.py +162 -0
- tesla_cli/core/providers/impl/teslaMate.py +133 -0
- tesla_cli/core/providers/impl/vehicle_api.py +126 -0
- tesla_cli/core/providers/loader.py +64 -0
- tesla_cli/core/providers/registry.py +228 -0
- tesla_cli/core/sources.py +740 -0
- tesla_cli/endpoints.json +3121 -0
- tesla_cli/infra/__init__.py +0 -0
- tesla_cli/infra/fleet_telemetry_stack.py +490 -0
- tesla_cli/infra/teslamate_stack.py +494 -0
- tesla_cli/py.typed +0 -0
- tesla_cli-4.8.0.dist-info/METADATA +208 -0
- tesla_cli-4.8.0.dist-info/RECORD +103 -0
- tesla_cli-4.8.0.dist-info/WHEEL +4 -0
- tesla_cli-4.8.0.dist-info/entry_points.txt +2 -0
- tesla_cli-4.8.0.dist-info/licenses/LICENSE +21 -0
tesla_cli/__init__.py
ADDED
tesla_cli/__main__.py
ADDED
|
File without changes
|
tesla_cli/api/app.py
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
"""FastAPI application for tesla-cli API server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import math
|
|
9
|
+
import time
|
|
10
|
+
from contextlib import asynccontextmanager
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from fastapi import FastAPI, Request
|
|
15
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
16
|
+
from fastapi.responses import StreamingResponse
|
|
17
|
+
|
|
18
|
+
from tesla_cli import __version__
|
|
19
|
+
from tesla_cli.core.backends import get_vehicle_backend
|
|
20
|
+
from tesla_cli.core.config import load_config, resolve_vin
|
|
21
|
+
|
|
22
|
+
# ── App factory ───────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _auto_provision_teslamate() -> None:
|
|
26
|
+
"""Install or start the managed TeslaMate stack if Docker is available."""
|
|
27
|
+
log = logging.getLogger("tesla-cli.teslamate-auto")
|
|
28
|
+
cfg = load_config()
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from tesla_cli.infra.teslamate_stack import TeslaMateStack
|
|
32
|
+
|
|
33
|
+
stack = TeslaMateStack(Path(cfg.teslaMate.stack_dir) if cfg.teslaMate.stack_dir else None)
|
|
34
|
+
stack.check_docker()
|
|
35
|
+
stack.check_docker_compose()
|
|
36
|
+
except Exception:
|
|
37
|
+
return # Docker not available — skip silently
|
|
38
|
+
|
|
39
|
+
if cfg.teslaMate.managed and stack.is_installed():
|
|
40
|
+
_ensure_teslamate_running(stack, log)
|
|
41
|
+
elif not cfg.teslaMate.managed and not cfg.teslaMate.database_url:
|
|
42
|
+
_auto_install_teslamate(stack, cfg, log)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ensure_teslamate_running(stack, log) -> None:
|
|
46
|
+
"""Start TeslaMate if installed but stopped, then sync tokens."""
|
|
47
|
+
if not stack.is_running():
|
|
48
|
+
log.info("TeslaMate stack installed but stopped — starting...")
|
|
49
|
+
try:
|
|
50
|
+
stack.start()
|
|
51
|
+
log.info("TeslaMate stack started.")
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
log.warning("Failed to start TeslaMate stack: %s", exc)
|
|
54
|
+
# Always sync tokens from keyring → TeslaMate
|
|
55
|
+
try:
|
|
56
|
+
import time as _t
|
|
57
|
+
|
|
58
|
+
_t.sleep(5) # Wait for TeslaMate to be fully ready
|
|
59
|
+
if stack.sync_tokens_from_keyring():
|
|
60
|
+
log.info("Tesla tokens synced to TeslaMate.")
|
|
61
|
+
else:
|
|
62
|
+
log.debug("No tokens to sync or sync failed.")
|
|
63
|
+
except Exception as exc:
|
|
64
|
+
log.debug("Token sync skipped: %s", exc)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _auto_install_teslamate(stack, cfg, log) -> None:
|
|
68
|
+
"""Install TeslaMate managed stack and update config."""
|
|
69
|
+
log.info("TeslaMate not configured — auto-installing managed stack...")
|
|
70
|
+
try:
|
|
71
|
+
from tesla_cli.core.config import save_config
|
|
72
|
+
|
|
73
|
+
ports = _find_free_ports(stack)
|
|
74
|
+
result = stack.install(**ports)
|
|
75
|
+
cfg = load_config()
|
|
76
|
+
cfg.teslaMate.database_url = result["database_url"]
|
|
77
|
+
cfg.teslaMate.managed = True
|
|
78
|
+
cfg.teslaMate.stack_dir = result["stack_dir"]
|
|
79
|
+
cfg.teslaMate.postgres_port = result["postgres_port"]
|
|
80
|
+
cfg.teslaMate.grafana_port = result["grafana_port"]
|
|
81
|
+
cfg.teslaMate.teslamate_port = result["teslamate_port"]
|
|
82
|
+
cfg.teslaMate.mqtt_port = result["mqtt_port"]
|
|
83
|
+
cfg.grafana.url = f"http://localhost:{result['grafana_port']}"
|
|
84
|
+
cfg.mqtt.broker = "localhost"
|
|
85
|
+
cfg.mqtt.port = result["mqtt_port"]
|
|
86
|
+
save_config(cfg)
|
|
87
|
+
health = "healthy" if result["healthy"] else "starting"
|
|
88
|
+
log.info(
|
|
89
|
+
"TeslaMate stack installed (%s). UI: http://localhost:%s Grafana: http://localhost:%s",
|
|
90
|
+
health,
|
|
91
|
+
result["teslamate_port"],
|
|
92
|
+
result["grafana_port"],
|
|
93
|
+
)
|
|
94
|
+
import time as _t
|
|
95
|
+
|
|
96
|
+
_t.sleep(8) # Wait for TeslaMate to fully start
|
|
97
|
+
if stack.sync_tokens_from_keyring():
|
|
98
|
+
log.info("Tesla tokens synced to TeslaMate after install.")
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
log.warning("Auto-install of TeslaMate stack failed: %s", exc)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _find_free_ports(stack) -> dict:
|
|
104
|
+
"""Find available ports for the TeslaMate stack services."""
|
|
105
|
+
defaults = {
|
|
106
|
+
"postgres_port": 5432,
|
|
107
|
+
"grafana_port": 3000,
|
|
108
|
+
"teslamate_port": 4000,
|
|
109
|
+
"mqtt_port": 1883,
|
|
110
|
+
}
|
|
111
|
+
ports = {}
|
|
112
|
+
for key, default in defaults.items():
|
|
113
|
+
port = default
|
|
114
|
+
while stack.port_in_use(port):
|
|
115
|
+
port += 1
|
|
116
|
+
ports[key] = port
|
|
117
|
+
return ports
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _auto_refresh_sources() -> None:
|
|
121
|
+
"""Periodically refresh stale data sources."""
|
|
122
|
+
import time as _t
|
|
123
|
+
|
|
124
|
+
log = logging.getLogger("tesla-cli.sources-refresh")
|
|
125
|
+
_t.sleep(60) # Wait for server to be fully ready
|
|
126
|
+
while True:
|
|
127
|
+
try:
|
|
128
|
+
from tesla_cli.core.sources import refresh_stale
|
|
129
|
+
|
|
130
|
+
result = refresh_stale()
|
|
131
|
+
refreshed = result.get("refreshed", [])
|
|
132
|
+
failed = result.get("failed", [])
|
|
133
|
+
if refreshed:
|
|
134
|
+
log.info("Sources refreshed: %s", ", ".join(refreshed))
|
|
135
|
+
if failed:
|
|
136
|
+
log.debug("Sources failed: %s", ", ".join(f["id"] for f in failed))
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
log.debug("Source auto-refresh failed: %s", exc)
|
|
139
|
+
_t.sleep(1800) # Every 30 minutes
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@asynccontextmanager
|
|
143
|
+
async def _lifespan(app: FastAPI):
|
|
144
|
+
"""Startup/shutdown lifecycle for the API server."""
|
|
145
|
+
import threading
|
|
146
|
+
|
|
147
|
+
threading.Thread(target=_auto_provision_teslamate, daemon=True).start()
|
|
148
|
+
threading.Thread(target=_auto_refresh_sources, daemon=True).start()
|
|
149
|
+
yield
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def create_app(vin: str | None = None, serve_ui: bool = False) -> FastAPI:
|
|
153
|
+
app = FastAPI(
|
|
154
|
+
title="tesla-cli API",
|
|
155
|
+
description="REST API for Tesla vehicle control and monitoring.",
|
|
156
|
+
version=__version__,
|
|
157
|
+
docs_url="/api/docs",
|
|
158
|
+
redoc_url="/api/redoc",
|
|
159
|
+
openapi_url="/api/openapi.json",
|
|
160
|
+
lifespan=_lifespan,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
_register_middleware(app)
|
|
164
|
+
_register_routes(app)
|
|
165
|
+
_register_system_endpoints(app)
|
|
166
|
+
_register_metrics(app)
|
|
167
|
+
_register_sse_stream(app)
|
|
168
|
+
_register_ui(app, serve_ui)
|
|
169
|
+
|
|
170
|
+
# Store resolved VIN in app state
|
|
171
|
+
app.state.override_vin = vin
|
|
172
|
+
|
|
173
|
+
return app
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ── Registration helpers ───────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _register_middleware(app: FastAPI) -> None:
|
|
180
|
+
"""Add CORS and API key middleware."""
|
|
181
|
+
app.add_middleware(
|
|
182
|
+
CORSMiddleware,
|
|
183
|
+
allow_origins=["*"],
|
|
184
|
+
allow_methods=["*"],
|
|
185
|
+
allow_headers=["*"],
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
from tesla_cli.api.auth import ApiKeyMiddleware
|
|
189
|
+
|
|
190
|
+
cfg = load_config()
|
|
191
|
+
app.add_middleware(ApiKeyMiddleware, api_key=cfg.server.api_key)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _register_routes(app: FastAPI) -> None:
|
|
195
|
+
"""Include all API routers."""
|
|
196
|
+
from tesla_cli.api.routes.auth import router as auth_router
|
|
197
|
+
from tesla_cli.api.routes.charge import router as charge_router
|
|
198
|
+
from tesla_cli.api.routes.climate import router as climate_router
|
|
199
|
+
from tesla_cli.api.routes.colombia import router as colombia_router
|
|
200
|
+
from tesla_cli.api.routes.dossier import router as dossier_router
|
|
201
|
+
from tesla_cli.api.routes.geofence import router as geofence_router
|
|
202
|
+
from tesla_cli.api.routes.notify import router as notify_router
|
|
203
|
+
from tesla_cli.api.routes.order import router as order_router
|
|
204
|
+
from tesla_cli.api.routes.security import router as security_router
|
|
205
|
+
from tesla_cli.api.routes.sources import router as sources_router
|
|
206
|
+
from tesla_cli.api.routes.teslaMate import router as teslaMate_router
|
|
207
|
+
from tesla_cli.api.routes.vehicle import router as vehicle_router
|
|
208
|
+
|
|
209
|
+
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
|
210
|
+
app.include_router(sources_router, prefix="/api/sources", tags=["Sources"])
|
|
211
|
+
app.include_router(colombia_router, prefix="/api/co", tags=["Colombia"])
|
|
212
|
+
app.include_router(vehicle_router, prefix="/api/vehicle", tags=["Vehicle"])
|
|
213
|
+
app.include_router(charge_router, prefix="/api/charge", tags=["Charge"])
|
|
214
|
+
app.include_router(climate_router, prefix="/api/climate", tags=["Climate"])
|
|
215
|
+
app.include_router(security_router, prefix="/api/security", tags=["Security"])
|
|
216
|
+
app.include_router(order_router, prefix="/api/order", tags=["Order"])
|
|
217
|
+
app.include_router(dossier_router, prefix="/api/dossier", tags=["Dossier"])
|
|
218
|
+
app.include_router(notify_router, prefix="/api/notify", tags=["Notify"])
|
|
219
|
+
app.include_router(geofence_router, prefix="/api/geofences", tags=["Geofences"])
|
|
220
|
+
app.include_router(teslaMate_router, prefix="/api/teslaMate", tags=["TeslaMate"])
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _register_system_endpoints(app: FastAPI) -> None:
|
|
224
|
+
"""Register /api/health, /api/status, /api/vehicles, /api/config, and provider endpoints."""
|
|
225
|
+
|
|
226
|
+
@app.get("/api/health", tags=["System"])
|
|
227
|
+
def api_health(deep: bool = False) -> dict:
|
|
228
|
+
"""Health check for Docker/Kubernetes liveness probes.
|
|
229
|
+
|
|
230
|
+
Returns 200 with {"status": "ok"} — always succeeds if server is running.
|
|
231
|
+
Use as: GET /api/health for Docker HEALTHCHECK or k8s livenessProbe.
|
|
232
|
+
Add ?deep=true for extended config/auth diagnostics.
|
|
233
|
+
"""
|
|
234
|
+
result: dict = {"status": "ok", "version": __version__}
|
|
235
|
+
if deep:
|
|
236
|
+
from tesla_cli.core.auth import tokens
|
|
237
|
+
|
|
238
|
+
cfg = load_config()
|
|
239
|
+
result["backend"] = cfg.general.backend
|
|
240
|
+
result["vin_configured"] = bool(cfg.general.default_vin)
|
|
241
|
+
result["has_auth_token"] = tokens.has_token(tokens.ORDER_REFRESH_TOKEN)
|
|
242
|
+
result["telemetry_enabled"] = cfg.telemetry.enabled
|
|
243
|
+
result["teslamate_managed"] = cfg.teslaMate.managed
|
|
244
|
+
return result
|
|
245
|
+
|
|
246
|
+
@app.get("/api/status", tags=["System"])
|
|
247
|
+
def api_status(request: Request) -> dict:
|
|
248
|
+
cfg = load_config()
|
|
249
|
+
return {
|
|
250
|
+
"version": __version__,
|
|
251
|
+
"backend": cfg.general.backend,
|
|
252
|
+
"vin": cfg.general.default_vin,
|
|
253
|
+
"server": "tesla-cli API",
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
@app.get("/api/vehicles", tags=["System"])
|
|
257
|
+
def api_vehicles() -> list:
|
|
258
|
+
"""List all configured vehicles (aliases + default VIN)."""
|
|
259
|
+
cfg = load_config()
|
|
260
|
+
vehicles = []
|
|
261
|
+
# Always include the default VIN first
|
|
262
|
+
default = cfg.general.default_vin
|
|
263
|
+
if default:
|
|
264
|
+
vehicles.append({"vin": default, "alias": "default", "is_default": True})
|
|
265
|
+
# Add any aliases that are different from the default
|
|
266
|
+
for alias, vin in cfg.vehicles.aliases.items():
|
|
267
|
+
if vin != default:
|
|
268
|
+
vehicles.append({"vin": vin, "alias": alias, "is_default": False})
|
|
269
|
+
return vehicles
|
|
270
|
+
|
|
271
|
+
@app.get("/api/config", tags=["System"])
|
|
272
|
+
def api_config() -> dict:
|
|
273
|
+
cfg = load_config()
|
|
274
|
+
return {
|
|
275
|
+
"backend": cfg.general.backend,
|
|
276
|
+
"default_vin": cfg.general.default_vin,
|
|
277
|
+
"cost_per_kwh": cfg.general.cost_per_kwh,
|
|
278
|
+
"teslaMate_url": bool(cfg.teslaMate.database_url),
|
|
279
|
+
"ha_url": cfg.home_assistant.url,
|
|
280
|
+
"abrp_configured": bool(cfg.abrp.user_token),
|
|
281
|
+
"geofences": list(cfg.geofences.zones.keys()),
|
|
282
|
+
"notifications": cfg.notifications.enabled,
|
|
283
|
+
"vehicles": cfg.vehicles.aliases,
|
|
284
|
+
"auth_enabled": bool(cfg.server.api_key),
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
@app.get("/api/providers", tags=["System"])
|
|
288
|
+
def api_providers() -> list:
|
|
289
|
+
"""Ecosystem provider status — availability and capabilities."""
|
|
290
|
+
from tesla_cli.core.providers import get_registry
|
|
291
|
+
|
|
292
|
+
return get_registry().status()
|
|
293
|
+
|
|
294
|
+
@app.get("/api/providers/capabilities", tags=["System"])
|
|
295
|
+
def api_provider_capabilities() -> dict:
|
|
296
|
+
"""Capability map — which providers serve which capabilities."""
|
|
297
|
+
from tesla_cli.core.providers import get_registry
|
|
298
|
+
from tesla_cli.core.providers.base import Capability
|
|
299
|
+
|
|
300
|
+
registry = get_registry()
|
|
301
|
+
out = {}
|
|
302
|
+
for cap in sorted(Capability.all()):
|
|
303
|
+
available = [p.name for p in registry.for_capability(cap)]
|
|
304
|
+
all_p = [p.name for p in registry.for_capability(cap, available_only=False)]
|
|
305
|
+
out[cap] = {"available": available, "all": all_p}
|
|
306
|
+
return out
|
|
307
|
+
|
|
308
|
+
@app.get("/api/config/validate", tags=["System"])
|
|
309
|
+
def api_config_validate() -> dict:
|
|
310
|
+
"""Run config validation checks — same as `tesla config validate`.
|
|
311
|
+
|
|
312
|
+
Returns {valid, errors, warnings, checks[]} suitable for a dashboard health widget.
|
|
313
|
+
"""
|
|
314
|
+
from tesla_cli.cli.commands.config_cmd import _run_config_checks
|
|
315
|
+
|
|
316
|
+
cfg = load_config()
|
|
317
|
+
checks = _run_config_checks(cfg)
|
|
318
|
+
errors = sum(1 for c in checks if c["status"] == "error")
|
|
319
|
+
warnings = sum(1 for c in checks if c["status"] == "warn")
|
|
320
|
+
return {"valid": errors == 0, "errors": errors, "warnings": warnings, "checks": checks}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _register_metrics(app: FastAPI) -> None:
|
|
324
|
+
"""Register the Prometheus-format /api/metrics endpoint."""
|
|
325
|
+
|
|
326
|
+
@app.get("/api/metrics", tags=["System"])
|
|
327
|
+
def api_metrics(request: Request):
|
|
328
|
+
"""Prometheus text format metrics — battery level, range, odometer, etc.
|
|
329
|
+
|
|
330
|
+
Designed to be scraped by Prometheus or read by Grafana.
|
|
331
|
+
Returns 200 with text/plain; set=0.0.4 even if vehicle is unreachable
|
|
332
|
+
(uses stale/empty values rather than error).
|
|
333
|
+
"""
|
|
334
|
+
from fastapi.responses import PlainTextResponse
|
|
335
|
+
|
|
336
|
+
cfg = load_config()
|
|
337
|
+
v = resolve_vin(cfg, app.state.override_vin)
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
backend = get_vehicle_backend(cfg)
|
|
341
|
+
data = backend.get_vehicle_data(v)
|
|
342
|
+
except Exception: # noqa: BLE001
|
|
343
|
+
data = {}
|
|
344
|
+
|
|
345
|
+
cs = data.get("charge_state") or {}
|
|
346
|
+
cl = data.get("climate_state") or {}
|
|
347
|
+
ds = data.get("drive_state") or {}
|
|
348
|
+
vs = data.get("vehicle_state") or {}
|
|
349
|
+
|
|
350
|
+
def _g(name: str, help_text: str, value, labels: str = "") -> str:
|
|
351
|
+
lbl = f'{{vin="{v}"{", " + labels if labels else ""}}}'
|
|
352
|
+
val = float(value) if value is not None else float("nan")
|
|
353
|
+
val_str = str(val) if val == val else "NaN"
|
|
354
|
+
return f"# HELP {name} {help_text}\n# TYPE {name} gauge\n{name}{lbl} {val_str}\n"
|
|
355
|
+
|
|
356
|
+
def _bool_g(name: str, help_text: str, value) -> str:
|
|
357
|
+
return _g(name, help_text, int(bool(value)) if value is not None else None)
|
|
358
|
+
|
|
359
|
+
lines = [
|
|
360
|
+
# Battery & Charging
|
|
361
|
+
_g("tesla_battery_level", "Battery level percent", cs.get("battery_level")),
|
|
362
|
+
_g("tesla_battery_range", "Estimated range in miles", cs.get("battery_range")),
|
|
363
|
+
_g("tesla_charge_limit", "Charge limit SoC percent", cs.get("charge_limit_soc")),
|
|
364
|
+
_g("tesla_charger_power", "Charger power in kW", cs.get("charger_power")),
|
|
365
|
+
_g("tesla_charger_voltage", "Charger voltage in V", cs.get("charger_voltage")),
|
|
366
|
+
_g("tesla_charger_current", "Charger current in A", cs.get("charger_actual_current")),
|
|
367
|
+
_g("tesla_charge_rate", "Charge rate in mph added", cs.get("charge_rate")),
|
|
368
|
+
_g(
|
|
369
|
+
"tesla_energy_added",
|
|
370
|
+
"Energy added in kWh this session",
|
|
371
|
+
cs.get("charge_energy_added"),
|
|
372
|
+
),
|
|
373
|
+
_g("tesla_time_to_full", "Hours to full charge", cs.get("time_to_full_charge")),
|
|
374
|
+
# Temperature
|
|
375
|
+
_g("tesla_inside_temp", "Inside temperature in Celsius", cl.get("inside_temp")),
|
|
376
|
+
_g("tesla_outside_temp", "Outside temperature in Celsius", cl.get("outside_temp")),
|
|
377
|
+
_g(
|
|
378
|
+
"tesla_driver_temp_setting",
|
|
379
|
+
"Driver temp setting in Celsius",
|
|
380
|
+
cl.get("driver_temp_setting"),
|
|
381
|
+
),
|
|
382
|
+
_bool_g(
|
|
383
|
+
"tesla_climate_on", "Climate system active (1=on 0=off)", cl.get("is_climate_on")
|
|
384
|
+
),
|
|
385
|
+
# TPMS Tire Pressure
|
|
386
|
+
_g("tesla_tpms_fl", "Tire pressure front-left in bar", vs.get("tpms_pressure_fl")),
|
|
387
|
+
_g("tesla_tpms_fr", "Tire pressure front-right in bar", vs.get("tpms_pressure_fr")),
|
|
388
|
+
_g("tesla_tpms_rl", "Tire pressure rear-left in bar", vs.get("tpms_pressure_rl")),
|
|
389
|
+
_g("tesla_tpms_rr", "Tire pressure rear-right in bar", vs.get("tpms_pressure_rr")),
|
|
390
|
+
# Location & Movement
|
|
391
|
+
_g("tesla_odometer", "Odometer in miles", vs.get("odometer")),
|
|
392
|
+
_g("tesla_speed", "Vehicle speed in mph", ds.get("speed")),
|
|
393
|
+
_g("tesla_latitude", "Vehicle latitude", ds.get("latitude")),
|
|
394
|
+
_g("tesla_longitude", "Vehicle longitude", ds.get("longitude")),
|
|
395
|
+
_g("tesla_heading", "Vehicle heading in degrees", ds.get("heading")),
|
|
396
|
+
# State
|
|
397
|
+
_bool_g("tesla_locked", "Doors locked (1=locked 0=unlocked)", vs.get("locked")),
|
|
398
|
+
_bool_g("tesla_sentry_mode", "Sentry mode active (1=on 0=off)", vs.get("sentry_mode")),
|
|
399
|
+
_bool_g("tesla_climate_on_state", "HVAC active (1=on 0=off)", cl.get("is_climate_on")),
|
|
400
|
+
_bool_g(
|
|
401
|
+
"tesla_charge_port_open", "Charge port door open", cs.get("charge_port_door_open")
|
|
402
|
+
),
|
|
403
|
+
]
|
|
404
|
+
|
|
405
|
+
return PlainTextResponse("".join(lines), media_type="text/plain; version=0.0.4")
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _register_sse_stream(app: FastAPI) -> None:
|
|
409
|
+
"""Register the real-time SSE vehicle stream endpoint."""
|
|
410
|
+
|
|
411
|
+
@app.get("/api/vehicle/stream", tags=["Vehicle"])
|
|
412
|
+
async def vehicle_stream(
|
|
413
|
+
request: Request,
|
|
414
|
+
interval: int = 10,
|
|
415
|
+
fanout: bool = False,
|
|
416
|
+
topics: str = "",
|
|
417
|
+
) -> StreamingResponse:
|
|
418
|
+
"""Server-Sent Events stream of live vehicle data.
|
|
419
|
+
|
|
420
|
+
Query params:
|
|
421
|
+
- `interval` — polling interval in seconds (default 10)
|
|
422
|
+
- `fanout` — also push each tick to ABRP + Home Assistant
|
|
423
|
+
- `topics` — comma-separated filter: `geofence`, `battery`, `climate`, `drive`, `location`
|
|
424
|
+
|
|
425
|
+
Event types:
|
|
426
|
+
- `vehicle` — full vehicle state snapshot (always emitted)
|
|
427
|
+
- `battery` — charge_state snapshot (when `topics` includes `battery`)
|
|
428
|
+
- `climate` — climate_state snapshot (when `topics` includes `climate`)
|
|
429
|
+
- `drive` — drive_state snapshot (when `topics` includes `drive`)
|
|
430
|
+
- `location` — {lat, lon, heading} (when `topics` includes `location`)
|
|
431
|
+
- `geofence` — enter/exit zone event (when `topics` includes `geofence`)
|
|
432
|
+
"""
|
|
433
|
+
cfg = load_config()
|
|
434
|
+
v = resolve_vin(cfg, app.state.override_vin)
|
|
435
|
+
topic_set = {t.strip() for t in topics.split(",") if t.strip()}
|
|
436
|
+
want_geofence = "geofence" in topic_set
|
|
437
|
+
want_battery = "battery" in topic_set
|
|
438
|
+
want_climate = "climate" in topic_set
|
|
439
|
+
want_drive = "drive" in topic_set
|
|
440
|
+
want_location = "location" in topic_set
|
|
441
|
+
geofence_state: dict[str, bool] = {} # zone_name → was_inside
|
|
442
|
+
|
|
443
|
+
async def _generate():
|
|
444
|
+
nonlocal geofence_state
|
|
445
|
+
backend = get_vehicle_backend(cfg)
|
|
446
|
+
while True:
|
|
447
|
+
if await request.is_disconnected():
|
|
448
|
+
break
|
|
449
|
+
try:
|
|
450
|
+
data = await asyncio.get_event_loop().run_in_executor(
|
|
451
|
+
None, lambda: backend.get_vehicle_data(v)
|
|
452
|
+
)
|
|
453
|
+
ts = int(time.time())
|
|
454
|
+
payload = json.dumps(
|
|
455
|
+
{
|
|
456
|
+
"ts": ts,
|
|
457
|
+
"data": _sanitize(data),
|
|
458
|
+
}
|
|
459
|
+
)
|
|
460
|
+
yield f"event: vehicle\ndata: {payload}\n\n"
|
|
461
|
+
|
|
462
|
+
if want_battery:
|
|
463
|
+
cs = data.get("charge_state") or {}
|
|
464
|
+
yield f"event: battery\ndata: {json.dumps({'ts': ts, 'data': _sanitize(cs)})}\n\n"
|
|
465
|
+
|
|
466
|
+
if want_climate:
|
|
467
|
+
cl = data.get("climate_state") or {}
|
|
468
|
+
yield f"event: climate\ndata: {json.dumps({'ts': ts, 'data': _sanitize(cl)})}\n\n"
|
|
469
|
+
|
|
470
|
+
if want_drive:
|
|
471
|
+
ds = data.get("drive_state") or {}
|
|
472
|
+
yield f"event: drive\ndata: {json.dumps({'ts': ts, 'data': _sanitize(ds)})}\n\n"
|
|
473
|
+
|
|
474
|
+
if want_location:
|
|
475
|
+
ds = data.get("drive_state") or {}
|
|
476
|
+
loc = {
|
|
477
|
+
"lat": ds.get("latitude"),
|
|
478
|
+
"lon": ds.get("longitude"),
|
|
479
|
+
"heading": ds.get("heading"),
|
|
480
|
+
"speed": ds.get("speed"),
|
|
481
|
+
}
|
|
482
|
+
yield f"event: location\ndata: {json.dumps({'ts': ts, 'data': loc})}\n\n"
|
|
483
|
+
|
|
484
|
+
if fanout:
|
|
485
|
+
await asyncio.get_event_loop().run_in_executor(
|
|
486
|
+
None, _fanout_telemetry, data, v, cfg
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
if want_geofence:
|
|
490
|
+
drive = data.get("drive_state") or data.get("response", {}).get(
|
|
491
|
+
"drive_state", {}
|
|
492
|
+
)
|
|
493
|
+
lat = drive.get("latitude") if isinstance(drive, dict) else None
|
|
494
|
+
lon = drive.get("longitude") if isinstance(drive, dict) else None
|
|
495
|
+
if lat is not None and lon is not None:
|
|
496
|
+
reload_cfg = load_config()
|
|
497
|
+
for name, zone in reload_cfg.geofences.zones.items():
|
|
498
|
+
dist = _haversine_km(lat, lon, zone["lat"], zone["lon"])
|
|
499
|
+
inside = dist <= zone.get("radius_km", 0.5)
|
|
500
|
+
was_inside = geofence_state.get(name)
|
|
501
|
+
if was_inside is None:
|
|
502
|
+
geofence_state[name] = inside
|
|
503
|
+
elif inside != was_inside:
|
|
504
|
+
geofence_state[name] = inside
|
|
505
|
+
event = "enter" if inside else "exit"
|
|
506
|
+
gf_payload = json.dumps(
|
|
507
|
+
{
|
|
508
|
+
"ts": ts,
|
|
509
|
+
"zone": name,
|
|
510
|
+
"event": event,
|
|
511
|
+
"lat": lat,
|
|
512
|
+
"lon": lon,
|
|
513
|
+
"dist_km": round(dist, 3),
|
|
514
|
+
}
|
|
515
|
+
)
|
|
516
|
+
yield f"event: geofence\ndata: {gf_payload}\n\n"
|
|
517
|
+
|
|
518
|
+
except Exception as exc: # noqa: BLE001
|
|
519
|
+
yield f"event: vehicle\ndata: {json.dumps({'error': str(exc)})}\n\n"
|
|
520
|
+
await asyncio.sleep(interval)
|
|
521
|
+
|
|
522
|
+
return StreamingResponse(
|
|
523
|
+
_generate(),
|
|
524
|
+
media_type="text/event-stream",
|
|
525
|
+
headers={
|
|
526
|
+
"Cache-Control": "no-cache",
|
|
527
|
+
"X-Accel-Buffering": "no",
|
|
528
|
+
},
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _register_ui(app: FastAPI, serve_ui: bool) -> None:
|
|
533
|
+
"""Mount static UI assets or redirect root to API docs."""
|
|
534
|
+
_ui_dist = Path(__file__).resolve().parent / "ui_dist"
|
|
535
|
+
|
|
536
|
+
if serve_ui and _ui_dist.exists() and (_ui_dist / "index.html").exists():
|
|
537
|
+
from fastapi.responses import FileResponse
|
|
538
|
+
from fastapi.staticfiles import StaticFiles
|
|
539
|
+
|
|
540
|
+
_assets = _ui_dist / "assets"
|
|
541
|
+
if _assets.exists():
|
|
542
|
+
app.mount("/assets", StaticFiles(directory=str(_assets)), name="ui-assets")
|
|
543
|
+
|
|
544
|
+
@app.get("/{path:path}", include_in_schema=False)
|
|
545
|
+
def spa_fallback(path: str):
|
|
546
|
+
"""Serve React SPA — static files or fallback to index.html."""
|
|
547
|
+
file = _ui_dist / path
|
|
548
|
+
if file.is_file() and ".." not in path:
|
|
549
|
+
return FileResponse(file)
|
|
550
|
+
return FileResponse(_ui_dist / "index.html")
|
|
551
|
+
else:
|
|
552
|
+
from fastapi.responses import RedirectResponse
|
|
553
|
+
|
|
554
|
+
@app.get("/", include_in_schema=False)
|
|
555
|
+
def root_redirect():
|
|
556
|
+
return RedirectResponse(url="/api/docs")
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _fanout_telemetry(data: dict, vin: str, cfg) -> None:
|
|
563
|
+
"""Push vehicle state to all configured telemetry/home-sync sinks."""
|
|
564
|
+
from tesla_cli.core.providers.base import Capability
|
|
565
|
+
from tesla_cli.core.providers.loader import build_registry
|
|
566
|
+
|
|
567
|
+
registry = build_registry(cfg)
|
|
568
|
+
registry.fanout(Capability.TELEMETRY_PUSH, "push", data=data, vin=vin)
|
|
569
|
+
registry.fanout(Capability.HOME_SYNC, "push", data=data, vin=vin)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
573
|
+
"""Great-circle distance in kilometres between two GPS points."""
|
|
574
|
+
r = 6371.0
|
|
575
|
+
dlat = math.radians(lat2 - lat1)
|
|
576
|
+
dlon = math.radians(lon2 - lon1)
|
|
577
|
+
a = (
|
|
578
|
+
math.sin(dlat / 2) ** 2
|
|
579
|
+
+ math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2
|
|
580
|
+
)
|
|
581
|
+
return r * 2 * math.asin(math.sqrt(a))
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _sanitize(data: Any) -> Any:
|
|
585
|
+
"""Recursively convert non-serializable values."""
|
|
586
|
+
if isinstance(data, dict):
|
|
587
|
+
return {k: _sanitize(v) for k, v in data.items()}
|
|
588
|
+
if isinstance(data, list):
|
|
589
|
+
return [_sanitize(i) for i in data]
|
|
590
|
+
try:
|
|
591
|
+
json.dumps(data)
|
|
592
|
+
return data
|
|
593
|
+
except (TypeError, ValueError):
|
|
594
|
+
return str(data)
|
tesla_cli/api/auth.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""API Key authentication middleware for tesla-cli server.
|
|
2
|
+
|
|
3
|
+
When `server.api_key` is configured (or TESLA_API_KEY env var is set),
|
|
4
|
+
all /api/* requests must include a matching key via:
|
|
5
|
+
- Header: X-API-Key: <key>
|
|
6
|
+
- Query param: ?api_key=<key>
|
|
7
|
+
|
|
8
|
+
Requests to / and /static/* are always allowed (web dashboard + assets).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
from fastapi import Request
|
|
16
|
+
from fastapi.responses import JSONResponse
|
|
17
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ApiKeyMiddleware(BaseHTTPMiddleware):
|
|
21
|
+
"""Enforce X-API-Key on /api/* paths when a key is configured."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, app, api_key: str = "") -> None:
|
|
24
|
+
super().__init__(app)
|
|
25
|
+
# env var takes precedence over config value
|
|
26
|
+
self._key = os.environ.get("TESLA_API_KEY", api_key).strip()
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def enabled(self) -> bool:
|
|
30
|
+
return bool(self._key)
|
|
31
|
+
|
|
32
|
+
async def dispatch(self, request: Request, call_next):
|
|
33
|
+
# Only protect /api/* paths
|
|
34
|
+
if not request.url.path.startswith("/api/"):
|
|
35
|
+
return await call_next(request)
|
|
36
|
+
|
|
37
|
+
# If no key configured → open access
|
|
38
|
+
if not self.enabled:
|
|
39
|
+
return await call_next(request)
|
|
40
|
+
|
|
41
|
+
# Check header first, then query param
|
|
42
|
+
provided = request.headers.get("X-API-Key") or request.query_params.get("api_key") or ""
|
|
43
|
+
|
|
44
|
+
if provided != self._key:
|
|
45
|
+
return JSONResponse(
|
|
46
|
+
status_code=401,
|
|
47
|
+
content={"detail": "Invalid or missing API key. Provide X-API-Key header."},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return await call_next(request)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""API route modules."""
|