From 1d2061613eac6669eb400a9a4d962da64cee0cc5 Mon Sep 17 00:00:00 2001 From: zlei9 Date: Sun, 29 Mar 2026 13:13:35 +0800 Subject: [PATCH] Initial commit with translated description --- CHANGELOG.md | 223 +++ README.md | 238 +++ SKILL.md | 201 ++ VERSION.txt | 1 + _meta.json | 6 + scripts/run_tests.sh | 20 + scripts/tesla.py | 2312 ++++++++++++++++++++++ tests/__init__.py | 10 + tests/test_charge_port_status.py | 40 + tests/test_charge_status_json.py | 44 + tests/test_cmd_climate_defrost.py | 99 + tests/test_cmd_climate_status.py | 99 + tests/test_cmd_mileage_record.py | 41 + tests/test_cmd_seats.py | 106 + tests/test_cmd_status_summary.py | 73 + tests/test_cmd_windows.py | 126 ++ tests/test_defaults_permissions.py | 32 + tests/test_formatting.py | 119 ++ tests/test_location_rounding.py | 27 + tests/test_mileage_db.py | 46 + tests/test_mileage_export_filter.py | 59 + tests/test_no_wake.py | 36 + tests/test_openings_format.py | 25 + tests/test_report_charging_power.py | 37 + tests/test_report_json.py | 65 + tests/test_report_openings.py | 46 + tests/test_report_privacy.py | 34 + tests/test_report_seat_heaters.py | 29 + tests/test_scheduled_departure_status.py | 30 + tests/test_summary_json.py | 53 + tests/test_time_helpers.py | 51 + tests/test_vehicle_select.py | 71 + 32 files changed, 4399 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 SKILL.md create mode 100644 VERSION.txt create mode 100644 _meta.json create mode 100644 scripts/run_tests.sh create mode 100644 scripts/tesla.py create mode 100644 tests/__init__.py create mode 100644 tests/test_charge_port_status.py create mode 100644 tests/test_charge_status_json.py create mode 100644 tests/test_cmd_climate_defrost.py create mode 100644 tests/test_cmd_climate_status.py create mode 100644 tests/test_cmd_mileage_record.py create mode 100644 tests/test_cmd_seats.py create mode 100644 tests/test_cmd_status_summary.py create mode 100644 tests/test_cmd_windows.py create mode 100644 tests/test_defaults_permissions.py create mode 100644 tests/test_formatting.py create mode 100644 tests/test_location_rounding.py create mode 100644 tests/test_mileage_db.py create mode 100644 tests/test_mileage_export_filter.py create mode 100644 tests/test_no_wake.py create mode 100644 tests/test_openings_format.py create mode 100644 tests/test_report_charging_power.py create mode 100644 tests/test_report_json.py create mode 100644 tests/test_report_openings.py create mode 100644 tests/test_report_privacy.py create mode 100644 tests/test_report_seat_heaters.py create mode 100644 tests/test_scheduled_departure_status.py create mode 100644 tests/test_summary_json.py create mode 100644 tests/test_time_helpers.py create mode 100644 tests/test_vehicle_select.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b4b007a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,223 @@ +# Changelog + +## 0.1.64 — 2026-01-29 +- Better UX: `charge status` now shows usable battery + (when charging) power details (kW/V/A) and charge port/cable state. +- Reliability: add unit tests for charging status JSON helper. + +## 0.1.63 — 2026-01-29 +- Better UX: show "Usable battery" (when available) in `report`, and include `usable_level_percent` in `summary --json`. +- Reliability: add unit tests for usable battery formatting. + +## 0.1.62 — 2026-01-29 +- Better UX: add `scheduled-departure status` (read-only) for scheduled departure / preconditioning / off-peak charging. +- Reliability: add unit tests for scheduled-departure JSON formatting. + +## 0.1.61 — 2026-01-29 +- Better UX: `mileage export` now supports time-window filtering via `--since-days` or `--since-ts`. +- Reliability: add unit tests for mileage export filtering helpers. + +## 0.1.60 — 2026-01-29 +- Better UX: add global `--debug` (or `MY_TESLA_DEBUG=1`) to print full tracebacks on errors. + +## 0.1.59 — 2026-01-29 +- Better UX / safety: `location` now supports `--digits N` (0–6) to control rounding precision for approximate coordinates. +- Reliability: add unit tests for coordinate rounding helper. + +## 0.1.58 — 2026-01-29 +- Better UX: `report` now includes scheduled departure / preconditioning / off-peak charging status when the vehicle reports it. + +## 0.1.57 — 2026-01-29 +- Dev hygiene: ignore common Python tooling caches (e.g., `.pytest_cache/`, `.mypy_cache/`) to keep the repo clean. + +## 0.1.56 — 2026-01-29 +- Better UX: `summary --json` now outputs a small **sanitized** JSON object (no location) for easy scripting; `--raw-json` is still available when you explicitly want raw `vehicle_data`. +- Reliability: add unit tests for summary JSON sanitization. + +## 0.1.55 — 2026-01-29 +- Better UX: `report` now includes a compact seat-heater summary line when the vehicle reports seat heater levels. +- JSON: `report --json` includes `climate.seat_heaters` when available. + +## 0.1.54 — 2026-01-29 +- New capability (safe): add `seats` command for seat heater status + setting levels (set requires `--yes`). + +## 0.1.53 — 2026-01-29 +- Better UX: `report` now includes a one-line openings summary (doors/trunk/frunk/windows) when the vehicle reports it. + +## 0.1.52 — 2026-01-29 +- New capability (safe): add `windows status` (read-only) with `--no-wake` + `--json`. + +## 0.1.51 — 2026-01-29 +- Fix: remove an invalid f-string escape in `list` output so `scripts/tesla.py` compiles cleanly and tests can import it. + +## 0.1.50 — 2026-01-29 +- Reliability: make `windows` command handler defensive (explicitly errors on unknown actions) and add unit tests. + +## 0.1.49 — 2026-01-29 +- Fix: repair an indentation bug in `report` output formatting that could break `python3 -m py_compile` / CLI execution. + +## 0.1.48 — 2026-01-29 +- Security: set best-effort `0600` permissions on local token cache (`~/.tesla_cache.json`) and defaults (`~/.my_tesla.json`). +- Reliability: add unit test for defaults file permission behavior. + +## 0.1.47 — 2026-01-29 +- Reliability: make `tests/` a package that disables Python bytecode writing, so running tests won’t create `__pycache__/` in the repo. + +## 0.1.46 — 2026-01-29 +- Fix: `mileage record` now supports `--json` (subcommand flag) so hourly cron runs can log machine-readable output. + +## 0.1.45 — 2026-01-29 +- New capability: add `mileage` tracking (local SQLite) to record odometer miles across **all cars**. + - Default behavior: `--no-wake` style (skip sleeping cars) + - Auto-wake policy: allow waking a car only if it hasn’t recorded mileage in **24 hours**. + - Includes `init`, `record`, `status`, and `export` commands. +- Docs: added a quick-start + an hourly `launchd` example. +- Tests: added unit tests for mileage DB helpers + record skip behavior. + +## 0.1.44 — 2026-01-29 +- New capability: add `climate defrost on|off` (max defrost / preconditioning). +- Reliability: add unit tests for the new defrost command wiring. + +## 0.1.43 — 2026-01-29 +- Reliability: prevent Python from writing `__pycache__/` bytecode when running the CLI (keeps the repo clean). + +## 0.1.42 — 2026-01-29 +- UX: `report` now includes charging power details (kW / V / A) when the car is actively charging. +- Reliability: add unit test coverage for the new report output. + +## 0.1.41 — 2026-01-29 +- UX: improve error/help messages by printing a copy/pastable invocation that works outside the repo (uses the script’s absolute path). + +## 0.1.40 — 2026-01-29 +- New capability (safe): add `charge-port status` (read-only) with `--no-wake` + `--json`. +- Reliability: add unit test coverage for the new charge port status formatter. + +## 0.1.39 — 2026-01-29 +- UX: add `version` / `--version` so you can quickly confirm the installed skill version. + +## 0.1.38 — 2026-01-29 +- UX: `list --json` now outputs a privacy-safe, machine-readable vehicle list (no VINs). + +## 0.1.37 — 2026-01-29 +- Packaging: keep `VERSION.txt` in sync with `VERSION` so installed skills report the correct version. + +## 0.1.36 — 2026-01-29 +- Reliability: harden test runner to clean stray Python bytecode and fail if `__pycache__` / `*.pyc` are produced. + +## 0.1.35 — 2026-01-29 +- Docs/privacy: document `MY_TESLA_DEFAULT_CAR` and clarify that `status --json` outputs raw `vehicle_data` (may include location); recommend `report --json` for sanitized output. + +## 0.1.34 — 2026-01-29 +- UX: make `--car` selection errors clearer when a partial name matches multiple vehicles (shows matches + suggests using an index). + +## 0.1.33 — 2026-01-29 +- UX: add `climate status` (read-only) with `--no-wake` and `--json` support for a focused climate-only view. + +## 0.1.32 — 2026-01-29 +- Reliability: add `./scripts/run_tests.sh` and recommend `PYTHONDONTWRITEBYTECODE=1` to prevent repo-local `__pycache__`. + +## 0.1.31 — 2026-01-29 +- UX: fix `status --summary` to actually include the one-line summary *and* the detailed status output. + +## 0.1.30 — 2026-01-29 +- New capability (safe): add `openings` command to show which doors/trunks/windows are open (supports `--no-wake` + `--json`). + +## 0.1.29 — 2026-01-29 +- UX: `report` now includes charge port door + cable status. `report --json` now includes scheduled charging + charge port fields. + +## 0.1.28 — 2026-01-29 +- Reliability/privacy: add unit test to ensure `report` output never echoes location fields from raw vehicle_data. + +## 0.1.27 — 2026-01-29 +- UX/privacy: `report --json` now outputs a sanitized report object by default (no location). Use `--raw-json` to get full vehicle_data. + +## 0.1.26 — 2026-01-29 +- UX: `charge status --json` now prints *only* JSON (subset of `charge_state`) for piping/parsing. + +## 0.1.25 — 2026-01-29 +- New capability (safe): add `charge amps ` to set charging current (requires `--yes`). + +## 0.1.24 — 2026-01-29 +- Reliability: add unit tests for scheduled-charging time parsing/formatting helpers. + +## 0.1.23 — 2026-01-28 +- UX: `status --json` now prints *only* JSON (no extra human text), making it safe to pipe/parse. + +## 0.1.22 — 2026-01-28 +- UX: include TPMS tire pressures in `report` output when available. +- Docs/privacy: remove personal default-car example from README/SKILL. + +## 0.1.21 — 2026-01-28 +- Reliability/privacy: remove accidentally committed Python bytecode (`__pycache__`) and sanitize unit test fixtures. + +## 0.1.20 — 2026-01-28 +- Improve UX: `--car` now accepts partial name (substring match) or a 1-based index from `list`. +- Add unit tests for vehicle selection. + +## 0.1.19 — 2026-01-28 +- Add `tires` command to show TPMS tire pressures (read-only; supports `--no-wake`). + +## 0.1.18 — 2026-01-28 +- Add `sentry` command (status/on/off) with `--yes` safety gate for toggles. +- Show Sentry state in `report` output + add a unit test for it. + +## 0.1.17 — 2026-01-28 +- Add unit tests for `--no-wake` behavior (wake gating + exit code). + +## 0.1.16 — 2026-01-28 +- Add `--no-wake` to read-only commands to avoid waking the vehicle (fails if asleep/offline). + +## 0.1.15 — 2026-01-28 +- Add `charge-port` command (open/close) with `--yes` safety gate. + +## 0.1.14 — 2026-01-28 +- Safety: require `--yes` for `unlock` and `charge start|stop` to avoid accidental disruptive actions. + +## 0.1.13 — 2026-01-28 +- Improve UX: clearer validation + errors for `charge limit` and `climate temp`. + +## 0.1.12 — 2026-01-28 +- Make `location` safer by default: show approximate (rounded) coordinates unless `--yes` is provided for precise. + +## 0.1.11 — 2026-01-28 +- Remove `--yes` safety gate from `location` (prints coordinates + maps link without confirmation). + +## 0.1.10 — 2026-01-28 +- Refactor: centralize missing-email handling into a single helper with a clearer example. +- Clarify --yes help text to include scheduled-charging set|off. + +## 0.1.9 — 2026-01-28 +- Add `scheduled-charging` command (status/set/off) with safety gate; show scheduled charging info in `report`. + +## 0.1.8 — 2026-01-28 +- Improve UX: clearer error when Tesla email is missing (instead of confusing auth failures). + +## 0.1.7 — 2026-01-28 +- Add `windows` command to vent/close windows (safety gated with `--yes`). + +## 0.1.6 — 2026-01-28 +- Add unit tests for status/report formatting helpers. +- Clarify `--yes` help text to cover all safety-gated commands. + +## 0.1.5 — 2026-01-28 +- Include `VERSION.txt` in published skill artifacts (ClawdHub ignores extensionless files like `VERSION`). + +## 0.1.4 — 2026-01-28 +- Add `trunk` command (trunk/frunk) with safety gate (`--yes`). +- Make `location` safety gated (`--yes`) to reduce accidental sensitive output. + +## 0.1.3 — 2026-01-28 +- Add `report` command: a one-screen, chat-friendly status report. +- Fix `climate temp` units: default is °F, with `--celsius` for °C. + +## 0.1.2 — 2026-01-28 +- Add `default-car` command and local defaults file (`~/.my_tesla.json`) so you can set a default vehicle. +- Reduce sensitive output: stop printing VINs in `auth`/`list` by default. + +## 0.1.1 — 2026-01-28 +- Add `summary` command for a one-line, chat-friendly status output. + +## 0.1.0 — 2026-01-28 +- Forked from the base `tesla` skill and enhanced into `my-tesla`. +- Added safety confirmation gate for disruptive actions. +- Added `charge limit` command. +- Added author attribution + versioning for publishing. diff --git a/README.md b/README.md new file mode 100644 index 0000000..83abb22 --- /dev/null +++ b/README.md @@ -0,0 +1,238 @@ +# My Tesla + +Tesla control skill for Clawdbot. + +Author: Parth Maniar — [@officialpm](https://github.com/officialpm) + +## What’s inside + +- `SKILL.md` — the skill instructions +- `scripts/tesla.py` — the CLI implementation (teslapy) +- `VERSION` + `CHANGELOG.md` — versioning for ClawdHub publishing + +## Install / auth + +Set `TESLA_EMAIL` and run: + +```bash +TESLA_EMAIL="you@email.com" python3 scripts/tesla.py auth +``` + +This uses a browser-based login flow and stores tokens locally in `~/.tesla_cache.json` (best-effort chmod `0600`). + +Optional defaults: +- `MY_TESLA_DEFAULT_CAR` — default vehicle display name (overrides `default-car` setting) +- `python3 scripts/tesla.py default-car "Name"` stores a local default in `~/.my_tesla.json` (best-effort chmod `0600`) + +## Usage + +```bash +# List vehicles (shows which one is default) +python3 scripts/tesla.py list +python3 scripts/tesla.py list --json # machine-readable, privacy-safe + +# Version +python3 scripts/tesla.py version +python3 scripts/tesla.py --version + +# Debugging +# If something fails unexpectedly, add --debug for a full traceback +# (or set MY_TESLA_DEBUG=1) +python3 scripts/tesla.py --debug status --no-wake + +# Pick a car (optional) +# --car accepts: exact name, partial name (substring match), or a 1-based index from `list` +python3 scripts/tesla.py --car "Model" report +python3 scripts/tesla.py --car 1 status + +# Set default car (used when you don't pass --car) +python3 scripts/tesla.py default-car "My Model 3" + +# One-line summary (best for chat) +python3 scripts/tesla.py summary +python3 scripts/tesla.py summary --no-wake # don't wake a sleeping car + +# Summary as JSON (privacy-safe) +# Unlike `status --json`, this emits a small sanitized object (no location). +# Includes `usable_level_percent` when the vehicle reports it. +python3 scripts/tesla.py summary --json +python3 scripts/tesla.py summary --json --raw-json # raw vehicle_data (may include location) + +# One-screen report (chat friendly, more detail) +# Includes battery/charging/climate + charge port/cable + (when available) TPMS tire pressures. +# Includes "Usable battery" when the vehicle reports it (helpful for health/degradation). +# Also includes a quick openings summary (doors/trunk/frunk/windows) when the vehicle reports it. +# When available, includes a compact seat heater summary line. +# When actively charging, also shows charging power details when available (kW / V / A). +# When the vehicle reports it, includes scheduled departure / preconditioning / off-peak charging status. +python3 scripts/tesla.py report +python3 scripts/tesla.py report --no-wake + +# Detailed status +python3 scripts/tesla.py status +python3 scripts/tesla.py status --no-wake +python3 scripts/tesla.py status --summary # include one-line summary + detailed output + +# JSON output (prints ONLY JSON; good for piping/parsing) +# NOTE: `status --json` outputs *raw* `vehicle_data`, which may include location/drive_state. +# Prefer `summary --json` (sanitized) or `report --json` (sanitized) unless you explicitly need the raw payload. +python3 scripts/tesla.py summary --json # sanitized summary object (no location) +python3 scripts/tesla.py report --json # sanitized report object (no location) +python3 scripts/tesla.py status --json # raw vehicle_data (may include location) +python3 scripts/tesla.py report --json --raw-json # raw vehicle_data (may include location) +python3 scripts/tesla.py summary --json --raw-json # raw vehicle_data (may include location) +python3 scripts/tesla.py charge status --json # includes usable battery + (when charging) power details (kW/V/A) + +python3 scripts/tesla.py --car "My Model 3" lock +# Climate (status is read-only) +python3 scripts/tesla.py climate status +python3 scripts/tesla.py climate status --no-wake +python3 scripts/tesla.py climate on +python3 scripts/tesla.py climate off +python3 scripts/tesla.py climate defrost on +python3 scripts/tesla.py climate defrost off +python3 scripts/tesla.py climate temp 72 # default: °F +python3 scripts/tesla.py climate temp 22 --celsius +python3 scripts/tesla.py charge limit 80 --yes # 50–100 +python3 scripts/tesla.py charge amps 16 --yes # 1–48 (conservative guardrail) + +# Scheduled charging (set/off are safety gated) +python3 scripts/tesla.py scheduled-charging status +python3 scripts/tesla.py scheduled-charging set 23:30 --yes +python3 scripts/tesla.py scheduled-charging off --yes + +# Scheduled departure (read-only) +python3 scripts/tesla.py scheduled-departure status +python3 scripts/tesla.py scheduled-departure status --no-wake +python3 scripts/tesla.py --json scheduled-departure status + +# Trunk / frunk (safety gated) +python3 scripts/tesla.py trunk trunk --yes +python3 scripts/tesla.py trunk frunk --yes + +# Windows +python3 scripts/tesla.py windows status +python3 scripts/tesla.py windows status --no-wake +python3 scripts/tesla.py windows status --json + +# Windows (safety gated) +python3 scripts/tesla.py windows vent --yes +python3 scripts/tesla.py windows close --yes + +# Seat heaters +python3 scripts/tesla.py seats status +python3 scripts/tesla.py seats status --no-wake +python3 scripts/tesla.py seats status --json + +# Seat heaters (safety gated) +# seat: driver|passenger|rear-left|rear-center|rear-right|3rd-left|3rd-right (or 0–6) +# level: 0–3 (0=off) +python3 scripts/tesla.py seats set driver 3 --yes + +# Charge port door +python3 scripts/tesla.py charge-port status +python3 scripts/tesla.py charge-port status --no-wake +python3 scripts/tesla.py charge-port status --json + +# Charge port door open/close (safety gated) +python3 scripts/tesla.py charge-port open --yes +python3 scripts/tesla.py charge-port close --yes + +# Sentry Mode (status is read-only; on/off safety gated) +python3 scripts/tesla.py sentry status +python3 scripts/tesla.py sentry status --no-wake +python3 scripts/tesla.py sentry on --yes +python3 scripts/tesla.py sentry off --yes + +# Location (approx by default; use --yes for precise coordinates) +python3 scripts/tesla.py location +python3 scripts/tesla.py location --no-wake +python3 scripts/tesla.py location --digits 1 # coarser rounding +python3 scripts/tesla.py location --digits 3 # a bit more precise (still approximate) +python3 scripts/tesla.py location --yes + +# Tire pressures (TPMS) +python3 scripts/tesla.py tires +python3 scripts/tesla.py tires --no-wake + +# Openings (doors/trunks/windows) +python3 scripts/tesla.py openings +python3 scripts/tesla.py openings --no-wake +python3 scripts/tesla.py openings --json + +# Mileage tracking (odometer) — local SQLite +python3 scripts/tesla.py mileage init +python3 scripts/tesla.py mileage record --no-wake --auto-wake-after-hours 24 +python3 scripts/tesla.py mileage status +python3 scripts/tesla.py mileage export --format csv > mileage.csv +python3 scripts/tesla.py mileage export --format json > mileage.json + +# Export a time window +python3 scripts/tesla.py mileage export --format csv --since-days 7 > mileage_last_7d.csv +python3 scripts/tesla.py mileage export --format json --since-ts 1738195200 > mileage_since_ts.json +``` + +## Mileage tracking (hourly) + +This feature records each vehicle’s **odometer miles** to a **local SQLite database** so we can build analytics later. + +Defaults: +- DB path: `~/.my_tesla/mileage.sqlite` (override with `MY_TESLA_MILEAGE_DB` or `mileage --db ...`) +- Wake behavior: **no wake by default**. The recorder will only allow waking a car **if it hasn’t recorded mileage in 24h**. + +### Quick start +```bash +python3 scripts/tesla.py mileage init +python3 scripts/tesla.py mileage record --no-wake --auto-wake-after-hours 24 +python3 scripts/tesla.py mileage status +``` + +### Run every hour (macOS launchd example) +Create `~/Library/LaunchAgents/com.mytesla.mileage.plist`: +```xml + + + + + Labelcom.mytesla.mileage + StartInterval3600 + ProgramArguments + + /usr/bin/python3 + /ABS/PATH/TO/scripts/tesla.py + mileage + record + --no-wake + --auto-wake-after-hours24 + --json + + EnvironmentVariables + + TESLA_EMAILyou@email.com + + StandardOutPath~/.my_tesla/mileage.log + StandardErrorPath~/.my_tesla/mileage.err.log + + +``` +Load it: +```bash +launchctl load -w ~/Library/LaunchAgents/com.mytesla.mileage.plist +``` + +## Tests + +```bash +# (Recommended) avoid writing __pycache__/ bytecode files into the repo +PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -v + +# Or use the helper (cleans stray bytecode first and fails if any is produced): +./scripts/run_tests.sh +``` + +## Privacy / safety + +- Never commit tokens, VINs, or location outputs. +- Some commands (unlock/charge start|stop|limit|amps/trunk/windows/seats set/sentry on|off/honk/flash/charge-port open|close/scheduled-charging set|off) require `--yes`. +- Read-only commands support `--no-wake` to avoid waking the car (will fail if the vehicle is asleep/offline). +- `location` shows *approximate* coords by default; add `--yes` for precise coordinates. diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..d2dac26 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,201 @@ +--- +name: my-tesla +description: "通过Tesla Owner API从macOS控制Tesla车辆。" +--- + +# My Tesla + +**Author:** Parth Maniar — [@officialpm](https://github.com/officialpm) + +A practical Tesla control skill for Clawdbot built on `teslapy`. + +## Setup + +### Requirements + +- `TESLA_EMAIL` env var set (your Tesla account email) +- Python 3.10+ + +### First-time authentication + +```bash +TESLA_EMAIL="you@email.com" python3 {baseDir}/scripts/tesla.py auth +``` + +This opens a Tesla login URL. Log in, then paste the callback URL back into the CLI. + +- Token cache: `~/.tesla_cache.json` (local only; best-effort chmod `0600`) +- Optional: set `MY_TESLA_DEFAULT_CAR` to a vehicle display name to pick a default car via env var +- Or persist a local default with: `python3 {baseDir}/scripts/tesla.py default-car "Name"` (writes `~/.my_tesla.json`; best-effort chmod `0600`) + +## Commands + +```bash +# List vehicles +python3 {baseDir}/scripts/tesla.py list +python3 {baseDir}/scripts/tesla.py list --json # machine-readable, privacy-safe + +# Version +python3 {baseDir}/scripts/tesla.py version +python3 {baseDir}/scripts/tesla.py --version + +# Debugging +# If something fails unexpectedly, add --debug for a full traceback +# (or set MY_TESLA_DEBUG=1) +python3 {baseDir}/scripts/tesla.py --debug status --no-wake + +# Pick a car (optional) +# --car accepts: exact name, partial name (substring match), or a 1-based index from `list` +python3 {baseDir}/scripts/tesla.py --car "Model" status +python3 {baseDir}/scripts/tesla.py --car 1 report + +# Set a default car (used when --car is not passed) +python3 {baseDir}/scripts/tesla.py default-car "My Model 3" + +# One-line summary (best for chat) +python3 {baseDir}/scripts/tesla.py summary +python3 {baseDir}/scripts/tesla.py summary --no-wake # don't wake a sleeping car + +# Summary as JSON (privacy-safe) +# Unlike `status --json`, this emits a small sanitized object (no location). +# Includes `usable_level_percent` when the vehicle reports it. +python3 {baseDir}/scripts/tesla.py summary --json +python3 {baseDir}/scripts/tesla.py summary --json --raw-json # raw vehicle_data (may include location) + +# One-screen report (chat friendly, more detail) +# Includes battery/charging/climate + (when available) TPMS tire pressures. +# Includes "Usable battery" when the vehicle reports it (helpful for health/degradation). +# Also includes a quick openings summary (doors/trunk/frunk/windows) when available. +# When available, includes a compact seat heater summary line. +# When the vehicle reports it, includes scheduled departure / preconditioning / off-peak charging status. +python3 {baseDir}/scripts/tesla.py report +python3 {baseDir}/scripts/tesla.py report --no-wake + +# Detailed status +python3 {baseDir}/scripts/tesla.py status +python3 {baseDir}/scripts/tesla.py status --no-wake +python3 {baseDir}/scripts/tesla.py status --summary # include one-line summary + detailed output +python3 {baseDir}/scripts/tesla.py --car "My Model 3" status + +# JSON output (prints ONLY JSON; good for piping/parsing) +# NOTE: `status --json` outputs *raw* `vehicle_data`, which may include location/drive_state. +# Prefer `summary --json` (sanitized) or `report --json` (sanitized) unless you explicitly need the raw payload. +python3 {baseDir}/scripts/tesla.py summary --json # sanitized summary object (no location) +python3 {baseDir}/scripts/tesla.py report --json # sanitized report object (no location; includes scheduled charging + charge port state) +python3 {baseDir}/scripts/tesla.py status --json # raw vehicle_data (may include location) +python3 {baseDir}/scripts/tesla.py report --json --raw-json # raw vehicle_data (may include location) +python3 {baseDir}/scripts/tesla.py summary --json --raw-json # raw vehicle_data (may include location) +python3 {baseDir}/scripts/tesla.py charge status --json # includes usable battery + (when charging) power details (kW/V/A) + +# Lock / unlock +python3 {baseDir}/scripts/tesla.py lock +python3 {baseDir}/scripts/tesla.py unlock + +# Climate (status is read-only) +python3 {baseDir}/scripts/tesla.py climate status +python3 {baseDir}/scripts/tesla.py climate status --no-wake +python3 {baseDir}/scripts/tesla.py climate on +python3 {baseDir}/scripts/tesla.py climate off +python3 {baseDir}/scripts/tesla.py climate defrost on +python3 {baseDir}/scripts/tesla.py climate defrost off +python3 {baseDir}/scripts/tesla.py climate temp 72 # default: °F +python3 {baseDir}/scripts/tesla.py climate temp 22 --celsius + +# Charging +python3 {baseDir}/scripts/tesla.py charge status +python3 {baseDir}/scripts/tesla.py charge status --no-wake +python3 {baseDir}/scripts/tesla.py charge start --yes +python3 {baseDir}/scripts/tesla.py charge stop --yes +python3 {baseDir}/scripts/tesla.py charge limit 80 --yes # 50–100 +python3 {baseDir}/scripts/tesla.py charge amps 16 --yes # 1–48 (conservative guardrail) + +# Scheduled charging (set/off are safety gated) +python3 {baseDir}/scripts/tesla.py scheduled-charging status +python3 {baseDir}/scripts/tesla.py scheduled-charging status --no-wake +python3 {baseDir}/scripts/tesla.py scheduled-charging set 23:30 --yes +python3 {baseDir}/scripts/tesla.py scheduled-charging off --yes + +# Scheduled departure (read-only) +# Shows scheduled departure, preconditioning, and off-peak charging flags (when the vehicle reports them). +python3 {baseDir}/scripts/tesla.py scheduled-departure status +python3 {baseDir}/scripts/tesla.py scheduled-departure status --no-wake +python3 {baseDir}/scripts/tesla.py --json scheduled-departure status + +# Location (approx by default; use --yes for precise coordinates) +python3 {baseDir}/scripts/tesla.py location +python3 {baseDir}/scripts/tesla.py location --no-wake +python3 {baseDir}/scripts/tesla.py location --digits 1 # coarser rounding +python3 {baseDir}/scripts/tesla.py location --digits 3 # a bit more precise (still approximate) +python3 {baseDir}/scripts/tesla.py location --yes + +# Tire pressures (TPMS) +python3 {baseDir}/scripts/tesla.py tires +python3 {baseDir}/scripts/tesla.py tires --no-wake + +# Openings (doors/trunks/windows) +python3 {baseDir}/scripts/tesla.py openings +python3 {baseDir}/scripts/tesla.py openings --no-wake +python3 {baseDir}/scripts/tesla.py openings --json + +# Trunk / frunk (safety gated) +python3 {baseDir}/scripts/tesla.py trunk trunk --yes +python3 {baseDir}/scripts/tesla.py trunk frunk --yes + +# Windows +python3 {baseDir}/scripts/tesla.py windows status +python3 {baseDir}/scripts/tesla.py windows status --no-wake +python3 {baseDir}/scripts/tesla.py windows status --json + +# Windows (safety gated) +python3 {baseDir}/scripts/tesla.py windows vent --yes +python3 {baseDir}/scripts/tesla.py windows close --yes + +# Seat heaters +python3 {baseDir}/scripts/tesla.py seats status +python3 {baseDir}/scripts/tesla.py seats status --no-wake +python3 {baseDir}/scripts/tesla.py seats status --json + +# Seat heaters (safety gated) +# seat: driver|passenger|rear-left|rear-center|rear-right|3rd-left|3rd-right (or 0–6) +# level: 0–3 (0=off) +python3 {baseDir}/scripts/tesla.py seats set driver 3 --yes + +# Sentry Mode (status is read-only; on/off safety gated) +python3 {baseDir}/scripts/tesla.py sentry status +python3 {baseDir}/scripts/tesla.py sentry status --no-wake +python3 {baseDir}/scripts/tesla.py sentry on --yes +python3 {baseDir}/scripts/tesla.py sentry off --yes + +# Charge port door +python3 {baseDir}/scripts/tesla.py charge-port status +python3 {baseDir}/scripts/tesla.py charge-port status --no-wake +python3 {baseDir}/scripts/tesla.py charge-port status --json + +# Mileage tracking (odometer) — local SQLite +python3 {baseDir}/scripts/tesla.py mileage init +python3 {baseDir}/scripts/tesla.py mileage record --no-wake --auto-wake-after-hours 24 +python3 {baseDir}/scripts/tesla.py mileage status +python3 {baseDir}/scripts/tesla.py mileage export --format csv +python3 {baseDir}/scripts/tesla.py mileage export --format csv --since-days 7 +python3 {baseDir}/scripts/tesla.py mileage export --format json +python3 {baseDir}/scripts/tesla.py mileage export --format json --since-ts 1738195200 + +# Charge port door open/close (safety gated) +python3 {baseDir}/scripts/tesla.py charge-port open --yes +python3 {baseDir}/scripts/tesla.py charge-port close --yes + +# Fun / attention-grabbing +python3 {baseDir}/scripts/tesla.py honk --yes +python3 {baseDir}/scripts/tesla.py flash --yes +``` + +## Safety defaults + +Some actions require an explicit confirmation flag: +- `unlock`, `charge start|stop|limit|amps`, `trunk`, `windows`, `seats set`, `sentry on|off`, `honk`, `flash`, `charge-port open|close`, and `scheduled-charging set|off` require `--yes` +- `location` is *approximate* by default; add `--yes` for precise coordinates (or `--digits N` to control rounding) + +## Privacy + +- Credentials are cached locally only (`~/.tesla_cache.json`). +- Do not commit tokens, logs, VINs, or location outputs. diff --git a/VERSION.txt b/VERSION.txt new file mode 100644 index 0000000..4a4ae87 --- /dev/null +++ b/VERSION.txt @@ -0,0 +1 @@ +0.1.64 \ No newline at end of file diff --git a/_meta.json b/_meta.json new file mode 100644 index 0000000..e23f066 --- /dev/null +++ b/_meta.json @@ -0,0 +1,6 @@ +{ + "ownerId": "kn73z1bagt8g3nycjr3vg6n37s802xcg", + "slug": "my-tesla", + "version": "0.1.64", + "publishedAt": 1769735093493 +} \ No newline at end of file diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh new file mode 100644 index 0000000..9f9f548 --- /dev/null +++ b/scripts/run_tests.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Prevent Python from writing __pycache__/ bytecode files into the repo. +export PYTHONDONTWRITEBYTECODE=1 + +cd "$(dirname "$0")/.." + +# Clean up any stray bytecode from previous runs (should never be committed). +find . -name '__pycache__' -type d -prune -exec rm -rf {} + +find . -name '*.pyc' -type f -delete + +python3 -m unittest discover -s tests -v + +# Sanity check: tests should not leave bytecode behind. +if find . -name '__pycache__' -o -name '*.pyc' | grep -q .; then + echo "ERROR: Python bytecode (__pycache__ / *.pyc) was created during tests." >&2 + echo "Tip: ensure PYTHONDONTWRITEBYTECODE=1 is honored and you're not running Python with -B disabled." >&2 + exit 1 +fi diff --git a/scripts/tesla.py b/scripts/tesla.py new file mode 100644 index 0000000..f73b2a6 --- /dev/null +++ b/scripts/tesla.py @@ -0,0 +1,2312 @@ +#!/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" + 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 [--celsius|--fahrenheit] + - defrost (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 50–100") + 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 1–48") + 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 (0–6) 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 0–6") + 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 0–6)" + ) + 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 0–3") + level = int(getattr(args, "level")) + if level < 0 or level > 3: + raise ValueError("Invalid level. Expected 0–3") + + 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 (0–6). 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 0–6)") + seats_parser.add_argument("level", nargs="?", help="For 'set': 0–3 (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() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..3d5ed2a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,10 @@ +"""Test package for my-tesla. + +We explicitly disable bytecode writing so running tests doesn't create __pycache__ +folders inside the repo (keeps the private repo clean + avoids accidental noisy +artifacts). +""" + +import sys + +sys.dont_write_bytecode = True diff --git a/tests/test_charge_port_status.py b/tests/test_charge_port_status.py new file mode 100644 index 0000000..66a40cf --- /dev/null +++ b/tests/test_charge_port_status.py @@ -0,0 +1,40 @@ +import unittest +import sys +from pathlib import Path + +# Allow importing scripts/tesla.py as a module +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import tesla # noqa: E402 + + +class ChargePortStatusTests(unittest.TestCase): + def test_charge_port_status_json_shape(self): + vehicle = {"display_name": "Test Car", "state": "online"} + data = { + "charge_state": { + "charge_port_door_open": True, + "charge_port_latch": "Engaged", + "conn_charge_cable": "SAE", + "charging_state": "Charging", + # extra fields should be ignored + "battery_level": 50, + }, + # drive_state/location must not be pulled into the object + "drive_state": {"latitude": 1.23, "longitude": 4.56}, + } + + out = tesla._charge_port_status_json(vehicle, data) + self.assertEqual(out["display_name"], "Test Car") + self.assertEqual(out["state"], "online") + self.assertEqual(out["charge_port_door_open"], True) + self.assertEqual(out["charge_port_latch"], "Engaged") + self.assertEqual(out["conn_charge_cable"], "SAE") + self.assertEqual(out["charging_state"], "Charging") + self.assertNotIn("drive_state", out) + self.assertNotIn("latitude", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_charge_status_json.py b/tests/test_charge_status_json.py new file mode 100644 index 0000000..f5fd7ba --- /dev/null +++ b/tests/test_charge_status_json.py @@ -0,0 +1,44 @@ +import unittest + +from scripts.tesla import _charge_status_json + + +class TestChargeStatusJson(unittest.TestCase): + def test_charge_status_json_includes_useful_fields(self): + charge = { + 'battery_level': 55, + 'battery_range': 123.4, + 'usable_battery_level': 52, + 'charging_state': 'Charging', + 'charge_limit_soc': 80, + 'time_to_full_charge': 1.5, + 'charge_rate': 22, + 'charger_power': 7, + 'charger_voltage': 240, + 'charger_actual_current': 29, + 'scheduled_charging_start_time': 60, + 'scheduled_charging_mode': 'DepartBy', + 'scheduled_charging_pending': True, + 'charge_port_door_open': True, + 'conn_charge_cable': 'SAEJ1772', + } + + out = _charge_status_json(charge) + + # Sanity checks: key presence and values pass through. + self.assertEqual(out.get('battery_level'), 55) + self.assertEqual(out.get('usable_battery_level'), 52) + self.assertEqual(out.get('charger_power'), 7) + self.assertEqual(out.get('charger_voltage'), 240) + self.assertEqual(out.get('charger_actual_current'), 29) + self.assertEqual(out.get('charge_port_door_open'), True) + self.assertEqual(out.get('conn_charge_cable'), 'SAEJ1772') + + def test_charge_status_json_handles_none(self): + out = _charge_status_json(None) + self.assertIsInstance(out, dict) + self.assertIsNone(out.get('battery_level')) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_cmd_climate_defrost.py b/tests/test_cmd_climate_defrost.py new file mode 100644 index 0000000..a3ffa98 --- /dev/null +++ b/tests/test_cmd_climate_defrost.py @@ -0,0 +1,99 @@ +import unittest +from unittest import mock + +# Import the tesla script as a module +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import tesla # noqa: E402 + + +class DummyVehicle: + def __init__(self, display_name="Test Car", state="online"): + self._display_name = display_name + self._state = state + self.command_calls = [] + + def __getitem__(self, k): + if k == "display_name": + return self._display_name + if k == "state": + return self._state + raise KeyError(k) + + def get(self, k, default=None): + if k == "display_name": + return self._display_name + if k == "state": + return self._state + return default + + def command(self, name, **kwargs): + self.command_calls.append((name, kwargs)) + + +class CmdClimateDefrostTests(unittest.TestCase): + def test_climate_defrost_on_calls_endpoint(self): + vehicle = DummyVehicle() + args = mock.Mock() + args.car = None + args.action = "defrost" + args.value = "on" + args.no_wake = False + args.json = False + args.celsius = False + args.fahrenheit = False + + with mock.patch.object(tesla, "get_tesla"), \ + mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \ + mock.patch.object(tesla, "require_email", return_value="test@example.com"), \ + mock.patch.object(tesla, "wake_vehicle") as wake: + tesla.cmd_climate(args) + + wake.assert_called_once() + self.assertEqual(vehicle.command_calls, [("SET_PRECONDITIONING_MAX", {"on": True})]) + + def test_climate_defrost_off_calls_endpoint(self): + vehicle = DummyVehicle() + args = mock.Mock() + args.car = None + args.action = "defrost" + args.value = "off" + args.no_wake = False + args.json = False + args.celsius = False + args.fahrenheit = False + + with mock.patch.object(tesla, "get_tesla"), \ + mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \ + mock.patch.object(tesla, "require_email", return_value="test@example.com"), \ + mock.patch.object(tesla, "wake_vehicle") as wake: + tesla.cmd_climate(args) + + wake.assert_called_once() + self.assertEqual(vehicle.command_calls, [("SET_PRECONDITIONING_MAX", {"on": False})]) + + def test_climate_defrost_requires_value(self): + vehicle = DummyVehicle() + args = mock.Mock() + args.car = None + args.action = "defrost" + args.value = None + args.no_wake = False + args.json = False + args.celsius = False + args.fahrenheit = False + + with mock.patch.object(tesla, "get_tesla"), \ + mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \ + mock.patch.object(tesla, "require_email", return_value="test@example.com"), \ + mock.patch.object(tesla, "wake_vehicle"): + with self.assertRaises(ValueError): + tesla.cmd_climate(args) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cmd_climate_status.py b/tests/test_cmd_climate_status.py new file mode 100644 index 0000000..7600de9 --- /dev/null +++ b/tests/test_cmd_climate_status.py @@ -0,0 +1,99 @@ +import io +import unittest +from contextlib import redirect_stdout +from unittest import mock + +# Import the tesla script as a module +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import tesla # noqa: E402 + + +class DummyVehicle: + def __init__(self, display_name="Test Car", state="online", data=None): + self._display_name = display_name + self._state = state + self._data = data or {} + + def __getitem__(self, k): + if k == "display_name": + return self._display_name + if k == "state": + return self._state + raise KeyError(k) + + def get(self, k, default=None): + if k == "display_name": + return self._display_name + if k == "state": + return self._state + return default + + def get_vehicle_data(self): + return self._data + + +class CmdClimateStatusTests(unittest.TestCase): + def test_climate_status_prints_readable_output(self): + data = { + "climate_state": { + "is_climate_on": False, + "inside_temp": 20, + "outside_temp": 10, + "driver_temp_setting": 21, + "passenger_temp_setting": 21, + } + } + vehicle = DummyVehicle(data=data) + + args = mock.Mock() + args.car = None + args.action = "status" + args.no_wake = True + args.json = False + + with mock.patch.object(tesla, "get_tesla"), \ + mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \ + mock.patch.object(tesla, "require_email", return_value="test@example.com"), \ + mock.patch.object(tesla, "_ensure_online_or_exit"): + buf = io.StringIO() + with redirect_stdout(buf): + tesla.cmd_climate(args) + + out = buf.getvalue() + self.assertIn("🚗 Test Car", out) + self.assertIn("Climate: Off", out) + self.assertIn("Inside:", out) + self.assertIn("Outside:", out) + self.assertIn("Setpoint:", out) + + def test_climate_status_json_is_json_only(self): + data = {"climate_state": {"is_climate_on": True, "inside_temp": 20}} + vehicle = DummyVehicle(data=data) + + args = mock.Mock() + args.car = None + args.action = "status" + args.no_wake = False + args.json = True + + with mock.patch.object(tesla, "get_tesla"), \ + mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \ + mock.patch.object(tesla, "require_email", return_value="test@example.com"), \ + mock.patch.object(tesla, "_ensure_online_or_exit"): + buf = io.StringIO() + with redirect_stdout(buf): + tesla.cmd_climate(args) + + out = buf.getvalue().strip() + # Should be valid JSON (starts with '{') with no extra human text. + self.assertTrue(out.startswith("{")) + self.assertIn('"inside_temp_c"', out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cmd_mileage_record.py b/tests/test_cmd_mileage_record.py new file mode 100644 index 0000000..5598f6f --- /dev/null +++ b/tests/test_cmd_mileage_record.py @@ -0,0 +1,41 @@ +import tempfile +import unittest +from types import SimpleNamespace +from unittest import mock +from pathlib import Path + +import scripts.tesla as tesla + + +class CmdMileageRecordTests(unittest.TestCase): + def test_record_skips_when_no_wake_and_asleep(self): + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "mileage.sqlite" + + class FakeVehicle(dict): + def get_vehicle_data(self): + raise AssertionError("should not be called when asleep + no-wake") + + v = FakeVehicle(display_name="Car", id_s="1", state="asleep") + + fake_tesla = SimpleNamespace(vehicle_list=lambda: [v]) + + args = SimpleNamespace( + action="record", + db=str(db), + no_wake=True, + auto_wake_after_hours=24.0, + json=True, + email=None, + car=None, + ) + + with mock.patch.object(tesla, "get_tesla", return_value=fake_tesla), \ + mock.patch.object(tesla, "require_email", return_value="x@example.com"), \ + mock.patch.object(tesla, "wake_vehicle", return_value=False): + # Should not raise + tesla.cmd_mileage(args) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cmd_seats.py b/tests/test_cmd_seats.py new file mode 100644 index 0000000..7505032 --- /dev/null +++ b/tests/test_cmd_seats.py @@ -0,0 +1,106 @@ +import io +import unittest +from contextlib import redirect_stdout +from unittest import mock + +# Import the tesla script as a module +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import tesla # noqa: E402 + + +class DummyVehicle: + def __init__(self, display_name="Test Car", state="online", vehicle_data=None): + self._display_name = display_name + self._state = state + self._vehicle_data = vehicle_data or {} + self.command_calls = [] + + def __getitem__(self, k): + if k == "display_name": + return self._display_name + if k == "state": + return self._state + raise KeyError(k) + + def get(self, k, default=None): + if k == "display_name": + return self._display_name + if k == "state": + return self._state + return default + + def get_vehicle_data(self): + return self._vehicle_data + + def command(self, name, **kwargs): + self.command_calls.append((name, kwargs)) + + +class SeatsTests(unittest.TestCase): + def test_parse_seat_heater_names(self): + self.assertEqual(tesla._parse_seat_heater("driver"), 0) + self.assertEqual(tesla._parse_seat_heater("passenger"), 1) + self.assertEqual(tesla._parse_seat_heater("rear-left"), 2) + self.assertEqual(tesla._parse_seat_heater("rear-center"), 3) + self.assertEqual(tesla._parse_seat_heater("rear-right"), 4) + self.assertEqual(tesla._parse_seat_heater("3rd-left"), 5) + self.assertEqual(tesla._parse_seat_heater("3rd-right"), 6) + self.assertEqual(tesla._parse_seat_heater("0"), 0) + + with self.assertRaises(ValueError): + tesla._parse_seat_heater("nope") + + def test_seats_status_json_is_json_only(self): + vehicle = DummyVehicle(vehicle_data={ + "climate_state": { + "seat_heater_left": 3, + "seat_heater_right": 1, + } + }) + + args = mock.Mock() + args.car = None + args.action = "status" + args.no_wake = True + args.json = True + + with mock.patch.object(tesla, "get_tesla"), \ + mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \ + mock.patch.object(tesla, "require_email", return_value="test@example.com"), \ + mock.patch.object(tesla, "_ensure_online_or_exit"): + buf = io.StringIO() + with redirect_stdout(buf): + tesla.cmd_seats(args) + + out = buf.getvalue().strip() + self.assertTrue(out.startswith("{")) + self.assertIn('"seat_heater_left"', out) + self.assertIn('"seat_heater_right"', out) + + def test_seats_set_calls_endpoint(self): + vehicle = DummyVehicle() + + args = mock.Mock() + args.car = None + args.action = "set" + args.seat = "driver" + args.level = "2" + args.yes = True + + with mock.patch.object(tesla, "get_tesla"), \ + mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \ + mock.patch.object(tesla, "require_email", return_value="test@example.com"), \ + mock.patch.object(tesla, "wake_vehicle") as wake: + tesla.cmd_seats(args) + + wake.assert_called_once() + self.assertEqual(vehicle.command_calls, [("REMOTE_SEAT_HEATER_REQUEST", {"heater": 0, "level": 2})]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cmd_status_summary.py b/tests/test_cmd_status_summary.py new file mode 100644 index 0000000..2011c42 --- /dev/null +++ b/tests/test_cmd_status_summary.py @@ -0,0 +1,73 @@ +import io +import unittest +from contextlib import redirect_stdout +from unittest import mock + +# Import the tesla script as a module +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import tesla # noqa: E402 + + +class DummyVehicle: + def __init__(self, display_name="Test Car", state="online", data=None): + self._display_name = display_name + self._state = state + self._data = data or {} + + def __getitem__(self, k): + if k == "display_name": + return self._display_name + if k == "state": + return self._state + raise KeyError(k) + + def get(self, k, default=None): + if k == "display_name": + return self._display_name + if k == "state": + return self._state + return default + + def get_vehicle_data(self): + return self._data + + +class CmdStatusSummaryTests(unittest.TestCase): + def test_status_summary_prints_summary_and_details(self): + data = { + "charge_state": {"battery_level": 55, "battery_range": 123.4, "charging_state": "Stopped"}, + "climate_state": {"inside_temp": 20, "is_climate_on": False}, + "vehicle_state": {"locked": True}, + } + vehicle = DummyVehicle(data=data) + + args = mock.Mock() + args.car = None + args.no_wake = False + args.summary = True + args.json = False + + # Patch networky bits. + with mock.patch.object(tesla, "get_tesla"), \ + mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \ + mock.patch.object(tesla, "require_email", return_value="test@example.com"), \ + mock.patch.object(tesla, "_ensure_online_or_exit"): + buf = io.StringIO() + with redirect_stdout(buf): + tesla.cmd_status(args) + + out = buf.getvalue() + # Summary line + self.assertIn("🚗 Test Car", out) + self.assertIn("🔋 55%", out) + # Detailed section should still appear + self.assertIn("Battery: 55% (123 mi)", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cmd_windows.py b/tests/test_cmd_windows.py new file mode 100644 index 0000000..6d50a8a --- /dev/null +++ b/tests/test_cmd_windows.py @@ -0,0 +1,126 @@ +import unittest +from unittest import mock + +# Import the tesla script as a module +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import tesla # noqa: E402 + + +class DummyVehicle: + def __init__(self, display_name="Test Car", state="online", vehicle_data=None): + self._display_name = display_name + self._state = state + self._vehicle_data = vehicle_data or {} + self.command_calls = [] + + def __getitem__(self, k): + if k == "display_name": + return self._display_name + if k == "state": + return self._state + raise KeyError(k) + + def get(self, k, default=None): + if k == "display_name": + return self._display_name + if k == "state": + return self._state + return default + + def get_vehicle_data(self): + return self._vehicle_data + + def command(self, name, **kwargs): + self.command_calls.append((name, kwargs)) + + +class CmdWindowsTests(unittest.TestCase): + def test_windows_status_outputs_open_states(self): + vehicle = DummyVehicle(vehicle_data={ + 'vehicle_state': { + 'fd_window': 0, + 'fp_window': 1, + 'rd_window': 0, + 'rp_window': None, + } + }) + args = mock.Mock() + args.car = None + args.action = "status" + args.yes = False + args.no_wake = True + args.json = True + + with mock.patch.object(tesla, "get_tesla"), \ + mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \ + mock.patch.object(tesla, "require_email", return_value="test@example.com"), \ + mock.patch.object(tesla, "wake_vehicle", return_value=True) as wake, \ + mock.patch("builtins.print") as p: + tesla.cmd_windows(args) + + # Should not require --yes for status + wake.assert_called_once_with(vehicle, allow_wake=False) + printed = "\n".join(str(call.args[0]) for call in p.call_args_list) + self.assertIn('"front_driver": "Closed"', printed) + self.assertIn('"front_passenger": "Open"', printed) + + def test_windows_vent_calls_endpoint(self): + vehicle = DummyVehicle() + args = mock.Mock() + args.car = None + args.action = "vent" + args.yes = True + args.no_wake = False + args.json = False + + with mock.patch.object(tesla, "get_tesla"), \ + mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \ + mock.patch.object(tesla, "require_email", return_value="test@example.com"), \ + mock.patch.object(tesla, "wake_vehicle") as wake: + tesla.cmd_windows(args) + + wake.assert_called_once() + self.assertEqual(vehicle.command_calls, [("WINDOW_CONTROL", {"command": "vent", "lat": 0, "lon": 0})]) + + def test_windows_close_calls_endpoint(self): + vehicle = DummyVehicle() + args = mock.Mock() + args.car = None + args.action = "close" + args.yes = True + args.no_wake = False + args.json = False + + with mock.patch.object(tesla, "get_tesla"), \ + mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \ + mock.patch.object(tesla, "require_email", return_value="test@example.com"), \ + mock.patch.object(tesla, "wake_vehicle") as wake: + tesla.cmd_windows(args) + + wake.assert_called_once() + self.assertEqual(vehicle.command_calls, [("WINDOW_CONTROL", {"command": "close", "lat": 0, "lon": 0})]) + + def test_windows_unknown_action_raises(self): + vehicle = DummyVehicle() + args = mock.Mock() + args.car = None + args.action = "nope" + args.yes = True + args.no_wake = False + args.json = False + + with mock.patch.object(tesla, "get_tesla"), \ + mock.patch.object(tesla, "get_vehicle", return_value=vehicle), \ + mock.patch.object(tesla, "require_email", return_value="test@example.com"), \ + mock.patch.object(tesla, "wake_vehicle"): + with self.assertRaises(ValueError): + tesla.cmd_windows(args) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_defaults_permissions.py b/tests/test_defaults_permissions.py new file mode 100644 index 0000000..9966572 --- /dev/null +++ b/tests/test_defaults_permissions.py @@ -0,0 +1,32 @@ +import os +import stat +import tempfile +import unittest +from pathlib import Path + +import scripts.tesla as tesla + + +class DefaultsPermissionsTests(unittest.TestCase): + def test_save_defaults_sets_0600_permissions_best_effort(self): + # On Windows this may not apply; but this repo targets macOS/Linux. + if os.name != "posix": + self.skipTest("POSIX-only permissions") + + with tempfile.TemporaryDirectory() as td: + p = Path(td) / "defaults.json" + + # Patch the module-level DEFAULTS_FILE for this test. + old = tesla.DEFAULTS_FILE + try: + tesla.DEFAULTS_FILE = p + tesla.save_defaults({"default_car": "Test"}) + + mode = stat.S_IMODE(p.stat().st_mode) + self.assertEqual(mode, 0o600) + finally: + tesla.DEFAULTS_FILE = old + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_formatting.py b/tests/test_formatting.py new file mode 100644 index 0000000..eb187c6 --- /dev/null +++ b/tests/test_formatting.py @@ -0,0 +1,119 @@ +import unittest +import sys +from pathlib import Path + +# Allow importing scripts/tesla.py as a module +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import tesla # noqa: E402 + + +class FormattingTests(unittest.TestCase): + def test_c_to_f(self): + self.assertAlmostEqual(tesla._c_to_f(0), 32) + self.assertAlmostEqual(tesla._c_to_f(20), 68) + + def test_fmt_temp_pair(self): + self.assertIsNone(tesla._fmt_temp_pair(None)) + self.assertEqual(tesla._fmt_temp_pair(20), "20°C (68°F)") + + def test_tire_pressure_formatting(self): + # 2.90 bar is ~42 psi (common on Model 3) + psi = tesla._bar_to_psi(2.90) + self.assertIsNotNone(psi) + self.assertTrue(40 <= psi <= 45) + self.assertEqual(tesla._fmt_tire_pressure(2.9), "2.90 bar (42 psi)") + self.assertIsNone(tesla._fmt_tire_pressure(None)) + + def test_short_status_contains_expected_bits(self): + vehicle = {"display_name": "Test Car"} + data = { + "charge_state": { + "battery_level": 55, + "battery_range": 123.4, + "charging_state": "Stopped", + }, + "climate_state": {"inside_temp": 20, "is_climate_on": False}, + "vehicle_state": {"locked": True}, + } + + out = tesla._short_status(vehicle, data) + self.assertIn("🚗 Test Car", out) + self.assertIn("Locked", out) + self.assertIn("55%", out) + self.assertIn("123 mi", out) + self.assertIn("⚡ Stopped", out) + self.assertIn("68°F", out) + self.assertIn("Off", out) + + def test_fmt_minutes_hhmm(self): + self.assertEqual(tesla._fmt_minutes_hhmm(0), "00:00") + self.assertEqual(tesla._fmt_minutes_hhmm(60), "01:00") + self.assertEqual(tesla._fmt_minutes_hhmm(23 * 60 + 59), "23:59") + self.assertIsNone(tesla._fmt_minutes_hhmm(-1)) + self.assertIsNone(tesla._fmt_minutes_hhmm("nope")) + + def test_parse_hhmm(self): + self.assertEqual(tesla._parse_hhmm("00:00"), 0) + self.assertEqual(tesla._parse_hhmm("01:30"), 90) + self.assertEqual(tesla._parse_hhmm("23:59"), 23 * 60 + 59) + with self.assertRaises(ValueError): + tesla._parse_hhmm("24:00") + with self.assertRaises(ValueError): + tesla._parse_hhmm("12:60") + with self.assertRaises(ValueError): + tesla._parse_hhmm("1230") + + def test_report_is_one_screen(self): + vehicle = {"display_name": "Test Car", "state": "online"} + data = { + "charge_state": { + "battery_level": 80, + "usable_battery_level": 78, + "battery_range": 250.2, + "charging_state": "Charging", + "charge_limit_soc": 90, + "time_to_full_charge": 1.5, + "charge_rate": 30, + "scheduled_charging_start_time": 120, + "scheduled_charging_pending": True, + }, + "climate_state": {"inside_temp": 21, "outside_temp": 10, "is_climate_on": True}, + "vehicle_state": { + "locked": False, + "sentry_mode": True, + "odometer": 12345.6, + "tpms_pressure_fl": 2.9, + "tpms_pressure_fr": 2.9, + "tpms_pressure_rl": 2.8, + "tpms_pressure_rr": 2.8, + }, + } + + out = tesla._report(vehicle, data) + # Basic structure + self.assertTrue(out.startswith("🚗 Test Car")) + self.assertIn("State: online", out) + self.assertIn("Locked: No", out) + self.assertIn("Sentry: On", out) + self.assertIn("Battery: 80% (250 mi)", out) + self.assertIn("Usable battery: 78%", out) + self.assertIn("Charging: Charging", out) + self.assertIn("Scheduled charging:", out) + self.assertIn("02:00", out) + self.assertIn("Inside:", out) + self.assertIn("Outside:", out) + self.assertIn("Tires (TPMS):", out) + self.assertIn("FL 2.90 bar (42 psi)", out) + self.assertIn("RL 2.80 bar (41 psi)", out) + self.assertIn("Odometer: 12346 mi", out) + + def test_round_coord(self): + self.assertEqual(tesla._round_coord(37.123456, 2), 37.12) + self.assertEqual(tesla._round_coord("-122.123456", 2), -122.12) + self.assertIsNone(tesla._round_coord("nope", 2)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_location_rounding.py b/tests/test_location_rounding.py new file mode 100644 index 0000000..aad120f --- /dev/null +++ b/tests/test_location_rounding.py @@ -0,0 +1,27 @@ +import unittest + +from scripts.tesla import _round_coord + + +class TestLocationRounding(unittest.TestCase): + def test_round_coord_basic(self): + self.assertEqual(_round_coord(37.123456, 2), 37.12) + self.assertEqual(_round_coord(-122.987654, 2), -122.99) + + def test_round_coord_digits_range(self): + # Reject overly-precise / invalid digit counts. + self.assertIsNone(_round_coord(1.2345, -1)) + self.assertIsNone(_round_coord(1.2345, 7)) + + # Allow the full supported range. + self.assertEqual(_round_coord(1.2345678, 0), 1.0) + self.assertEqual(_round_coord(1.2345678, 6), 1.234568) + + def test_round_coord_invalid_inputs(self): + self.assertIsNone(_round_coord(None, 2)) + self.assertIsNone(_round_coord("", 2)) + self.assertIsNone(_round_coord(1.23, "nope")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mileage_db.py b/tests/test_mileage_db.py new file mode 100644 index 0000000..9318735 --- /dev/null +++ b/tests/test_mileage_db.py @@ -0,0 +1,46 @@ +import os +import tempfile +import unittest +from pathlib import Path + +import scripts.tesla as tesla + + +class MileageDbTests(unittest.TestCase): + def test_init_and_insert(self): + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "mileage.sqlite" + tesla.mileage_init_db(db) + + conn = tesla._db_connect(db) + try: + tesla.mileage_insert_point( + conn, + ts_utc=1700000000, + vehicle_id="123", + vehicle_name="Car", + odometer_mi=100.0, + state="online", + source="test", + note=None, + ) + conn.commit() + + last = tesla.mileage_last_success_ts(conn, "123") + self.assertEqual(last, 1700000000) + finally: + conn.close() + + def test_resolve_db_path_env(self): + with tempfile.TemporaryDirectory() as td: + p = str(Path(td) / "x.sqlite") + os.environ["MY_TESLA_MILEAGE_DB"] = p + try: + out = tesla.resolve_mileage_db_path(None) + self.assertEqual(str(out), p) + finally: + os.environ.pop("MY_TESLA_MILEAGE_DB", None) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mileage_export_filter.py b/tests/test_mileage_export_filter.py new file mode 100644 index 0000000..f253639 --- /dev/null +++ b/tests/test_mileage_export_filter.py @@ -0,0 +1,59 @@ +import sqlite3 +import unittest +from unittest.mock import patch + +from scripts.tesla import mileage_fetch_points, resolve_since_ts + + +class TestMileageExportFilter(unittest.TestCase): + def test_resolve_since_ts_prefers_explicit_ts(self): + self.assertEqual(resolve_since_ts(since_ts=123, since_days=7), 123) + + def test_resolve_since_ts_since_days(self): + with patch("scripts.tesla.time.time", return_value=1000): + # 1 day = 86400 seconds + self.assertEqual(resolve_since_ts(since_days=1), 1000 - 86400) + + def test_mileage_fetch_points_filters(self): + conn = sqlite3.connect(":memory:") + try: + conn.execute( + """ + CREATE TABLE 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 + ); + """ + ) + + # Insert three points + conn.execute( + "INSERT INTO mileage_points(ts_utc, vehicle_id, vehicle_name, odometer_mi, state, source, note) VALUES (?,?,?,?,?,?,?)", + (10, "v1", "Car", 1.0, "online", "test", None), + ) + conn.execute( + "INSERT INTO mileage_points(ts_utc, vehicle_id, vehicle_name, odometer_mi, state, source, note) VALUES (?,?,?,?,?,?,?)", + (20, "v1", "Car", 2.0, "online", "test", None), + ) + conn.execute( + "INSERT INTO mileage_points(ts_utc, vehicle_id, vehicle_name, odometer_mi, state, source, note) VALUES (?,?,?,?,?,?,?)", + (30, "v1", "Car", 3.0, "online", "test", None), + ) + + all_rows = mileage_fetch_points(conn) + self.assertEqual([r[0] for r in all_rows], [10, 20, 30]) + + filtered = mileage_fetch_points(conn, since_ts=21) + self.assertEqual([r[0] for r in filtered], [30]) + finally: + conn.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_no_wake.py b/tests/test_no_wake.py new file mode 100644 index 0000000..f076e46 --- /dev/null +++ b/tests/test_no_wake.py @@ -0,0 +1,36 @@ +import unittest +import sys +from pathlib import Path +from unittest import mock + +# Allow importing scripts/tesla.py as a module +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import tesla # noqa: E402 + + +class DummyVehicle(dict): + def sync_wake_up(self): + raise AssertionError("sync_wake_up should not be called in this test") + + +class NoWakeTests(unittest.TestCase): + def test_wake_vehicle_allow_wake_false_offline_returns_false(self): + v = DummyVehicle(state="asleep", display_name="Test Car") + self.assertFalse(tesla.wake_vehicle(v, allow_wake=False)) + + def test_wake_vehicle_online_returns_true(self): + v = DummyVehicle(state="online", display_name="Test Car") + self.assertTrue(tesla.wake_vehicle(v, allow_wake=False)) + + def test_ensure_online_or_exit_exits_3_when_no_wake(self): + v = DummyVehicle(state="asleep", display_name="Test Car") + with mock.patch.object(tesla, "wake_vehicle", return_value=False): + with self.assertRaises(SystemExit) as ctx: + tesla._ensure_online_or_exit(v, allow_wake=False) + self.assertEqual(ctx.exception.code, 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_openings_format.py b/tests/test_openings_format.py new file mode 100644 index 0000000..fa94111 --- /dev/null +++ b/tests/test_openings_format.py @@ -0,0 +1,25 @@ +import unittest + +from scripts.tesla import _fmt_open + + +class TestOpeningsFormat(unittest.TestCase): + def test_fmt_open_none(self): + self.assertIsNone(_fmt_open(None)) + + def test_fmt_open_bool(self): + self.assertEqual(_fmt_open(True), "Open") + self.assertEqual(_fmt_open(False), "Closed") + + def test_fmt_open_intish(self): + self.assertEqual(_fmt_open(1), "Open") + self.assertEqual(_fmt_open(0), "Closed") + self.assertEqual(_fmt_open("1"), "Open") + self.assertEqual(_fmt_open("0"), "Closed") + + def test_fmt_open_invalid(self): + self.assertIsNone(_fmt_open("maybe")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_report_charging_power.py b/tests/test_report_charging_power.py new file mode 100644 index 0000000..501400c --- /dev/null +++ b/tests/test_report_charging_power.py @@ -0,0 +1,37 @@ +import unittest +import sys +from pathlib import Path + +# Allow importing scripts/tesla.py as a module +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import tesla # noqa: E402 + + +class ReportChargingPowerTests(unittest.TestCase): + def test_report_includes_charging_power_details_when_present(self): + vehicle = {"display_name": "Test Car", "state": "online"} + data = { + "charge_state": { + "battery_level": 50, + "battery_range": 123.4, + "charging_state": "Charging", + "charger_power": 7, + "charger_voltage": 240, + "charger_actual_current": 30, + }, + "climate_state": {}, + "vehicle_state": {}, + } + + out = tesla._report(vehicle, data) + self.assertIn("Charging: Charging", out) + self.assertIn("Charging power:", out) + self.assertIn("7 kW", out) + self.assertIn("240V", out) + self.assertIn("30A", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_report_json.py b/tests/test_report_json.py new file mode 100644 index 0000000..c2c53e0 --- /dev/null +++ b/tests/test_report_json.py @@ -0,0 +1,65 @@ +import unittest +import sys +from pathlib import Path + +# Allow importing scripts/tesla.py as a module +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import tesla # noqa: E402 + + +class ReportJsonTests(unittest.TestCase): + def test_report_json_is_sanitized(self): + vehicle = {"display_name": "Test Car", "state": "online"} + data = { + "charge_state": { + "battery_level": 80, + "battery_range": 250.2, + "charging_state": "Charging", + "charge_limit_soc": 90, + "time_to_full_charge": 1.5, + "charge_rate": 30, + "charge_port_door_open": True, + "conn_charge_cable": "SAE", + "scheduled_charging_mode": "Start", + "scheduled_charging_pending": True, + "scheduled_charging_start_time": 60, # 01:00 + }, + "climate_state": { + "inside_temp": 21, + "outside_temp": 10, + "is_climate_on": True, + "seat_heater_left": 3, + "seat_heater_right": 1, + }, + "vehicle_state": {"locked": False, "sentry_mode": True, "odometer": 12345.6}, + # This is intentionally present in raw vehicle_data, but should not show up in report JSON. + "drive_state": {"latitude": 37.1234, "longitude": -122.5678}, + } + + out = tesla._report_json(vehicle, data) + self.assertIn("vehicle", out) + self.assertEqual(out["vehicle"]["display_name"], "Test Car") + + # Must not include raw drive_state/location + self.assertNotIn("drive_state", out) + self.assertNotIn("location", out) + self.assertNotIn("latitude", str(out)) + self.assertNotIn("longitude", str(out)) + + # Expected useful bits + self.assertEqual(out["battery"]["level_percent"], 80) + self.assertEqual(out["charging"]["charging_state"], "Charging") + self.assertEqual(out["charging"]["charge_port_door_open"], True) + self.assertEqual(out["charging"]["conn_charge_cable"], "SAE") + self.assertEqual(out["scheduled_charging"]["start_time_hhmm"], "01:00") + self.assertEqual(out["security"]["locked"], False) + + # Seat heaters should be present when vehicle reports them + self.assertIn("seat_heaters", out["climate"]) + self.assertEqual(out["climate"]["seat_heaters"]["seat_heater_left"], 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_report_openings.py b/tests/test_report_openings.py new file mode 100644 index 0000000..a6587c8 --- /dev/null +++ b/tests/test_report_openings.py @@ -0,0 +1,46 @@ +import unittest +import sys +from pathlib import Path + +# Allow importing scripts/tesla.py as a module +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import tesla # noqa: E402 + + +class ReportOpeningsTests(unittest.TestCase): + def test_report_includes_openings_when_fields_present(self): + vehicle = {"display_name": "Test Car", "state": "online"} + data = { + "charge_state": {"battery_level": 50, "battery_range": 123.4}, + "climate_state": {"inside_temp": 20, "outside_temp": 10, "is_climate_on": False}, + "vehicle_state": { + "locked": True, + "sentry_mode": False, + "df": 1, # open + "rt": 0, # closed + }, + } + + out = tesla._report(vehicle, data) + self.assertIn("Openings:", out) + self.assertIn("Driver front door", out) + + def test_report_json_openings_omits_when_unknown(self): + vehicle = {"display_name": "Test Car", "state": "online"} + data = {"vehicle_state": {"locked": True}} + out = tesla._report_json(vehicle, data) + self.assertNotIn("openings", out) + + def test_report_json_openings_all_closed(self): + vehicle = {"display_name": "Test Car", "state": "online"} + data = {"vehicle_state": {"df": 0, "rt": 0}} + out = tesla._report_json(vehicle, data) + self.assertIn("openings", out) + self.assertEqual(out["openings"]["open"], []) + self.assertEqual(out["openings"]["all_closed"], True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_report_privacy.py b/tests/test_report_privacy.py new file mode 100644 index 0000000..5fc0eb3 --- /dev/null +++ b/tests/test_report_privacy.py @@ -0,0 +1,34 @@ +import unittest +import sys +from pathlib import Path + +# Allow importing scripts/tesla.py as a module +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import tesla # noqa: E402 + + +class ReportPrivacyTests(unittest.TestCase): + def test_report_string_does_not_leak_location_fields(self): + vehicle = {"display_name": "Test Car", "state": "online"} + data = { + "charge_state": {"battery_level": 50, "battery_range": 123.4}, + "climate_state": {"inside_temp": 20, "outside_temp": 10, "is_climate_on": False}, + "vehicle_state": {"locked": True, "sentry_mode": False}, + # Raw vehicle_data can include precise coords; report output must not echo them. + "drive_state": {"latitude": 37.123456, "longitude": -122.987654}, + } + + out = tesla._report(vehicle, data) + + # No location fields or raw coordinate strings should appear. + self.assertNotIn("drive_state", out) + self.assertNotIn("latitude", out) + self.assertNotIn("longitude", out) + self.assertNotIn("37.123", out) + self.assertNotIn("-122.987", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_report_seat_heaters.py b/tests/test_report_seat_heaters.py new file mode 100644 index 0000000..6ea98bd --- /dev/null +++ b/tests/test_report_seat_heaters.py @@ -0,0 +1,29 @@ +import unittest +import sys +from pathlib import Path + +# Allow importing scripts/tesla.py as a module +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import tesla # noqa: E402 + + +class ReportSeatHeatersTests(unittest.TestCase): + def test_report_includes_seat_heaters_when_available(self): + vehicle = {"display_name": "Test Car", "state": "online"} + data = { + "charge_state": {"battery_level": 50, "battery_range": 123.4}, + "climate_state": {"is_climate_on": True, "seat_heater_left": 3, "seat_heater_right": 1}, + "vehicle_state": {}, + } + + out = tesla._report(vehicle, data) + self.assertIn("Seat heaters:", out) + # Compact labels (D=driver, P=passenger) + self.assertIn("D 3", out) + self.assertIn("P 1", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_scheduled_departure_status.py b/tests/test_scheduled_departure_status.py new file mode 100644 index 0000000..7c259e9 --- /dev/null +++ b/tests/test_scheduled_departure_status.py @@ -0,0 +1,30 @@ +import unittest + +from scripts.tesla import _scheduled_departure_status_json + + +class TestScheduledDepartureStatus(unittest.TestCase): + def test_scheduled_departure_status_json(self): + charge = { + 'scheduled_departure_enabled': True, + 'scheduled_departure_time': 7 * 60 + 30, + 'preconditioning_enabled': False, + 'off_peak_charging_enabled': True, + } + out = _scheduled_departure_status_json(charge) + self.assertEqual(out['scheduled_departure_enabled'], True) + self.assertEqual(out['scheduled_departure_time'], 450) + self.assertEqual(out['scheduled_departure_time_hhmm'], '07:30') + self.assertEqual(out['preconditioning_enabled'], False) + self.assertEqual(out['off_peak_charging_enabled'], True) + + def test_scheduled_departure_status_json_missing(self): + out = _scheduled_departure_status_json({}) + # Ensure keys exist with None values for stable JSON schemas. + self.assertIn('scheduled_departure_enabled', out) + self.assertIn('scheduled_departure_time', out) + self.assertIn('scheduled_departure_time_hhmm', out) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_summary_json.py b/tests/test_summary_json.py new file mode 100644 index 0000000..186e1cb --- /dev/null +++ b/tests/test_summary_json.py @@ -0,0 +1,53 @@ +import unittest +import sys +from pathlib import Path + +# Allow importing scripts/tesla.py as a module +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import tesla # noqa: E402 + + +class SummaryJsonTests(unittest.TestCase): + def test_summary_json_is_sanitized(self): + vehicle = {"display_name": "Test Car", "state": "online"} + data = { + "charge_state": { + "battery_level": 63, + "battery_range": 199.9, + "usable_battery_level": 61, + "charging_state": "Disconnected", + }, + "climate_state": { + "inside_temp": 20, + "is_climate_on": False, + }, + "vehicle_state": {"locked": True}, + # Present in raw vehicle_data, must not show up in summary JSON. + "drive_state": {"latitude": 37.1234, "longitude": -122.5678}, + } + + out = tesla._summary_json(vehicle, data) + + self.assertIn("vehicle", out) + self.assertEqual(out["vehicle"]["display_name"], "Test Car") + + # Must not include raw drive_state/location + self.assertNotIn("drive_state", out) + self.assertNotIn("location", out) + self.assertNotIn("latitude", str(out)) + self.assertNotIn("longitude", str(out)) + + # Expected useful bits + self.assertEqual(out["battery"]["level_percent"], 63) + self.assertEqual(out["battery"]["range_mi"], 199.9) + self.assertEqual(out["battery"]["usable_level_percent"], 61) + self.assertEqual(out["charging"]["charging_state"], "Disconnected") + self.assertEqual(out["security"]["locked"], True) + self.assertIn("summary", out) + self.assertIsInstance(out["summary"], str) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_time_helpers.py b/tests/test_time_helpers.py new file mode 100644 index 0000000..ee7368e --- /dev/null +++ b/tests/test_time_helpers.py @@ -0,0 +1,51 @@ +import unittest + +from scripts.tesla import _fmt_minutes_hhmm, _parse_hhmm + + +class TestTimeHelpers(unittest.TestCase): + def test_fmt_minutes_hhmm_basic(self): + self.assertEqual(_fmt_minutes_hhmm(0), "00:00") + self.assertEqual(_fmt_minutes_hhmm(1), "00:01") + self.assertEqual(_fmt_minutes_hhmm(60), "01:00") + self.assertEqual(_fmt_minutes_hhmm(23 * 60 + 59), "23:59") + + def test_fmt_minutes_hhmm_wraps_24h(self): + # Tesla sometimes uses minutes-from-midnight; be defensive. + self.assertEqual(_fmt_minutes_hhmm(24 * 60), "00:00") + self.assertEqual(_fmt_minutes_hhmm(25 * 60 + 5), "01:05") + + def test_fmt_minutes_hhmm_invalid(self): + self.assertIsNone(_fmt_minutes_hhmm(-1)) + self.assertIsNone(_fmt_minutes_hhmm("")) + self.assertIsNone(_fmt_minutes_hhmm(None)) + + def test_parse_hhmm(self): + self.assertEqual(_parse_hhmm("00:00"), 0) + self.assertEqual(_parse_hhmm("01:05"), 65) + self.assertEqual(_parse_hhmm("23:59"), 23 * 60 + 59) + + def test_parse_hhmm_strips(self): + self.assertEqual(_parse_hhmm(" 07:30 "), 7 * 60 + 30) + + def test_parse_hhmm_invalid(self): + for bad in [ + None, + "", + " ", + "7:30", # must be zero-padded? actually current parser allows; but still has ':' so ok + ]: + if bad == "7:30": + # Current implementation allows single-digit hours; keep behavior. + self.assertEqual(_parse_hhmm(bad), 7 * 60 + 30) + else: + with self.assertRaises(ValueError): + _parse_hhmm(bad) + + for bad in ["24:00", "00:60", "-1:00", "ab:cd", "123"]: + with self.assertRaises(Exception): + _parse_hhmm(bad) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_vehicle_select.py b/tests/test_vehicle_select.py new file mode 100644 index 0000000..2f8c891 --- /dev/null +++ b/tests/test_vehicle_select.py @@ -0,0 +1,71 @@ +import unittest +import sys +from pathlib import Path + +# Allow importing scripts/tesla.py as a module +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import tesla # noqa: E402 + + +class VehicleSelectTests(unittest.TestCase): + def setUp(self): + self.vehicles = [ + {"display_name": "My Model 3"}, + {"display_name": "Road Trip"}, + {"display_name": "Model Y"}, + ] + + def test_select_vehicle_default_first(self): + v = tesla._select_vehicle(self.vehicles, None) + self.assertEqual(v["display_name"], "My Model 3") + + def test_select_vehicle_exact_case_insensitive(self): + v = tesla._select_vehicle(self.vehicles, "model y") + self.assertEqual(v["display_name"], "Model Y") + + def test_select_vehicle_partial_substring(self): + v = tesla._select_vehicle(self.vehicles, "road") + self.assertEqual(v["display_name"], "Road Trip") + + def test_select_vehicle_index_1_based(self): + v = tesla._select_vehicle(self.vehicles, "2") + self.assertEqual(v["display_name"], "Road Trip") + + def test_select_vehicle_ambiguous_returns_none(self): + vehicles = [ + {"display_name": "Alpha"}, + {"display_name": "Alphanumeric"}, + ] + self.assertIsNone(tesla._select_vehicle(vehicles, "alp")) + + def test_get_vehicle_ambiguous_error_is_helpful(self): + class FakeTesla: + def vehicle_list(self_inner): + return [ + {"display_name": "Alpha"}, + {"display_name": "Alphanumeric"}, + {"display_name": "Beta"}, + ] + + # Force ambiguity and assert we mention that it's ambiguous + show index hint. + stderr = sys.stderr + try: + from io import StringIO + + buf = StringIO() + sys.stderr = buf + with self.assertRaises(SystemExit) as ctx: + tesla.get_vehicle(FakeTesla(), name="alp") + self.assertEqual(ctx.exception.code, 1) + out = buf.getvalue() + self.assertIn("ambiguous", out.lower()) + self.assertIn("--car ", out) + self.assertIn("Matches:", out) + finally: + sys.stderr = stderr + + +if __name__ == "__main__": + unittest.main()