From fd1ad7b97e019de594fa5f48ec6cf93de80c050e Mon Sep 17 00:00:00 2001 From: itbird Date: Sat, 14 Nov 2026 06:26:19 +0000 Subject: [PATCH] Add parked climate keeper, charge port, and official records. Phase 8 adds signed climate keeper, charge port, seat cooling, bioweapon defense, and software update commands, plus non-waking charging history, invoices, and release notes. Health checks show driver-seat presence, skip the unlocked alert when that seat is occupied, and repeat energy-mode alerts at most once an hour. --- .agents/skills/agentdrive/SKILL.md | 14 +- README.md | 4 +- config/regions.yaml | 1 + config/scopes.yaml | 3 +- config/vehicles.yaml | 12 + core/cli.py | 111 ++++ core/family.py | 15 + core/fleet/auth.py | 3 +- core/fleet/charging.py | 203 ++++++-- core/fleet/check_state.py | 133 ++++- core/fleet/failures.py | 46 ++ core/fleet/health.py | 42 +- core/fleet/http.py | 28 + core/fleet/queries.py | 58 ++- core/fleet/report.py | 5 +- core/fleet/snapshot.py | 116 +++++ core/fleet/software.py | 87 ++++ core/ops.py | 226 ++++++++- core/safety/catalog.py | 57 +++ core/safety/gateway.py | 45 +- docs/00-before-you-start.md | 8 +- docs/01-install-and-safety.md | 2 +- docs/03-developer-app.md | 2 +- docs/06-queries-and-commands.md | 73 ++- docs/07-assistants.md | 4 +- docs/08-skills.md | 3 +- docs/09-troubleshooting.md | 2 +- docs/prerequisites/assistant.md | 2 +- docs/prerequisites/developer-app.md | 2 +- setup/install.sh | 3 +- setup/patch_tesla_control_phase8.py | 123 +++++ tests/test_charge_amps_and_departure.py | 3 +- tests/test_phase8.py | 647 ++++++++++++++++++++++++ 33 files changed, 1960 insertions(+), 123 deletions(-) create mode 100644 core/fleet/failures.py create mode 100644 core/fleet/software.py create mode 100644 setup/patch_tesla_control_phase8.py create mode 100644 tests/test_phase8.py diff --git a/.agents/skills/agentdrive/SKILL.md b/.agents/skills/agentdrive/SKILL.md index 824349d..ee2ad23 100644 --- a/.agents/skills/agentdrive/SKILL.md +++ b/.agents/skills/agentdrive/SKILL.md @@ -1,6 +1,6 @@ --- name: agentdrive -description: Use AgentDrive to read a personal vehicle and send parked commands through the agentdrive program. Use when the user asks about battery, climate, lock state, tyre pressure, a health check, the daily vehicle report, a coarse area, an in-car charging timetable, charging current, a departure time that preconditions the cabin, sentry mode, seat or steering heat, defrost, cabin preconditioning, the rear trunk, the front trunk, remote start, or a parked command such as flash lights, lock, or charging. Also use it when the user has more than one car, a family member asks for an action the owner must approve, whether the battery is enough for a trip, a charge limit, charging habits, a home tariff, or a weekly, monthly, or yearly chart. Do not sign vehicle commands in this skill. +description: Use AgentDrive to read a personal vehicle and send parked commands through the agentdrive program. Use when the user asks about battery, climate, lock state, tyre pressure, a health check, the daily vehicle report, a coarse area, an in-car charging timetable, charging current, a departure time that preconditions the cabin, sentry mode, seat or steering heat, seat cooling, automatic seat climate, defrost, cabin preconditioning, climate keeper, bioweapon defense mode, the charge port, official charging history or an invoice, a software update, the rear trunk, the front trunk, remote start, or a parked command such as flash lights, lock, or charging. Also use it when the user has more than one car, a family member asks for an action the owner must approve, whether the battery is enough for a trip, a charge limit, charging habits, a home tariff, or a weekly, monthly, or yearly chart. Do not sign vehicle commands in this skill. --- # AgentDrive @@ -39,9 +39,9 @@ Security trade-off of a shared key: every agent that can read the private key fi 1. `agentdrive doctor` 2. `agentdrive check` — the read-only vehicle status check. It does not wake a sleeping vehicle and it does not send a command. 3. `agentdrive where` — a coarse area: suburb or locality, state or region, and country. It does not wake a sleeping vehicle and it does not send a command. A rounded point is sent to Nominatim. `agentdrive where --precise` prints precise latitude and longitude and a map link when the car is already online. It still does not wake the car. Precise coordinates are not written to the local history. Do not copy them into memory. -4. A vehicle command only after doctor and check have finished, and only when the user asks. Park and speed 0 are required. Unlock, turning sentry off, the rear trunk, the front trunk, remote start, charging changes, and setting or removing a departure schedule need a fresh confirmation. A schedule cannot supply that confirmation and cannot run sentry off, the trunk, the front trunk, remote start, or a departure schedule change. +4. A vehicle command only after doctor and check have finished, and only when the user asks. Park and speed 0 are required. Unlock, turning sentry off, the rear trunk, the front trunk, remote start, charging changes, setting or removing a departure schedule, climate keeper keep, dog, and camp, bioweapon defense on, the charge port, and scheduling or cancelling a software update need a fresh confirmation. A schedule cannot supply that confirmation and cannot run those commands. -`agentdrive sentry-on` and `agentdrive sentry-off` change sentry mode. Both wake a sleeping car: if the vehicle list says the car is not online, they send one wake and poll for up to 45 seconds, then send the signed command. `sentry-off` needs a fresh confirmation that names turning sentry off, for example `I confirm turn sentry off`. A schedule cannot supply that confirmation and cannot run `sentry-off`. `agentdrive check`, `agentdrive report`, `agentdrive where`, history, `agentdrive advice`, and `agentdrive tariff` do not wake a sleeping car. Advice does not change a charge limit, a timetable, or preconditioning. +`agentdrive sentry-on` and `agentdrive sentry-off` change sentry mode. Both wake a sleeping car: if the vehicle list says the car is not online, they send one wake and poll for up to 45 seconds, then send the signed command. `sentry-off` needs a fresh confirmation that names turning sentry off, for example `I confirm turn sentry off`. A schedule cannot supply that confirmation and cannot run `sentry-off`. `agentdrive check`, `agentdrive report`, `agentdrive where`, history, official charging history, software status, `agentdrive advice`, and `agentdrive tariff` do not wake a sleeping car. Advice does not change a charge limit, a timetable, or preconditioning. `agentdrive seat-heat`, `agentdrive steering-heat`, `agentdrive defrost-on`, `agentdrive defrost-off`, and `agentdrive precondition` are parked climate commands. They use the same confirmation pattern as climate on, climate off, and set temperature. `seat-heat` takes a seat the signed tool accepts (`front-left`, `front-right`, `2nd-row-left`, `2nd-row-center`, `2nd-row-right`, `3rd-row-left`, `3rd-row-right`) and a level (`off`, `low`, `medium`, or `high`). `steering-heat` is `on` or `off`. Defrost is max defrost, on or off, for the front and rear together. `precondition` starts cabin preconditioning now. It does not add a departure schedule. A departure time is `agentdrive departure`. @@ -51,6 +51,10 @@ Security trade-off of a shared key: every agent that can read the private key fi `agentdrive departure show` reads the departure schedules and does not wake a sleeping vehicle. `agentdrive departure set` and `agentdrive departure remove` change them. Each change needs a fresh confirmation, for example `I confirm departure schedule set` or `I confirm departure schedule remove`. A scheduled job cannot supply that confirmation and cannot run those two commands. `departure show` is a read. The schedule preconditions the cabin by a time, on selected days, at a place. Latitude and longitude are optional. Omit both and the command uses the car's current location after the same wake other parked changes use. If that location read says the car is asleep, it is not woken again: say the car must be online, or pass coordinates. It does not set a battery switch or an off-peak charging time. `precondition` is still the command that starts the cabin now. Precise coordinates are not written to the local history. +`agentdrive climate-keeper off` turns climate keeper off and does not need a confirmation. `agentdrive climate-keeper keep`, `dog`, and `camp` need a fresh confirmation because they use energy, for example `I confirm climate keeper keep`, `I confirm climate keeper dog`, or `I confirm climate keeper camp`. `agentdrive charge-port open` and `agentdrive charge-port close` need a fresh confirmation, for example `I confirm open charge port` or `I confirm close charge port`. If close fails because a cable is plugged in, say that and do not send it again. `agentdrive seat-cool ` sets front-seat cooling (`front-left` or `front-right`, level `off`, `low`, `medium`, or `high`). `agentdrive seat-climate on` or `off` sets automatic seat climate (`front-left`, `front-right`, or `both`). If the car has no seat cooling, or the climate is not on, say so and do not keep retrying. `agentdrive bioweapon off` does not need a confirmation. `agentdrive bioweapon on` needs one, for example `I confirm turn bioweapon defense on`. If the car does not support bioweapon defense mode, say so. `agentdrive software` shows the installed version, the update status, and official release notes. It does not wake a sleeping car: say the car is asleep. `agentdrive software schedule ` and `agentdrive software cancel` need a fresh confirmation, for example `I confirm schedule software update` or `I confirm cancel software update`. A delay is `2h`, `10m`, `30s`, a whole number of seconds, or `now`. + +`agentdrive charging-history` is the official account charging history. It does not wake a car. Show the date, site name, energy, and cost as Tesla returns them, and include the sentence `Home charging is not included`. Do not store site coordinates or a VIN. `agentdrive charging-invoice --save` writes that PDF under the config home. Without `--save`, do not write the file. + `agentdrive trunk-open` and `agentdrive trunk-close` open and close the rear trunk. Closing works when the vehicle can close the rear trunk. `agentdrive frunk-open` opens the front trunk. The front trunk cannot be closed from AgentDrive. `agentdrive remote-start` enables keyless driving so the car can be driven without the phone key for a short time. It does not drive the car, it does not start an engine, and it does not ask for a password. Each command needs a fresh confirmation that names the action: open rear trunk, close rear trunk, open frunk, or enable remote start. Do not call `tesla-control` for these. The shared core signs them. `agentdrive status` answers a direct question and may wake a sleeping vehicle. It is still not a lock, unlock, climate, or charging command. Use `agentdrive check` for the check that must happen first. @@ -86,7 +90,7 @@ agentdrive family remove rider `charging` means the charging changes: start, stop, charge limit, charging current, and the in-car timetable changes. Low-risk actions the owner allowed, such as status or lock, run directly. Run them with `--as `. -Unlock and charging changes are not run for a family member, even if the owner allowed the request and even if a confirmation sentence is on the command. Turning sentry off, the trunk, the front trunk, remote start, and departure changes are the same. The command writes a pending request and prints relay text. Read that text to the owner. Do not send it yourself, and do not approve it yourself. +Unlock and charging changes are not run for a family member, even if the owner allowed the request and even if a confirmation sentence is on the command. Turning sentry off, the trunk, the front trunk, remote start, departure changes, climate keeper keep, dog, or camp, bioweapon defense on, a charge-port open or close, and scheduling or cancelling a software update are the same. The command writes a pending request and prints relay text. Read that text to the owner. Do not send it yourself, and do not approve it yourself. ```bash agentdrive --as rider --car daily unlock @@ -104,7 +108,7 @@ Approve runs the action once. The car must still be in Park at speed 0. A reques ## Health check -On install, schedule a routine that runs `agentdrive check --json` every 30 minutes. Exit 0 means there is nothing new to report, including when the vehicle is asleep and when the same alerts are still active, so stay quiet. Exit 1 means a new alert the user should hear. Notify only when the exit code is 1. The same alert stays quiet until it clears and later returns. Do not schedule unlock, turning sentry off, the trunk, the front trunk, remote start, a charging change, or a departure schedule change. `charge-amps`, `charge-schedule set`, and `charge-schedule remove` are charging changes. `departure set` and `departure remove` are departure schedule changes. `sentry-off` is a sensitive command. Do not put `agentdrive report` or `agentdrive history due` on that health-check loop. When the car is already online, `check` also saves local history, including when there is nothing new to notify. It does not wake the car to do that. AgentDrive does not run that job and does not install cron. +On install, schedule a routine that runs `agentdrive check --json` every 30 minutes. Exit 0 means there is nothing new to report, including when the vehicle is asleep and when the same alerts are still active, so stay quiet. Exit 1 means a new alert the user should hear. Notify only when the exit code is 1. The same alert stays quiet until it clears and later returns. An energy-mode alert is the exception: climate left on, climate keeper keep, dog, or camp, or bioweapon defense mode, while the car is parked and no one is in the driver seat, repeats at most once an hour while that mode stays on and stops when the mode turns off. If someone is in the driver seat, that alert is not sent, and the parked-and-unlocked alert is not sent. The driver-seat reading covers only the driver seat. It is not a guarantee that nobody is inside. There is no rear-seat, passenger, child, or pet detection. Do not schedule unlock, turning sentry off, the trunk, the front trunk, remote start, a charging change, a departure schedule change, climate keeper keep, dog, or camp, bioweapon defense on, the charge port, or a software update. `charge-amps`, `charge-schedule set`, and `charge-schedule remove` are charging changes. `departure set` and `departure remove` are departure schedule changes. `sentry-off` is a sensitive command. Do not put `agentdrive report` or `agentdrive history due` on that health-check loop. When the car is already online, `check` also saves local history, including when there is nothing new to notify. It does not wake the car to do that. AgentDrive does not run that job and does not install cron. ## Morning history diff --git a/README.md b/README.md index 13b2b73..f357faf 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,9 @@ You say what you want. The AI agent runs AgentDrive on your computer. Reads use Say it in a sentence. The AI agent does the rest. -**Run the parked car from the chat.** Lock or unlock it, turn the climate on or off, set the temperature, heat a seat, heat the steering wheel, turn defrost on or off, or precondition the cabin. Turn sentry mode on or off. Open the rear trunk and close it when the car can, open the front trunk (the frunk), and enable remote start so the car can be driven without the phone key. Start or stop charging, set a charge limit, set the charging current, and schedule cabin warm-up before you leave. Flash the lights or honk. Unlock, turning sentry off, charging, the trunk, the front trunk, and remote start each wait for a confirmation. You can also set the car's own charging timetable, or ask which area the car is parked in. If several cars share the account, give each a nickname and say which one you mean. A family member can ask for a change. Unlock and charging changes wait until you approve them. +**Run the parked car from the chat.** Lock or unlock it, turn the climate on or off, set the temperature, heat a seat, cool a front seat, heat the steering wheel, turn defrost on or off, or precondition the cabin. Set climate keeper to keep, dog, or camp, or turn it off. Turn bioweapon defense mode on or off. Turn sentry mode on or off. Open the rear trunk and close it when the car can, open the front trunk (the frunk), and enable remote start so the car can be driven without the phone key. Open or close the charge port. Start or stop charging, set a charge limit, set the charging current, and schedule cabin warm-up before you leave. Ask for the official charging history and an invoice, or for the software version, release notes, and a scheduled update. Flash the lights or honk. Unlock, turning sentry off, charging, the trunk, the front trunk, remote start, climate keeper, bioweapon defense, the charge port, and a software update each wait for a confirmation. You can also set the car's own charging timetable, or ask which area the car is parked in. If several cars share the account, give each a nickname and say which one you mean. A family member can ask for a change. Unlock, charging changes, climate keeper, bioweapon defense, the charge port, and a software update wait until you approve them. -**Get a health alert when something new needs you.** Tyre pressure, a low battery, a charge problem, or the car left unlocked or open. You hear about it when it is new. The same alert then stays quiet until it clears and comes back. +**Get a health alert when something new needs you.** Tyre pressure, a low battery, a charge problem, or the car left unlocked or open. You hear about it when it is new. The same alert then stays quiet until it clears and comes back. If someone appears to be in the driver seat, you are not told the car is unlocked. That reading covers only the driver seat and is not a guarantee nobody is inside. Climate, climate keeper, or bioweapon defense left on while the car is parked and the driver seat is empty is mentioned at most once an hour until it is turned off. **Review how the car was used.** Ask for a usage review, or receive a morning briefing: the previous day, the previous week on Monday, the previous month on the 1st, and the previous year on 1 January. A week, a month, or a year includes a short summary and a chart saved on this computer. Ask whether the battery is enough for a trip, or whether charging habits and your home electricity hours suggest a change. Some mornings include a short tip, a service reminder when one is due, and on Monday one official Tesla note when there is something new. diff --git a/config/regions.yaml b/config/regions.yaml index 0759a7d..621e199 100644 --- a/config/regions.yaml +++ b/config/regions.yaml @@ -30,6 +30,7 @@ personal_endpoints: nearby_charging_sites: /api/1/vehicles/{vehicle_id}/nearby_charging_sites charging_history: /api/1/dx/charging/history charging_invoice: /api/1/dx/charging/invoice/{invoice_id} + release_notes: /api/1/vehicles/{vehicle_id}/release_notes # Business-only routes. The client refuses these even if a caller asks. blocked_endpoints: diff --git a/config/scopes.yaml b/config/scopes.yaml index c341b85..9b483d0 100644 --- a/config/scopes.yaml +++ b/config/scopes.yaml @@ -1,5 +1,5 @@ # OAuth scope sets. vehicle_location is the Fleet API scope for a location read. -# Charging management is omitted until the owner asks for it. +# vehicle_charging_cmds is the scope for official charging history and invoices. version: 1 default: @@ -9,6 +9,7 @@ default: - vehicle_device_data - vehicle_location - vehicle_cmds + - vehicle_charging_cmds optional: charging: vehicle_charging_cmds diff --git a/config/vehicles.yaml b/config/vehicles.yaml index e81469e..df2f36e 100644 --- a/config/vehicles.yaml +++ b/config/vehicles.yaml @@ -53,6 +53,10 @@ capabilities: - defrost_on - defrost_off - cabin_precondition + - climate_keeper_off + - seat_cooler + - auto_seat_climate + - bioweapon_off sensitive_commands: - unlock - remote_start @@ -68,6 +72,14 @@ capabilities: - charging_schedule_remove - departure_schedule_set - departure_schedule_remove + - climate_keeper_keep + - climate_keeper_dog + - climate_keeper_camp + - charge_port_open + - charge_port_close + - software_schedule + - software_cancel + - bioweapon_on charging_reads: - nearby_charging_sites - charging_history diff --git a/core/cli.py b/core/cli.py index b476345..705d2db 100644 --- a/core/cli.py +++ b/core/cli.py @@ -54,8 +54,11 @@ parse_amps, parse_limit, parse_on_off, + parse_seat_climate, + parse_seat_cool, parse_seat_heat, parse_temp, + parse_update_delay, preferred_vin, resolve_thresholds, ) @@ -134,6 +137,56 @@ def build_parser() -> argparse.ArgumentParser: seat_heat.add_argument("level") steering_heat = sub.add_parser("steering-heat", parents=[confirmed]) steering_heat.add_argument("state") + climate_keeper = sub.add_parser( + "climate-keeper", + parents=[confirmed], + help="Set climate keeper to off, keep, dog, or camp. keep, dog, and camp need a confirmation.", + ) + climate_keeper.add_argument("mode", choices=("off", "keep", "dog", "camp")) + charge_port = sub.add_parser( + "charge-port", + parents=[confirmed], + help="Open or close the charge port. Needs a confirmation.", + ) + charge_port.add_argument("action", choices=("open", "close")) + seat_cool = sub.add_parser("seat-cool", parents=[confirmed], help="Set front seat cooling. Climate must be on.") + seat_cool.add_argument("seat") + seat_cool.add_argument("level") + seat_climate = sub.add_parser( + "seat-climate", + parents=[confirmed], + help="Turn automatic seat climate on or off for a front seat.", + ) + seat_climate.add_argument("seat") + seat_climate.add_argument("state") + bioweapon = sub.add_parser( + "bioweapon", + parents=[confirmed], + help="Turn bioweapon defense mode on or off. On needs a confirmation.", + ) + bioweapon.add_argument("state", choices=("on", "off")) + charging_history = sub.add_parser( + "charging-history", + help="Official Tesla charging history for this account. Does not wake the car. Home charging is not included.", + ) + charging_history.add_argument("--json", action="store_true") + charging_invoice = sub.add_parser( + "charging-invoice", + help="Save an official charging invoice PDF when asked. Does not wake the car.", + ) + charging_invoice.add_argument("invoice_id") + charging_invoice.add_argument("--save", action="store_true", help="Write the PDF under the config home.") + software = sub.add_parser("software", help="Software status and release notes, or schedule or cancel an update.") + software_sub = software.add_subparsers(dest="software_action") + software_show = software_sub.add_parser("show", help="Show version, update status, and release notes. Does not wake the car.") + software_show.add_argument("--json", action="store_true") + software_schedule = software_sub.add_parser( + "schedule", + parents=[confirmed], + help="Schedule an over-the-air update. Needs a confirmation.", + ) + software_schedule.add_argument("delay", help="Delay such as 2h, 10m, 30s, a number of seconds, or now") + software_sub.add_parser("cancel", parents=[confirmed], help="Cancel a scheduled update. Needs a confirmation.") charge_limit = sub.add_parser("charge-limit", parents=[confirmed]) charge_limit.add_argument("percent") charge_amps = sub.add_parser( @@ -345,6 +398,21 @@ def main(argv: list[str] | None = None) -> int: return _charge_schedule(args, home) if args.command == "departure": return _departure(args, home) + if args.command == "climate-keeper": + args.command = f"climate-keeper-{args.mode}" + return _vehicle(args, home) + if args.command == "charge-port": + args.command = f"charge-port-{args.action}" + return _vehicle(args, home) + if args.command == "bioweapon": + args.command = f"bioweapon-{args.state}" + return _vehicle(args, home) + if args.command == "charging-history": + return _charging_history(args, home) + if args.command == "charging-invoice": + return _charging_invoice(args, home) + if args.command == "software": + return _software(args, home) if args.command in {"refresh", "status", *PUBLIC_COMMANDS}: return _vehicle(args, home) except AmbiguousAdapter as exc: @@ -709,6 +777,10 @@ def _vehicle(args, home: Path) -> int: parse_amps(argument) elif args.command == "seat-heat": extra = list(parse_seat_heat(args.seat, args.level)) + elif args.command == "seat-cool": + extra = list(parse_seat_cool(args.seat, args.level)) + elif args.command == "seat-climate": + extra = list(parse_seat_climate(args.seat, args.state)) elif args.command == "steering-heat": extra = [parse_on_off(args.state, "steering heat")] app = _open_app(home, args) @@ -848,6 +920,45 @@ def _guard_read_if_actor(args, home: Path, action: str) -> None: _guard_read(args, home, action) +def _charging_history(args, home: Path) -> int: + _guard_read(args, home, "charging-history") + print(scrub(_open_app(home, args).charging_history(as_json=bool(args.json)))) + return 0 + + +def _charging_invoice(args, home: Path) -> int: + _guard_read(args, home, "charging-invoice") + print(scrub(_open_app(home, args).charging_invoice(args.invoice_id, save=bool(args.save)))) + return 0 + + +def _software(args, home: Path) -> int: + action = args.software_action or "show" + if action == "show": + _guard_read(args, home, "software") + print(scrub(_open_app(home, args).software(preferred=preferred_vin(), as_json=bool(getattr(args, "json", False))))) + return 0 + command = "software-schedule" if action == "schedule" else "software-cancel" + extra = [parse_update_delay(args.delay)] if action == "schedule" else None + app = _open_app(home, args) + held = _family_hold(args, home, app, command, extra=extra) + if held is not None: + print(scrub(held)) + return 0 + print( + scrub( + app.command( + command, + preferred=preferred_vin(), + argument=None, + statement=getattr(args, "confirm", None), + extra=extra, + ) + ) + ) + return 0 + + def _family(args, home: Path) -> int: _owner_only(args) action = args.family_action or "list" diff --git a/core/family.py b/core/family.py index 0a22b3d..5bb83fe 100644 --- a/core/family.py +++ b/core/family.py @@ -48,6 +48,13 @@ "tips", "wake", "where", + "charging-history", + "charging-invoice", + "software", + "seat-cool", + "seat-climate", + "climate-keeper-off", + "bioweapon-off", } # Sensitive actions. These are requests even if the owner lists them. @@ -69,6 +76,14 @@ "trunk-close", "trunk-open", "unlock", + "climate-keeper-keep", + "climate-keeper-dog", + "climate-keeper-camp", + "charge-port-open", + "charge-port-close", + "software-schedule", + "software-cancel", + "bioweapon-on", } diff --git a/core/fleet/auth.py b/core/fleet/auth.py index 22d0e65..cca0141 100644 --- a/core/fleet/auth.py +++ b/core/fleet/auth.py @@ -16,7 +16,8 @@ def default_scopes(scopes: dict | None = None, *, include_charging: bool = False charging = (document.get("optional") or {}).get("charging") if not charging: raise ConfigError("Charging scope is missing from scopes.yaml") - chosen.append(str(charging)) + if str(charging) not in chosen: + chosen.append(str(charging)) return chosen diff --git a/core/fleet/charging.py b/core/fleet/charging.py index 724db4b..f6ef4dc 100644 --- a/core/fleet/charging.py +++ b/core/fleet/charging.py @@ -1,16 +1,23 @@ """Personal-account charging reads. -Field names below are candidates. They are marked unconfirmed until an owner -compares them with a live response. Nearby sites expose location and stall -counts. +Nearby sites expose location and stall counts. Official charging history is +account-level. The public record is the date, site name, energy, and cost +Tesla returns. Coordinates, a street address, and the VIN are not copied +into that record. Home charging is not in this history. """ from __future__ import annotations +import re from typing import Any from core.config import assert_path_allowed, endpoint, fill_path -from core.errors import EndpointNotAllowed +from core.errors import ConfigError, EndpointNotAllowed + +HOME_CHARGING_NOTE = "Home charging is not included." +_INVOICE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{0,79}$") +_VIN = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$") +_CHARGING_FEE = "CHARGING" FIELD_STATUS = "unconfirmed_against_a_live_response" @@ -34,10 +41,17 @@ def history_path(regions: dict) -> str: return path +def parse_invoice_id(value: str) -> str: + """Accept an invoice content id. A VIN-shaped value is refused.""" + text = str(value or "").strip() + if not _INVOICE_ID.fullmatch(text) or ".." in text or _VIN.fullmatch(text.upper()): + raise ConfigError("Invoice id is not usable.") + return text + + def invoice_path(regions: dict, invoice_id: str) -> str: - if not invoice_id or "/" in invoice_id or ".." in invoice_id: - raise EndpointNotAllowed("Invoice id is not usable") - path = fill_path(endpoint(regions, "charging_invoice"), invoice_id=invoice_id) + identified = parse_invoice_id(invoice_id) + path = fill_path(endpoint(regions, "charging_invoice"), invoice_id=identified) assert_path_allowed(regions, path) return path @@ -135,37 +149,154 @@ def normalize_nearby(payload: dict) -> dict: } -def normalize_history(payload: dict) -> dict: - body = payload.get("response", payload) - rows = [] - container_key = None +def history_rows(payload: dict) -> list[dict]: + """Public charging rows. Coordinates, addresses, and the VIN are dropped.""" + records = [] + for row in _raw_rows(payload): + if not isinstance(row, dict): + continue + records.append( + { + "date": _as_text(row.get("chargeStartDateTime")), + "site": _as_text(row.get("siteLocationName")), + "energy": _energy(row), + "cost": _cost(row), + "invoice_id": _invoice_id(row), + } + ) + return records + + +def format_history(records: list[dict], *, as_json: bool) -> str: + """Date, site name, energy, and cost as Tesla returned them.""" + import json + + public = [_public_row(row) for row in records] + if as_json: + return json.dumps({"note": HOME_CHARGING_NOTE, "records": public}, indent=2) + "\n" + lines = [f"Official Tesla charging history. {HOME_CHARGING_NOTE}"] + if not public: + lines.append("No charging sessions were returned.") + return "\n".join(lines) + for row in public: + invoice = f" | invoice {row['invoice_id']}" if row["invoice_id"] else "" + lines.append(f"{row['date']} | {row['site']} | {row['energy']} | {row['cost']}{invoice}") + return "\n".join(lines) + + +def format_invoice_skipped(invoice_id: str) -> str: + return ( + f"Invoice {invoice_id} was not saved.\n" + f"{HOME_CHARGING_NOTE}\n" + "Ask to save it and the PDF is written under the config home." + ) + + +def format_invoice_saved(path: str) -> str: + return f"Saved invoice to {path}\n{HOME_CHARGING_NOTE}" + + +def _public_row(row: dict) -> dict: + return { + "date": row.get("date") or "", + "site": row.get("site") or "", + "energy": row.get("energy") or "", + "cost": row.get("cost") or "", + "invoice_id": row.get("invoice_id") or "", + } + + +def _raw_rows(payload: dict) -> list: + body = payload.get("response", payload) if isinstance(payload, dict) else None + if isinstance(body, list): + return body if isinstance(body, dict): for key in ("data", "history", "charging_history", "results"): if isinstance(body.get(key), list): - rows = body[key] - container_key = key - break - elif isinstance(body, list): - rows = body - container_key = "root" - preview = [] - for row in rows: - if isinstance(row, dict): - preview.append(sorted(row.keys())) - return { - "field_status": FIELD_STATUS, - "container_key": container_key, - "count": len(rows), - "row_field_names": preview, - "note": "Charging history field names are unconfirmed until checked against a real call.", - } + return body[key] + return [] -def normalize_invoice(payload: dict) -> dict: - body = payload.get("response", payload) - keys = sorted(body.keys()) if isinstance(body, dict) else [] - return { - "field_status": FIELD_STATUS, - "field_names": keys, - "note": "Invoice field names are unconfirmed until checked against a real call.", - } +def _as_text(value) -> str: + if value is None or isinstance(value, bool): + return "" + return str(value).strip() + + +def _number_text(value) -> str: + if isinstance(value, bool) or value is None: + return "" + if isinstance(value, str): + return value.strip() + if isinstance(value, int): + return str(value) + if isinstance(value, float): + return f"{value:g}" + return str(value).strip() + + +def _fees(row: dict, kind: str | None = None) -> list[dict]: + fees = row.get("fees") + if not isinstance(fees, list): + return [] + chosen = [] + for fee in fees: + if not isinstance(fee, dict): + continue + if kind is not None and str(fee.get("feeType") or "").upper() != kind: + continue + chosen.append(fee) + return chosen + + +def _energy(row: dict) -> str: + """Charging usageBase and uom, as returned. Tiers are not added in.""" + parts = [] + for fee in _fees(row, _CHARGING_FEE): + usage = _number_text(fee.get("usageBase")) + unit = _as_text(fee.get("uom")) + if usage and unit: + parts.append(f"{usage} {unit}") + elif usage: + parts.append(usage) + return "; ".join(parts) + + +def _cost(row: dict) -> str: + """Currency and totalDue for each fee Tesla billed, charging first.""" + ordered = _fees(row, _CHARGING_FEE) + [ + fee for fee in _fees(row) if str(fee.get("feeType") or "").upper() != _CHARGING_FEE + ] + parts = [] + for fee in ordered: + amount = _number_text(fee.get("totalDue")) + if amount == "": + continue + kind = _as_text(fee.get("feeType")).lower() + if kind and kind != "charging": + try: + if float(amount) == 0: + continue + except ValueError: + pass + currency = _as_text(fee.get("currencyCode")) + label = f"{currency} {amount}".strip() + if kind and kind != "charging": + label = f"{label} {kind}".strip() + parts.append(label) + return "; ".join(parts) + + +def _invoice_id(row: dict) -> str: + invoices = row.get("invoices") + if isinstance(invoices, dict): + invoices = [invoices] + if not isinstance(invoices, list): + return "" + for item in invoices: + if not isinstance(item, dict): + continue + content = _as_text(item.get("contentId")) + if content and _INVOICE_ID.fullmatch(content) and not _VIN.fullmatch(content.upper()): + return content + return "" diff --git a/core/fleet/check_state.py b/core/fleet/check_state.py index 0d46e1f..392808d 100644 --- a/core/fleet/check_state.py +++ b/core/fleet/check_state.py @@ -3,19 +3,26 @@ The file lives next to that car's history. With one car it stays ``$AGENTDRIVE_HOME/check-alerts.json``. Another car uses ``$AGENTDRIVE_HOME/cars//check-alerts.json``. It stores alert codes -only: no VIN, no location, and no alert text. A skipped check does not +and, for energy-mode alerts, the last time that mode was announced. +No VIN, no location, and no alert text. A skipped check does not read or write it. AgentDrive does not run the health check itself. + +Energy-mode alerts (climate on, climate keeper keep/dog/camp, bioweapon +defense) are announced at most once an hour while the mode stays on. +Every other alert stays quiet until it clears. """ from __future__ import annotations import json +from datetime import datetime, timezone from pathlib import Path from core.atomic import write_atomic from core.fleet.health import CheckReport CHECK_ALERTS_NAME = "check-alerts.json" +ENERGY_INTERVAL_SECONDS = 3600 # Printed on adapter install. The morning briefing is a separate daily job. HEALTH_CHECK_NOTE = ( @@ -26,6 +33,12 @@ "and when the same alerts are still active. " "Exit 1 means a new alert the owner should hear. Notify only when the exit code is 1. " "The same alert stays quiet until it clears and later returns. " + "An energy-mode alert is the exception: climate left on, climate keeper keep, dog, or camp, " + "or bioweapon defense mode, while parked with no one in the driver seat, repeats at most once " + "an hour while that mode stays on and stops when the mode turns off. " + "If someone is in the driver seat, that alert is not sent, and the parked-and-unlocked alert " + "is not sent. The driver-seat reading covers only the driver seat and is not a guarantee that " + "nobody is inside. " ) @@ -35,44 +48,44 @@ def load_codes(home: Path) -> set[str]: A missing or unreadable file means nothing is remembered, so the next online check treats every current alert as new. """ - path = Path(home) / CHECK_ALERTS_NAME - if not path.is_file(): - return set() - try: - data = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError, UnicodeError): - return set() - codes = data.get("codes") if isinstance(data, dict) else None - if not isinstance(codes, list): - return set() - return {item for item in codes if isinstance(item, str) and item} + return set(_load_state(home)["codes"]) def save_codes(home: Path, codes: list[str]) -> None: """Replace the remembered codes with the alerts active on this online check.""" - unique: list[str] = [] - seen: set[str] = set() - for code in codes: - if not isinstance(code, str) or not code or code in seen: - continue - seen.add(code) - unique.append(code) - payload = json.dumps({"codes": unique}, indent=2) + "\n" - write_atomic(Path(home) / CHECK_ALERTS_NAME, payload) + _save_state(home, _unique(codes), {}) -def apply_rising_edge(home: Path, report: CheckReport) -> CheckReport: - """Exit 1 only for alert codes that were not active on the last online check. +def apply_rising_edge(home: Path, report: CheckReport, now: datetime | None = None) -> CheckReport: + """Exit 1 only for alerts the owner has not already been told about. - A skipped report is returned unchanged. The remembered file is left as it - was, including when that file does not exist yet. + A normal alert stays quiet until it clears. An energy-mode alert is + repeated at most once an hour while that mode stays on, including when + the driver seat was occupied in between, and the timer clears when the + mode turns off. A skipped report is returned unchanged. """ if report.skipped: return report - previous = load_codes(home) + moment = _clock(now) + state = _load_state(home) + previous = set(state["codes"]) + notified = { + code: stamp + for code, stamp in state["energy_notified"].items() + if code in set(report.energy_modes) + } + fresh = [] + for alert in report.alerts: + if alert.code.startswith("energy:"): + last = notified.get(alert.code) + if last is None or (moment - last).total_seconds() >= ENERGY_INTERVAL_SECONDS: + fresh.append(alert) + notified[alert.code] = moment + continue + if alert.code not in previous: + fresh.append(alert) current = [alert.code for alert in report.alerts] - save_codes(home, current) - fresh = [alert for alert in report.alerts if alert.code not in previous] + _save_state(home, current, notified) if len(fresh) == len(report.alerts): return report return CheckReport( @@ -82,4 +95,68 @@ def apply_rising_edge(home: Path, report: CheckReport) -> CheckReport: alerts=fresh, tyre_bar=report.tyre_bar, low_battery_percent=report.low_battery_percent, + driver_present=report.driver_present, + energy_modes=list(report.energy_modes), ) + + +def _load_state(home: Path) -> dict: + path = Path(home) / CHECK_ALERTS_NAME + empty = {"codes": [], "energy_notified": {}} + if not path.is_file(): + return empty + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, UnicodeError): + return empty + if not isinstance(data, dict): + return empty + codes = data.get("codes") + raw_times = data.get("energy_notified") + parsed: dict[str, datetime] = {} + if isinstance(raw_times, dict): + for code, stamp in raw_times.items(): + moment = _parse_time(stamp) + if isinstance(code, str) and code.startswith("energy:") and moment is not None: + parsed[code] = moment + return {"codes": _unique(codes if isinstance(codes, list) else []), "energy_notified": parsed} + + +def _save_state(home: Path, codes: list[str], notified: dict[str, datetime]) -> None: + unique = _unique(codes) + payload: dict = {"codes": unique} + if notified: + payload["energy_notified"] = {code: _iso(notified[code]) for code in sorted(notified)} + text = json.dumps(payload, indent=2) + "\n" + write_atomic(Path(home) / CHECK_ALERTS_NAME, text) + + +def _unique(codes) -> list[str]: + unique: list[str] = [] + seen: set[str] = set() + for code in codes: + if not isinstance(code, str) or not code or code in seen: + continue + seen.add(code) + unique.append(code) + return unique + + +def _clock(now: datetime | None) -> datetime: + moment = now or datetime.now(timezone.utc) + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + return moment.astimezone(timezone.utc) + + +def _iso(moment: datetime) -> str: + return _clock(moment).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _parse_time(value) -> datetime | None: + if not isinstance(value, str): + return None + try: + return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + except ValueError: + return None diff --git a/core/fleet/failures.py b/core/fleet/failures.py new file mode 100644 index 0000000..21a1b63 --- /dev/null +++ b/core/fleet/failures.py @@ -0,0 +1,46 @@ +"""Turn a signed-command failure into a sentence the owner can act on.""" + +from __future__ import annotations + + +def clarify_command_failure(command: str, message: str) -> str | None: + """Return a clearer message, or None to keep the original failure.""" + text = " ".join(str(message or "").lower().split()) + if command == "charge-port-close" and _cable(text): + return "The charge port did not close because a cable is plugged in. The command was not sent again." + if command in {"seat-cool", "seat-climate"} and _climate_off(text): + return "Climate is not on, so the seat was not changed. The command was not sent again." + if command == "seat-cool" and _unsupported(text): + return "This car does not have seat cooling. The command was not sent again." + if command == "seat-climate" and _unsupported(text): + return "This car does not support automatic seat climate. The command was not sent again." + if command in {"bioweapon-on", "bioweapon-off"} and _unsupported(text): + return "This car does not support bioweapon defense mode. The command was not sent again." + return None + + +def _cable(text: str) -> bool: + return any(phrase in text for phrase in ("cable", "plugged", "connector")) + + +def _climate_off(text: str) -> bool: + if "climate is not on" in text or "climate is off" in text or "climate_off" in text: + return True + if "hvac" in text and "off" in text: + return True + return "climate" in text and "not on" in text + + +def _unsupported(text: str) -> bool: + return any( + phrase in text + for phrase in ( + "not supported", + "not_supported", + "unsupported", + "does not support", + "doesn't support", + "not available", + "unavailable", + ) + ) diff --git a/core/fleet/health.py b/core/fleet/health.py index 5fd5c8a..18fab03 100644 --- a/core/fleet/health.py +++ b/core/fleet/health.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field -from core.fleet.snapshot import Observation +from core.fleet.snapshot import Observation, driver_seat_text DEFAULT_TYRE_BAR = 2.5 DEFAULT_LOW_BATTERY_PERCENT = 30 @@ -32,6 +32,9 @@ class CheckReport: alerts: list[Alert] = field(default_factory=list) tyre_bar: float = DEFAULT_TYRE_BAR low_battery_percent: int = DEFAULT_LOW_BATTERY_PERCENT + driver_present: bool | None = None + # Modes still running, including when the driver seat suppresses the alert. + energy_modes: list[str] = field(default_factory=list) @property def exit_code(self) -> int: @@ -45,6 +48,8 @@ def as_dict(self) -> dict: "tyre_bar": self.tyre_bar, "low_battery_percent": self.low_battery_percent, "alerts": [{"code": item.code, "message": item.message} for item in self.alerts], + "driver_present": self.driver_present, + "driver_seat": None if self.skipped else driver_seat_text(self.driver_present), } @@ -100,13 +105,44 @@ def evaluate_alerts( state = observation.charging_state or "error" alerts.append(Alert("charge_error", f"Charging reported an error ({state}). Check the cable and the charge port.")) if _parked(observation): - if observation.locked is False: + # Occupied means someone appears to be in the driver seat. Unknown does not suppress. + if observation.locked is False and observation.driver_present is not True: alerts.append(Alert("unlocked", "The vehicle is unlocked while parked. Lock it if nobody is with it.")) if observation.open_closure is True: alerts.append(Alert("open", "A door, trunk, frunk, or window is open while the vehicle is parked. Close it.")) + if observation.driver_present is not True: + alerts.extend(_energy_alerts(observation)) return alerts +def active_energy_modes(observation: Observation) -> list[str]: + """Energy-heavy modes that are on while parked, whether or not the seat is occupied.""" + if not _parked(observation): + return [] + modes: list[str] = [] + keeper = observation.climate_keeper + if keeper in {"keep", "dog", "camp"}: + modes.append(f"energy:keeper:{keeper}") + if observation.bioweapon_on is True: + modes.append("energy:bioweapon") + if observation.climate_on is True: + modes.append("energy:climate") + return modes + + +_ENERGY_MESSAGES = { + "energy:climate": "Climate is on while the vehicle is parked and no one is in the driver seat.", + "energy:keeper:keep": "Climate keeper is set to keep while the vehicle is parked and no one is in the driver seat.", + "energy:keeper:dog": "Climate keeper is set to dog while the vehicle is parked and no one is in the driver seat.", + "energy:keeper:camp": "Climate keeper is set to camp while the vehicle is parked and no one is in the driver seat.", + "energy:bioweapon": "Bioweapon defense mode is on while the vehicle is parked and no one is in the driver seat.", +} + + +def _energy_alerts(observation: Observation) -> list[Alert]: + return [Alert(code, _ENERGY_MESSAGES[code]) for code in active_energy_modes(observation)] + + def report_from_observation( observation: Observation, *, @@ -121,6 +157,8 @@ def report_from_observation( alerts=alerts, tyre_bar=tyre_bar, low_battery_percent=low_battery_percent, + driver_present=observation.driver_present, + energy_modes=active_energy_modes(observation), ) diff --git a/core/fleet/http.py b/core/fleet/http.py index 9d59a13..5867551 100644 --- a/core/fleet/http.py +++ b/core/fleet/http.py @@ -42,6 +42,15 @@ def request_json( def get_text(self, url: str, timeout: float = 20) -> tuple[int, str]: """Return the status code and the response body as text.""" + def request_bytes( + self, + method: str, + url: str, + headers: dict[str, str], + timeout: float = 60, + ) -> tuple[int, bytes]: + """Return the status code and the raw body. Used for invoice PDFs.""" + def build_request( url: str, @@ -140,3 +149,22 @@ def get_text(self, url: str, timeout: float = 20) -> tuple[int, str]: return exc.code, exc.read().decode("utf-8", errors="replace") except (URLError, TimeoutError) as exc: raise HttpError("Public key URL could not be reached") from None + + def request_bytes( + self, + method: str, + url: str, + headers: dict[str, str], + timeout: float = 60, + ) -> tuple[int, bytes]: + try: + with open_url(url, method=method, headers=headers, timeout=timeout) as response: + return response.status, response.read() + except HTTPError as exc: + return exc.code, exc.read() + except TimeoutError: + raise HttpError("Fleet API request timed out", timed_out=True) from None + except URLError as exc: + if isinstance(getattr(exc, "reason", None), TimeoutError): + raise HttpError("Fleet API request timed out", timed_out=True) from None + raise HttpError("Fleet API request failed") from None diff --git a/core/fleet/queries.py b/core/fleet/queries.py index e3304ec..4772647 100644 --- a/core/fleet/queries.py +++ b/core/fleet/queries.py @@ -158,8 +158,58 @@ def nearby_charging_sites(self, vehicle_id: str) -> dict: path = charging.nearby_path(self.regions, _check_vehicle_id(vehicle_id)) return charging.normalize_nearby(self._get(path)) - def charging_history(self) -> dict: - return charging.normalize_history(self._get(charging.history_path(self.regions))) + def charging_history(self) -> list[dict]: + """Account charging history. This does not call the vehicle and does not wake it.""" + path = charging.history_path(self.regions) + records: list[dict] = [] + seen: set[tuple] = set() + for page in range(1, 11): + try: + payload = self._get(f"{path}?pageNo={page}&pageSize=50") + except QueryError as exc: + if exc.status in (401, 403): + raise QueryError( + "Charging history needs the vehicle_charging_cmds scope. Sign in again and include it.", + status=exc.status, + ) from exc + raise + batch = charging.history_rows(payload) + if not batch: + break + fresh = [] + for row in batch: + key = (row["date"], row["site"], row["energy"], row["cost"], row["invoice_id"]) + if key in seen: + continue + seen.add(key) + fresh.append(row) + if not fresh: + break + records.extend(fresh) + if len(batch) < 50: + break + return records + + def charging_invoice_pdf(self, invoice_id: str) -> bytes: + """Download one invoice PDF. The caller writes it only when asked.""" + path = charging.invoice_path(self.regions, invoice_id) + headers = self._headers() + headers["Accept"] = "application/pdf" + status, payload = self.http.request_bytes("GET", self._url(path), headers) + if status in (401, 403): + raise QueryError( + "Charging invoices need the vehicle_charging_cmds scope. Sign in again and include it.", + status=status, + ) + if status == 404: + raise QueryError("That invoice was not found.", status=status) + if status >= 400 or not payload.startswith(b"%PDF"): + raise QueryError("The invoice was not a PDF.", status=status) + return payload - def charging_invoice(self, invoice_id: str) -> dict: - return charging.normalize_invoice(self._get(charging.invoice_path(self.regions, invoice_id))) + def release_notes(self, vehicle_id: str, *, staged: bool = False) -> dict: + """Official release notes. Call this only when the vehicle list already says online.""" + path = fill_path(endpoint(self.regions, "release_notes"), vehicle_id=_check_vehicle_id(vehicle_id)) + if staged: + path = f"{path}?staged=true" + return self._get(path) diff --git a/core/fleet/report.py b/core/fleet/report.py index 17cdf19..803335f 100644 --- a/core/fleet/report.py +++ b/core/fleet/report.py @@ -9,7 +9,7 @@ import json from dataclasses import dataclass, field -from core.fleet.snapshot import Observation +from core.fleet.snapshot import Observation, driver_seat_text MILES_TO_KM = 1.609344 @@ -58,6 +58,7 @@ def vehicle_report(name: str, observation: Observation) -> VehicleReport: ), f"Charge connection: {charging}", f"Locked: {_yes_no(observation.locked)}", + f"Driver seat: {driver_seat_text(observation.driver_present)}", f"Gear: {_gear(observation)}", f"Doors, windows, and trunk: {_closures(observation)}", f"Sentry: {_on_off(observation.sentry_on)}", @@ -77,6 +78,8 @@ def vehicle_report(name: str, observation: Observation) -> VehicleReport: "charge_limit_percent": observation.charge_limit_soc, "charging_state": observation.charging_state, "locked": observation.locked, + "driver_present": observation.driver_present, + "driver_seat": driver_seat_text(observation.driver_present), "shift_state": observation.shift_state, "speed": observation.speed, "parked": _parked(observation), diff --git a/core/fleet/snapshot.py b/core/fleet/snapshot.py index 54ad275..6dd1316 100644 --- a/core/fleet/snapshot.py +++ b/core/fleet/snapshot.py @@ -53,6 +53,19 @@ class Observation: departure_schedules: tuple[DepartureRow, ...] = () departure_schedule_section_present: bool = False departure_schedule_list_present: bool = False + driver_present: bool | None = None + climate_keeper: str | None = None + bioweapon_on: bool | None = None + seat_cool: dict[str, int] | None = None + auto_seat_left: bool | None = None + auto_seat_right: bool | None = None + seat_cooling: bool | None = None + charge_port_open: bool | None = None + charge_port_latch: str | None = None + charge_cable: str | None = None + car_version: str | None = None + software_update_status: str | None = None + software_update_version: str | None = None def _section(payload: dict, name: str) -> dict: @@ -158,6 +171,59 @@ def seat_heater_names() -> tuple[str, ...]: return tuple(SEAT_API_FIELDS.values()) +# Front seats only. The signed seat-cooler command has no rear positions. +SEAT_COOLER_NAMES = ("front-left", "front-right") +SEAT_COOLER_LEVELS = {"off": 0, "low": 1, "medium": 2, "high": 3} +_SEAT_COOLER_FIELDS = { + "front-left": ("seat_fan_front_left", "seat_cooler_left"), + "front-right": ("seat_fan_front_right", "seat_cooler_right"), +} +_KEEPER_NAMES = { + "0": "off", + "off": "off", + "1": "keep", + "on": "keep", + "keep": "keep", + "2": "dog", + "dog": "dog", + "3": "camp", + "camp": "camp", +} +_EMPTY_CABLE = {"", "", "invalid", "", "null", "none", "disconnected"} + + +def climate_keeper_name(value) -> str | None: + """Map a climate-keeper reading to off, keep, dog, or camp.""" + if isinstance(value, bool) or value is None or value == "": + return None + if isinstance(value, (int, float)): + key = str(int(value)) + else: + key = str(value).strip().lower() + return _KEEPER_NAMES.get(key) + + +def driver_seat_text(present: bool | None) -> str: + """The check and report line. Unknown is not treated as empty.""" + if present is True: + return "someone appears to be in the driver seat" + if present is False: + return "no one detected in the driver seat" + return "the driver-seat sensor did not report" + + +def cable_plugged(observation: Observation) -> bool: + """True when the charge state says a cable is still attached.""" + latch = str(observation.charge_port_latch or "").strip().lower() + if latch == "engaged": + return True + cable = str(observation.charge_cable or "").strip().lower() + if cable not in _EMPTY_CABLE: + return True + state = str(observation.charging_state or "").strip().lower() + return bool(state) and state != "disconnected" + + def _heater_level(value) -> int | None: if isinstance(value, bool) or value is None or value == "": return None @@ -179,6 +245,38 @@ def _seat_heat(climate: dict) -> dict[str, int]: return heaters +def _seat_cool(climate: dict) -> dict[str, int]: + coolers: dict[str, int] = {} + for name, keys in _SEAT_COOLER_FIELDS.items(): + for key in keys: + if key not in climate: + continue + level = _heater_level(climate.get(key)) + if level is not None: + coolers[name] = level + break + return coolers + + +def _present(value) -> bool | None: + if isinstance(value, bool): + return value + if value in (0, 1) and not isinstance(value, bool): + return bool(value) + return None + + +def _text(value) -> str | None: + if value is None or isinstance(value, bool): + return None + text = str(value).strip() + return text or None + + +def _flag(value) -> bool | None: + return value if isinstance(value, bool) else None + + def _optional_mode(value) -> int | str | None: if value is None or value == "" or isinstance(value, bool): return None @@ -219,6 +317,11 @@ def from_vehicle_data(payload: dict) -> Observation: vehicle = _section(payload, "vehicle_state") climate = _section(payload, "climate_state") charge = _section(payload, "charge_state") + config = _section(payload, "vehicle_config") + update = vehicle.get("software_update") if isinstance(vehicle.get("software_update"), dict) else {} + bioweapon = _flag(climate.get("bioweapon_mode")) + if bioweapon is None: + bioweapon = _flag(climate.get("bioweapon_mode_on")) request = charge.get("charge_current_request") amps = charge.get("charge_amps") if amps is None: @@ -264,4 +367,17 @@ def from_vehicle_data(payload: dict) -> Observation: departure_schedules=departures, departure_schedule_section_present=departure_section, departure_schedule_list_present=departure_list, + driver_present=_present(vehicle.get("is_user_present")) if "is_user_present" in vehicle else None, + climate_keeper=climate_keeper_name(climate.get("climate_keeper_mode")) if "climate_keeper_mode" in climate else None, + bioweapon_on=bioweapon, + seat_cool=_seat_cool(climate), + auto_seat_left=_flag(climate.get("auto_seat_climate_left")) if "auto_seat_climate_left" in climate else None, + auto_seat_right=_flag(climate.get("auto_seat_climate_right")) if "auto_seat_climate_right" in climate else None, + seat_cooling=_flag(config.get("has_seat_cooling")) if "has_seat_cooling" in config else None, + charge_port_open=_flag(charge.get("charge_port_door_open")) if "charge_port_door_open" in charge else None, + charge_port_latch=_text(charge.get("charge_port_latch")) if "charge_port_latch" in charge else None, + charge_cable=_text(charge.get("conn_charge_cable")) if "conn_charge_cable" in charge else None, + car_version=_text(vehicle.get("car_version")), + software_update_status=_text(update.get("status")) if update else None, + software_update_version=_text(update.get("version")) if update else None, ) diff --git a/core/fleet/software.py b/core/fleet/software.py new file mode 100644 index 0000000..362f517 --- /dev/null +++ b/core/fleet/software.py @@ -0,0 +1,87 @@ +"""Official software status and release notes. + +Reading uses the vehicle list first. A car that is not online is left asleep. +Notes are the title and description Tesla returns. Image URLs are not copied. +""" + +from __future__ import annotations + +import json + +from core.fleet.report import skip_text +from core.fleet.snapshot import Observation + + +def notes_from(payload: dict) -> list[dict]: + """Title, subtitle, description, and customer version. No image URLs.""" + notes = _note_list(payload) + public = [] + for item in notes: + if not isinstance(item, dict): + continue + public.append( + { + "title": _text(item.get("title")), + "subtitle": _text(item.get("subtitle")), + "description": _text(item.get("description")), + "customer_version": _text(item.get("customer_version")), + } + ) + return public + + +def format_software(state: str, observation: Observation | None, notes: list[dict], *, skipped: bool) -> str: + if skipped or observation is None: + label = state or "unknown" + return f"{skip_text(label)}\nSoftware status was not read." + lines = [ + "state: online", + f"Installed: {observation.car_version or '?'}", + f"Update status: {observation.software_update_status or 'none'}", + f"Update version: {observation.software_update_version or 'none'}", + "Release notes:", + ] + if not notes: + lines.append("No release notes were returned.") + for note in notes: + title = note.get("title") or "Note" + version = note.get("customer_version") + heading = f"{title} ({version})" if version else title + lines.append(heading) + if note.get("subtitle"): + lines.append(note["subtitle"]) + if note.get("description"): + lines.append(note["description"]) + return "\n".join(lines) + + +def format_software_json(state: str, observation: Observation | None, notes: list[dict], *, skipped: bool) -> str: + payload = { + "skipped": skipped, + "state": state or "unknown", + "installed": None if observation is None else observation.car_version, + "update_status": None if observation is None else observation.software_update_status, + "update_version": None if observation is None else observation.software_update_version, + "release_notes": notes if not skipped else [], + "text": format_software(state, observation, notes, skipped=skipped), + } + return json.dumps(payload, indent=2) + "\n" + + +def _note_list(payload: dict) -> list: + body = payload.get("response", payload) if isinstance(payload, dict) else None + if isinstance(body, dict) and isinstance(body.get("response"), dict): + nested = body["response"] + if "release_notes" in nested: + body = nested + if isinstance(body, dict) and isinstance(body.get("release_notes"), list): + return body["release_notes"] + if isinstance(body, list): + return body + return [] + + +def _text(value) -> str: + if value is None or isinstance(value, bool): + return "" + return " ".join(str(value).split()) diff --git a/core/ops.py b/core/ops.py index d66251b..cf0048b 100644 --- a/core/ops.py +++ b/core/ops.py @@ -1,9 +1,10 @@ """Vehicle reads and signed commands used by the command line. Status wakes a sleeping vehicle and polls for up to 45 seconds. The health -check, the daily report, where, charge-schedule show, and departure show -never wake. Unlock and other sensitive commands go through the safety -gateway, which asks for a one-time confirmation. +check, the daily report, where, charge-schedule show, departure show, +official charging history, and a software-status read never wake. Unlock +and other sensitive commands go through the safety gateway, which asks +for a one-time confirmation. Check and report also save local history when the car is already online. That write does not wake the car, and a history problem does not change the @@ -13,6 +14,8 @@ from __future__ import annotations import json +import os +import re import sys import time from dataclasses import dataclass @@ -20,7 +23,7 @@ from adapters.template import TemplateAdapter from core.config import load_regions -from core.errors import ConfigError, ConfirmationRequired, TokenError +from core.errors import CommandFailed, ConfigError, ConfirmationRequired, TokenError from core.fleet.area import WhereReport, area_from_vehicle_payload, precise_from_vehicle_payload, precise_point from core.fleet.charge_schedule import ( ScheduleReport, @@ -35,7 +38,9 @@ matching_ids as matching_departure_ids, schedules_from_vehicle_data as departure_schedules_from_vehicle_data, ) +from core.fleet.charging import format_history, format_invoice_saved, format_invoice_skipped, parse_invoice_id from core.fleet.commands import TeslaControl +from core.fleet.failures import clarify_command_failure from core.fleet.credentials import load_client_credentials from core.fleet.check_state import apply_rising_edge from core.fleet.health import ( @@ -48,7 +53,18 @@ from core.fleet.http import HttpError, UrllibClient from core.fleet.queries import FleetReader, QueryError, VehicleAsleep, location_scope_denied from core.fleet.report import VehicleReport, skipped_vehicle_report, skip_text, vehicle_report -from core.fleet.snapshot import SEAT_API_FIELDS, SEAT_HEATER_LEVELS, Observation, from_vehicle_data, seat_heater_names +from core.fleet.snapshot import ( + SEAT_API_FIELDS, + SEAT_COOLER_LEVELS, + SEAT_COOLER_NAMES, + SEAT_HEATER_LEVELS, + Observation, + cable_plugged, + driver_seat_text, + from_vehicle_data, + seat_heater_names, +) +from core.fleet.software import format_software, format_software_json, notes_from from core.fleet.tokens import AtomicTokenStore, FleetTokenRefresher, TokenManager, audience_for from core.garage import resolve_vehicle from core.paths import resolve_key, resolve_token_file @@ -88,6 +104,18 @@ "trunk-close": "trunk_close", "frunk-open": "frunk_open", "remote-start": "remote_start", + "climate-keeper-off": "climate_keeper_off", + "climate-keeper-keep": "climate_keeper_keep", + "climate-keeper-dog": "climate_keeper_dog", + "climate-keeper-camp": "climate_keeper_camp", + "charge-port-open": "charge_port_open", + "charge-port-close": "charge_port_close", + "seat-cool": "seat_cooler", + "seat-climate": "auto_seat_climate", + "bioweapon-on": "bioweapon_on", + "bioweapon-off": "bioweapon_off", + "software-schedule": "software_schedule", + "software-cancel": "software_cancel", } @@ -116,6 +144,49 @@ def parse_seat_heat(seat: str, level: str) -> tuple[str, str]: return seat_name, level_name +def parse_seat_cool(seat: str, level: str) -> tuple[str, str]: + """Front seats only, at the levels the signed seat-cooler command accepts.""" + seat_name = " ".join(str(seat).strip().lower().split()) + level_name = str(level).strip().lower() + allowed = ", ".join(SEAT_COOLER_NAMES) + if seat_name not in SEAT_COOLER_NAMES: + raise ConfigError(f"seat cooling supports {allowed}") + if level_name not in SEAT_COOLER_LEVELS: + raise ConfigError("seat cooling level must be off, low, medium, or high") + return seat_name, level_name + + +def parse_seat_climate(seat: str, state: str) -> tuple[str, str]: + """Map a front seat to the signed auto-seat-and-climate position token.""" + seat_name = " ".join(str(seat).strip().lower().split()) + state_name = str(state).strip().lower() + tokens = {"front-left": "L", "front-right": "R", "both": "LR"} + if seat_name not in tokens: + raise ConfigError("automatic seat climate supports front-left, front-right, or both") + if state_name not in {"on", "off"}: + raise ConfigError("automatic seat climate must be on or off") + return tokens[seat_name], state_name + + +_DELAY = re.compile(r"^(?:(\d+(?:\.\d+)?)h)?(?:(\d+(?:\.\d+)?)m)?(?:(\d+(?:\.\d+)?)s)?$") +_DELAY_LIMIT = 7 * 24 * 3600 + + +def parse_update_delay(value: str) -> str: + """A tesla-control duration: 2h, 10m, 30s, 1h30m, a whole number of seconds, or now.""" + text = " ".join(str(value).strip().lower().split()) + if text in {"now", "0"}: + return "0s" + if text.isdigit(): + seconds = int(text) + if seconds > _DELAY_LIMIT: + raise ConfigError("delay must be at most 7 days") + return f"{seconds}s" + if not _DELAY.fullmatch(text) or not any(char.isdigit() for char in text): + raise ConfigError("delay must be a duration such as 2h, 10m, 30s, or a whole number of seconds") + return text + + def parse_on_off(value: str, label: str) -> str: text = str(value).strip().lower() if text not in {"on", "off"}: @@ -273,7 +344,7 @@ def status(self, *, preferred: str | None, sleep=time.sleep) -> str: text = format_status(chosen, observation, key) return redact(text, [key, preferred or ""]) - def check(self, *, preferred: str | None, tyre_bar: float, low_battery_percent: int) -> CheckReport: + def check(self, *, preferred: str | None, tyre_bar: float, low_battery_percent: int, now=None) -> CheckReport: selection = self._bind(preferred) chosen = selection.row state = vehicle_state_name(chosen) or "unknown" @@ -285,7 +356,7 @@ def check(self, *, preferred: str | None, tyre_bar: float, low_battery_percent: # History records the full online reading. Notification dedupe is separate, # and a skipped check never reaches this write. self._remember(payload, observation, tyre_bar=tyre_bar, low_battery_percent=low_battery_percent, store=selection.store) - return apply_rising_edge(selection.store, report) + return apply_rising_edge(selection.store, report, now=now) def peek(self, *, preferred: str | None) -> ParkedPeek: """Read battery, climate, and schedules when the car is already online. @@ -441,29 +512,102 @@ def command( self.control.run(vin=key, access_token=token, args=["wake"]) return _tagged("OK: wake", selection.label) _key, observation, label = self._read_online(preferred, sleep=sleep) + self._reject_unsupported(name, _key, observation) token = self.tokens.access_token() def observe() -> Observation: payload = self.reader.vehicle_data(_key, DATA_ENDPOINTS) return from_vehicle_data(payload) - observed = self.gateway.execute( - spec, - shift_state=observation.shift_state, - speed=observation.speed, - adapter=_cli_adapter(statement), - via_schedule=False, - vin=_key, - access_token=token, - observe=observe, - extra_args=extra_args, - car=label, - ) + try: + observed = self.gateway.execute( + spec, + shift_state=observation.shift_state, + speed=observation.speed, + adapter=_cli_adapter(statement), + via_schedule=False, + vin=_key, + access_token=token, + observe=observe, + extra_args=extra_args, + car=label, + ) + except CommandFailed as exc: + clarified = clarify_command_failure(name, str(exc)) + if clarified: + raise CommandFailed(clarified) from exc + raise if name == "charge-amps" and extra_args: return _tagged(charge_amps_result(extra_args[0], observed), label) - if extra_args: - return _tagged(f"OK: {name} {' '.join(extra_args)}", label) - return _tagged(f"OK: {name}", label) + return _tagged(f"OK: {_result_name(name, extra_args)}", label) + + def _reject_unsupported(self, name: str, key: str, observation: Observation) -> None: + """Stop before a signed command when the reading already explains the failure.""" + if name == "seat-cool": + supported = observation.seat_cooling + if supported is None: + payload = self.reader.vehicle_data(key, ["vehicle_config"]) + supported = from_vehicle_data(payload).seat_cooling + if supported is False: + raise ConfigError("This car does not have seat cooling.") + if observation.climate_on is not True: + raise ConfigError("Climate is not on, so seat cooling was not changed.") + if name == "seat-climate" and observation.climate_on is not True: + raise ConfigError("Climate is not on, so automatic seat climate was not changed.") + if name == "charge-port-close" and cable_plugged(observation): + raise ConfigError("The charge port did not close because a cable is plugged in.") + + def charging_history(self, *, as_json: bool = False) -> str: + """Official account charging history. This does not list vehicles and does not wake a car.""" + return format_history(self.reader.charging_history(), as_json=as_json) + + def charging_invoice(self, invoice_id: str, *, save: bool) -> str: + """Save the PDF under the config home only when save is true.""" + identified = parse_invoice_id(invoice_id) + if not save: + return format_invoice_skipped(identified) + data = self.reader.charging_invoice_pdf(identified) + directory = self.home / "invoices" + directory.mkdir(parents=True, exist_ok=True) + if os.name != "nt": + os.chmod(directory, 0o700) + path = directory / f"{identified}.pdf" + fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.write(fd, data) + finally: + os.close(fd) + if os.name != "nt": + os.chmod(path, 0o600) + return format_invoice_saved(str(path)) + + def software(self, *, preferred: str | None, as_json: bool = False) -> str: + """Status and release notes. A sleeping car is not woken.""" + try: + selection = self._bind(preferred) + except VehicleAsleep: + return _software_text("asleep", None, [], skipped=True, as_json=as_json) + chosen = selection.row + state = vehicle_state_name(chosen) or "unknown" + if state != "online": + return _software_text(state, None, [], skipped=True, as_json=as_json) + key = vehicle_key(chosen) + try: + payload = self.reader.vehicle_data(key, DATA_ENDPOINTS) + except VehicleAsleep: + return _software_text("asleep", None, [], skipped=True, as_json=as_json) + observation = from_vehicle_data(payload) + notes: list[dict] = [] + try: + notes = notes_from( + self.reader.release_notes(key, staged=observation.software_update_status is not None) + ) + except VehicleAsleep: + return _software_text("asleep", None, [], skipped=True, as_json=as_json) + except (TimeoutError, HttpError, QueryError): + notes = [] + self._remember(payload, observation, store=selection.store) + return _software_text("online", observation, notes, skipped=False, as_json=as_json) def _bind(self, preferred: str | None): return resolve_vehicle( @@ -670,6 +814,12 @@ def _tagged(text: str, label: str) -> str: def _extra(name: str, argument: str | None) -> list[str] | None: if name == "seat-heat": raise ConfigError("seat and level are required") + if name == "seat-cool": + raise ConfigError("seat and level are required") + if name == "seat-climate": + raise ConfigError("seat and state are required") + if name == "software-schedule": + raise ConfigError("delay must be a duration such as 2h, 10m, 30s, or a whole number of seconds") if name == "steering-heat": raise ConfigError("steering heat must be on or off") if name == "set-temp": @@ -762,13 +912,39 @@ def _redact_report(report: VehicleReport, secrets: list[str]) -> VehicleReport: return VehicleReport(skipped=report.skipped, state=report.state, text=redact(report.text, secrets), fields=fields) +def _result_name(name: str, extra_args: list[str] | None) -> str: + if name.startswith("climate-keeper-"): + return "climate-keeper " + name.removeprefix("climate-keeper-") + if name.startswith("charge-port-"): + return "charge-port " + name.removeprefix("charge-port-") + if name.startswith("bioweapon-"): + return "bioweapon " + name.removeprefix("bioweapon-") + if name == "software-schedule" and extra_args: + return "software schedule " + extra_args[0] + if name == "software-cancel": + return "software cancel" + if name == "seat-climate" and extra_args and len(extra_args) >= 2: + seats = {"L": "front-left", "R": "front-right", "LR": "both"} + return f"seat-climate {seats.get(extra_args[0], extra_args[0])} {extra_args[1]}" + if extra_args: + return name + " " + " ".join(extra_args) + return name + + +def _software_text(state, observation, notes, *, skipped: bool, as_json: bool) -> str: + if as_json: + return format_software_json(state, observation, notes, skipped=skipped) + return format_software(state, observation, notes, skipped=skipped) + + def format_check(report: CheckReport) -> str: if report.skipped: return skip_text(report.state) + lines = ["state: online", f"Driver seat: {driver_seat_text(report.driver_present)}"] if not report.alerts: - return "state: online\nno alerts" - lines = ["state: online"] - lines.extend(f"alert: {item.message}" for item in report.alerts) + lines.append("no alerts") + else: + lines.extend(f"alert: {item.message}" for item in report.alerts) return "\n".join(lines) diff --git a/core/safety/catalog.py b/core/safety/catalog.py index b37ebc4..08e786d 100644 --- a/core/safety/catalog.py +++ b/core/safety/catalog.py @@ -17,6 +17,9 @@ # Charging current is upstream `charging-set-amps` (`set_charging_amps`). # Max defrost is `preconditioning-max`, added to the local tesla-control build because # upstream does not register SetPreconditioningMax. The manual override is not sent. +# Climate keeper, seat cooling, and bioweapon defense are added the same way. +# Charge port open/close and software schedule/cancel are already upstream commands. +# Automatic seat climate is upstream `auto-seat-and-climate`. TOOL_ARGS: dict[str, tuple[str, ...]] = { "flash_lights": ("flash-lights",), "climate_on": ("climate-on",), @@ -44,6 +47,18 @@ "charging_schedule_remove": ("charging-schedule-remove",), "departure_schedule_set": ("precondition-schedule-add",), "departure_schedule_remove": ("precondition-schedule-remove",), + "climate_keeper_off": ("climate-keeper", "off"), + "climate_keeper_keep": ("climate-keeper", "keep"), + "climate_keeper_dog": ("climate-keeper", "dog"), + "climate_keeper_camp": ("climate-keeper", "camp"), + "charge_port_open": ("charge-port-open",), + "charge_port_close": ("charge-port-close",), + "seat_cooler": ("seat-cooler",), + "auto_seat_climate": ("auto-seat-and-climate",), + "bioweapon_on": ("bioweapon-mode", "on"), + "bioweapon_off": ("bioweapon-mode", "off"), + "software_schedule": ("software-update-start",), + "software_cancel": ("software-update-cancel",), } VERIFY_KIND = { @@ -73,6 +88,18 @@ "charging_schedule_remove": "charge_schedule_remove", "departure_schedule_set": "departure_schedule_set", "departure_schedule_remove": "departure_schedule_remove", + "climate_keeper_off": "climate_keeper_off", + "climate_keeper_keep": "climate_keeper_keep", + "climate_keeper_dog": "climate_keeper_dog", + "climate_keeper_camp": "climate_keeper_camp", + "charge_port_open": "charge_port_open", + "charge_port_close": "charge_port_closed", + "seat_cooler": "seat_cooler", + "auto_seat_climate": "auto_seat_climate", + "bioweapon_on": "bioweapon_on", + "bioweapon_off": "bioweapon_off", + "software_schedule": "software_scheduled", + "software_cancel": "software_cancelled", } @@ -83,6 +110,14 @@ "frunk_open": "open frunk", "remote_start": "enable remote start", "sentry_off": "turn sentry off", + "climate_keeper_keep": "climate keeper keep", + "climate_keeper_dog": "climate keeper dog", + "climate_keeper_camp": "climate keeper camp", + "charge_port_open": "open charge port", + "charge_port_close": "close charge port", + "software_schedule": "schedule software update", + "software_cancel": "cancel software update", + "bioweapon_on": "turn bioweapon defense on", } # Normalized command strings a schedule must not store. `drive` is the signed remote-start command. @@ -165,6 +200,8 @@ def schedule_blocks(command, sensitive: set[str] | None = None) -> bool: return True if _blocks_charge_schedule(raw, text) or _blocks_charge_amps(text) or _blocks_departure_change(text): return True + if _blocks_phase8(text): + return True if _blocks_request_approval(text): return True head = _schedule_text(raw.split()[0]) @@ -190,6 +227,26 @@ def _blocks_request_approval(text: str) -> bool: return text.startswith("request approve ") or text.startswith("request deny ") +def _blocks_phase8(text: str) -> bool: + """Block confirmation-required phase 8 commands. Reads and off states stay allowed.""" + parts = text.split() + if parts[:2] == ["climate", "keeper"] and len(parts) >= 3 and parts[2] in {"keep", "dog", "camp"}: + return True + if text.startswith("climate-keeper keep") or text.startswith("climate-keeper dog") or text.startswith("climate-keeper camp"): + return True + if parts[:2] == ["bioweapon", "on"] or text.startswith("bioweapon-on") or text.startswith("bioweapon on"): + return True + if parts[:2] == ["charge", "port"] or text.startswith("charge-port"): + return True + if parts[:2] in (["software", "schedule"], ["software", "cancel"]): + return True + if text.startswith("software-schedule") or text.startswith("software-cancel"): + return True + if text.startswith("software-update-start") or text.startswith("software-update-cancel"): + return True + return False + + def _blocks_departure_change(text: str) -> bool: """Block departure writes. A show read stays allowed.""" parts = text.split() diff --git a/core/safety/gateway.py b/core/safety/gateway.py index a928023..7cac54f 100644 --- a/core/safety/gateway.py +++ b/core/safety/gateway.py @@ -6,7 +6,7 @@ from core.fleet.commands import TeslaControl from core.fleet.charge_schedule import charge_schedule_satisfied from core.fleet.departure import departure_satisfied -from core.fleet.snapshot import SEAT_HEATER_LEVELS, Observation, defrost_state +from core.fleet.snapshot import SEAT_COOLER_LEVELS, SEAT_HEATER_LEVELS, Observation, defrost_state from core.safety.catalog import CommandSpec, load_specs from core.safety.confirm import Confirmation, ConfirmationBroker, new_challenge from core.safety.parked import require_parked @@ -108,6 +108,24 @@ def _verify(kind: str, observed: Observation, extra_args: list[str]) -> None: return if kind in {"departure_schedule_set", "departure_schedule_remove"} and departure_satisfied(kind, observed, extra_args): return + if kind.startswith("climate_keeper_") and observed.climate_keeper == kind.removeprefix("climate_keeper_"): + return + if kind == "charge_port_open" and observed.charge_port_open is True: + return + if kind == "charge_port_closed" and observed.charge_port_open is False: + return + if kind == "seat_cooler" and _seat_cool_matches(observed, extra_args): + return + if kind == "auto_seat_climate" and _auto_seat_matches(observed, extra_args): + return + if kind == "bioweapon_on" and observed.bioweapon_on is True: + return + if kind == "bioweapon_off" and observed.bioweapon_on is False: + return + if kind == "software_scheduled" and (observed.software_update_status or "").lower() == "scheduled": + return + if kind == "software_cancelled" and (observed.software_update_status or "").lower() != "scheduled": + return raise VerificationFailed("The vehicle state after the command did not match the request. The command was not sent again.") @@ -145,6 +163,31 @@ def _seat_matches(observed: Observation, extra_args: list[str]) -> bool: return heaters.get(extra_args[0]) == expected +def _seat_cool_matches(observed: Observation, extra_args: list[str]) -> bool: + if len(extra_args) < 2: + return False + expected = SEAT_COOLER_LEVELS.get(extra_args[1]) + if expected is None: + return False + coolers = observed.seat_cool or {} + return coolers.get(extra_args[0]) == expected + + +def _auto_seat_matches(observed: Observation, extra_args: list[str]) -> bool: + if len(extra_args) < 2 or extra_args[1] not in {"on", "off"}: + return False + token = extra_args[0] + if not token or any(char not in {"L", "R"} for char in token): + return False + expected = extra_args[1] == "on" + seats = [] + if "L" in token: + seats.append(observed.auto_seat_left) + if "R" in token: + seats.append(observed.auto_seat_right) + return bool(seats) and all(item is expected for item in seats) + + def _steering_matches(observed: Observation, extra_args: list[str]) -> bool: if not extra_args or extra_args[0] not in {"on", "off"}: return False diff --git a/docs/00-before-you-start.md b/docs/00-before-you-start.md index 40d2a3c..80074e4 100644 --- a/docs/00-before-you-start.md +++ b/docs/00-before-you-start.md @@ -38,7 +38,11 @@ Advice does not change the car. A charging change still needs my confirmation. Also schedule a health check that runs agentdrive check --json every 30 minutes. Notify me only when it exits 1. Exit 0 means nothing new, including when the car is asleep and when the same alert is still active. That alert stays quiet until -it clears and later returns. Do not wake the car from that check, and do not +it clears and later returns. An energy-mode alert repeats at most once an hour +while climate, climate keeper, or bioweapon defense stays on and the driver seat +is empty, and it is not sent when someone is in the driver seat. The driver-seat +reading covers only the driver seat and is not a guarantee nobody is inside. +Do not wake the car from that check, and do not schedule unlock, turning sentry off, the trunk, the front trunk, remote start, a charging change, or a departure schedule change. This check is separate from the one daily morning job above. AgentDrive does not run it itself. If several cars are on the account, ask me for a nickname for each, run `agentdrive cars name`, and set a default only if I choose one. Pass `--car ` when a command is for one car. With one car, keep the commands as they are. With several cars, run the health check and `agentdrive history due` once per car. A confirmation names the car, for example `I confirm unlock on daily`. @@ -97,7 +101,7 @@ bash setup/install.sh `gate-present` prints the disclaimer and the security file in full. -After install, the AI agent schedules `agentdrive check --json` every 30 minutes. Notify only when that command exits 1. Exit 0 means nothing new, including when the car is asleep and when the same alerts are still active. The same alert stays quiet until it clears and later returns. The check does not wake the car. Do not schedule unlock, turning sentry off, the trunk, the front trunk, remote start, a charging change, or a departure schedule change on it. AgentDrive does not run that job. It is separate from the daily morning briefing. Precise coordinates are used only when you ask for a precise location or a departure place taken from the car, and they are not saved in the local history. +After install, the AI agent schedules `agentdrive check --json` every 30 minutes. Notify only when that command exits 1. Exit 0 means nothing new, including when the car is asleep and when the same alerts are still active. The same alert stays quiet until it clears and later returns. An energy-mode alert (climate on, climate keeper keep, dog, or camp, or bioweapon defense) while parked and the driver seat is empty repeats at most once an hour until the mode is off, and it is not sent when someone is in the driver seat. The parked-and-unlocked alert is also skipped when someone is in the driver seat. That reading covers only the driver seat and is not a guarantee nobody is inside. The check does not wake the car. Do not schedule unlock, turning sentry off, the trunk, the front trunk, remote start, a charging change, or a departure schedule change on it. AgentDrive does not run that job. It is separate from the daily morning briefing. Precise coordinates are used only when you ask for a precise location or a departure place taken from the car, and they are not saved in the local history. After install, the AI agent reads `agentdrive report-schedule`. The default is 07:30 local time and enabled. It creates one daily job at that time that runs `agentdrive history due` and sends that usage briefing. On Monday it sends the previous week, on the 1st the previous month, and on 1 January the previous year. Every other morning it sends the previous day. `agentdrive report` remains available when you want the car's current status, including the skip line when the car is asleep. A later change of time, or turning the briefing off, is `agentdrive report-schedule set HH:MM`, `disable`, or `enable`. The AI agent then updates its own job. AgentDrive does not install that job. diff --git a/docs/01-install-and-safety.md b/docs/01-install-and-safety.md index c42b8a0..6094f80 100644 --- a/docs/01-install-and-safety.md +++ b/docs/01-install-and-safety.md @@ -32,7 +32,7 @@ A state-changing command is sent only when the shift state is Park and the speed ## After install — health check -On install, the AI agent schedules `agentdrive check --json` every 30 minutes. The check is read-only and does not wake a sleeping car. Notify only when the exit code is 1. Exit 0 means nothing new, including when the car is asleep and when the same alerts are still active. The same alert stays quiet until it clears and later returns. Do not schedule unlock, turning sentry off, the trunk, the front trunk, remote start, a charging change, or a departure schedule change. AgentDrive does not run that job and does not install cron. The morning usage briefing stays the separate daily job described below. +On install, the AI agent schedules `agentdrive check --json` every 30 minutes. The check is read-only and does not wake a sleeping car. Notify only when the exit code is 1. Exit 0 means nothing new, including when the car is asleep and when the same alerts are still active. The same alert stays quiet until it clears and later returns. An energy-mode alert (climate on, climate keeper keep, dog, or camp, or bioweapon defense) while parked and the driver seat is empty repeats at most once an hour until the mode is off, and it is not sent when someone is in the driver seat. The parked-and-unlocked alert is also skipped when someone is in the driver seat. That reading covers only the driver seat and is not a guarantee nobody is inside. Do not schedule unlock, turning sentry off, the trunk, the front trunk, remote start, a charging change, a departure schedule change, climate keeper keep, dog, or camp, bioweapon defense on, the charge port, or a software update. AgentDrive does not run that job and does not install cron. The morning usage briefing stays the separate daily job described below. ## After install — daily report diff --git a/docs/03-developer-app.md b/docs/03-developer-app.md index 545515f..948ae9c 100644 --- a/docs/03-developer-app.md +++ b/docs/03-developer-app.md @@ -9,7 +9,7 @@ The full form steps are in [Developer application](prerequisites/developer-app.m ## What you type 1. Sign in at with the vehicle account. -2. Create an application with a unique English name and a description of personal use of your own car. Grant `openid`, `offline_access`, `user_data`, `vehicle_device_data`, `vehicle_location`, and `vehicle_cmds`. `vehicle_location` is required for a location read. +2. Create an application with a unique English name and a description of personal use of your own car. Grant `openid`, `offline_access`, `user_data`, `vehicle_device_data`, `vehicle_location`, `vehicle_cmds`, and `vehicle_charging_cmds`. `vehicle_location` is required for a location read. `vehicle_charging_cmds` is required for official charging history and invoices. 3. Set the allowed origin to the bare hostname, such as `your-username.github.io`. 4. Set the redirect to `https://your-username.github.io/callback`. 5. Save the client id and client secret into `~/.config/agentdrive/client.json` (or `$AGENTDRIVE_HOME/client.json`) with keys `client_id` and `client_secret`, mode 0600, or into mode 0600 files under `~/.config/agentdrive/secrets/` (or `$AGENTDRIVE_HOME/secrets/`). The command line also reads `AGENTDRIVE_CLIENT_ID` and `AGENTDRIVE_CLIENT_SECRET`. Do not paste the secret into chat. A secret can contain `&`; the token request is form-encoded. diff --git a/docs/06-queries-and-commands.md b/docs/06-queries-and-commands.md index 8655d8a..bc37815 100644 --- a/docs/06-queries-and-commands.md +++ b/docs/06-queries-and-commands.md @@ -12,7 +12,7 @@ Reads use the Fleet API: the vehicle list, `vehicle_data`, and wake. Location on Health checks, alert memory, the morning briefing, and the encrypted history are kept per car. The first car you name keeps the history and alert file that were already in the config home. Another car gets its own files under the config home. An alert that is already active on one car is still new on another car. `agentdrive history`, `agentdrive tips`, and `agentdrive service` read the selected car and do not contact it. With several cars and no default, pass `--car`. The morning job runs `agentdrive history due` once per car when you have several. The health check runs `agentdrive check --json --car ` once per car. With one car, both commands stay as they are. -`agentdrive check` is the read-only health check for a schedule. It reads the vehicle list first and does not wake a sleeping car. If the car is not online, the command skips, prints that it skipped, and exits 0. That skip does not change which alerts are remembered. If the car is online it looks for a TPMS warning, a tyre below the configured bar threshold, a low battery that is not charging, sentry off while that same low battery is not charging, the car unlocked or open while parked, or a charge error. It exits 1 only when an alert is new. The printed alerts are those new ones. The same alert exits 0, and is not printed again, until it clears and later returns. A different new alert still exits 1 while an older one continues. `--json` prints a short object for the AI agent. On install, schedule `agentdrive check --json` every 30 minutes and notify only when the exit code is 1. AgentDrive does not run that job and does not install cron. The morning usage briefing stays the separate daily job at the report-schedule time. Thresholds come from `--tyre-threshold`, `--battery-threshold`, or the `tyre_pressure_bar` and `low_battery_percent` habits in the profile. The default tyre line is 2.5 bar and the default battery line is 30 percent. Sentry off uses that same battery line. The check does not request location. Remembered alert codes are stored in `$AGENTDRIVE_HOME/check-alerts.json` (or `~/.config/agentdrive/check-alerts.json`). That file is alert codes only. Do not copy it into the git repo. +`agentdrive check` is the read-only health check for a schedule. It reads the vehicle list first and does not wake a sleeping car. If the car is not online, the command skips, prints that it skipped, and exits 0. That skip does not change which alerts are remembered. If the car is online it looks for a TPMS warning, a tyre below the configured bar threshold, a low battery that is not charging, sentry off while that same low battery is not charging, the car unlocked or open while parked, a charge error, or an energy-heavy mode left on while parked. When the car is online, check and report also say whether someone appears to be in the driver seat, or that no one was detected in the driver seat. That reading is `vehicle_state.is_user_present`. It covers only the driver seat. It is not a guarantee that nobody is inside. There is no rear-seat, passenger, child, or pet detection. If someone appears to be in the driver seat, the parked-and-unlocked alert is not sent. Energy-mode alerts are climate left on, climate keeper set to keep, dog, or camp, and bioweapon defense mode. They are sent only when the car is parked and no one is in the driver seat. Each of those repeats at most once an hour while that mode stays on, which is the exception to the usual once-until-cleared rule, and it stops when the mode turns off. Other alerts still stay quiet until they clear. It exits 1 only when an alert is new, or when an energy-mode alert is due again. The printed alerts are those. The same ordinary alert exits 0, and is not printed again, until it clears and later returns. A different new alert still exits 1 while an older one continues. `--json` prints a short object for the AI agent. On install, schedule `agentdrive check --json` every 30 minutes and notify only when the exit code is 1. AgentDrive does not run that job and does not install cron. The morning usage briefing stays the separate daily job at the report-schedule time. Thresholds come from `--tyre-threshold`, `--battery-threshold`, or the `tyre_pressure_bar` and `low_battery_percent` habits in the profile. The default tyre line is 2.5 bar and the default battery line is 30 percent. Sentry off uses that same battery line. The check does not request location. Remembered alert codes are stored in `$AGENTDRIVE_HOME/check-alerts.json` (or `~/.config/agentdrive/check-alerts.json`). That file is alert codes and, for an energy-mode alert, the last time it was announced. It has no VIN, no location, and no alert text. Do not copy it into the git repo. `agentdrive where` is a read-only coarse area. It reads the vehicle list first. If the car is asleep or offline, it skips with the same kind of message as `check`, does not wake the car, and exits 0. If the car is already online, it calls `vehicle_data` with `endpoints=location_data` only. @@ -28,9 +28,9 @@ agentdrive where --precise agentdrive where --precise --json ``` -A location read needs the official `vehicle_location` scope. The owner sign-in scope list is `openid`, `offline_access`, `user_data`, `vehicle_device_data`, `vehicle_location`, and `vehicle_cmds`. If the token lacks `vehicle_location`, or the firmware refuses the read until that scope is granted, the command tells you to re-authorize and include `vehicle_location`, and it exits 1. Sign in again so the new token carries that scope. `status` and `check` stay free of location. +A location read needs the official `vehicle_location` scope. The owner sign-in scope list is `openid`, `offline_access`, `user_data`, `vehicle_device_data`, `vehicle_location`, `vehicle_cmds`, and `vehicle_charging_cmds`. `vehicle_charging_cmds` is what official charging history and invoices use. If the token lacks `vehicle_location`, or the firmware refuses the read until that scope is granted, the command tells you to re-authorize and include `vehicle_location`, and it exits 1. Sign in again so the new token carries that scope. `status` and `check` stay free of location. -`agentdrive report` is the daily briefing. It uses that same parked snapshot (`charge_state`, `climate_state`, `vehicle_state`, and `drive_state`). It does not request `location_data` and it does not print coordinates. It does not call wake. If the vehicle list says the car is asleep, offline, or otherwise not online, it prints the same skip line as `check` and exits 0. A timeout or HTTP 408 is the same skip. When the car is online the briefing includes battery, charge connection, lock, gear, doors, windows, trunk, sentry, climate, tyres, and TPMS. `--json` prints the same briefing as an object. The AI agent sends that output every time. Exit 0 here is not a reason to stay quiet. +`agentdrive report` is the daily briefing. It uses that same parked snapshot (`charge_state`, `climate_state`, `vehicle_state`, and `drive_state`). It does not request `location_data` and it does not print coordinates. It does not call wake. If the vehicle list says the car is asleep, offline, or otherwise not online, it prints the same skip line as `check` and exits 0. A timeout or HTTP 408 is the same skip. When the car is online the briefing includes battery, charge connection, lock, whether someone appears to be in the driver seat, gear, doors, windows, trunk, sentry, climate, tyres, and TPMS. The driver-seat line covers only the driver seat. It is not a guarantee that nobody is inside. There is no rear-seat, passenger, child, or pet detection. `--json` prints the same briefing as an object. The AI agent sends that output every time. Exit 0 here is not a reason to stay quiet. `agentdrive report-schedule` shows the daily time. The default is 07:30 local time and enabled, even when `runtime.yaml` has no `report_schedule` key. `agentdrive report-schedule set HH:MM` stores a 24-hour time. `disable` and `enable` store the on/off state. The file is `$AGENTDRIVE_HOME/runtime.yaml` (or `~/.config/agentdrive/runtime.yaml`). Writing it does not change the AI agent's job. After a change, the AI agent updates the job. The command also prints a crontab example, such as `30 7 * * * agentdrive history due`. AgentDrive does not install that line and does not install a systemd timer. @@ -84,6 +84,18 @@ Commands: | `trunk-close` | signed `trunk-close` | yes, one time. The sentence names close rear trunk | | `frunk-open` | signed `frunk-open` | yes, one time. The sentence names open frunk | | `remote-start` | signed `drive` | yes, one time. The sentence names enable remote start | +| `climate-keeper off` | signed `climate-keeper off` | no | +| `climate-keeper keep`, `dog`, `camp` | signed `climate-keeper` | yes, one time, because it uses energy. The sentence names climate keeper keep, climate keeper dog, or climate keeper camp | +| `charge-port open`, `charge-port close` | signed `charge-port-open` or `charge-port-close` | yes, one time. The sentence names open charge port or close charge port | +| `seat-cool ` | signed `seat-cooler` | no | +| `seat-climate on` or `off` | signed `auto-seat-and-climate` | no | +| `bioweapon off` | signed `bioweapon-mode off` | no | +| `bioweapon on` | signed `bioweapon-mode on` | yes, one time. The sentence names turn bioweapon defense on | +| `software` | no signed command; vehicle state and `release_notes` when the car is already online | no, and it does not wake | +| `software schedule ` | signed `software-update-start` | yes, one time. The sentence names schedule software update | +| `software cancel` | signed `software-update-cancel` | yes, one time. The sentence names cancel software update | +| `charging-history` | `GET /api/1/dx/charging/history` | no, and it does not wake the car. Home charging is not included | +| `charging-invoice --save` | `GET /api/1/dx/charging/invoice/{id}` | no, and it does not wake the car. The PDF is written only when you ask | | `history trips`, `history charges`, `history snapshots` | no signed command; saved history only | no, and it does not contact the car | | `history due`, `history daily`, `history weekly`, `history monthly`, `history yearly` | no signed command; saved history only. Week, month, and year also write a local chart | no | | `advice trip `, `advice charge` | no signed command; saved history, plus a non-waking read when the car is already online | no, and it does not wake the car or change a setting | @@ -92,7 +104,7 @@ Commands: | `news` | no signed command; reads the official Tesla site for the owner's country | no, and it does not wake the car | | `service`, `service log rotation`, `service log replacement`, `service log maintenance` | no signed command; uses the saved odometer | no, and it does not wake the car | -Unlock, turning sentry off, every charging change, setting or removing a departure schedule, the rear trunk, the front trunk, and remote start go through the existing confirmation layer. A schedule cannot confirm them and cannot run sentry off, the trunk, the front trunk, remote start, or a departure schedule change. When a nickname is in use for that command, the confirmation also names the car. `I confirm unlock on daily` approves unlock for the car named daily. `I confirm unlock` does not. With one car and no `--car`, the sentence stays `I confirm unlock`. The prompt is `Confirm unlock on daily. Type a sentence that names the action and the car and includes confirm:`. The result line names the car too: `OK: unlock on daily`. The health check stays `agentdrive check`. The morning job stays `agentdrive history due`. State-changing commands are sent only in Park at speed 0, then read back once. +Unlock, turning sentry off, every charging change, setting or removing a departure schedule, the rear trunk, the front trunk, remote start, climate keeper keep, dog, and camp, opening or closing the charge port, turning bioweapon defense on, and scheduling or cancelling a software update go through the existing confirmation layer. A schedule cannot confirm them and cannot run sentry off, the trunk, the front trunk, remote start, a departure schedule change, climate keeper keep, dog, or camp, the charge port, bioweapon defense on, or a software update. When a nickname is in use for that command, the confirmation also names the car. `I confirm unlock on daily` approves unlock for the car named daily. `I confirm unlock` does not. With one car and no `--car`, the sentence stays `I confirm unlock`. The prompt is `Confirm unlock on daily. Type a sentence that names the action and the car and includes confirm:`. The result line names the car too: `OK: unlock on daily`. The health check stays `agentdrive check`. The morning job stays `agentdrive history due`. State-changing commands are sent only in Park at speed 0, then read back once. ## Several cars @@ -109,7 +121,7 @@ agentdrive unlock --car weekend --confirm "I confirm unlock on weekend" ## Family members -You can register a handle for someone in the household and choose what they may ask for. Low-risk reads and parked actions that you allow run directly. Unlock, charging changes, turning sentry off, the trunk, the front trunk, remote start, and departure changes do not. Those become a pending request. The command prints text for the assistant to relay to you. AgentDrive does not send that message itself. +You can register a handle for someone in the household and choose what they may ask for. Low-risk reads and parked actions that you allow run directly. Unlock, charging changes, turning sentry off, the trunk, the front trunk, remote start, departure changes, climate keeper keep, dog, and camp, bioweapon defense on, the charge port, and a software update do not. Those become a pending request. The command prints text for the assistant to relay to you. AgentDrive does not send that message itself. ```bash agentdrive family add rider @@ -143,6 +155,57 @@ This action was not run. Tell the owner: rider asked to unlock on daily. Approve with `agentdrive request approve ab12cd34` or deny with `agentdrive request deny ab12cd34`. ``` +## Climate keeper, charge port, seats, bioweapon defense, and software + +```bash +agentdrive climate-keeper off +agentdrive climate-keeper keep --confirm "I confirm climate keeper keep" +agentdrive climate-keeper dog --confirm "I confirm climate keeper dog" +agentdrive climate-keeper camp --confirm "I confirm climate keeper camp" +agentdrive charge-port open --confirm "I confirm open charge port" +agentdrive charge-port close --confirm "I confirm close charge port" +agentdrive seat-cool front-left low +agentdrive seat-climate front-left on +agentdrive seat-climate both off +agentdrive bioweapon off +agentdrive bioweapon on --confirm "I confirm turn bioweapon defense on" +agentdrive software +agentdrive software schedule 2h --confirm "I confirm schedule software update" +agentdrive software cancel --confirm "I confirm cancel software update" +``` + +`climate-keeper` signs `set_climate_keeper_mode`. `off` does not need a confirmation. `keep`, `dog`, and `camp` do, because they use energy. A scheduled job cannot supply that sentence. Upstream `tesla-control` does not include this subcommand, so the installer adds `climate-keeper` to the local build. The manual override is not sent. + +`charge-port open` and `charge-port close` sign `charge_port_door_open` and `charge_port_door_close`. Both need a confirmation. If the port does not close because a cable is plugged in, the command says so and is not sent again. + +`seat-cool` signs `remote_seat_cooler_request` for `front-left` or `front-right`, at `off`, `low`, `medium`, or `high`. `seat-climate` signs `remote_auto_seat_climate_request` for `front-left`, `front-right`, or `both`. If the car has no seat cooling, or the climate is not on, the command says so and does not keep trying. Upstream `tesla-control` does not include seat cooling, so the installer adds `seat-cooler`. Automatic seat climate is the upstream `auto-seat-and-climate` command. + +`bioweapon off` does not need a confirmation. `bioweapon on` does, because it uses energy. The sentence is `I confirm turn bioweapon defense on`. If the car does not support bioweapon defense mode, the command says so. The installer adds `bioweapon-mode` because upstream `tesla-control` does not register it. The signed call sets the manual override so the car keeps the choice you asked for. + +`software` reads the installed version, the update status, and official release notes from `release_notes`. If the vehicle list says the car is asleep, it says so and does not wake the car. `software schedule` signs `schedule_software_update`. The delay is `2h`, `10m`, `30s`, a whole number of seconds, or `now`. `software cancel` signs `cancel_software_update`. Both need a confirmation. A scheduled job cannot run them. Upstream `tesla-control` already has `software-update-start` and `software-update-cancel`. + +With several cars, pass `--car `. A confirmation then names that car, for example `I confirm climate keeper dog on daily`. + +## Official charging history + +```bash +agentdrive charging-history +agentdrive charging-history --json +agentdrive charging-invoice abc-123-efg +agentdrive charging-invoice abc-123-efg --save +``` + +`charging-history` calls `GET /api/1/dx/charging/history` on the account. It does not wake a car and it does not ask the car for data. Each line shows the date, the site name, the energy, and the cost as Tesla returns them. Home charging is not included. Coordinates and the VIN are not printed and are not stored. `--json` prints those same fields. + +`charging-invoice` uses the invoice id from that history. Without `--save` it does not download the PDF. With `--save` it calls `GET /api/1/dx/charging/invoice/{id}` and writes the PDF under `$AGENTDRIVE_HOME/invoices` (or `~/.config/agentdrive/invoices`). The output still says home charging is not included. If the token cannot read charging history, sign in again and include `vehicle_charging_cmds`. + +```text +Official Tesla charging history. Home charging is not included. +2026-07-27T11:43:45-07:00 | Sample Supercharger | 40 kwh | USD 18.4 | invoice abc-123-efg +``` + +That block is sample data, not a vehicle reading. + ## Sentry and cabin climate `sentry-on` and `sentry-off` wake a sleeping car. If the vehicle list says the car is not online, they send one wake and poll for up to 45 seconds, then send the signed `sentry-mode` command. `check`, `report`, `where`, and history do not wake a sleeping car. Seat heat, steering-wheel heat, defrost, and precondition wake a sleeping car the same way the other parked commands do. diff --git a/docs/07-assistants.md b/docs/07-assistants.md index 092213c..9f471c1 100644 --- a/docs/07-assistants.md +++ b/docs/07-assistants.md @@ -28,10 +28,10 @@ Copy `adapters/template.py` for a host that is not listed. The same five operati ## Health check by default -Setup for an AI agent includes this instruction: set up a recurring health check by default. The routine runs `agentdrive check --json` every 30 minutes. It is read-only. It reads the vehicle list first and does not wake a sleeping car. Exit 0 means there is nothing new to report, including when the car is asleep and when the same alerts are still active, so the AI agent stays quiet. Exit 1 means a new alert (tyre pressure, TPMS warning, low battery while not charging, sentry off while the battery is low and the vehicle is not charging, unlocked or open while parked, or a charge error). Notify only on exit 1. The same alert stays quiet until it clears and later returns. `agentdrive adapter install` prints the same note. AgentDrive does not run that job and does not install cron. The morning usage briefing stays the separate daily job. The config home and the private-key path work on Windows as well as macOS and Linux. +Setup for an AI agent includes this instruction: set up a recurring health check by default. The routine runs `agentdrive check --json` every 30 minutes. It is read-only. It reads the vehicle list first and does not wake a sleeping car. Exit 0 means there is nothing new to report, including when the car is asleep and when the same alerts are still active, so the AI agent stays quiet. Exit 1 means a new alert (tyre pressure, TPMS warning, low battery while not charging, sentry off while the battery is low and the vehicle is not charging, unlocked or open while parked, or a charge error). Notify only on exit 1. The same alert stays quiet until it clears and later returns. An energy-mode alert (climate on, climate keeper keep, dog, or camp, or bioweapon defense) while parked and the driver seat is empty repeats at most once an hour until the mode is off, and it is not sent when someone is in the driver seat. The parked-and-unlocked alert is also skipped when someone is in the driver seat. That reading covers only the driver seat and is not a guarantee nobody is inside. `agentdrive adapter install` prints the same note. AgentDrive does not run that job and does not install cron. The morning usage briefing stays the separate daily job. The config home and the private-key path work on Windows as well as macOS and Linux. ```text -Schedule a routine named health-check that runs `agentdrive check --json` every 30 minutes. With several cars, run it once per car as `agentdrive check --json --car `. Notify me only when the exit code is 1. Exit 0 means nothing new, including when the car is asleep and when the same alerts are still active. The same alert stays quiet until it clears and later returns. An alert on one car does not silence the same alert on another car. Do not wake the car from that routine. Do not schedule unlock, turning sentry off, the trunk, the front trunk, remote start, a charging change, or a departure schedule change. +Schedule a routine named health-check that runs `agentdrive check --json` every 30 minutes. With several cars, run it once per car as `agentdrive check --json --car `. Notify me only when the exit code is 1. Exit 0 means nothing new, including when the car is asleep and when the same alerts are still active. The same alert stays quiet until it clears and later returns, except an energy-mode alert, which repeats at most once an hour while climate, climate keeper, or bioweapon defense stays on and the driver seat is empty. An alert on one car does not silence the same alert on another car. Do not wake the car from that routine. Do not schedule unlock, turning sentry off, the trunk, the front trunk, remote start, a charging change, or a departure schedule change. ``` ## Daily vehicle report by default diff --git a/docs/08-skills.md b/docs/08-skills.md index 82d54eb..f8afab3 100644 --- a/docs/08-skills.md +++ b/docs/08-skills.md @@ -9,13 +9,14 @@ Phase 1 answers a question you just asked, or sends a parked command you just as - **Status** answers a question you just asked. It does not push a notification and it does not include coordinates or a VIN. - **Flash lights** requires Park and speed 0, sends one signed command, and reads the car again. - **Climate on or off** uses the same park rule and checks the climate flag afterward. Seat heat, steering-wheel heat, defrost, and preconditioning the cabin now use that same park rule. Turning sentry off needs a fresh confirmation. Sentry on does not. -- **`agentdrive check`** is the health check. It does not wake a sleeping car. On install, schedule `agentdrive check --json` every 30 minutes. With several cars, run that check once per car with `--car `. Notify only when it exits 1. The same alert exits 0 until it clears and later returns. An alert is remembered per car. See [AI agents](07-assistants.md). +- **`agentdrive check`** is the health check. It does not wake a sleeping car. On install, schedule `agentdrive check --json` every 30 minutes. With several cars, run that check once per car with `--car `. Notify only when it exits 1. The same alert exits 0 until it clears and later returns, except an energy-mode alert, which repeats at most once an hour while climate, climate keeper, or bioweapon defense stays on and the driver seat is empty. If someone is in the driver seat, that alert is not sent and the parked-and-unlocked alert is not sent. The driver-seat reading covers only the driver seat and is not a guarantee nobody is inside. An alert is remembered per car. See [AI agents](07-assistants.md). - **`agentdrive report`** is the live parked snapshot. **`agentdrive history due`** is the morning usage briefing. On install, create one daily job at the time in `agentdrive report-schedule` (default 07:30 local time, enabled). Run `agentdrive history due` and send that briefing. On Monday it is the previous week, on the 1st the previous month, and on 1 January the previous year. When that briefing prints a Chart path, attach the PNG. The Dashboard path is a local HTML file. Run `agentdrive report` when you want the car's current status, including the skip line. AgentDrive does not run that job itself. It is separate from the health check. - **`agentdrive advice trip `** says whether the saved driving history and the current battery cover a trip. **`agentdrive advice charge`** flags charging habits and compares them with a home tariff. **`agentdrive tariff set`** stores off-peak and peak hours. These are advice only. They do not wake a sleeping car and they do not change a charge limit, a timetable, or preconditioning. A later change still needs a confirmation. - **`agentdrive where`** is a coarse area: suburb or locality, state or region, and country. It does not wake a sleeping car. A rounded point is sent to Nominatim. `where --precise` prints latitude, longitude, and a map link when the car is already online, and it still does not wake the car. Precise coordinates are not saved in the local history. It is not part of the recurring health check. - **`agentdrive charge-schedule show`**, **`set`**, and **`remove`** read and change the in-car charging timetable. `show` does not wake a sleeping car. `set` and `remove` need Park, speed 0, and a fresh confirmation. A scheduled job cannot confirm them. - **`agentdrive charge-amps`** sets the charging current. It needs Park, speed 0, and a fresh confirmation. The car may use less than the amps you ask for. A scheduled job cannot run it. - **`agentdrive departure show`**, **`set`**, and **`remove`** read and change a departure time that preconditions the cabin. `show` does not wake a sleeping car. `set` and `remove` need Park, speed 0, and a fresh confirmation. A scheduled job cannot run those two. Omit the coordinates on `set` to use the car's current location. This is not `precondition`, which starts the cabin now. +- **Climate keeper, the charge port, seat cooling, bioweapon defense, and software updates** are parked commands. `climate-keeper off` and `bioweapon off` do not need a confirmation. `climate-keeper keep`, `dog`, and `camp`, `bioweapon on`, `charge-port open`, `charge-port close`, `software schedule`, and `software cancel` do. A scheduled job cannot run those. `software` and `charging-history` do not wake a sleeping car. Home charging is not included in the official history. Seat cooling and automatic seat climate say so when the hardware does not support them or the climate is not on. - **Rear trunk, front trunk, and remote start** need Park, speed 0, and a fresh confirmation. Open the rear trunk and close it when the car can, open the front trunk, or enable remote start so the car can be driven without the phone key. The front trunk cannot be closed from here. Remote start does not drive the car. A scheduled job cannot run these commands. The health check stays `agentdrive check`. The morning job stays `agentdrive history due`. Calling onboarding, trip advisor, or charging tips fails. The Monday weekly briefing can include one new official item from the Tesla site for the owner's country. `agentdrive news` prints that item. When there is nothing new, that part is left out. diff --git a/docs/09-troubleshooting.md b/docs/09-troubleshooting.md index cd1d2df..613ddcd 100644 --- a/docs/09-troubleshooting.md +++ b/docs/09-troubleshooting.md @@ -24,7 +24,7 @@ Other stops: - A command exits non-zero: do not send it again until a read shows the car's state. - Partner registration says the domain is invalid: keep the error. The key has to be at the domain root (`https://.github.io/.well-known/appspecific/com.tesla.3p.public-key.pem`). A project subpath does not work. Any other HTTPS host at a domain root, for example a custom domain, can serve the same file. - The car is not in Park or the speed is missing: the command is refused before `tesla-control` runs. -- `agentdrive check` exits 0 when the car is asleep. That is not a failure. It did not wake the car. Exit 1 means a new alert. The same alert exits 0 until it clears and later returns. A skip does not change the remembered alerts. Exit 2 means the read itself failed. A problem saving local history prints `history:` on the error stream and leaves the check's exit code and briefing as they are. +- `agentdrive check` exits 0 when the car is asleep. That is not a failure. It did not wake the car. Exit 1 means a new alert. The same alert exits 0 until it clears and later returns. An energy-mode alert repeats at most once an hour while that mode stays on and the driver seat is empty. A skip does not change the remembered alerts. Exit 2 means the read itself failed. A problem saving local history prints `history:` on the error stream and leaves the check's exit code and briefing as they are. - `agentdrive service log` needs an odometer already saved by an online check or report. Until that reading exists, the event is not logged. The next online check does not wake the car. - `agentdrive report` exits 0 when the car is asleep, offline, or the read times out or returns HTTP 408. The text is the same skip line as `check`. It did not wake the car. The AI agent still sends that line. The daily time is `report_schedule` in `$AGENTDRIVE_HOME/runtime.yaml`. When that key is absent, the default is 07:30 local time and enabled. Changing it does not edit the AI agent's job. - `agentdrive where` and `agentdrive where --precise` exit 0 when the car is asleep or offline. They did not wake the car. `area unavailable` means the coarse area could not be resolved, and that line does not include coordinates. `--precise` prints latitude, longitude, and a map link only when the car is already online. A line that says to re-authorize with the `vehicle_location` scope means the token, or the firmware, refused the location read. Sign in again and include that scope. Do not paste a coordinate into the issue. diff --git a/docs/prerequisites/assistant.md b/docs/prerequisites/assistant.md index d602a4b..b3f1ea0 100644 --- a/docs/prerequisites/assistant.md +++ b/docs/prerequisites/assistant.md @@ -12,7 +12,7 @@ The AI agent has to read these docs, run shell commands, store files on the mach The AI agent follows these docs and runs commands on your computer. It can use `adapters/template.py` when it can call the same five operations: schedule, memory, confirm, secrets, and notify. If the AI agent can only chat, it can still walk you through the pages, but it cannot publish the key or refresh tokens by itself. -On install, set up a recurring health check by default. Schedule a routine that runs `agentdrive check --json` every 30 minutes. The check is read-only and does not wake a sleeping car. Exit 0 means there is nothing new to report, including when the vehicle is asleep and when the same alerts are still active. Exit 1 means a new alert the owner should hear. Notify only when the exit code is 1. The same alert stays quiet until it clears and later returns. Do not put that check on a schedule that wakes the car, and do not schedule unlock, the trunk, the front trunk, remote start, a charging change, or a departure schedule change. AgentDrive does not run that job and does not install cron. The morning history below stays the separate daily job. +On install, set up a recurring health check by default. Schedule a routine that runs `agentdrive check --json` every 30 minutes. The check is read-only and does not wake a sleeping car. Exit 0 means there is nothing new to report, including when the vehicle is asleep and when the same alerts are still active. Exit 1 means a new alert the owner should hear. Notify only when the exit code is 1. The same alert stays quiet until it clears and later returns. An energy-mode alert (climate on, climate keeper keep, dog, or camp, or bioweapon defense) while parked and the driver seat is empty repeats at most once an hour until the mode is off, and it is not sent when someone is in the driver seat. The parked-and-unlocked alert is also skipped when someone is in the driver seat. That reading covers only the driver seat and is not a guarantee nobody is inside. Do not put that check on a schedule that wakes the car, and do not schedule unlock, the trunk, the front trunk, remote start, a charging change, a departure schedule change, climate keeper keep, dog, or camp, bioweapon defense on, the charge port, or a software update. AgentDrive does not run that job and does not install cron. The morning history below stays the separate daily job. On install, also set up the morning history. Read `agentdrive report-schedule`. The default is 07:30 local time and enabled. Create one daily job at that time that runs `agentdrive history due` and send that briefing. On Monday it is the previous week, on the 1st the previous month, and on 1 January the previous year. `agentdrive report` is the live snapshot when the user wants the car's current status, including when the car is asleep. If the user asks for a different time or to turn the briefing off, update the stored schedule and update the job. AgentDrive does not run in the background. The same job is used for Grok Bot, Muse, and any other shell-capable AI agent. diff --git a/docs/prerequisites/developer-app.md b/docs/prerequisites/developer-app.md index 5e284b8..4d33796 100644 --- a/docs/prerequisites/developer-app.md +++ b/docs/prerequisites/developer-app.md @@ -16,7 +16,7 @@ Open and sign in with the vehicle account. Complet ![Diagram: you submit a unique application name and the application exists](../images/developer-app-2.svg) -Create an application with a unique English name. Describe personal use of your own vehicle: occasional reads and occasional commands, not a service for other people. Select the scopes you actually want. The default set for this toolkit is `openid`, `offline_access`, `user_data`, `vehicle_device_data`, `vehicle_location`, and `vehicle_cmds`. `vehicle_location` is the Fleet API scope required to read location, including the coarse area from `agentdrive where`. Add `vehicle_charging_cmds` only if you want charging changes later. If the portal's fields do not match a private individual, stop and read the form. Do not invent a company. +Create an application with a unique English name. Describe personal use of your own vehicle: occasional reads and occasional commands, not a service for other people. Select the scopes you actually want. The default set for this toolkit is `openid`, `offline_access`, `user_data`, `vehicle_device_data`, `vehicle_location`, `vehicle_cmds`, and `vehicle_charging_cmds`. `vehicle_location` is the Fleet API scope required to read location, including the coarse area from `agentdrive where`. `vehicle_charging_cmds` is required for official charging history and invoices. If the portal's fields do not match a private individual, stop and read the form. Do not invent a company. ## Step 3 — Set the origin and the redirect to the public-key hostname diff --git a/setup/install.sh b/setup/install.sh index 2fbcde9..b48a5e5 100755 --- a/setup/install.sh +++ b/setup/install.sh @@ -53,11 +53,12 @@ install_vehicle_command() { rm -rf "$src" git clone --depth 1 https://github.com/teslamotors/vehicle-command.git "$src" else - # Drop the previous local max-defrost insertion so pull can fast-forward. + # Drop previous local command insertions so pull can fast-forward. git -C "$src" checkout -- cmd/tesla-control/commands.go git -C "$src" pull --ff-only fi python3 "${ROOT}/setup/patch_tesla_control_defrost.py" "${src}/cmd/tesla-control/commands.go" + python3 "${ROOT}/setup/patch_tesla_control_phase8.py" "${src}/cmd/tesla-control/commands.go" ( cd "$src" go build -o "${bindir}/tesla-control" ./cmd/tesla-control diff --git a/setup/patch_tesla_control_phase8.py b/setup/patch_tesla_control_phase8.py new file mode 100644 index 0000000..edbc1c3 --- /dev/null +++ b/setup/patch_tesla_control_phase8.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Add climate keeper, seat cooling, and bioweapon defense to tesla-control. + +Upstream tesla-control does not register SetClimateKeeperMode, SetSeatCooler, +or SetBioweaponDefenseMode. This inserts those three subcommands and leaves +the rest of the file alone. It does not add a second signer. + +Climate keeper passes manual override false, matching a Fleet API body that +only sends the mode. Bioweapon defense passes manual override true because +the owner asked for that mode. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ANCHOR = '\t"product-info": {' +MARKERS = ('"climate-keeper":', '"seat-cooler":', '"bioweapon-mode":') + +SNIPPET = """\t"climate-keeper": { +\t\thelp: "Set climate keeper mode to MODE ('off', 'keep', 'dog', or 'camp').", +\t\trequiresAuth: true, +\t\trequiresFleetAPI: false, +\t\targs: []Argument{ +\t\t\t{name: "MODE", help: "off, keep, dog, or camp"}, +\t\t}, +\t\thandler: func(ctx context.Context, _ *account.Account, car *vehicle.Vehicle, args map[string]string) error { +\t\t\tmodes := map[string]vehicle.ClimateKeeperMode{ +\t\t\t\t"off": vehicle.ClimateKeeperModeOff, +\t\t\t\t"keep": vehicle.ClimateKeeperModeOn, +\t\t\t\t"dog": vehicle.ClimateKeeperModeDog, +\t\t\t\t"camp": vehicle.ClimateKeeperModeCamp, +\t\t\t} +\t\t\tmode, ok := modes[args["MODE"]] +\t\t\tif !ok { +\t\t\t\treturn fmt.Errorf("climate keeper mode must be off, keep, dog, or camp") +\t\t\t} +\t\t\treturn car.SetClimateKeeperMode(ctx, mode, false) +\t\t}, +\t}, +\t"seat-cooler": { +\t\thelp: "Set seat cooling at SEAT to LEVEL. Front seats only.", +\t\trequiresAuth: true, +\t\trequiresFleetAPI: false, +\t\targs: []Argument{ +\t\t\t{name: "SEAT", help: "front-left or front-right"}, +\t\t\t{name: "LEVEL", help: "off, low, medium, or high"}, +\t\t}, +\t\thandler: func(ctx context.Context, _ *account.Account, car *vehicle.Vehicle, args map[string]string) error { +\t\t\tseats := map[string]vehicle.SeatPosition{ +\t\t\t\t"front-left": vehicle.SeatFrontLeft, +\t\t\t\t"front-right": vehicle.SeatFrontRight, +\t\t\t} +\t\t\tposition, ok := seats[args["SEAT"]] +\t\t\tif !ok { +\t\t\t\treturn fmt.Errorf("seat cooling supports front-left and front-right only") +\t\t\t} +\t\t\tlevels := map[string]vehicle.Level{ +\t\t\t\t"off": vehicle.LevelOff, +\t\t\t\t"low": vehicle.LevelLow, +\t\t\t\t"medium": vehicle.LevelMed, +\t\t\t\t"high": vehicle.LevelHigh, +\t\t\t} +\t\t\tlevel, ok := levels[args["LEVEL"]] +\t\t\tif !ok { +\t\t\t\treturn fmt.Errorf("seat cooling level must be off, low, medium, or high") +\t\t\t} +\t\t\treturn car.SetSeatCooler(ctx, level, position) +\t\t}, +\t}, +\t"bioweapon-mode": { +\t\thelp: "Set bioweapon defense mode to STATE ('on' or 'off').", +\t\trequiresAuth: true, +\t\trequiresFleetAPI: false, +\t\targs: []Argument{ +\t\t\t{name: "STATE", help: "'on' or 'off'"}, +\t\t}, +\t\thandler: func(ctx context.Context, _ *account.Account, car *vehicle.Vehicle, args map[string]string) error { +\t\t\tvar state bool +\t\t\tswitch args["STATE"] { +\t\t\tcase "on": +\t\t\t\tstate = true +\t\t\tcase "off": +\t\t\t\tstate = false +\t\t\tdefault: +\t\t\t\treturn fmt.Errorf("bioweapon defense mode must be 'on' or 'off'") +\t\t\t} +\t\t\treturn car.SetBioweaponDefenseMode(ctx, state, true) +\t\t}, +\t}, +""" + + +def ensure_phase8_commands(path: Path) -> bool: + """Insert the subcommands when they are missing. Return True when the file changes.""" + text = path.read_text(encoding="utf-8") + present = [marker for marker in MARKERS if marker in text] + if len(present) == len(MARKERS): + return False + if present: + raise SystemExit("tesla-control commands.go has only some of the phase 8 commands, so none were added") + if ANCHOR not in text: + raise SystemExit("tesla-control commands.go has no product-info command, so the phase 8 commands were not added") + path.write_text(text.replace(ANCHOR, SNIPPET + ANCHOR, 1), encoding="utf-8") + return True + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print("Usage: patch_tesla_control_phase8.py path/to/commands.go", file=sys.stderr) + return 2 + target = Path(argv[1]) + if not target.is_file(): + print("tesla-control commands.go was not found", file=sys.stderr) + return 1 + changed = ensure_phase8_commands(target) + print("phase 8 commands added" if changed else "phase 8 commands already present") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/tests/test_charge_amps_and_departure.py b/tests/test_charge_amps_and_departure.py index 187a65d..89223de 100644 --- a/tests/test_charge_amps_and_departure.py +++ b/tests/test_charge_amps_and_departure.py @@ -688,11 +688,12 @@ def departure_remove(self, schedule_id, **kwargs): def test_installer_does_not_add_a_tesla_control_patch_for_these_commands(): text = (ROOT / "setup" / "install.sh").read_text(encoding="utf-8") assert "setup/patch_tesla_control_defrost.py" in text + assert "setup/patch_tesla_control_phase8.py" in text assert "charging-set-amps" not in text assert "precondition-schedule" not in text assert "set_scheduled_departure" not in text names = {path.name for path in (ROOT / "setup").glob("patch_tesla_control*.py")} - assert names == {"patch_tesla_control_defrost.py"} + assert names == {"patch_tesla_control_defrost.py", "patch_tesla_control_phase8.py"} def test_readme_mentions_both_and_does_not_add_a_wake_caveat(): diff --git a/tests/test_phase8.py b/tests/test_phase8.py new file mode 100644 index 0000000..ffd5f40 --- /dev/null +++ b/tests/test_phase8.py @@ -0,0 +1,647 @@ +"""Phase 8 parked controls, official records, presence, and energy-mode alerts.""" + +import importlib.util +import json +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +from core.cli import main +from core.errors import CommandFailed, ConfigError, ConfirmationRequired, NotParked, ScheduleBlocked, VerificationFailed +from core.family import classify +from core.fleet.charging import format_history, history_rows, parse_invoice_id +from core.fleet.check_state import CHECK_ALERTS_NAME, apply_rising_edge +from core.fleet.health import active_energy_modes, evaluate_alerts, report_from_observation +from core.fleet.snapshot import cable_plugged, climate_keeper_name, driver_seat_text, from_vehicle_data +from core.ops import parse_seat_climate, parse_seat_cool, parse_update_delay +from core.safety.catalog import load_specs, schedule_blocks +from tests.test_cars_and_family import VIN_A, Http as CarsHttp +from tests.test_cars_and_family import make_app as cars_app +from tests.test_cars_and_family import payload as cars_payload +from tests.test_cars_and_family import row +from tests.test_refresh_and_check import VIN, Control, Http, listed, make_app, urls, vehicle_payload +from tests.test_safety import Adapter, Runner, execute, parked + +ROOT = Path(__file__).resolve().parents[1] +T0 = datetime(2026, 11, 14, 5, 0, tzinfo=timezone.utc) + + +def _patch_module(): + spec = importlib.util.spec_from_file_location( + "patch_tesla_control_phase8", + ROOT / "setup" / "patch_tesla_control_phase8.py", + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _report(observation): + return report_from_observation(observation, tyre_bar=2.5, low_battery_percent=30) + + +def test_new_commands_are_mapped_and_confirmation_follows_energy_use(): + specs = load_specs() + assert specs["climate_keeper_off"].tier == "standard" + assert specs["climate_keeper_off"].tool_args == ("climate-keeper", "off") + for mode in ("keep", "dog", "camp"): + spec = specs[f"climate_keeper_{mode}"] + assert spec.tier == "sensitive" + assert spec.tool_args == ("climate-keeper", mode) + assert spec.action == f"climate keeper {mode}" + assert specs["charge_port_open"].tier == "sensitive" + assert specs["charge_port_open"].tool_args == ("charge-port-open",) + assert specs["charge_port_open"].action == "open charge port" + assert specs["charge_port_close"].tool_args == ("charge-port-close",) + assert specs["charge_port_close"].action == "close charge port" + assert specs["seat_cooler"].tier == "standard" + assert specs["seat_cooler"].tool_args == ("seat-cooler",) + assert specs["auto_seat_climate"].tool_args == ("auto-seat-and-climate",) + assert specs["bioweapon_on"].tier == "sensitive" + assert specs["bioweapon_on"].action == "turn bioweapon defense on" + assert specs["bioweapon_on"].tool_args == ("bioweapon-mode", "on") + assert specs["bioweapon_off"].tier == "standard" + assert specs["software_schedule"].tier == "sensitive" + assert specs["software_schedule"].tool_args == ("software-update-start",) + assert specs["software_schedule"].action == "schedule software update" + assert specs["software_cancel"].action == "cancel software update" + assert specs["software_cancel"].tool_args == ("software-update-cancel",) + + +def test_each_command_succeeds_and_a_mismatch_is_not_sent_again(): + cases = ( + ("climate_keeper_off", None, parked(climate_keeper="off"), ["climate-keeper", "off"]), + ("climate_keeper_keep", None, parked(climate_keeper="keep"), ["climate-keeper", "keep"]), + ("climate_keeper_dog", None, parked(climate_keeper="dog"), ["climate-keeper", "dog"]), + ("climate_keeper_camp", None, parked(climate_keeper="camp"), ["climate-keeper", "camp"]), + ("charge_port_open", None, parked(charge_port_open=True), ["charge-port-open"]), + ("charge_port_close", None, parked(charge_port_open=False), ["charge-port-close"]), + ("seat_cooler", ["front-left", "low"], parked(seat_cool={"front-left": 1}), ["seat-cooler", "front-left", "low"]), + ("auto_seat_climate", ["L", "on"], parked(auto_seat_left=True), ["auto-seat-and-climate", "L", "on"]), + ("auto_seat_climate", ["LR", "off"], parked(auto_seat_left=False, auto_seat_right=False), ["auto-seat-and-climate", "LR", "off"]), + ("bioweapon_on", None, parked(bioweapon_on=True), ["bioweapon-mode", "on"]), + ("bioweapon_off", None, parked(bioweapon_on=False), ["bioweapon-mode", "off"]), + ("software_schedule", ["2h"], parked(software_update_status="scheduled"), ["software-update-start", "2h"]), + ("software_cancel", None, parked(software_update_status="available"), ["software-update-cancel"]), + ) + phrases = { + "climate_keeper_keep": "I confirm climate keeper keep", + "climate_keeper_dog": "I confirm climate keeper dog", + "climate_keeper_camp": "I confirm climate keeper camp", + "charge_port_open": "I confirm open charge port", + "charge_port_close": "I confirm close charge port", + "bioweapon_on": "I confirm turn bioweapon defense on", + "software_schedule": "I confirm schedule software update", + "software_cancel": "I confirm cancel software update", + } + for command, extra, after, args in cases: + statement = phrases.get(command) + adapter = Adapter(statement) if statement else Adapter() + _, runner = execute(command, extra=extra, after=after, adapter=adapter) + assert runner.calls[0]["args"] == args + if statement: + assert adapter.calls == 1 + else: + assert adapter.calls == 0 + + runner = Runner() + with pytest.raises(VerificationFailed): + execute("seat_cooler", extra=["front-right", "high"], after=parked(seat_cool={"front-right": 0}), runner=runner) + with pytest.raises(VerificationFailed): + execute("climate_keeper_dog", adapter=Adapter("I confirm climate keeper dog"), after=parked(climate_keeper="off"), runner=runner) + with pytest.raises(VerificationFailed): + execute("bioweapon_on", adapter=Adapter("I confirm turn bioweapon defense on"), after=parked(bioweapon_on=False), runner=runner) + assert len(runner.calls) == 3 + + +def test_confirmation_is_required_for_energy_and_sensitive_commands_only(): + runner = Runner() + for command in ( + "climate_keeper_keep", + "climate_keeper_dog", + "climate_keeper_camp", + "charge_port_open", + "charge_port_close", + "bioweapon_on", + "software_schedule", + "software_cancel", + ): + with pytest.raises(ConfirmationRequired): + execute(command, adapter=Adapter("yes"), extra=["2h"] if command == "software_schedule" else None, runner=runner) + assert runner.calls == [] + _, runner = execute("climate_keeper_off", after=parked(climate_keeper="off")) + assert runner.calls[0]["args"] == ["climate-keeper", "off"] + _, runner = execute("bioweapon_off", after=parked(bioweapon_on=False)) + assert runner.calls[0]["args"][0] == "bioweapon-mode" + + +def test_new_commands_require_park_and_a_schedule_cannot_confirm_them(): + runner = Runner() + blocked = ( + "climate_keeper_dog", + "charge_port_open", + "bioweapon_on", + "software_schedule", + "seat_cooler", + ) + for command in blocked: + with pytest.raises(NotParked): + execute( + command, + shift="D", + speed=5, + extra=["front-left", "low"] if command == "seat_cooler" else (["2h"] if command == "software_schedule" else None), + adapter=Adapter("I confirm climate keeper dog"), + runner=runner, + ) + assert runner.calls == [] + for text in ( + "climate-keeper dog", + "climate-keeper keep", + "climate-keeper camp", + "bioweapon on", + "charge-port open", + "charge-port close", + "software schedule 2h", + "software cancel", + ): + assert schedule_blocks(text) is True + assert schedule_blocks("climate-keeper off") is False + assert schedule_blocks("bioweapon off") is False + assert schedule_blocks("software") is False + assert schedule_blocks("software show") is False + assert schedule_blocks("charging-history") is False + with pytest.raises(ScheduleBlocked): + execute( + "climate_keeper_dog", + via_schedule=True, + adapter=Adapter("I confirm climate keeper dog"), + after=parked(climate_keeper="dog"), + runner=Runner(), + ) + _, runner = execute("climate_keeper_off", via_schedule=True, after=parked(climate_keeper="off")) + assert runner.calls[0]["args"] == ["climate-keeper", "off"] + + +def test_app_reports_unsupported_hardware_climate_off_and_a_plugged_cable(monkeypatch): + monkeypatch.setattr("core.ops.sys.stdin.isatty", lambda: False) + cooling = vehicle_payload( + climate_state={"is_climate_on": True}, + vehicle_config={"has_seat_cooling": False}, + ) + http = Http(listed("online"), cooling) + control = Control() + with pytest.raises(ConfigError) as caught: + make_app(http, control).command("seat-cool", preferred=None, argument=None, statement=None, extra=["front-left", "low"], sleep=lambda _s: None) + assert "does not have seat cooling" in str(caught.value) + assert control.calls == [] + + climate_off = vehicle_payload( + climate_state={"is_climate_on": False}, + vehicle_config={"has_seat_cooling": True}, + ) + control = Control() + with pytest.raises(ConfigError) as caught: + make_app(Http(listed("online"), climate_off), control).command( + "seat-cool", preferred=None, argument=None, statement=None, extra=["front-left", "off"], sleep=lambda _s: None + ) + assert "Climate is not on" in str(caught.value) + assert control.calls == [] + + control = Control() + with pytest.raises(ConfigError) as caught: + make_app(Http(listed("online"), vehicle_payload(climate_state={"is_climate_on": False})), control).command( + "seat-climate", preferred=None, argument=None, statement=None, extra=["L", "on"], sleep=lambda _s: None + ) + assert "Climate is not on" in str(caught.value) + assert control.calls == [] + + class Unsupported: + def __init__(self): + self.calls = [] + + def run(self, *, vin, access_token, args, timeout=90): + self.calls.append(args) + raise CommandFailed("tesla-control failed (exit 1). The command was not sent again. not_supported") + + control = Unsupported() + with pytest.raises(CommandFailed) as caught: + make_app(Http(listed("online"), vehicle_payload()), control).command( + "bioweapon-on", + preferred=None, + argument=None, + statement="I confirm turn bioweapon defense on", + sleep=lambda _s: None, + ) + assert "does not support bioweapon defense mode" in str(caught.value) + assert len(control.calls) == 1 + + plugged = vehicle_payload(charge_state={"charging_state": "Charging", "charge_port_door_open": True, "charge_port_latch": "Engaged"}) + control = Control() + with pytest.raises(ConfigError) as caught: + make_app(Http(listed("online"), plugged), control).command( + "charge-port-close", + preferred=None, + argument=None, + statement="I confirm close charge port", + sleep=lambda _s: None, + ) + assert "cable is plugged in" in str(caught.value) + assert control.calls == [] + + +def test_app_sends_the_new_commands_and_names_them(monkeypatch): + monkeypatch.setattr("core.ops.sys.stdin.isatty", lambda: False) + body = vehicle_payload( + climate_state={ + "is_climate_on": True, + "climate_keeper_mode": "dog", + "bioweapon_mode": False, + "seat_fan_front_left": 1, + "auto_seat_climate_left": True, + }, + charge_state={"charging_state": "Disconnected", "charge_port_door_open": True, "charge_port_latch": "Disengaged"}, + vehicle_config={"has_seat_cooling": True}, + vehicle_state={"software_update": {"status": "scheduled", "version": "2026.8"}, "car_version": "2026.4"}, + ) + control = Control() + app = make_app(Http(listed("online"), body), control) + assert "climate-keeper dog" in app.command( + "climate-keeper-dog", + preferred=None, + argument=None, + statement="I confirm climate keeper dog", + sleep=lambda _s: None, + ) + assert control.calls[-1]["args"] == ["climate-keeper", "dog"] + text = app.command( + "charge-port-open", + preferred=None, + argument=None, + statement="I confirm open charge port", + sleep=lambda _s: None, + ) + assert text == "OK: charge-port open" + assert control.calls[-1]["args"] == ["charge-port-open"] + assert "seat-cool front-left low" in app.command( + "seat-cool", preferred=None, argument=None, statement=None, extra=["front-left", "low"], sleep=lambda _s: None + ) + assert control.calls[-1]["args"] == ["seat-cooler", "front-left", "low"] + assert "seat-climate front-left on" in app.command( + "seat-climate", preferred=None, argument=None, statement=None, extra=["L", "on"], sleep=lambda _s: None + ) + closed = vehicle_payload(charge_state={"charging_state": "Disconnected", "charge_port_door_open": False}) + app = make_app(Http(listed("online"), closed), control) + assert app.command( + "charge-port-close", + preferred=None, + argument=None, + statement="I confirm close charge port", + sleep=lambda _s: None, + ) == "OK: charge-port close" + off = vehicle_payload(climate_state={"bioweapon_mode": False}) + app = make_app(Http(listed("online"), off), control) + assert app.command("bioweapon-off", preferred=None, argument=None, statement=None, sleep=lambda _s: None) == "OK: bioweapon off" + on = vehicle_payload(climate_state={"bioweapon_mode": True}) + app = make_app(Http(listed("online"), on), control) + assert "bioweapon on" in app.command( + "bioweapon-on", + preferred=None, + argument=None, + statement="I confirm turn bioweapon defense on", + sleep=lambda _s: None, + ) + scheduled = vehicle_payload(vehicle_state={"software_update": {"status": "scheduled", "version": "2026.8"}}) + app = make_app(Http(listed("online"), scheduled), control) + assert app.command( + "software-schedule", + preferred=None, + argument=None, + statement="I confirm schedule software update", + extra=["2h"], + sleep=lambda _s: None, + ) == "OK: software schedule 2h" + assert control.calls[-1]["args"] == ["software-update-start", "2h"] + cancelled = vehicle_payload(vehicle_state={"software_update": {"status": "available", "version": "2026.8"}}) + app = make_app(Http(listed("online"), cancelled), control) + assert app.command( + "software-cancel", + preferred=None, + argument=None, + statement="I confirm cancel software update", + sleep=lambda _s: None, + ) == "OK: software cancel" + + +def test_presence_parsing_and_check_report_lines(): + present = from_vehicle_data({"response": {"vehicle_state": {"is_user_present": True}}}) + empty = from_vehicle_data({"response": {"vehicle_state": {"is_user_present": False}}}) + missing = from_vehicle_data({"response": {"vehicle_state": {"locked": True}}}) + assert present.driver_present is True + assert empty.driver_present is False + assert missing.driver_present is None + assert driver_seat_text(True) == "someone appears to be in the driver seat" + assert driver_seat_text(False) == "no one detected in the driver seat" + assert climate_keeper_name("on") == "keep" + assert climate_keeper_name(2) == "dog" + assert climate_keeper_name("camp") == "camp" + + http = Http(listed("online"), vehicle_payload(vehicle_state={"is_user_present": True, "locked": False})) + report = make_app(http).check(preferred=None, tyre_bar=2.5, low_battery_percent=30) + from core.ops import format_check + from core.fleet.report import vehicle_report + + text = format_check(report) + assert "someone appears to be in the driver seat" in text + assert "unlocked" not in text + briefing = vehicle_report("Example", from_vehicle_data(vehicle_payload(vehicle_state={"is_user_present": False}))) + assert "no one detected in the driver seat" in briefing.text + assert briefing.fields["driver_present"] is False + + +def test_unlocked_alert_is_suppressed_when_the_driver_seat_is_occupied(): + occupied = parked(locked=False, driver_present=True) + empty = parked(locked=False, driver_present=False) + unknown = parked(locked=False, driver_present=None) + assert "unlocked" not in [item.code for item in evaluate_alerts(occupied)] + assert "unlocked" in [item.code for item in evaluate_alerts(empty)] + assert "unlocked" in [item.code for item in evaluate_alerts(unknown)] + + +def test_energy_mode_alerts_are_hourly_unless_the_driver_seat_is_occupied(tmp_path): + occupied = parked(driver_present=True, climate_on=True, climate_keeper="dog", bioweapon_on=True, locked=False) + assert evaluate_alerts(occupied) == [] + assert active_energy_modes(occupied) == ["energy:keeper:dog", "energy:bioweapon", "energy:climate"] + + home = tmp_path / "daily" + first = apply_rising_edge(home, _report(parked(driver_present=False, climate_on=True, climate_keeper="off")), now=T0) + assert [item.code for item in first.alerts] == ["energy:climate"] + assert first.exit_code == 1 + quiet = apply_rising_edge(home, _report(parked(driver_present=False, climate_on=True)), now=T0 + timedelta(minutes=30)) + assert quiet.alerts == [] + assert quiet.exit_code == 0 + seated = apply_rising_edge( + home, + _report(parked(driver_present=True, climate_on=True, locked=False)), + now=T0 + timedelta(minutes=40), + ) + assert seated.alerts == [] + still = apply_rising_edge(home, _report(parked(driver_present=False, climate_on=True)), now=T0 + timedelta(minutes=50)) + assert still.alerts == [] + again = apply_rising_edge(home, _report(parked(driver_present=False, climate_on=True)), now=T0 + timedelta(hours=1)) + assert [item.code for item in again.alerts] == ["energy:climate"] + cleared = apply_rising_edge(home, _report(parked(driver_present=False, climate_on=False)), now=T0 + timedelta(hours=1, minutes=5)) + assert cleared.alerts == [] + saved = json.loads((home / CHECK_ALERTS_NAME).read_text(encoding="utf-8")) + assert "energy:climate" not in saved.get("energy_notified", {}) + returned = apply_rising_edge(home, _report(parked(driver_present=False, climate_on=True)), now=T0 + timedelta(hours=1, minutes=6)) + assert [item.code for item in returned.alerts] == ["energy:climate"] + + other = tmp_path / "weekend" + dog = apply_rising_edge(other, _report(parked(driver_present=False, climate_keeper="dog", climate_on=True, bioweapon_on=True)), now=T0) + assert [item.code for item in dog.alerts] == ["energy:keeper:dog", "energy:bioweapon", "energy:climate"] + for mode in ("keep", "camp"): + codes = [item.code for item in evaluate_alerts(parked(driver_present=False, climate_keeper=mode))] + assert f"energy:keeper:{mode}" in codes + low = apply_rising_edge(tmp_path / "plain", _report(parked(battery_level=10, charging_state="Disconnected", sentry_on=True)), now=T0) + assert [item.code for item in low.alerts] == ["low_battery"] + again_low = apply_rising_edge( + tmp_path / "plain", + _report(parked(battery_level=10, charging_state="Disconnected", sentry_on=True)), + now=T0 + timedelta(hours=2), + ) + assert again_low.alerts == [] + + +def test_official_records_do_not_wake_or_keep_coordinates(tmp_path): + class Records: + def __init__(self): + self.calls = [] + self.pdf = b"%PDF-1.4 sample" + + def request_json(self, method, url, headers, timeout=30, body=None): + self.calls.append(url) + if "charging/history" in url: + return 200, { + "response": { + "data": [ + { + "vin": VIN, + "siteLocationName": "Sample Supercharger", + "chargeStartDateTime": "2026-07-27T11:43:45-07:00", + "siteEntryLocation": {"latitude": 12.345678, "longitude": 98.765432}, + "fees": [ + { + "feeType": "CHARGING", + "currencyCode": "USD", + "usageBase": 40, + "uom": "kwh", + "totalDue": 18.4, + } + ], + "invoices": [{"contentId": "abc-123-efg", "fileName": "secret.pdf"}], + } + ] + } + } + if url.endswith("/wake_up"): + raise AssertionError("charging history woke the car") + return 500, {} + + def request_bytes(self, method, url, headers, timeout=60): + self.calls.append(url) + assert "charging/invoice/abc-123-efg" in url + return 200, self.pdf + + http = Records() + app = make_app(http, home=tmp_path) + text = app.charging_history() + assert "Home charging is not included." in text + assert "2026-07-27T11:43:45-07:00" in text + assert "Sample Supercharger" in text + assert "40 kwh" in text + assert "USD 18.4" in text + assert "abc-123-efg" in text + assert VIN not in text + assert "12.345678" not in text + assert "latitude" not in text + assert not any("wake_up" in url or url.rstrip("/").endswith("/vehicles") for url in http.calls) + rendered = json.loads(app.charging_history(as_json=True)) + assert rendered["records"][0]["site"] == "Sample Supercharger" + assert "vin" not in json.dumps(rendered) + + skipped = app.charging_invoice("abc-123-efg", save=False) + assert "was not saved" in skipped + assert not (tmp_path / "invoices").exists() + saved = app.charging_invoice("abc-123-efg", save=True) + path = tmp_path / "invoices" / "abc-123-efg.pdf" + assert path.is_file() + assert path.read_bytes().startswith(b"%PDF") + assert str(path) in saved + assert path.stat().st_mode & 0o777 == 0o600 + with pytest.raises(ConfigError): + parse_invoice_id("TESTVN00000000001") + + asleep = Http(listed("asleep")) + software = make_app(asleep, home=tmp_path).software(preferred=None) + assert "asleep" in software + assert "not woken" in software + assert not any("wake_up" in url or "vehicle_data" in url or "release_notes" in url for url in urls(asleep)) + + +def test_software_read_uses_release_notes_only_when_online(): + class Notes: + def __init__(self): + self.calls = [] + + def request_json(self, method, url, headers, timeout=30, body=None): + self.calls.append(url) + if url.rstrip("/").endswith("/vehicles"): + return 200, listed("online") + if "vehicle_data" in url: + return 200, vehicle_payload( + vehicle_state={ + "car_version": "2026.4.1", + "software_update": {"status": "available", "version": "2026.8.2"}, + } + ) + if "release_notes" in url: + assert "staged=true" in url + return 200, { + "response": { + "release_notes": [ + { + "title": "Minor fixes", + "description": "This release contains minor fixes and improvements.", + "customer_version": "2026.8.2", + "image_url": "https://vehicle-files.example/secret", + } + ] + } + } + if url.endswith("/wake_up"): + raise AssertionError("software read woke the car") + return 500, {} + + http = Notes() + text = make_app(http).software(preferred=None) + assert "2026.4.1" in text + assert "available" in text + assert "2026.8.2" in text + assert "Minor fixes" in text + assert "minor fixes and improvements" in text + assert "image_url" not in text + assert "vehicle-files" not in text + assert not any(url.endswith("/wake_up") for url in http.calls) + + +def test_family_sensitive_commands_wait_for_the_owner(tmp_path, monkeypatch, capsys): + from core.family import expand_action + from core.garage import remember_car + + http = CarsHttp([row(VIN_A)], cars_payload()) + control = Control() + app = cars_app(tmp_path, http, control) + remember_car(tmp_path, http.rows, "0001", "daily") + monkeypatch.setattr("core.cli.build_app", lambda home: app) + assert main(["--home", str(tmp_path), "family", "add", "rider"]) == 0 + assert main(["--home", str(tmp_path), "family", "allow", "rider", "climate-keeper-dog"]) == 0 + assert classify(tmp_path, "rider", "climate-keeper-dog") == "request" + assert classify(tmp_path, "rider", "bioweapon-on") == "deny" + capsys.readouterr() + assert main( + [ + "--home", + str(tmp_path), + "--as", + "rider", + "--car", + "daily", + "climate-keeper", + "dog", + "--confirm", + "I confirm climate keeper dog on daily", + ] + ) == 0 + relay = capsys.readouterr().out + assert "Pending request" in relay + assert "climate-keeper-dog" in relay + assert "This action was not run." in relay + assert control.calls == [] + for action, kind in ( + ("charge-port-open", "request"), + ("charge-port-close", "request"), + ("software-schedule", "request"), + ("software-cancel", "request"), + ("bioweapon-on", "request"), + ("climate-keeper-off", "direct"), + ("seat-cool", "direct"), + ("charging-history", "direct"), + ("software", "direct"), + ): + assert expand_action(action) == [(action, kind)] + + +def test_parsers_reject_unsupported_seat_and_delay_values(): + assert parse_seat_cool("Front-Left", "HIGH") == ("front-left", "high") + assert parse_seat_climate("both", "OFF") == ("LR", "off") + assert parse_update_delay("2h") == "2h" + assert parse_update_delay("now") == "0s" + assert parse_update_delay("90") == "90s" + with pytest.raises(ConfigError): + parse_seat_cool("2nd-row-left", "low") + with pytest.raises(ConfigError): + parse_seat_climate("front-center", "on") + with pytest.raises(ConfigError): + parse_update_delay("later") + + +def test_phase8_patch_inserts_the_sdk_commands_once(tmp_path): + ensure = _patch_module().ensure_phase8_commands + target = tmp_path / "commands.go" + target.write_text('package main\nvar commands = map[string]*Command{\n\t"product-info": {\n\t},\n}\n', encoding="utf-8") + assert ensure(target) is True + text = target.read_text(encoding="utf-8") + assert text.count('"climate-keeper":') == 1 + assert text.count('"seat-cooler":') == 1 + assert text.count('"bioweapon-mode":') == 1 + assert "SetClimateKeeperMode(ctx, mode, false)" in text + assert "SetSeatCooler(ctx, level, position)" in text + assert "SetBioweaponDefenseMode(ctx, state, true)" in text + assert ensure(target) is False + install = (ROOT / "setup" / "install.sh").read_text(encoding="utf-8") + assert "patch_tesla_control_phase8.py" in install + rows = history_rows( + {"response": {"data": [{"vin": VIN, "siteLocationName": "Sample Supercharger", "chargeStartDateTime": "2026-01-01T00:00:00Z"}]}} + ) + rendered = format_history(rows, as_json=False) + assert VIN not in rendered + assert cable_plugged(parked(charging_state="Charging")) is True + assert cable_plugged(parked(charging_state="Disconnected", charge_port_latch="Disengaged")) is False + + +def test_docs_and_skill_describe_the_new_commands_and_the_driver_seat_limit(): + queries = (ROOT / "docs" / "06-queries-and-commands.md").read_text(encoding="utf-8") + skill = (ROOT / ".agents" / "skills" / "agentdrive" / "SKILL.md").read_text(encoding="utf-8") + readme = (ROOT / "README.md").read_text(encoding="utf-8") + for text in (queries, skill): + assert "climate-keeper" in text + assert "I confirm climate keeper dog" in text + assert "I confirm climate keeper keep" in text + assert "I confirm open charge port" in text + assert "I confirm close charge port" in text + assert "I confirm schedule software update" in text + assert "I confirm cancel software update" in text + assert "I confirm turn bioweapon defense on" in text + assert "Home charging is not included" in text + assert "once an hour" in text or "once per hour" in text + assert "driver seat" in text.lower() + assert "not a guarantee" in text.lower() + assert "pet" in text.lower() + assert "climate keeper" in readme.lower() + assert "charge port" in readme.lower() + assert "## Next" not in readme + assert "may wake" not in readme.lower() + assert "cloudflare" not in readme.lower() + assert "cloudflare" not in queries.lower()