Initial commit with translated description
This commit is contained in:
223
CHANGELOG.md
Normal file
223
CHANGELOG.md
Normal 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` (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 <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
238
README.md
Normal file
@@ -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
|
||||
<?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
201
SKILL.md
Normal 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 # 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.
|
||||
1
VERSION.txt
Normal file
1
VERSION.txt
Normal file
@@ -0,0 +1 @@
|
||||
0.1.64
|
||||
6
_meta.json
Normal file
6
_meta.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn73z1bagt8g3nycjr3vg6n37s802xcg",
|
||||
"slug": "my-tesla",
|
||||
"version": "0.1.64",
|
||||
"publishedAt": 1769735093493
|
||||
}
|
||||
20
scripts/run_tests.sh
Normal file
20
scripts/run_tests.sh
Normal 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
2312
scripts/tesla.py
Normal file
File diff suppressed because it is too large
Load Diff
10
tests/__init__.py
Normal file
10
tests/__init__.py
Normal 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
|
||||
40
tests/test_charge_port_status.py
Normal file
40
tests/test_charge_port_status.py
Normal 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()
|
||||
44
tests/test_charge_status_json.py
Normal file
44
tests/test_charge_status_json.py
Normal 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()
|
||||
99
tests/test_cmd_climate_defrost.py
Normal file
99
tests/test_cmd_climate_defrost.py
Normal 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()
|
||||
99
tests/test_cmd_climate_status.py
Normal file
99
tests/test_cmd_climate_status.py
Normal 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()
|
||||
41
tests/test_cmd_mileage_record.py
Normal file
41
tests/test_cmd_mileage_record.py
Normal 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
106
tests/test_cmd_seats.py
Normal 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()
|
||||
73
tests/test_cmd_status_summary.py
Normal file
73
tests/test_cmd_status_summary.py
Normal 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
126
tests/test_cmd_windows.py
Normal 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()
|
||||
32
tests/test_defaults_permissions.py
Normal file
32
tests/test_defaults_permissions.py
Normal 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
119
tests/test_formatting.py
Normal 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()
|
||||
27
tests/test_location_rounding.py
Normal file
27
tests/test_location_rounding.py
Normal 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
46
tests/test_mileage_db.py
Normal 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()
|
||||
59
tests/test_mileage_export_filter.py
Normal file
59
tests/test_mileage_export_filter.py
Normal 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
36
tests/test_no_wake.py
Normal 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()
|
||||
25
tests/test_openings_format.py
Normal file
25
tests/test_openings_format.py
Normal 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()
|
||||
37
tests/test_report_charging_power.py
Normal file
37
tests/test_report_charging_power.py
Normal 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
65
tests/test_report_json.py
Normal 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()
|
||||
46
tests/test_report_openings.py
Normal file
46
tests/test_report_openings.py
Normal 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()
|
||||
34
tests/test_report_privacy.py
Normal file
34
tests/test_report_privacy.py
Normal 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()
|
||||
29
tests/test_report_seat_heaters.py
Normal file
29
tests/test_report_seat_heaters.py
Normal 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()
|
||||
30
tests/test_scheduled_departure_status.py
Normal file
30
tests/test_scheduled_departure_status.py
Normal 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()
|
||||
53
tests/test_summary_json.py
Normal file
53
tests/test_summary_json.py
Normal 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()
|
||||
51
tests/test_time_helpers.py
Normal file
51
tests/test_time_helpers.py
Normal 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()
|
||||
71
tests/test_vehicle_select.py
Normal file
71
tests/test_vehicle_select.py
Normal 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()
|
||||
Reference in New Issue
Block a user