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.
Files changed (103) hide show
  1. tesla_cli/__init__.py +3 -0
  2. tesla_cli/__main__.py +5 -0
  3. tesla_cli/api/__init__.py +0 -0
  4. tesla_cli/api/app.py +594 -0
  5. tesla_cli/api/auth.py +50 -0
  6. tesla_cli/api/routes/__init__.py +1 -0
  7. tesla_cli/api/routes/auth.py +389 -0
  8. tesla_cli/api/routes/charge.py +178 -0
  9. tesla_cli/api/routes/climate.py +69 -0
  10. tesla_cli/api/routes/colombia.py +138 -0
  11. tesla_cli/api/routes/dossier.py +140 -0
  12. tesla_cli/api/routes/geofence.py +135 -0
  13. tesla_cli/api/routes/notify.py +86 -0
  14. tesla_cli/api/routes/order.py +25 -0
  15. tesla_cli/api/routes/security.py +119 -0
  16. tesla_cli/api/routes/sources.py +107 -0
  17. tesla_cli/api/routes/teslaMate.py +344 -0
  18. tesla_cli/api/routes/vehicle.py +322 -0
  19. tesla_cli/cli/__init__.py +0 -0
  20. tesla_cli/cli/app.py +255 -0
  21. tesla_cli/cli/commands/__init__.py +0 -0
  22. tesla_cli/cli/commands/abrp.py +278 -0
  23. tesla_cli/cli/commands/automations.py +772 -0
  24. tesla_cli/cli/commands/ble.py +235 -0
  25. tesla_cli/cli/commands/charge.py +1213 -0
  26. tesla_cli/cli/commands/climate.py +316 -0
  27. tesla_cli/cli/commands/config_cmd.py +1158 -0
  28. tesla_cli/cli/commands/data_cmd.py +584 -0
  29. tesla_cli/cli/commands/dossier.py +2208 -0
  30. tesla_cli/cli/commands/energy.py +298 -0
  31. tesla_cli/cli/commands/geofence.py +252 -0
  32. tesla_cli/cli/commands/ha.py +316 -0
  33. tesla_cli/cli/commands/media.py +161 -0
  34. tesla_cli/cli/commands/mqtt_cmd.py +413 -0
  35. tesla_cli/cli/commands/notify.py +249 -0
  36. tesla_cli/cli/commands/order.py +1352 -0
  37. tesla_cli/cli/commands/providers_cmd.py +194 -0
  38. tesla_cli/cli/commands/scenes.py +299 -0
  39. tesla_cli/cli/commands/security.py +158 -0
  40. tesla_cli/cli/commands/serve.py +475 -0
  41. tesla_cli/cli/commands/setup.py +886 -0
  42. tesla_cli/cli/commands/telemetry.py +323 -0
  43. tesla_cli/cli/commands/teslaMate.py +2411 -0
  44. tesla_cli/cli/commands/vehicle.py +3000 -0
  45. tesla_cli/cli/i18n.py +604 -0
  46. tesla_cli/cli/output.py +156 -0
  47. tesla_cli/core/__init__.py +0 -0
  48. tesla_cli/core/auth/__init__.py +0 -0
  49. tesla_cli/core/auth/browser_login.py +233 -0
  50. tesla_cli/core/auth/encryption.py +70 -0
  51. tesla_cli/core/auth/oauth.py +275 -0
  52. tesla_cli/core/auth/portal_scrape.py +113 -0
  53. tesla_cli/core/auth/tessie.py +12 -0
  54. tesla_cli/core/auth/tokens.py +41 -0
  55. tesla_cli/core/automation.py +370 -0
  56. tesla_cli/core/backends/__init__.py +54 -0
  57. tesla_cli/core/backends/base.py +116 -0
  58. tesla_cli/core/backends/dossier.py +1073 -0
  59. tesla_cli/core/backends/energy.py +111 -0
  60. tesla_cli/core/backends/fleet.py +343 -0
  61. tesla_cli/core/backends/fleet_signed.py +445 -0
  62. tesla_cli/core/backends/fleet_telemetry.py +150 -0
  63. tesla_cli/core/backends/http.py +82 -0
  64. tesla_cli/core/backends/order.py +487 -0
  65. tesla_cli/core/backends/owner.py +213 -0
  66. tesla_cli/core/backends/runt.py +73 -0
  67. tesla_cli/core/backends/simit.py +65 -0
  68. tesla_cli/core/backends/teslaMate.py +557 -0
  69. tesla_cli/core/backends/tessie.py +141 -0
  70. tesla_cli/core/config.py +185 -0
  71. tesla_cli/core/exceptions.py +104 -0
  72. tesla_cli/core/models/__init__.py +0 -0
  73. tesla_cli/core/models/automation.py +39 -0
  74. tesla_cli/core/models/charge.py +152 -0
  75. tesla_cli/core/models/climate.py +23 -0
  76. tesla_cli/core/models/dossier.py +450 -0
  77. tesla_cli/core/models/drive.py +33 -0
  78. tesla_cli/core/models/energy.py +35 -0
  79. tesla_cli/core/models/order.py +88 -0
  80. tesla_cli/core/models/vehicle.py +26 -0
  81. tesla_cli/core/providers/__init__.py +52 -0
  82. tesla_cli/core/providers/base.py +180 -0
  83. tesla_cli/core/providers/impl/__init__.py +1 -0
  84. tesla_cli/core/providers/impl/abrp.py +93 -0
  85. tesla_cli/core/providers/impl/apprise_notify.py +93 -0
  86. tesla_cli/core/providers/impl/ble.py +113 -0
  87. tesla_cli/core/providers/impl/ha.py +137 -0
  88. tesla_cli/core/providers/impl/mqtt.py +162 -0
  89. tesla_cli/core/providers/impl/teslaMate.py +133 -0
  90. tesla_cli/core/providers/impl/vehicle_api.py +126 -0
  91. tesla_cli/core/providers/loader.py +64 -0
  92. tesla_cli/core/providers/registry.py +228 -0
  93. tesla_cli/core/sources.py +740 -0
  94. tesla_cli/endpoints.json +3121 -0
  95. tesla_cli/infra/__init__.py +0 -0
  96. tesla_cli/infra/fleet_telemetry_stack.py +490 -0
  97. tesla_cli/infra/teslamate_stack.py +494 -0
  98. tesla_cli/py.typed +0 -0
  99. tesla_cli-4.8.0.dist-info/METADATA +208 -0
  100. tesla_cli-4.8.0.dist-info/RECORD +103 -0
  101. tesla_cli-4.8.0.dist-info/WHEEL +4 -0
  102. tesla_cli-4.8.0.dist-info/entry_points.txt +2 -0
  103. tesla_cli-4.8.0.dist-info/licenses/LICENSE +21 -0
tesla_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Tesla CLI - Order tracking and vehicle control."""
2
+
3
+ __version__ = "4.0.0"
tesla_cli/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m tesla_cli`."""
2
+
3
+ from tesla_cli.cli.app import main
4
+
5
+ main()
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."""