Files
officialpm_my-tesla/scripts/tesla.py

2313 lines
78 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "teslapy>=2.0.0",
# ]
# ///
"""
Tesla vehicle control via unofficial API.
Supports multiple vehicles.
"""
import argparse
import json
import os
import sys
import sqlite3
import time
import traceback
from datetime import datetime, timezone
from pathlib import Path
# Keep the repo clean: don't write __pycache__/ bytecode files when running the CLI.
# (Also helps keep private repos from accumulating noisy artifacts.)
sys.dont_write_bytecode = True
CACHE_FILE = Path.home() / ".tesla_cache.json"
DEFAULTS_FILE = Path.home() / ".my_tesla.json"
SKILL_DIR = Path(__file__).resolve().parent.parent
def _invocation(extra: str = "") -> str:
"""Return a copy/pastable invocation string for help/error messages.
Use the absolute path to this script so the suggestion works even when the
current working directory is not the repo root.
"""
prog = str(Path(__file__).resolve())
return f"python3 {prog}{(' ' + extra) if extra else ''}"
def read_skill_version() -> str:
"""Read the skill version from VERSION.txt/VERSION in the repo.
ClawdHub ignores extensionless files like `VERSION`, so published artifacts
also include `VERSION.txt`. Prefer VERSION.txt when present.
"""
for name in ("VERSION.txt", "VERSION"):
p = SKILL_DIR / name
try:
if p.exists():
v = p.read_text().strip()
if v:
return v
except Exception:
continue
return "(unknown)"
def resolve_email(args, prompt: bool = True) -> str:
"""Resolve Tesla account email from args/env, optionally prompting."""
email = getattr(args, "email", None) or os.environ.get("TESLA_EMAIL")
if isinstance(email, str) and email.strip():
return email.strip()
if not prompt:
return None
return input("Tesla email: ").strip()
def require_email(args) -> str:
"""Require a Tesla email to be provided via --email or TESLA_EMAIL."""
email = resolve_email(args, prompt=False)
if not email:
print(
"❌ Missing Tesla email. Set TESLA_EMAIL or pass --email\n"
f" Example: TESLA_EMAIL=\"you@email.com\" {_invocation('list')}",
file=sys.stderr,
)
sys.exit(2)
return email
def get_tesla(email: str):
"""Get authenticated Tesla instance."""
import teslapy
def custom_auth(url):
print(f"\n🔐 Open this URL in your browser:\n{url}\n")
print("Log in to Tesla, then paste the final URL here")
print("(it will start with https://auth.tesla.com/void/callback?...)")
return input("\nCallback URL: ").strip()
tesla = teslapy.Tesla(email, authenticator=custom_auth, cache_file=str(CACHE_FILE))
if not tesla.authorized:
tesla.fetch_token()
print("✅ Authenticated successfully!")
# Best-effort: keep the local OAuth cache file private.
if CACHE_FILE.exists():
_chmod_0600(CACHE_FILE)
return tesla
def load_defaults():
"""Load optional user defaults from ~/.my_tesla.json (local only)."""
try:
if DEFAULTS_FILE.exists():
return json.loads(DEFAULTS_FILE.read_text())
except Exception:
pass
return {}
def _chmod_0600(path: Path):
"""Best-effort: set file permissions to user read/write only."""
try:
path.chmod(0o600)
except Exception:
# Non-POSIX FS or permission error; ignore.
pass
def save_defaults(obj: dict):
# Defaults can include human-readable vehicle names; keep them private.
DEFAULTS_FILE.write_text(json.dumps(obj, indent=2) + "\n")
_chmod_0600(DEFAULTS_FILE)
def resolve_default_car_name():
# Highest priority: env var
env_name = os.environ.get("MY_TESLA_DEFAULT_CAR")
if env_name:
return env_name.strip()
defaults = load_defaults()
name = defaults.get("default_car")
return name.strip() if isinstance(name, str) and name.strip() else None
def _select_vehicle(vehicles, target_name: str):
"""Select a vehicle from a list by name (exact/partial) or 1-based index.
- Exact match is case-insensitive.
- If no exact match, a case-insensitive *substring* match is attempted.
- If target_name is a digit (e.g., "1"), it's treated as a 1-based index.
"""
if not vehicles:
return None
if not target_name:
return vehicles[0]
s = target_name.strip()
if s.isdigit():
idx = int(s) - 1
if 0 <= idx < len(vehicles):
return vehicles[idx]
return None
s_l = s.lower()
# 1) Exact match (case-insensitive)
for v in vehicles:
if v.get('display_name', '').lower() == s_l:
return v
# 2) Substring match (case-insensitive)
matches = [v for v in vehicles if s_l in v.get('display_name', '').lower()]
if len(matches) == 1:
return matches[0]
# Ambiguous / not found
return None
def get_vehicle(tesla, name: str = None):
"""Get vehicle by name/index, else default car, else first vehicle."""
vehicles = tesla.vehicle_list()
if not vehicles:
print("❌ No vehicles found on this account", file=sys.stderr)
sys.exit(1)
target_name = name or resolve_default_car_name()
if target_name:
selected = _select_vehicle(vehicles, target_name)
if selected:
return selected
# Give a more helpful error (and show numeric indices too).
s = str(target_name).strip()
ambiguous = False
matches = []
if s and not s.isdigit():
s_l = s.lower()
matches = [
(i + 1, v) for i, v in enumerate(vehicles)
if s_l in v.get('display_name', '').lower()
]
ambiguous = len(matches) > 1
options = "\n".join(
f" {i+1}. {v.get('display_name')}" for i, v in enumerate(vehicles)
)
if ambiguous:
match_lines = "\n".join(
f" {idx}. {v.get('display_name')}" for idx, v in matches
)
print(
f"❌ Vehicle '{target_name}' is ambiguous (matched multiple vehicles).\n"
" Tip: use a more specific name, or choose by index: --car <N>\n"
f"Matches:\n{match_lines}\n\n"
f"All vehicles:\n{options}",
file=sys.stderr,
)
else:
print(
f"❌ Vehicle '{target_name}' not found.\n"
" Tip: you can pass --car with a partial name (substring match) or a 1-based index.\n"
f"Available vehicles:\n{options}",
file=sys.stderr,
)
sys.exit(1)
return vehicles[0]
def wake_vehicle(vehicle, allow_wake: bool = True) -> bool:
"""Wake vehicle if asleep.
Returns True if the vehicle is (or becomes) online.
If allow_wake is False and the vehicle is not online, returns False.
"""
state = vehicle.get('state')
if state == 'online':
return True
if not allow_wake:
return False
try:
print("⏳ Waking vehicle...", file=sys.stderr)
vehicle.sync_wake_up()
return True
except Exception as e:
print(
f"❌ Failed to wake vehicle (state was: {state}). Try again, or run: {_invocation('wake')}\n"
f" Details: {e}",
file=sys.stderr,
)
sys.exit(1)
def cmd_auth(args):
"""Authenticate with Tesla."""
email = resolve_email(args)
if not email:
print("❌ Missing Tesla email. Set TESLA_EMAIL or pass --email", file=sys.stderr)
sys.exit(2)
tesla = get_tesla(email)
vehicles = tesla.vehicle_list()
print(f"\n✅ Authentication cached at {CACHE_FILE}")
print(f"\n🚗 Found {len(vehicles)} vehicle(s):")
for v in vehicles:
# Avoid printing VINs by default.
print(f" - {v['display_name']} ({v['state']})")
def cmd_list(args):
"""List all vehicles."""
tesla = get_tesla(require_email(args))
vehicles = tesla.vehicle_list()
default_name = resolve_default_car_name()
if getattr(args, "json", False):
# Keep JSON output small + privacy-safe (no VINs).
out = []
for i, v in enumerate(vehicles):
name = v.get('display_name')
out.append({
'index': i + 1,
'display_name': name,
'state': v.get('state'),
'is_default': bool(default_name and isinstance(name, str) and name.lower() == default_name.lower()),
})
print(json.dumps({'vehicles': out, 'default_car': default_name}, indent=2))
return
print(f"Found {len(vehicles)} vehicle(s):\n")
for i, v in enumerate(vehicles):
star = " (default)" if default_name and v['display_name'].lower() == default_name.lower() else ""
print(f"{i+1}. {v['display_name']}{star}")
# Avoid printing VIN in normal output (privacy).
print(f" State: {v['state']}")
print()
if default_name:
print(f"Default car: {default_name}")
else:
print(f"Default car: (none) — set with: {_invocation('default-car "Name"')}")
def _c_to_f(c):
try:
return c * 9 / 5 + 32
except Exception:
return None
def _fmt_bool(b, yes="Yes", no="No"):
return yes if b else no
def _short_status(vehicle, data):
charge = data.get('charge_state', {})
climate = data.get('climate_state', {})
vs = data.get('vehicle_state', {})
batt = charge.get('battery_level')
rng = charge.get('battery_range')
charging = charge.get('charging_state')
locked = vs.get('locked')
inside_c = climate.get('inside_temp')
inside_f = _c_to_f(inside_c) if inside_c is not None else None
climate_on = climate.get('is_climate_on')
parts = [f"🚗 {vehicle['display_name']}"]
if locked is not None:
parts.append(f"🔒 {_fmt_bool(locked, 'Locked', 'Unlocked')}")
if batt is not None:
if rng is not None:
parts.append(f"🔋 {batt}% ({rng:.0f} mi)")
else:
parts.append(f"🔋 {batt}%")
if charging:
parts.append(f"{charging}")
if inside_c is not None and inside_f is not None:
parts.append(f"🌡️ {inside_f:.0f}°F")
if climate_on is not None:
parts.append(f"❄️ {_fmt_bool(climate_on, 'On', 'Off')}")
return "".join(parts)
def _summary_json(vehicle, data: dict) -> dict:
"""Sanitized, machine-readable one-line summary.
Unlike `status --json`, this does NOT emit raw vehicle_data (which may include location).
"""
charge = data.get('charge_state', {})
climate = data.get('climate_state', {})
vs = data.get('vehicle_state', {})
inside_c = climate.get('inside_temp')
inside_f = _c_to_f(inside_c) if inside_c is not None else None
out = {
"vehicle": {
"display_name": vehicle.get("display_name"),
"state": vehicle.get("state"),
},
"summary": _short_status(vehicle, data),
"security": {
"locked": vs.get("locked"),
},
"battery": {
"level_percent": charge.get("battery_level"),
"range_mi": charge.get("battery_range"),
"usable_level_percent": charge.get("usable_battery_level"),
},
"charging": {
"charging_state": charge.get("charging_state"),
},
"climate": {
"inside_temp_c": inside_c,
"inside_temp_f": inside_f,
"is_climate_on": climate.get("is_climate_on"),
},
}
# Drop empty nested dicts / None values.
for k in list(out.keys()):
v = out[k]
if isinstance(v, dict):
v2 = {kk: vv for kk, vv in v.items() if vv is not None}
if v2:
out[k] = v2
else:
del out[k]
elif v is None:
del out[k]
return out
def _fmt_temp_pair(c):
if c is None:
return None
f = _c_to_f(c)
if f is None:
return None
return f"{c}°C ({f:.0f}°F)"
def _bar_to_psi(bar):
"""Convert bar to PSI.
Tesla APIs commonly return tire pressures in bar.
"""
try:
return float(bar) * 14.5037738
except Exception:
return None
def _fmt_tire_pressure(bar):
"""Format tire pressure as "X.X bar (Y psi)"."""
if bar is None:
return None
try:
b = float(bar)
except Exception:
return None
psi = _bar_to_psi(b)
if psi is None:
return None
return f"{b:.2f} bar ({psi:.0f} psi)"
def _fmt_minutes_hhmm(minutes):
"""Format minutes-from-midnight as HH:MM.
Tesla endpoints commonly represent scheduled times as minutes after midnight.
"""
try:
m = int(minutes)
except Exception:
return None
if m < 0:
return None
hh = (m // 60) % 24
mm = m % 60
return f"{hh:02d}:{mm:02d}"
def _report(vehicle, data):
"""One-screen status report (safe for chat)."""
charge = data.get('charge_state', {})
climate = data.get('climate_state', {})
vs = data.get('vehicle_state', {})
lines = []
lines.append(f"🚗 {vehicle['display_name']}")
lines.append(f"State: {vehicle.get('state')}")
locked = vs.get('locked')
if locked is not None:
lines.append(f"Locked: {_fmt_bool(locked, 'Yes', 'No')}")
sentry = vs.get('sentry_mode')
if sentry is not None:
lines.append(f"Sentry: {_fmt_bool(sentry, 'On', 'Off')}")
openings = _openings_one_line(vs)
if openings:
lines.append(f"Openings: {openings}")
batt = charge.get('battery_level')
usable = charge.get('usable_battery_level')
rng = charge.get('battery_range')
if batt is not None and rng is not None:
lines.append(f"Battery: {batt}% ({rng:.0f} mi)")
elif batt is not None:
lines.append(f"Battery: {batt}%")
# Some vehicles report usable battery level separately (helpful for health/degradation).
if usable is not None:
try:
lines.append(f"Usable battery: {int(usable)}%")
except Exception:
lines.append(f"Usable battery: {usable}%")
charging_state = charge.get('charging_state')
if charging_state is not None:
extra = []
limit = charge.get('charge_limit_soc')
if limit is not None:
extra.append(f"limit {limit}%")
if charging_state == 'Charging':
ttf = charge.get('time_to_full_charge')
if ttf is not None:
extra.append(f"{ttf:.1f}h to full")
rate = charge.get('charge_rate')
if rate is not None:
extra.append(f"{rate} mph")
suffix = f" ({', '.join(extra)})" if extra else ""
lines.append(f"Charging: {charging_state}{suffix}")
# When actively charging, show power details if available.
# This is useful to sanity-check a slow/fast charge session at a glance.
if charging_state == 'Charging':
p = charge.get('charger_power')
v = charge.get('charger_voltage')
a = charge.get('charger_actual_current')
bits = []
if p is not None:
bits.append(f"{p} kW")
if v is not None:
bits.append(f"{v}V")
if a is not None:
bits.append(f"{a}A")
if bits:
lines.append(f"Charging power: {' '.join(bits)}")
# Charge port / cable state
cpd = charge.get('charge_port_door_open')
if cpd is not None:
lines.append(f"Charge port door: {_fmt_bool(cpd, 'Open', 'Closed')}")
cable = charge.get('conn_charge_cable')
if cable is not None:
lines.append(f"Charge cable: {cable}")
sched_time = charge.get('scheduled_charging_start_time')
sched_mode = charge.get('scheduled_charging_mode')
sched_pending = charge.get('scheduled_charging_pending')
if sched_time is not None or sched_mode is not None or sched_pending is not None:
bits = []
if isinstance(sched_mode, str) and sched_mode.strip():
bits.append(sched_mode.strip())
elif sched_pending is not None:
bits.append('On' if sched_pending else 'Off')
hhmm = _fmt_minutes_hhmm(sched_time)
if hhmm:
bits.append(hhmm)
if bits:
lines.append(f"Scheduled charging: {' '.join(bits)}")
# Scheduled departure / off-peak charging (read-only)
dep_enabled = charge.get('scheduled_departure_enabled')
dep_time = charge.get('scheduled_departure_time')
precond = charge.get('preconditioning_enabled')
off_peak = charge.get('off_peak_charging_enabled')
if dep_enabled is not None or dep_time is not None or precond is not None or off_peak is not None:
bits = []
if dep_enabled is not None:
bits.append('On' if dep_enabled else 'Off')
hhmm = _fmt_minutes_hhmm(dep_time)
if hhmm:
bits.append(hhmm)
if precond is not None:
bits.append(f"precond {'On' if precond else 'Off'}")
if off_peak is not None:
bits.append(f"off-peak {'On' if off_peak else 'Off'}")
if bits:
lines.append(f"Scheduled departure: {' '.join(bits)}")
inside = _fmt_temp_pair(climate.get('inside_temp'))
outside = _fmt_temp_pair(climate.get('outside_temp'))
if inside:
lines.append(f"Inside: {inside}")
if outside:
lines.append(f"Outside: {outside}")
climate_on = climate.get('is_climate_on')
if climate_on is not None:
lines.append(f"Climate: {_fmt_bool(climate_on, 'On', 'Off')}")
heaters = _seat_heater_fields(climate)
if heaters:
lines.append(f"Seat heaters: {_seat_heaters_one_line(heaters)}")
# Tire pressures (TPMS) if available
fl = _fmt_tire_pressure(vs.get('tpms_pressure_fl'))
fr = _fmt_tire_pressure(vs.get('tpms_pressure_fr'))
rl = _fmt_tire_pressure(vs.get('tpms_pressure_rl'))
rr = _fmt_tire_pressure(vs.get('tpms_pressure_rr'))
if any([fl, fr, rl, rr]):
lines.append(
"Tires (TPMS): "
f"FL {fl or '(?)'} | FR {fr or '(?)'} | RL {rl or '(?)'} | RR {rr or '(?)'}"
)
odo = vs.get('odometer')
if odo is not None:
lines.append(f"Odometer: {odo:.0f} mi")
return "\n".join(lines)
def _report_json(vehicle, data: dict) -> dict:
"""Sanitized JSON equivalent of `_report`.
Intentionally excludes location/drive_state.
"""
charge = data.get('charge_state', {})
climate = data.get('climate_state', {})
vs = data.get('vehicle_state', {})
out = {
"vehicle": {
"display_name": vehicle.get('display_name'),
"state": vehicle.get('state'),
},
"battery": {
"level_percent": charge.get('battery_level'),
"range_mi": charge.get('battery_range'),
"usable_battery_level_percent": charge.get('usable_battery_level'),
},
"charging": {
"charging_state": charge.get('charging_state'),
"charge_limit_percent": charge.get('charge_limit_soc'),
"minutes_to_full_charge": charge.get('minutes_to_full_charge'),
"time_to_full_charge_hours": charge.get('time_to_full_charge'),
"charge_rate_mph": charge.get('charge_rate'),
"charger_power_kw": charge.get('charger_power'),
"charger_voltage_v": charge.get('charger_voltage'),
"charger_actual_current_a": charge.get('charger_actual_current'),
"charge_current_request_a": charge.get('charge_current_request'),
"charge_current_request_max_a": charge.get('charge_current_request_max'),
"charging_amps": charge.get('charging_amps'),
"charge_port_door_open": charge.get('charge_port_door_open'),
"conn_charge_cable": charge.get('conn_charge_cable'),
},
"scheduled_charging": {
"mode": charge.get('scheduled_charging_mode'),
"pending": charge.get('scheduled_charging_pending'),
"start_time_hhmm": _fmt_minutes_hhmm(charge.get('scheduled_charging_start_time')),
},
"scheduled_departure": {
"enabled": charge.get('scheduled_departure_enabled'),
"time_hhmm": _fmt_minutes_hhmm(charge.get('scheduled_departure_time')),
"preconditioning_enabled": charge.get('preconditioning_enabled'),
"off_peak_charging_enabled": charge.get('off_peak_charging_enabled'),
},
"climate": {
"inside_temp_c": climate.get('inside_temp'),
"outside_temp_c": climate.get('outside_temp'),
"is_climate_on": climate.get('is_climate_on'),
"seat_heaters": _seat_heater_fields(climate) or None,
},
"security": {
"locked": vs.get('locked'),
"sentry_mode": vs.get('sentry_mode'),
},
"openings": _openings_json(vs),
"tpms": {
"pressure_fl": vs.get('tpms_pressure_fl'),
"pressure_fr": vs.get('tpms_pressure_fr'),
"pressure_rl": vs.get('tpms_pressure_rl'),
"pressure_rr": vs.get('tpms_pressure_rr'),
},
"odometer_mi": vs.get('odometer'),
}
# Drop empty nested dicts for cleaner output.
for k in list(out.keys()):
v = out[k]
if isinstance(v, dict):
v2 = {kk: vv for kk, vv in v.items() if vv is not None}
if v2:
out[k] = v2
else:
del out[k]
elif v is None:
del out[k]
return out
def _ensure_online_or_exit(vehicle, allow_wake: bool):
if wake_vehicle(vehicle, allow_wake=allow_wake):
return
state = vehicle.get('state')
name = vehicle.get('display_name', 'Vehicle')
print(
f" {name} is currently '{state}'. Skipping wake because --no-wake was set.\n"
f" Re-run without --no-wake, or run: {_invocation('wake')}",
file=sys.stderr,
)
sys.exit(3)
def cmd_report(args):
"""One-screen status report."""
tesla = get_tesla(require_email(args))
vehicle = get_vehicle(tesla, args.car)
_ensure_online_or_exit(vehicle, allow_wake=not getattr(args, 'no_wake', False))
data = vehicle.get_vehicle_data()
if args.json:
# Default JSON output is a structured, sanitized report object.
# Use --raw-json if you explicitly want the full vehicle_data payload
# (which may include location/drive_state).
if getattr(args, "raw_json", False):
print(json.dumps(data, indent=2))
else:
print(json.dumps(_report_json(vehicle, data), indent=2))
return
print(_report(vehicle, data))
def cmd_status(args):
"""Get vehicle status."""
tesla = get_tesla(require_email(args))
vehicle = get_vehicle(tesla, args.car)
_ensure_online_or_exit(vehicle, allow_wake=not getattr(args, 'no_wake', False))
data = vehicle.get_vehicle_data()
# When --json is requested, print *only* JSON (no extra human text), so it can
# be reliably piped/parsed.
if args.json:
print(json.dumps(data, indent=2))
return
charge = data.get('charge_state', {})
climate = data.get('climate_state', {})
vehicle_state = data.get('vehicle_state', {})
if getattr(args, 'summary', False):
# Print a one-line summary *in addition* to the detailed view.
# (If you only want the one-liner, use the `summary` command.)
print(_short_status(vehicle, data))
print()
# Human-friendly detailed view
print(f"🚗 {vehicle['display_name']}")
print(f" State: {vehicle.get('state')}")
batt = charge.get('battery_level')
rng = charge.get('battery_range')
if batt is not None and rng is not None:
print(f" Battery: {batt}% ({rng:.0f} mi)")
elif batt is not None:
print(f" Battery: {batt}%")
charging_state = charge.get('charging_state')
if charging_state is not None:
print(f" Charging: {charging_state}")
inside_c = climate.get('inside_temp')
outside_c = climate.get('outside_temp')
if inside_c is not None:
inside_f = _c_to_f(inside_c)
if inside_f is not None:
print(f" Inside temp: {inside_c}°C ({inside_f:.0f}°F)")
if outside_c is not None:
outside_f = _c_to_f(outside_c)
if outside_f is not None:
print(f" Outside temp: {outside_c}°C ({outside_f:.0f}°F)")
climate_on = climate.get('is_climate_on')
if climate_on is not None:
print(f" Climate on: {climate_on}")
locked = vehicle_state.get('locked')
if locked is not None:
print(f" Locked: {locked}")
odo = vehicle_state.get('odometer')
if odo is not None:
print(f" Odometer: {odo:.0f} mi")
def cmd_lock(args):
"""Lock the vehicle."""
tesla = get_tesla(require_email(args))
vehicle = get_vehicle(tesla, args.car)
wake_vehicle(vehicle)
vehicle.command('LOCK')
print(f"🔒 {vehicle['display_name']} locked")
def cmd_unlock(args):
"""Unlock the vehicle."""
require_yes(args, 'unlock')
tesla = get_tesla(require_email(args))
vehicle = get_vehicle(tesla, args.car)
wake_vehicle(vehicle)
vehicle.command('UNLOCK')
print(f"🔓 {vehicle['display_name']} unlocked")
def cmd_climate(args):
"""Control climate.
Actions:
- status (read-only)
- on/off
- temp <value> [--celsius|--fahrenheit]
- defrost <on|off> (max defrost / preconditioning)
"""
tesla = get_tesla(require_email(args))
vehicle = get_vehicle(tesla, args.car)
if args.action == 'status':
# Read-only action can skip waking the car.
allow_wake = not getattr(args, 'no_wake', False)
_ensure_online_or_exit(vehicle, allow_wake=allow_wake)
data = vehicle.get_vehicle_data()
climate = data.get('climate_state', {})
out = {
'is_climate_on': climate.get('is_climate_on'),
'inside_temp_c': climate.get('inside_temp'),
'outside_temp_c': climate.get('outside_temp'),
'driver_temp_setting_c': climate.get('driver_temp_setting'),
'passenger_temp_setting_c': climate.get('passenger_temp_setting'),
}
if args.json:
print(json.dumps(out, indent=2))
return
inside = _fmt_temp_pair(climate.get('inside_temp'))
outside = _fmt_temp_pair(climate.get('outside_temp'))
print(f"🚗 {vehicle['display_name']}")
if out.get('is_climate_on') is not None:
print(f"Climate: {_fmt_bool(out.get('is_climate_on'), 'On', 'Off')}")
if inside:
print(f"Inside: {inside}")
if outside:
print(f"Outside: {outside}")
driver = _fmt_temp_pair(climate.get('driver_temp_setting'))
passenger = _fmt_temp_pair(climate.get('passenger_temp_setting'))
if driver or passenger:
print(f"Setpoint: driver {driver or '(unknown)'} | passenger {passenger or '(unknown)'}")
return
# Mutating actions (wake is allowed)
wake_vehicle(vehicle)
if args.action == 'on':
vehicle.command('CLIMATE_ON')
print(f"❄️ {vehicle['display_name']} climate turned on")
elif args.action == 'off':
vehicle.command('CLIMATE_OFF')
print(f"🌡️ {vehicle['display_name']} climate turned off")
elif args.action == 'temp':
if args.value is None:
raise ValueError("Missing temperature value (e.g., climate temp 72 or climate temp 22 --celsius)")
value = float(args.value)
# Default is Fahrenheit unless --celsius is provided.
in_f = True
if getattr(args, "celsius", False):
in_f = False
elif getattr(args, "fahrenheit", False):
in_f = True
temp_c = (value - 32) * 5 / 9 if in_f else value
vehicle.command('CHANGE_CLIMATE_TEMPERATURE_SETTING', driver_temp=temp_c, passenger_temp=temp_c)
print(f"🌡️ {vehicle['display_name']} temperature set to {value:g}°{'F' if in_f else 'C'}")
elif args.action == 'defrost':
if args.value is None or str(args.value).strip().lower() not in ('on', 'off'):
raise ValueError("Missing defrost value. Use: climate defrost on|off")
on = str(args.value).strip().lower() == 'on'
vehicle.command('SET_PRECONDITIONING_MAX', on=on)
print(f"🧊 {vehicle['display_name']} max defrost {('enabled' if on else 'disabled')}")
else:
raise ValueError(f"Unknown action: {args.action}")
def _charge_status_json(charge: dict) -> dict:
"""Small, privacy-safe charging status object.
Intended for piping/parsing via `charge status --json`.
"""
charge = charge or {}
return {
'battery_level': charge.get('battery_level'),
'battery_range': charge.get('battery_range'),
'usable_battery_level': charge.get('usable_battery_level'),
'charging_state': charge.get('charging_state'),
'charge_limit_soc': charge.get('charge_limit_soc'),
'time_to_full_charge': charge.get('time_to_full_charge'),
'charge_rate': charge.get('charge_rate'),
'charger_power': charge.get('charger_power'),
'charger_voltage': charge.get('charger_voltage'),
'charger_actual_current': charge.get('charger_actual_current'),
'scheduled_charging_start_time': charge.get('scheduled_charging_start_time'),
'scheduled_charging_mode': charge.get('scheduled_charging_mode'),
'scheduled_charging_pending': charge.get('scheduled_charging_pending'),
'charge_port_door_open': charge.get('charge_port_door_open'),
'conn_charge_cable': charge.get('conn_charge_cable'),
}
def cmd_charge(args):
"""Control charging."""
tesla = get_tesla(require_email(args))
vehicle = get_vehicle(tesla, args.car)
# Read-only action can skip waking the car.
allow_wake = True
if args.action == 'status':
allow_wake = not getattr(args, 'no_wake', False)
_ensure_online_or_exit(vehicle, allow_wake=allow_wake)
if args.action == 'status':
data = vehicle.get_vehicle_data()
charge = data['charge_state']
if args.json:
# Print *only* JSON (no extra human text) so it can be piped/parsed.
# Keep it focused to avoid leaking unrelated vehicle details.
out = _charge_status_json(charge)
# Drop nulls for cleanliness.
out = {k: v for k, v in out.items() if v is not None}
print(json.dumps(out, indent=2))
return
print(f"🔋 {vehicle['display_name']} Battery: {charge['battery_level']}%")
print(f" Range: {charge['battery_range']:.0f} mi")
usable = charge.get('usable_battery_level')
if usable is not None:
try:
print(f" Usable: {int(usable)}%")
except Exception:
print(f" Usable: {usable}%")
print(f" State: {charge['charging_state']}")
print(f" Limit: {charge['charge_limit_soc']}%")
cpd = charge.get('charge_port_door_open')
if cpd is not None:
print(f" Charge port door: {_fmt_bool(cpd, 'Open', 'Closed')}")
cable = charge.get('conn_charge_cable')
if cable is not None:
print(f" Charge cable: {cable}")
if charge['charging_state'] == 'Charging':
if charge.get('time_to_full_charge') is not None:
print(f" Time left: {charge['time_to_full_charge']:.1f} hrs")
if charge.get('charge_rate') is not None:
print(f" Rate: {charge['charge_rate']} mph")
# Power details when available
p = charge.get('charger_power')
v = charge.get('charger_voltage')
a = charge.get('charger_actual_current')
bits = []
if p is not None:
bits.append(f"{p} kW")
if v is not None:
bits.append(f"{v}V")
if a is not None:
bits.append(f"{a}A")
if bits:
print(f" Power: {' '.join(bits)}")
return
if args.action == 'start':
require_yes(args, 'charge start')
vehicle.command('START_CHARGE')
print(f"{vehicle['display_name']} charging started")
return
if args.action == 'stop':
require_yes(args, 'charge stop')
vehicle.command('STOP_CHARGE')
print(f"🛑 {vehicle['display_name']} charging stopped")
return
if args.action == 'limit':
require_yes(args, 'charge limit')
if args.value is None:
raise ValueError("Missing charge limit percent (e.g., charge limit 80)")
pct = int(args.value)
if pct < 50 or pct > 100:
raise ValueError("Invalid charge limit percent. Expected 50100")
vehicle.command('CHANGE_CHARGE_LIMIT', percent=pct)
print(f"🎚️ {vehicle['display_name']} charge limit set to {pct}%")
return
if args.action == 'amps':
require_yes(args, 'charge amps')
if args.value is None:
raise ValueError("Missing amps value (e.g., charge amps 16)")
amps = int(args.value)
if amps < 1 or amps > 48:
# Conservative guardrail. Many cars support 5-48A depending on setup.
raise ValueError("Invalid amps. Expected 148")
vehicle.command('CHARGING_AMPS', charging_amps=amps)
print(f"🔌 {vehicle['display_name']} charging amps set to {amps}A")
return
raise ValueError(f"Unknown action: {args.action}")
def _parse_hhmm(value: str):
"""Parse HH:MM into minutes after midnight."""
if not isinstance(value, str) or not value.strip():
raise ValueError("Missing time. Expected HH:MM (e.g., 23:30)")
s = value.strip()
if ":" not in s:
raise ValueError("Invalid time. Expected HH:MM (e.g., 23:30)")
hh_s, mm_s = s.split(":", 1)
hh = int(hh_s)
mm = int(mm_s)
if hh < 0 or hh > 23 or mm < 0 or mm > 59:
raise ValueError("Invalid time. Expected HH:MM using 24-hour time")
return hh * 60 + mm
def cmd_scheduled_charging(args):
"""Get/set scheduled charging (requires --yes to change)."""
tesla = get_tesla(require_email(args))
vehicle = get_vehicle(tesla, args.car)
# Read-only action can skip waking the car.
allow_wake = True
if args.action == 'status':
allow_wake = not getattr(args, 'no_wake', False)
_ensure_online_or_exit(vehicle, allow_wake=allow_wake)
if args.action == 'status':
data = vehicle.get_vehicle_data()
charge = data.get('charge_state', {})
sched_time = charge.get('scheduled_charging_start_time')
sched_mode = charge.get('scheduled_charging_mode')
sched_pending = charge.get('scheduled_charging_pending')
if args.json:
print(json.dumps({'scheduled_charging_start_time': sched_time,
'scheduled_charging_mode': sched_mode,
'scheduled_charging_pending': sched_pending}, indent=2))
return
hhmm = _fmt_minutes_hhmm(sched_time)
mode = (sched_mode.strip() if isinstance(sched_mode, str) else None)
if not mode and sched_pending is not None:
mode = 'On' if sched_pending else 'Off'
print(f"🚗 {vehicle['display_name']}")
print(f"Scheduled charging: {mode or '(unknown)'}")
if hhmm:
print(f"Start time: {hhmm}")
return
# Mutating actions
require_yes(args, 'scheduled-charging')
if args.action == 'off':
vehicle.command('SCHEDULED_CHARGING', enable=False, time=0)
print(f"⏱️ {vehicle['display_name']} scheduled charging disabled")
return
if args.action == 'set':
minutes = _parse_hhmm(args.time)
vehicle.command('SCHEDULED_CHARGING', enable=True, time=minutes)
print(f"⏱️ {vehicle['display_name']} scheduled charging set to {_fmt_minutes_hhmm(minutes)}")
return
raise ValueError(f"Unknown action: {args.action}")
def _scheduled_departure_status_json(charge: dict) -> dict:
"""Small, privacy-safe scheduled departure status object."""
charge = charge or {}
return {
'scheduled_departure_enabled': charge.get('scheduled_departure_enabled'),
'scheduled_departure_time': charge.get('scheduled_departure_time'),
'scheduled_departure_time_hhmm': _fmt_minutes_hhmm(charge.get('scheduled_departure_time')),
'preconditioning_enabled': charge.get('preconditioning_enabled'),
'off_peak_charging_enabled': charge.get('off_peak_charging_enabled'),
}
def cmd_scheduled_departure(args):
"""Get scheduled departure / off-peak charging / preconditioning status (read-only)."""
tesla = get_tesla(require_email(args))
vehicle = get_vehicle(tesla, args.car)
allow_wake = not getattr(args, 'no_wake', False)
_ensure_online_or_exit(vehicle, allow_wake=allow_wake)
data = vehicle.get_vehicle_data()
charge = data.get('charge_state', {})
out = _scheduled_departure_status_json(charge)
if getattr(args, 'json', False):
print(json.dumps(out, indent=2))
return
print(f"🚗 {vehicle['display_name']}")
if out.get('scheduled_departure_enabled') is not None:
print(f"Scheduled departure: {_fmt_bool(out.get('scheduled_departure_enabled'), 'On', 'Off')}")
else:
print("Scheduled departure: (unknown)")
hhmm = out.get('scheduled_departure_time_hhmm')
if hhmm:
print(f"Departure time: {hhmm}")
if out.get('preconditioning_enabled') is not None:
print(f"Preconditioning: {_fmt_bool(out.get('preconditioning_enabled'), 'On', 'Off')}")
if out.get('off_peak_charging_enabled') is not None:
print(f"Off-peak charging: {_fmt_bool(out.get('off_peak_charging_enabled'), 'On', 'Off')}")
def _round_coord(x, digits: int = 2):
"""Round a coordinate for safer display.
digits=2 is roughly ~1km precision (varies with latitude) and is intended
as a non-sensitive default.
We cap digits to a small range to avoid accidentally producing overly
precise coordinates.
"""
try:
d = int(digits)
except Exception:
return None
# 0..6 is still plenty for display; tighter by default.
if d < 0 or d > 6:
return None
try:
return round(float(x), d)
except Exception:
return None
def cmd_location(args):
"""Get vehicle location.
Default output is *approximate* (rounded) to reduce accidental leakage.
Use --yes for precise coordinates.
Use --digits N (06) to control rounding precision for approximate output.
"""
tesla = get_tesla(require_email(args))
vehicle = get_vehicle(tesla, args.car)
_ensure_online_or_exit(vehicle, allow_wake=not getattr(args, 'no_wake', False))
data = vehicle.get_vehicle_data()
drive = data['drive_state']
lat, lon = drive['latitude'], drive['longitude']
if getattr(args, "yes", False):
print(f"📍 {vehicle['display_name']} Location (precise): {lat}, {lon}")
print(f" https://www.google.com/maps?q={lat},{lon}")
return
digits = getattr(args, 'digits', 2)
lat_r = _round_coord(lat, digits)
lon_r = _round_coord(lon, digits)
if lat_r is None or lon_r is None:
raise ValueError("Invalid or missing location coordinates (try --digits 0..6)")
print(f"📍 {vehicle['display_name']} Location (approx): {lat_r}, {lon_r}")
print(f" https://www.google.com/maps?q={lat_r},{lon_r}")
print(" (Use --yes for precise coordinates)")
def cmd_tires(args):
"""Show tire pressures (TPMS) (read-only)."""
tesla = get_tesla(require_email(args))
vehicle = get_vehicle(tesla, args.car)
# Read-only action can skip waking the car.
allow_wake = not getattr(args, 'no_wake', False)
_ensure_online_or_exit(vehicle, allow_wake=allow_wake)
data = vehicle.get_vehicle_data()
vs = data.get('vehicle_state', {})
fl = _fmt_tire_pressure(vs.get('tpms_pressure_fl'))
fr = _fmt_tire_pressure(vs.get('tpms_pressure_fr'))
rl = _fmt_tire_pressure(vs.get('tpms_pressure_rl'))
rr = _fmt_tire_pressure(vs.get('tpms_pressure_rr'))
if args.json:
print(json.dumps({
'tpms_pressure_fl': vs.get('tpms_pressure_fl'),
'tpms_pressure_fr': vs.get('tpms_pressure_fr'),
'tpms_pressure_rl': vs.get('tpms_pressure_rl'),
'tpms_pressure_rr': vs.get('tpms_pressure_rr'),
}, indent=2))
return
print(f"🚗 {vehicle['display_name']}")
print("Tire pressures (TPMS):")
print(f" FL: {fl or '(unknown)'}")
print(f" FR: {fr or '(unknown)'}")
print(f" RL: {rl or '(unknown)'}")
print(f" RR: {rr or '(unknown)'}")
def _fmt_open(v):
if v is None:
return None
# Tesla often uses 0/1 ints for open states.
if isinstance(v, bool):
return 'Open' if v else 'Closed'
try:
i = int(v)
return 'Open' if i else 'Closed'
except Exception:
return None
def _openings_one_line(vs: dict) -> str:
"""Return a one-line openings summary from vehicle_state.
Returns None if no openings fields are present.
"""
out = _openings_json(vs)
if out is None:
return None
if out.get("all_closed"):
return "All closed"
if out.get("open"):
# This is a human-facing string; keep it readable.
title = {
"driver_front_door": "Driver front door",
"driver_rear_door": "Driver rear door",
"passenger_front_door": "Passenger front door",
"passenger_rear_door": "Passenger rear door",
"frunk": "Frunk",
"trunk": "Trunk",
"front_driver_window": "Front driver window",
"front_passenger_window": "Front passenger window",
"rear_driver_window": "Rear driver window",
"rear_passenger_window": "Rear passenger window",
}
labels = [title.get(x, x) for x in out["open"]]
return "Open: " + ", ".join(labels)
return None
def _openings_json(vs: dict) -> dict:
"""Sanitized openings JSON from vehicle_state.
Returns None if no openings fields are present.
"""
if not isinstance(vs, dict):
return None
fields = [
("df", "driver_front_door"),
("dr", "driver_rear_door"),
("pf", "passenger_front_door"),
("pr", "passenger_rear_door"),
("ft", "frunk"),
("rt", "trunk"),
("fd_window", "front_driver_window"),
("fp_window", "front_passenger_window"),
("rd_window", "rear_driver_window"),
("rp_window", "rear_passenger_window"),
]
any_known = False
open_items = []
for key, label in fields:
raw = vs.get(key)
if raw is None:
continue
any_known = True
if _fmt_open(raw) == 'Open':
open_items.append(label)
if not any_known:
return None
return {
"open": open_items,
"all_closed": len(open_items) == 0,
}
def cmd_openings(args):
"""Show which doors/trunks/windows are open (read-only)."""
tesla = get_tesla(require_email(args))
vehicle = get_vehicle(tesla, args.car)
allow_wake = not getattr(args, 'no_wake', False)
_ensure_online_or_exit(vehicle, allow_wake=allow_wake)
data = vehicle.get_vehicle_data()
vs = data.get('vehicle_state', {})
out = {
'doors': {
'driver_front': _fmt_open(vs.get('df')),
'driver_rear': _fmt_open(vs.get('dr')),
'passenger_front': _fmt_open(vs.get('pf')),
'passenger_rear': _fmt_open(vs.get('pr')),
},
'trunks': {
'frunk': _fmt_open(vs.get('ft')),
'trunk': _fmt_open(vs.get('rt')),
},
'windows': {
'front_driver': _fmt_open(vs.get('fd_window')),
'front_passenger': _fmt_open(vs.get('fp_window')),
'rear_driver': _fmt_open(vs.get('rd_window')),
'rear_passenger': _fmt_open(vs.get('rp_window')),
},
}
# Drop unknown keys for cleaner output.
for k in list(out.keys()):
out[k] = {kk: vv for kk, vv in out[k].items() if vv is not None}
if not out[k]:
del out[k]
if args.json:
print(json.dumps(out, indent=2))
return
print(f"🚗 {vehicle['display_name']}")
if not out:
print("Openings: (unavailable)")
return
def _section(title, d):
if not d:
return
print(f"{title}:")
for kk, vv in d.items():
print(f" - {kk.replace('_',' ')}: {vv}")
_section('Doors', out.get('doors'))
_section('Trunks', out.get('trunks'))
_section('Windows', out.get('windows'))
def cmd_trunk(args):
"""Toggle frunk/trunk (requires --yes)."""
require_yes(args, 'trunk')
tesla = get_tesla(require_email(args))
vehicle = get_vehicle(tesla, args.car)
wake_vehicle(vehicle)
which = 'front' if args.which == 'frunk' else 'rear'
vehicle.command('ACTUATE_TRUNK', which_trunk=which)
label = 'Frunk' if which == 'front' else 'Trunk'
print(f"🧳 {vehicle['display_name']} {label} toggled")
def cmd_windows(args):
"""Windows: status (read-only) or vent/close (requires --yes)."""
tesla = get_tesla(require_email(args))
vehicle = get_vehicle(tesla, args.car)
# Read-only action can skip waking the car.
if args.action == 'status':
allow_wake = not getattr(args, 'no_wake', False)
_ensure_online_or_exit(vehicle, allow_wake=allow_wake)
data = vehicle.get_vehicle_data()
vs = data.get('vehicle_state', {})
out = {
'front_driver': _fmt_open(vs.get('fd_window')),
'front_passenger': _fmt_open(vs.get('fp_window')),
'rear_driver': _fmt_open(vs.get('rd_window')),
'rear_passenger': _fmt_open(vs.get('rp_window')),
}
# Drop unknowns for cleaner output
out = {k: v for k, v in out.items() if v is not None}
if getattr(args, 'json', False):
print(json.dumps(out, indent=2))
return
print(f"🚗 {vehicle['display_name']}")
if not out:
print("Windows: (unavailable)")
return
print("Windows:")
for k, v in out.items():
print(f" - {k.replace('_',' ')}: {v}")
return
# Mutating actions
require_yes(args, 'windows')
wake_vehicle(vehicle)
# Tesla API requires lat/lon parameters; 0/0 works for this endpoint.
if args.action == 'vent':
vehicle.command('WINDOW_CONTROL', command='vent', lat=0, lon=0)
print(f"🪟 {vehicle['display_name']} windows vented")
return
if args.action == 'close':
vehicle.command('WINDOW_CONTROL', command='close', lat=0, lon=0)
print(f"🪟 {vehicle['display_name']} windows closed")
return
raise ValueError(f"Unknown action: {args.action}")
def _seat_heater_fields(climate_state: dict) -> dict:
"""Extract seat heater levels from climate_state (if present)."""
if not isinstance(climate_state, dict):
return {}
# Common Tesla API fields (may vary by model/firmware).
keys = [
"seat_heater_left", # driver
"seat_heater_right", # passenger
"seat_heater_rear_left",
"seat_heater_rear_center",
"seat_heater_rear_right",
"seat_heater_third_row_left",
"seat_heater_third_row_right",
]
out = {k: climate_state.get(k) for k in keys if k in climate_state}
# Drop unknown/nulls for clean output.
return {k: v for k, v in out.items() if v is not None}
def _seat_heaters_one_line(fields: dict) -> str:
"""Format seat heater levels in a compact one-line form.
Example: "D 3 | P 2 | RL 1".
"""
if not isinstance(fields, dict) or not fields:
return ""
labels = {
"seat_heater_left": "D",
"seat_heater_right": "P",
"seat_heater_rear_left": "RL",
"seat_heater_rear_center": "RC",
"seat_heater_rear_right": "RR",
"seat_heater_third_row_left": "3L",
"seat_heater_third_row_right": "3R",
}
parts = []
for k in [
"seat_heater_left",
"seat_heater_right",
"seat_heater_rear_left",
"seat_heater_rear_center",
"seat_heater_rear_right",
"seat_heater_third_row_left",
"seat_heater_third_row_right",
]:
if k in fields and fields.get(k) is not None:
parts.append(f"{labels.get(k, k)} {fields.get(k)}")
return " | ".join(parts)
_SEAT_NAME_TO_HEATER_ID = {
# Tesla's REMOTE_SEAT_HEATER_REQUEST uses numeric "heater" ids.
# These mappings are the common convention used by community clients.
"driver": 0,
"front-left": 0,
"front_left": 0,
"left": 0,
"passenger": 1,
"front-right": 1,
"front_right": 1,
"right": 1,
"rear-left": 2,
"rear_left": 2,
"rear-center": 3,
"rear_center": 3,
"rear-right": 4,
"rear_right": 4,
"3rd-left": 5,
"3rd_left": 5,
"third-left": 5,
"third_left": 5,
"3rd-right": 6,
"3rd_right": 6,
"third-right": 6,
"third_right": 6,
}
def _parse_seat_heater(seat: str) -> int:
"""Parse a seat name into a Tesla heater id.
Accepts friendly names (driver/passenger/rear-left/etc) or a numeric id.
"""
if seat is None:
raise ValueError("Missing seat. Example: seats set driver 3")
s = str(seat).strip().lower()
if not s:
raise ValueError("Missing seat. Example: seats set driver 3")
if s.isdigit():
hid = int(s)
if hid < 0 or hid > 6:
raise ValueError("Invalid seat heater id. Expected 06")
return hid
hid = _SEAT_NAME_TO_HEATER_ID.get(s)
if hid is None:
raise ValueError(
"Unknown seat. Use one of: driver, passenger, rear-left, rear-center, rear-right, 3rd-left, 3rd-right (or 06)"
)
return hid
def cmd_seats(args):
"""Seat heaters: status (read-only) or set (requires --yes)."""
tesla = get_tesla(require_email(args))
vehicle = get_vehicle(tesla, args.car)
if args.action == "status":
allow_wake = not getattr(args, "no_wake", False)
_ensure_online_or_exit(vehicle, allow_wake=allow_wake)
data = vehicle.get_vehicle_data()
climate = data.get("climate_state", {})
out = _seat_heater_fields(climate)
if getattr(args, "json", False):
print(json.dumps(out, indent=2))
return
print(f"🚗 {vehicle['display_name']}")
if not out:
print("Seat heaters: (unavailable)")
return
print("Seat heaters (0=off .. 3=high):")
for k, v in out.items():
label = k.replace("seat_heater_", "").replace("_", " ")
print(f" - {label}: {v}")
return
if args.action == "set":
require_yes(args, "seats set")
wake_vehicle(vehicle)
heater = _parse_seat_heater(getattr(args, "seat", None))
if getattr(args, "level", None) is None:
raise ValueError("Missing level. Expected 03")
level = int(getattr(args, "level"))
if level < 0 or level > 3:
raise ValueError("Invalid level. Expected 03")
vehicle.command("REMOTE_SEAT_HEATER_REQUEST", heater=heater, level=level)
print(f"🔥 {vehicle['display_name']} seat heater {heater} set to {level}")
return
raise ValueError(f"Unknown action: {args.action}")
def cmd_sentry(args):
"""Get/set Sentry Mode (on/off requires --yes)."""
tesla = get_tesla(require_email(args))
vehicle = get_vehicle(tesla, args.car)
# Read-only action can skip waking the car.
allow_wake = True
if args.action == 'status':
allow_wake = not getattr(args, 'no_wake', False)
_ensure_online_or_exit(vehicle, allow_wake=allow_wake)
if args.action == 'status':
data = vehicle.get_vehicle_data()
sentry = data.get('vehicle_state', {}).get('sentry_mode')
if args.json:
print(json.dumps({'sentry_mode': sentry}, indent=2))
return
if sentry is None:
print(f"🚗 {vehicle['display_name']}\nSentry: (unknown)")
else:
print(f"🚗 {vehicle['display_name']}\nSentry: {_fmt_bool(sentry, 'On', 'Off')}")
return
# Mutating actions
require_yes(args, 'sentry')
wake_vehicle(vehicle)
if args.action == 'on':
vehicle.command('SET_SENTRY_MODE', on=True)
print(f"🛡️ {vehicle['display_name']} Sentry turned on")
return
if args.action == 'off':
vehicle.command('SET_SENTRY_MODE', on=False)
print(f"🛡️ {vehicle['display_name']} Sentry turned off")
return
raise ValueError(f"Unknown action: {args.action}")
def cmd_honk(args):
"""Honk the horn."""
require_yes(args, 'honk')
tesla = get_tesla(require_email(args))
vehicle = get_vehicle(tesla, args.car)
wake_vehicle(vehicle)
vehicle.command('HONK_HORN')
print(f"📢 {vehicle['display_name']} honked!")
def require_yes(args, action: str):
if not getattr(args, "yes", False):
print(f"❌ Refusing to run '{action}' without --yes (safety gate)", file=sys.stderr)
sys.exit(2)
def cmd_flash(args):
"""Flash the lights."""
require_yes(args, 'flash')
tesla = get_tesla(require_email(args))
vehicle = get_vehicle(tesla, args.car)
wake_vehicle(vehicle)
vehicle.command('FLASH_LIGHTS')
print(f"💡 {vehicle['display_name']} flashed lights!")
def _charge_port_status_json(vehicle, data: dict) -> dict:
"""Small, privacy-safe charge port status object."""
charge = (data or {}).get('charge_state', {})
return {
'display_name': (vehicle or {}).get('display_name'),
'state': (vehicle or {}).get('state'),
'charge_port_door_open': charge.get('charge_port_door_open'),
'charge_port_latch': charge.get('charge_port_latch'),
'conn_charge_cable': charge.get('conn_charge_cable'),
'charging_state': charge.get('charging_state'),
}
def cmd_charge_port(args):
"""Charge port operations.
- status: read-only (supports --no-wake)
- open/close: requires --yes
"""
tesla = get_tesla(require_email(args))
vehicle = get_vehicle(tesla, args.car)
if args.action == 'status':
_ensure_online_or_exit(vehicle, allow_wake=not getattr(args, 'no_wake', False))
data = vehicle.get_vehicle_data()
out = _charge_port_status_json(vehicle, data)
if getattr(args, 'json', False):
# Keep this privacy-safe + stable.
print(json.dumps(out, indent=2))
return
print(f"🔌 {vehicle.get('display_name')}")
print(f" State: {vehicle.get('state')}")
if out.get('charge_port_door_open') is not None:
print(f" Port door open: {_fmt_bool(out.get('charge_port_door_open'))}")
if out.get('charge_port_latch') is not None:
print(f" Port latch: {out.get('charge_port_latch')}")
if out.get('conn_charge_cable') is not None:
print(f" Cable: {out.get('conn_charge_cable')}")
if out.get('charging_state') is not None:
print(f" Charging: {out.get('charging_state')}")
return
# open/close
require_yes(args, 'charge-port')
wake_vehicle(vehicle)
if args.action == 'open':
vehicle.command('CHARGE_PORT_DOOR_OPEN')
print(f"🔌 {vehicle['display_name']} charge port opened")
elif args.action == 'close':
vehicle.command('CHARGE_PORT_DOOR_CLOSE')
print(f"🔌 {vehicle['display_name']} charge port closed")
else:
raise ValueError(f"Unknown action: {args.action}")
def cmd_wake(args):
"""Wake up the vehicle."""
tesla = get_tesla(require_email(args))
vehicle = get_vehicle(tesla, args.car)
print(f"⏳ Waking {vehicle['display_name']}...")
vehicle.sync_wake_up()
print(f"{vehicle['display_name']} is awake")
def cmd_summary(args):
"""One-line status summary.
- default (human): prints a single line for chat
- with --json: prints a sanitized JSON object (privacy-safe)
- with --json --raw-json: prints raw vehicle_data (may include location)
"""
tesla = get_tesla(require_email(args))
vehicle = get_vehicle(tesla, args.car)
_ensure_online_or_exit(vehicle, allow_wake=not getattr(args, 'no_wake', False))
data = vehicle.get_vehicle_data()
if getattr(args, 'json', False):
if getattr(args, 'raw_json', False):
print(json.dumps(data, indent=2))
else:
print(json.dumps(_summary_json(vehicle, data), indent=2))
return
print(_short_status(vehicle, data))
# ----------------------------
# Mileage tracking (SQLite)
# ----------------------------
MILEAGE_DIR = Path.home() / ".my_tesla"
MILEAGE_DB_DEFAULT = MILEAGE_DIR / "mileage.sqlite"
def resolve_mileage_db_path(args=None) -> Path:
"""Resolve mileage DB path.
Priority:
1) --db (for mileage commands)
2) MY_TESLA_MILEAGE_DB env var
3) ~/.my_tesla/mileage.sqlite
"""
if args is not None and getattr(args, "db", None):
return Path(getattr(args, "db")).expanduser()
env = os.environ.get("MY_TESLA_MILEAGE_DB")
if env and env.strip():
return Path(env.strip()).expanduser()
return MILEAGE_DB_DEFAULT
def resolve_since_ts(*, since_ts: int | None = None, since_days: float | None = None) -> int | None:
"""Resolve a cutoff timestamp (UTC epoch seconds) for mileage export.
- since_ts wins if provided.
- since_days is interpreted as "now - N days".
Returns None when no cutoff is requested.
"""
if since_ts is not None:
try:
return int(since_ts)
except Exception:
raise ValueError("--since-ts must be an integer epoch timestamp (seconds)")
if since_days is None:
return None
try:
days = float(since_days)
except Exception:
raise ValueError("--since-days must be a number (e.g., 7 or 0.5)")
if days < 0:
raise ValueError("--since-days must be >= 0")
return int(time.time() - days * 86400)
def mileage_fetch_points(conn, *, since_ts: int | None = None):
"""Fetch mileage points ordered by timestamp asc, optionally filtered."""
if since_ts is None:
cur = conn.execute(
"SELECT ts_utc, vehicle_id, vehicle_name, odometer_mi, state, source, note FROM mileage_points ORDER BY ts_utc ASC"
)
return cur.fetchall()
cur = conn.execute(
"SELECT ts_utc, vehicle_id, vehicle_name, odometer_mi, state, source, note FROM mileage_points WHERE ts_utc >= ? ORDER BY ts_utc ASC",
(int(since_ts),),
)
return cur.fetchall()
def _db_connect(path: Path):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(path))
conn.execute("PRAGMA journal_mode=WAL;")
conn.execute("PRAGMA foreign_keys=ON;")
return conn
def mileage_init_db(path: Path):
conn = _db_connect(path)
try:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS mileage_points (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts_utc INTEGER NOT NULL,
vehicle_id TEXT,
vehicle_name TEXT,
odometer_mi REAL,
state TEXT,
source TEXT,
note TEXT
);
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_mileage_points_vehicle_ts ON mileage_points(vehicle_id, ts_utc);"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_mileage_points_ts ON mileage_points(ts_utc);"
)
conn.commit()
finally:
conn.close()
def _vehicle_identity(vehicle) -> tuple[str, str]:
vid = vehicle.get("id_s") or str(vehicle.get("vehicle_id") or "")
name = vehicle.get("display_name") or "Vehicle"
return (vid, name)
def mileage_last_success_ts(conn, vehicle_id: str) -> int | None:
cur = conn.execute(
"SELECT ts_utc FROM mileage_points WHERE vehicle_id=? AND odometer_mi IS NOT NULL ORDER BY ts_utc DESC LIMIT 1",
(vehicle_id,),
)
row = cur.fetchone()
return int(row[0]) if row else None
def mileage_insert_point(
conn,
*,
ts_utc: int,
vehicle_id: str,
vehicle_name: str,
odometer_mi: float | None,
state: str | None,
source: str,
note: str | None = None,
):
conn.execute(
"""
INSERT INTO mileage_points(ts_utc, vehicle_id, vehicle_name, odometer_mi, state, source, note)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(ts_utc, vehicle_id, vehicle_name, odometer_mi, state, source, note),
)
def _fmt_dt(ts_utc: int) -> str:
dt = datetime.fromtimestamp(ts_utc, tz=timezone.utc)
return dt.strftime("%Y-%m-%d %H:%M UTC")
def cmd_mileage(args):
"""Mileage tracking commands."""
db_path = resolve_mileage_db_path(args)
if args.action == "init":
mileage_init_db(db_path)
print(f"✅ Mileage DB ready: {db_path}")
return
# record/status/export require DB
mileage_init_db(db_path)
if args.action == "record":
tesla = get_tesla(require_email(args))
vehicles = tesla.vehicle_list()
if not vehicles:
print("❌ No vehicles found on this account", file=sys.stderr)
sys.exit(1)
ts = int(time.time())
allow_any_wake = not getattr(args, "no_wake", False)
wake_after_hours = float(getattr(args, "auto_wake_after_hours", 24.0) or 24.0)
wake_after_sec = int(wake_after_hours * 3600)
results = []
conn = _db_connect(db_path)
try:
for v in vehicles:
vid, name = _vehicle_identity(v)
state = v.get("state")
# Determine if we should allow waking based on last successful capture.
last_ts = mileage_last_success_ts(conn, vid) if vid else None
too_old = last_ts is None or (ts - last_ts) >= wake_after_sec
allow_wake = allow_any_wake and bool(too_old)
# Try to avoid waking unless threshold exceeded.
if not wake_vehicle(v, allow_wake=allow_wake):
mileage_insert_point(
conn,
ts_utc=ts,
vehicle_id=vid,
vehicle_name=name,
odometer_mi=None,
state=state,
source="skip",
note=(
f"skipped (state={state}); no_wake={getattr(args,'no_wake',False)}; "
f"last_ok={_fmt_dt(last_ts) if last_ts else 'never'}"
),
)
results.append({
"vehicle": name,
"vehicle_id": vid,
"state": state,
"recorded": False,
"reason": "asleep_or_offline",
"last_success_ts_utc": last_ts,
"woke": False,
})
continue
# Online now.
data = v.get_vehicle_data()
vs = data.get("vehicle_state", {})
odo = vs.get("odometer")
mileage_insert_point(
conn,
ts_utc=ts,
vehicle_id=vid,
vehicle_name=name,
odometer_mi=float(odo) if odo is not None else None,
state=v.get("state"),
source="vehicle_data",
note=None,
)
results.append({
"vehicle": name,
"vehicle_id": vid,
"state": v.get("state"),
"recorded": odo is not None,
"odometer_mi": float(odo) if odo is not None else None,
"woke": bool(allow_wake and state != 'online'),
})
conn.commit()
finally:
conn.close()
if args.json:
print(json.dumps({"db": str(db_path), "ts_utc": ts, "results": results}, indent=2))
return
ok = sum(1 for r in results if r.get("recorded"))
print(f"✅ Recorded mileage: {ok}/{len(results)} vehicles")
for r in results:
if r.get("recorded"):
print(f"- {r['vehicle']}: {r.get('odometer_mi'):.1f} mi")
else:
print(f"- {r['vehicle']}: skipped ({r.get('reason')})")
return
if args.action == "status":
conn = _db_connect(db_path)
try:
cur = conn.execute(
"""
SELECT vehicle_name, vehicle_id, ts_utc, odometer_mi
FROM mileage_points
WHERE odometer_mi IS NOT NULL
ORDER BY ts_utc DESC
"""
)
rows = cur.fetchall()
finally:
conn.close()
# latest per vehicle
latest = {}
for name, vid, ts, odo in rows:
if vid not in latest:
latest[vid] = {"vehicle": name, "vehicle_id": vid, "ts_utc": int(ts), "odometer_mi": float(odo)}
out = {"db": str(db_path), "vehicles": list(latest.values())}
if args.json:
print(json.dumps(out, indent=2))
return
print(f"Mileage DB: {db_path}")
if not latest:
print("No mileage points recorded yet. Run: " + _invocation("mileage record"))
return
for v in out["vehicles"]:
print(f"- {v['vehicle']}: {v['odometer_mi']:.1f} mi (last: {_fmt_dt(v['ts_utc'])})")
return
if args.action == "export":
fmt = getattr(args, "format", "csv")
since_ts = resolve_since_ts(
since_ts=getattr(args, "since_ts", None),
since_days=getattr(args, "since_days", None),
)
conn = _db_connect(db_path)
try:
rows = mileage_fetch_points(conn, since_ts=since_ts)
finally:
conn.close()
if fmt == "json":
items = [
{
"ts_utc": int(ts),
"vehicle_id": vid,
"vehicle": name,
"odometer_mi": odo,
"state": state,
"source": source,
"note": note,
}
for (ts, vid, name, odo, state, source, note) in rows
]
print(json.dumps({"db": str(db_path), "since_ts_utc": since_ts, "items": items}, indent=2))
return
# csv
import csv
w = csv.writer(sys.stdout)
w.writerow(["ts_utc", "vehicle_id", "vehicle", "odometer_mi", "state", "source", "note"])
for (ts, vid, name, odo, state, source, note) in rows:
w.writerow([int(ts), vid, name, odo, state, source, note or ""])
return
raise ValueError(f"Unknown mileage action: {args.action}")
def cmd_version(args):
"""Print the installed skill version."""
# Keep output simple for scripts.
print(read_skill_version())
def cmd_default_car(args):
"""Set or show the default car used when --car is not provided."""
if not args.name:
name = resolve_default_car_name()
if name:
print(f"Default car: {name}")
else:
print("Default car: (none)")
return
defaults = load_defaults()
defaults["default_car"] = args.name
save_defaults(defaults)
print(f"✅ Default car set to: {args.name}")
print(f"Saved to: {DEFAULTS_FILE}")
def main():
parser = argparse.ArgumentParser(description="Tesla vehicle control")
parser.add_argument("--email", "-e", help="Tesla account email")
parser.add_argument("--car", "-c", help="Vehicle name (default: first vehicle)")
parser.add_argument("--json", "-j", action="store_true", help="Output JSON")
parser.add_argument(
"--raw-json",
action="store_true",
help=(
"When used with --json on supported commands, output raw vehicle_data (may include location). "
"Default JSON output is sanitized/summary for safety."
),
)
parser.add_argument(
"--yes",
action="store_true",
help=(
"Safety confirmation for sensitive/disruptive actions "
"(unlock/charge start|stop|limit|amps/trunk/windows/seats set/honk/flash/charge-port open|close/"
"scheduled-charging set|off/sentry on|off/location precise)"
),
)
parser.add_argument(
"--debug",
action="store_true",
help="Print a full Python traceback on errors (also enabled by MY_TESLA_DEBUG=1)",
)
parser.add_argument(
"--version",
action="store_true",
help="Print skill version and exit",
)
subparsers = parser.add_subparsers(dest="command")
# Auth
subparsers.add_parser("auth", help="Authenticate with Tesla")
# List
subparsers.add_parser("list", help="List all vehicles")
# Version
subparsers.add_parser("version", help="Print skill version")
# Status
status_parser = subparsers.add_parser("status", help="Get vehicle status")
status_parser.add_argument("--summary", action="store_true", help="Also print a one-line summary")
status_parser.add_argument("--no-wake", action="store_true", help="Do not wake the car (fails if asleep)")
# Summary (alias)
summary_parser = subparsers.add_parser("summary", help="One-line status summary")
summary_parser.add_argument("--no-wake", action="store_true", help="Do not wake the car (fails if asleep)")
# Report (one-screen)
report_parser = subparsers.add_parser("report", help="One-screen status report")
report_parser.add_argument("--no-wake", action="store_true", help="Do not wake the car (fails if asleep)")
# Default car
default_parser = subparsers.add_parser("default-car", help="Set/show default vehicle name")
default_parser.add_argument("name", nargs="?", help="Vehicle display name to set as default")
# Mileage tracking (odometer)
mileage_parser = subparsers.add_parser("mileage", help="Record odometer mileage to a local SQLite DB")
mileage_parser.add_argument("action", choices=["init", "record", "status", "export"], help="init|record|status|export")
mileage_parser.add_argument("--json", action="store_true", help="Output JSON (record/status only)")
mileage_parser.add_argument("--db", help="Path to the SQLite DB (default: ~/.my_tesla/mileage.sqlite)")
mileage_parser.add_argument("--no-wake", action="store_true", help="Do not wake sleeping cars")
mileage_parser.add_argument(
"--auto-wake-after-hours",
type=float,
default=24.0,
help="If a car hasn't recorded mileage in this many hours, allow waking it (default: 24)",
)
mileage_parser.add_argument("--format", choices=["csv", "json"], default="csv", help="For export: csv|json")
mileage_parser.add_argument(
"--since-ts",
type=int,
help="(export only) Only include points with ts_utc >= this epoch timestamp (seconds)",
)
mileage_parser.add_argument(
"--since-days",
type=float,
help="(export only) Only include points from the last N days (e.g., 7 or 0.5)",
)
# Lock/unlock
subparsers.add_parser("lock", help="Lock the vehicle")
subparsers.add_parser("unlock", help="Unlock the vehicle")
# Climate
climate_parser = subparsers.add_parser("climate", help="Climate control")
climate_parser.add_argument("action", choices=["status", "on", "off", "temp", "defrost"])
climate_parser.add_argument(
"value",
nargs="?",
help="For 'temp': temperature value. For 'defrost': on|off.",
)
climate_parser.add_argument("--no-wake", action="store_true", help="(status only) Do not wake the car")
temp_units = climate_parser.add_mutually_exclusive_group()
temp_units.add_argument("--fahrenheit", "-f", action="store_true", help="Temperature value is in °F (default)")
temp_units.add_argument("--celsius", action="store_true", help="Temperature value is in °C")
# Charge
charge_parser = subparsers.add_parser("charge", help="Charging control")
charge_parser.add_argument("action", choices=["status", "start", "stop", "limit", "amps"])
charge_parser.add_argument(
"value",
nargs="?",
help="For 'limit': percent (e.g., 80). For 'amps': amps (e.g., 16).",
)
charge_parser.add_argument("--no-wake", action="store_true", help="(status only) Do not wake the car")
# Scheduled charging
sched_parser = subparsers.add_parser("scheduled-charging", help="Get/set scheduled charging (set/off requires --yes)")
sched_parser.add_argument("action", choices=["status", "set", "off"], help="status|set|off")
sched_parser.add_argument("time", nargs="?", help="Start time for 'set' as HH:MM (24-hour)")
sched_parser.add_argument("--no-wake", action="store_true", help="(status only) Do not wake the car")
# Scheduled departure (read-only)
dep_parser = subparsers.add_parser(
"scheduled-departure",
help="Scheduled departure / preconditioning / off-peak charging status (read-only)",
)
dep_parser.add_argument("action", choices=["status"], help="status")
dep_parser.add_argument("--no-wake", action="store_true", help="Do not wake the car (fails if asleep)")
# Location
location_parser = subparsers.add_parser(
"location",
help="Get vehicle location (approx by default; use --yes for precise)",
)
location_parser.add_argument("--no-wake", action="store_true", help="Do not wake the car (fails if asleep)")
location_parser.add_argument(
"--digits",
type=int,
default=2,
help="(approx output) Rounding precision for latitude/longitude (06). Default: 2",
)
# Tire pressures (TPMS)
tires_parser = subparsers.add_parser("tires", help="Show tire pressures (TPMS)")
tires_parser.add_argument("--no-wake", action="store_true", help="Do not wake the car (fails if asleep)")
# Openings (doors/trunks/windows)
openings_parser = subparsers.add_parser("openings", help="Show which doors/trunks/windows are open")
openings_parser.add_argument("--no-wake", action="store_true", help="Do not wake the car (fails if asleep)")
# Trunk / frunk
trunk_parser = subparsers.add_parser("trunk", help="Toggle trunk/frunk (requires --yes)")
trunk_parser.add_argument("which", choices=["trunk", "frunk"], help="Which to actuate")
# Windows
windows_parser = subparsers.add_parser("windows", help="Windows status (read-only) or vent/close (requires --yes)")
windows_parser.add_argument("action", choices=["status", "vent", "close"], help="status|vent|close")
windows_parser.add_argument("--no-wake", action="store_true", help="(status only) Do not wake the car")
windows_parser.add_argument("--json", action="store_true", help="(status only) Output JSON")
# Seat heaters
seats_parser = subparsers.add_parser("seats", help="Seat heater status (read-only) or set level (requires --yes)")
seats_parser.add_argument("action", choices=["status", "set"], help="status|set")
seats_parser.add_argument("seat", nargs="?", help="For 'set': driver|passenger|rear-left|rear-center|rear-right|3rd-left|3rd-right (or 06)")
seats_parser.add_argument("level", nargs="?", help="For 'set': 03 (0=off)")
seats_parser.add_argument("--no-wake", action="store_true", help="(status only) Do not wake the car")
seats_parser.add_argument("--json", action="store_true", help="(status only) Output JSON")
# Sentry
sentry_parser = subparsers.add_parser("sentry", help="Get/set Sentry Mode (on/off requires --yes)")
sentry_parser.add_argument("action", choices=["status", "on", "off"], help="status|on|off")
sentry_parser.add_argument("--no-wake", action="store_true", help="(status only) Do not wake the car")
# Honk/flash
subparsers.add_parser("honk", help="Honk the horn")
subparsers.add_parser("flash", help="Flash the lights")
# Charge port
charge_port_parser = subparsers.add_parser(
"charge-port",
help="Charge port status (read-only) or open/close (requires --yes)",
)
charge_port_parser.add_argument("action", choices=["status", "open", "close"], help="status|open|close")
charge_port_parser.add_argument("--no-wake", action="store_true", help="(status only) Do not wake the car")
# Wake
subparsers.add_parser("wake", help="Wake up the vehicle")
args = parser.parse_args()
if getattr(args, "version", False):
cmd_version(args)
return
if not getattr(args, "command", None):
parser.print_help(sys.stderr)
sys.exit(2)
commands = {
"auth": cmd_auth,
"list": cmd_list,
"version": cmd_version,
"status": cmd_status,
"summary": cmd_summary,
"report": cmd_report,
"lock": cmd_lock,
"unlock": cmd_unlock,
"climate": cmd_climate,
"charge": cmd_charge,
"scheduled-charging": cmd_scheduled_charging,
"scheduled-departure": cmd_scheduled_departure,
"location": cmd_location,
"tires": cmd_tires,
"openings": cmd_openings,
"trunk": cmd_trunk,
"windows": cmd_windows,
"seats": cmd_seats,
"sentry": cmd_sentry,
"honk": cmd_honk,
"flash": cmd_flash,
"charge-port": cmd_charge_port,
"wake": cmd_wake,
"default-car": cmd_default_car,
"mileage": cmd_mileage,
}
try:
commands[args.command](args)
except KeyboardInterrupt:
print("\n⛔ Interrupted", file=sys.stderr)
sys.exit(130)
except Exception as e:
debug = bool(getattr(args, "debug", False)) or os.environ.get("MY_TESLA_DEBUG") == "1"
if debug:
# Print both a friendly line and a full traceback.
print(f"❌ Error: {e}", file=sys.stderr)
traceback.print_exc()
else:
print(f"❌ Error: {e}", file=sys.stderr)
print(" Tip: re-run with --debug for a full traceback", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()