Initial commit with translated description

This commit is contained in:
2026-03-29 13:13:35 +08:00
commit 1d2061613e
32 changed files with 4399 additions and 0 deletions

223
CHANGELOG.md Normal file
View File

@@ -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` (06) 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 wont 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 hasnt 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 scripts 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 <N>` 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.

238
README.md Normal file
View File

@@ -0,0 +1,238 @@
# My Tesla
Tesla control skill for Clawdbot.
Author: Parth Maniar — [@officialpm](https://github.com/officialpm)
## Whats 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 # 50100
python3 scripts/tesla.py charge amps 16 --yes # 148 (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 06)
# level: 03 (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 vehicles **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 hasnt 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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.mytesla.mileage</string>
<key>StartInterval</key><integer>3600</integer>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/ABS/PATH/TO/scripts/tesla.py</string>
<string>mileage</string>
<string>record</string>
<string>--no-wake</string>
<string>--auto-wake-after-hours</string><string>24</string>
<string>--json</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>TESLA_EMAIL</key><string>you@email.com</string>
</dict>
<key>StandardOutPath</key><string>~/.my_tesla/mileage.log</string>
<key>StandardErrorPath</key><string>~/.my_tesla/mileage.err.log</string>
</dict>
</plist>
```
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.

201
SKILL.md Normal file
View File

@@ -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 # 50100
python3 {baseDir}/scripts/tesla.py charge amps 16 --yes # 148 (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 06)
# level: 03 (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.

1
VERSION.txt Normal file
View File

@@ -0,0 +1 @@
0.1.64

6
_meta.json Normal file
View File

@@ -0,0 +1,6 @@
{
"ownerId": "kn73z1bagt8g3nycjr3vg6n37s802xcg",
"slug": "my-tesla",
"version": "0.1.64",
"publishedAt": 1769735093493
}

20
scripts/run_tests.sh Normal file
View File

@@ -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

2312
scripts/tesla.py Normal file

File diff suppressed because it is too large Load Diff

10
tests/__init__.py Normal file
View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

106
tests/test_cmd_seats.py Normal file
View File

@@ -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()

View File

@@ -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()

126
tests/test_cmd_windows.py Normal file
View File

@@ -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()

View File

@@ -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()

119
tests/test_formatting.py Normal file
View File

@@ -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()

View File

@@ -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()

46
tests/test_mileage_db.py Normal file
View File

@@ -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()

View File

@@ -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()

36
tests/test_no_wake.py Normal file
View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

65
tests/test_report_json.py Normal file
View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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 <N>", out)
self.assertIn("Matches:", out)
finally:
sys.stderr = stderr
if __name__ == "__main__":
unittest.main()