diff --git a/.agents/skills/agentdrive/SKILL.md b/.agents/skills/agentdrive/SKILL.md index 82fa252..fb201f6 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, 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. 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, defrost, cabin preconditioning, the rear trunk, the front trunk, remote start, or a parked command such as flash lights, lock, or charging. Do not sign vehicle commands in this skill. --- # AgentDrive @@ -38,22 +38,26 @@ 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 only: suburb or locality, state or region, and country. It does not wake a sleeping vehicle, it does not print coordinates, and it does not send a command. A rounded point is sent to Nominatim. Do not copy coordinates 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, and charging changes need a fresh confirmation. A schedule cannot supply that confirmation and cannot run sentry off, the trunk, the front trunk, or remote start. +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. `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`, and history do not wake a sleeping car. -`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. +`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`. + +`agentdrive charge-amps ` sets the charging current. It is a charging command. It needs a fresh confirmation that names the action, for example `I confirm charging set amps`. The value is a whole number from 1 to 80. The car may use less, down to what the charger allows. A scheduled job cannot supply that confirmation and cannot run `charge-amps`. `agentdrive charge-schedule show` reads the in-car charging timetable and does not wake a sleeping vehicle. `agentdrive charge-schedule set` and `agentdrive charge-schedule remove` change that timetable. They are charging commands. Each one needs a fresh confirmation. A scheduled job cannot supply that confirmation. +`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 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. ## 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, or a charging change. `charge-schedule set` and `charge-schedule remove` are charging 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. 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. ## Morning history diff --git a/.agents/skills/agentdrive/prompts/install.md b/.agents/skills/agentdrive/prompts/install.md index 7ddc987..eea53af 100644 --- a/.agents/skills/agentdrive/prompts/install.md +++ b/.agents/skills/agentdrive/prompts/install.md @@ -31,7 +31,7 @@ The check does not wake a sleeping vehicle. 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. 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, or a charging change on that routine. +Do not schedule unlock, turning sentry off, the trunk, the front trunk, remote start, a charging change, or a departure schedule change on that routine. Do not put `agentdrive report` or `agentdrive history due` on that health-check loop. AgentDrive does not run that job and does not install cron. diff --git a/DISCLAIMER.md b/DISCLAIMER.md index cfa6dad..20035a3 100644 --- a/DISCLAIMER.md +++ b/DISCLAIMER.md @@ -6,10 +6,12 @@ Use AgentDrive only on a vehicle you own, or a vehicle you are explicitly allowe To the maximum extent permitted by law, the software and the documentation are provided as is, without warranty of any kind, and the author's liability is excluded. Nothing in this disclaimer excludes, restricts, or modifies any consumer guarantee, right, or remedy under the Australian Consumer Law that cannot be excluded. Where the Australian Consumer Law allows liability for a failure to comply with a consumer guarantee to be limited, the author's liability is limited to the maximum extent permitted by that law. -A command that changes the vehicle is sent only when the shift state is Park and the speed is zero. After every command, AgentDrive reads the vehicle again and reports that observed state. Unlock, remote start, opening the front trunk, opening or closing the rear trunk, turning sentry mode off, and any charging change each require a fresh explicit confirmation. That confirmation covers that single action and then expires. It is never reused, and a schedule cannot supply it or skip it. A schedule also cannot run remote start, the rear trunk, the front trunk, or turning sentry mode off. Turning sentry mode on, seat heat, steering-wheel heat, defrost, and preconditioning the cabin now are parked commands with that same park and speed check. They do not add a separate confirmation. +A command that changes the vehicle is sent only when the shift state is Park and the speed is zero. After every command, AgentDrive reads the vehicle again and reports that observed state. Unlock, remote start, opening the front trunk, opening or closing the rear trunk, turning sentry mode off, any charging change, and setting or removing a departure schedule each require a fresh explicit confirmation. That confirmation covers that single action and then expires. It is never reused, and a schedule cannot supply it or skip it. A schedule also cannot run remote start, the rear trunk, the front trunk, turning sentry mode off, or a departure schedule change. Turning sentry mode on, seat heat, steering-wheel heat, defrost, and preconditioning the cabin now are parked commands with that same park and speed check. They do not add a separate confirmation. Follow Tesla's current safety guidance, developer terms, and Fleet API terms. Where those terms and this project differ, the terms win. Obey the road rules that apply where you are, and do not interact with the AI agent while you are driving. A private key together with an access token can operate the vehicle inside the scopes you approved. Keep both of them private. The author is not responsible if they leak, or if a command is sent in error. +Precise coordinates are used only when you ask for a precise location or a departure place taken from the car. They are not written to the local history. + Reported problems, safety issues, and suspected abuse are reviewed and fixed promptly. Ordinary reports go to GitHub Issues. Security reports go through GitHub private vulnerability reporting, as described in SECURITY.md. diff --git a/README.md b/README.md index e1fbedd..c2a80c7 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ 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, and set a charge limit. 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. +**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. **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. @@ -75,7 +75,7 @@ Only ask me for the approvals those instructions say the human must do. ## Disclaimer -Experimental and testing purposes only. AgentDrive is unofficial and is not a Tesla product. Use it only on a vehicle you own, or a vehicle you are explicitly allowed to control. To the maximum extent permitted by law the software is provided as is, and the author's liability is excluded. Nothing here excludes a right under the Australian Consumer Law that cannot be excluded. The full text is in [DISCLAIMER.md](DISCLAIMER.md). Read [SECURITY.md](SECURITY.md) before you install. +Experimental and testing purposes only. AgentDrive is unofficial and is not a Tesla product. Use it only on a vehicle you own, or a vehicle you are explicitly allowed to control. To the maximum extent permitted by law the software is provided as is, and the author's liability is excluded. Nothing here excludes a right under the Australian Consumer Law that cannot be excluded. The full text is in [DISCLAIMER.md](DISCLAIMER.md). Read [SECURITY.md](SECURITY.md) before you install. Precise coordinates are used only when you ask for them, and they are not saved in the local history. ## Supported agents diff --git a/SECURITY.md b/SECURITY.md index 666a48a..c81733a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,7 +14,7 @@ Turn on multi-factor authentication for the vehicle account, the code host that Never commit or paste a private key, client secret, access token, refresh token, authorization code, VIN, license plate, or live location. Each owner generates a new key pair and registers a new application. Whoever holds the private key and a valid token can act on the vehicle within the approved scopes. -`agentdrive where` is the one command that sends a location onward, and only after rounding. It sends latitude and longitude rounded to two decimal places (about 1 km) to OpenStreetMap Nominatim, and it prints a suburb or locality, a state or region, and a country. It does not print or log the precise or the rounded coordinates. The read needs the `vehicle_location` scope. `status` and `check` do not request location. +`agentdrive where` reads location only when the vehicle list already says the car is online. It does not wake the car. The default command sends latitude and longitude rounded to two decimal places (about 1 km) to OpenStreetMap Nominatim and prints a suburb or locality, a state or region, and a country. It does not print that rounded point. `agentdrive where --precise` prints the precise latitude and longitude and an OpenStreetMap link. It does not send that precise point to Nominatim. A departure schedule can use the same precise point when you omit coordinates. Precise coordinates are not written to the local history. The read needs the `vehicle_location` scope. `status` and `check` do not request location. Local secret files should be readable only by your user. The doctor command checks that the private-key file is present and, on macOS and Linux, not group- or world-readable. On Windows, limit the file ACL to your user. Unix mode bits are not how Windows records that restriction. The token store writes the replacement refresh token with an atomic rename before the previous value is dropped from memory. @@ -26,7 +26,7 @@ A private key that is already paired with another agent you trust can be the key AgentDrive refuses a state-changing command unless the vehicle reports shift state Park and speed 0. It then reads the vehicle again. If that read does not match the command you asked for, the result is a failure, and the command is not sent a second time. -These actions are sensitive: unlock, remote start, opening the front trunk, opening or closing the rear trunk, sentry mode off, and any charging change. Each one needs an explicit confirmation for that action. A previous "yes" does not carry over. A schedule cannot confirm them, cannot skip the check, and cannot run remote start, the rear trunk, the front trunk, or sentry mode off. Turning sentry mode on, seat heat, steering-wheel heat, defrost, and preconditioning the cabin now are parked commands. They are not on this sensitive list. +These actions are sensitive: unlock, remote start, opening the front trunk, opening or closing the rear trunk, sentry mode off, any charging change, and setting or removing a departure schedule. Each one needs an explicit confirmation for that action. A previous "yes" does not carry over. A schedule cannot confirm them, cannot skip the check, and cannot run remote start, the rear trunk, the front trunk, sentry mode off, or a departure schedule change. Turning sentry mode on, seat heat, steering-wheel heat, defrost, and preconditioning the cabin now are parked commands. They are not on this sensitive list. Remote commands that can unlock a vehicle create a real theft risk if the credentials leak. Do not leave valuables in the vehicle. After an unlock, confirm that the vehicle is locked again. Prefer leaving sentry mode on when the vehicle is parked somewhere unfamiliar. diff --git a/adapters/base.py b/adapters/base.py index 563334f..c7778b4 100644 --- a/adapters/base.py +++ b/adapters/base.py @@ -84,6 +84,6 @@ def _persist(self) -> None: def _schedule_command_is_sensitive(command, sensitive: set[str]) -> bool: - # Sensitive spec names, the charge-schedule family, sentry off, and trunk, frunk, and remote start. + # Sensitive spec names, charge-schedule, charge-amps, departure changes, sentry off, and trunk, frunk, and remote start. # `check` and `history` stay allowed for the health check and the morning report. return schedule_blocks(command, sensitive) diff --git a/config/vehicles.yaml b/config/vehicles.yaml index e313d64..e81469e 100644 --- a/config/vehicles.yaml +++ b/config/vehicles.yaml @@ -10,9 +10,10 @@ protocol: On firmware that hides coordinates unless location_data is requested, a plain vehicle_data call returns no position. Coordinates, when returned, appear on the drive state. status and check do not request - location_data. The where command requests that endpoint only when the - vehicle is already online, rounds the point before it leaves the - process, and does not copy coordinates into assistant memory or output. + location_data. where and where --precise request it only when the + vehicle is already online. The default where command prints a coarse + area. Precise coordinates are used only for where --precise and for a + departure place taken from the car. They are not written to history. models: - id: model_s @@ -37,6 +38,7 @@ capabilities: - vehicle_config - location_data - charge_schedule_data + - preconditioning_schedule_data phase_1_commands: - flash_lights - climate_on @@ -64,6 +66,8 @@ capabilities: - charging_set_amps - charging_schedule_set - charging_schedule_remove + - departure_schedule_set + - departure_schedule_remove charging_reads: - nearby_charging_sites - charging_history diff --git a/core/cli.py b/core/cli.py index b0abbf3..48c2111 100644 --- a/core/cli.py +++ b/core/cli.py @@ -4,6 +4,7 @@ import argparse import sys +import time from pathlib import Path from adapters.detect import VALID_ADAPTERS, resolve_adapter @@ -15,6 +16,7 @@ from core.errors import AgentDriveError, AmbiguousAdapter, ConfigError from core.fleet.area import format_where, format_where_json from core.fleet.charge_schedule import format_schedule_report, format_schedule_report_json, parse_schedule_id, parse_schedule_write +from core.fleet.departure import format_departure_report, format_departure_report_json, parse_departure_write from core.fleet.http import UrllibClient from core.fleet.report import format_report, format_report_json from core.history.official import render_news @@ -25,6 +27,7 @@ build_app, format_check, format_check_json, + parse_amps, parse_limit, parse_on_off, parse_seat_heat, @@ -105,6 +108,12 @@ def build_parser() -> argparse.ArgumentParser: steering_heat.add_argument("state") charge_limit = sub.add_parser("charge-limit", parents=[confirmed]) charge_limit.add_argument("percent") + charge_amps = sub.add_parser( + "charge-amps", + parents=[confirmed], + help="Set the charging current in amps. Needs a confirmation. The car may use less.", + ) + charge_amps.add_argument("amps") schedule = sub.add_parser("charge-schedule", help="Read or change the in-car charging timetable") schedule_sub = schedule.add_subparsers(dest="schedule_action", required=True) @@ -125,6 +134,26 @@ def build_parser() -> argparse.ArgumentParser: remove = schedule_sub.add_parser("remove", parents=[confirmed], help="Remove one in-car charging schedule by id") remove.add_argument("schedule_id", metavar="ID") + departure = sub.add_parser("departure", help="Read or change a departure time that preconditions the cabin") + departure_sub = departure.add_subparsers(dest="departure_action", required=True) + departure_show = departure_sub.add_parser("show", help="Read departure schedules. Does not wake the car.") + departure_show.add_argument("--json", action="store_true") + departure_set = departure_sub.add_parser("set", parents=[confirmed], help="Add or change a departure precondition schedule") + departure_set.add_argument("days", help="all, weekdays, or comma-separated Sun, Mon, Tues, Wed, Thurs, Fri, Sat") + departure_set.add_argument("time", help="Cabin ready by this local time, HH:MM") + departure_set.add_argument("latitude", nargs="?", default=None, help="Optional. Omit both coordinates to use the car's current location.") + departure_set.add_argument("longitude", nargs="?", default=None) + departure_set.add_argument("--once", action="store_true", help="Run once. Omit to repeat every week.") + departure_set.add_argument( + "--enabled", + choices=("true", "false"), + default="true", + help="true adds an enabled schedule. false adds a disabled schedule.", + ) + departure_set.add_argument("--id", default=None, help="Change this schedule id. Omit to add a schedule.") + departure_remove = departure_sub.add_parser("remove", parents=[confirmed], help="Remove one departure schedule by id") + departure_remove.add_argument("schedule_id", metavar="ID") + check = sub.add_parser("check") check.add_argument("--json", action="store_true") check.add_argument("--tyre-threshold", type=float, default=None) @@ -141,8 +170,13 @@ def build_parser() -> argparse.ArgumentParser: report_action.add_parser("enable") report_action.add_parser("disable") - where = sub.add_parser("where", help="Print a coarse area. Does not wake the vehicle or print coordinates.") + where = sub.add_parser("where", help="Print a coarse area. Does not wake the vehicle.") where.add_argument("--json", action="store_true") + where.add_argument( + "--precise", + action="store_true", + help="Print latitude, longitude, and a map link when the car is already online. Does not wake the car.", + ) history = sub.add_parser("history", help="Read saved vehicle history on this computer. Does not contact the car.") history_sub = history.add_subparsers(dest="history_action", required=True) @@ -217,6 +251,8 @@ def main(argv: list[str] | None = None) -> int: return _service(args, home) if args.command == "charge-schedule": return _charge_schedule(args, home) + if args.command == "departure": + return _departure(args, home) if args.command in {"refresh", "status", *PUBLIC_COMMANDS}: return _vehicle(args, home) except AmbiguousAdapter as exc: @@ -335,7 +371,7 @@ def _report_schedule(args, home: Path) -> int: def _where(args, home: Path) -> int: - report = build_app(home).where(preferred=preferred_vin()) + report = build_app(home).where(preferred=preferred_vin(), precise=bool(args.precise)) rendered = format_where_json(report) if args.json else format_where(report) print(scrub(rendered)) return report.exit_code @@ -414,6 +450,51 @@ def _charge_schedule(args, home: Path) -> int: raise ConfigError("unknown command") +def _departure(args, home: Path) -> int: + if args.departure_action == "show": + report = build_app(home).departure_show(preferred=preferred_vin()) + text = format_departure_report_json(report) if args.json else format_departure_report(report) + print(scrub(text)) + return 0 + statement = getattr(args, "confirm", None) + if args.departure_action == "set": + schedule_id = args.id + if args.enabled == "false" and not schedule_id: + schedule_id = str(int(time.time())) + spec = parse_departure_write( + args.days, + args.time, + args.latitude, + args.longitude, + once=args.once, + enabled=args.enabled == "true", + schedule_id=schedule_id, + ) + print( + scrub( + build_app(home).departure_set( + spec, + preferred=preferred_vin(), + statement=statement, + ) + ) + ) + return 0 + if args.departure_action == "remove": + schedule_id = parse_schedule_id(args.schedule_id) + print( + scrub( + build_app(home).departure_remove( + schedule_id, + preferred=preferred_vin(), + statement=statement, + ) + ) + ) + return 0 + raise ConfigError("unknown command") + + def _vehicle(args, home: Path) -> int: argument = None extra = None @@ -423,6 +504,9 @@ def _vehicle(args, home: Path) -> int: elif args.command == "charge-limit": argument = args.percent parse_limit(argument) + elif args.command == "charge-amps": + argument = args.amps + parse_amps(argument) elif args.command == "seat-heat": extra = list(parse_seat_heat(args.seat, args.level)) elif args.command == "steering-heat": diff --git a/core/fleet/area.py b/core/fleet/area.py index 8e137d4..646ab75 100644 --- a/core/fleet/area.py +++ b/core/fleet/area.py @@ -1,9 +1,9 @@ -"""Coarse area for a vehicle that is already online. +"""Location reads for a vehicle that is already online. -Latitude and longitude are rounded to two decimal places before any value -leaves the process. The only outbound use of that rounded point is one -OpenStreetMap Nominatim reverse lookup. Raw and rounded coordinates are not -returned, printed, or logged. +The default area rounds latitude and longitude to two decimal places before +that point is sent to Nominatim. The rounded point is not printed. A precise +point is formatted and returned only when the caller asks for it, and that +point is not sent to Nominatim. History does not store either point. Nominatim's usage policy allows light use: at most one request per second, a User-Agent that identifies the application, and no bulk geocoding. This @@ -19,6 +19,7 @@ from decimal import Decimal, ROUND_HALF_UP from urllib.parse import urlencode +from core.errors import ConfigError from core.fleet.http import HttpClient from core.fleet.queries import location_scope_denied @@ -55,17 +56,20 @@ def __init__(self) -> None: @dataclass(frozen=True) class WhereReport: - """Public result. This object has no coordinate fields.""" + """Public result. A coarse area has no coordinates. A precise result does.""" kind: str state: str = "" suburb: str = "" region: str = "" country: str = "" + latitude: str = "" + longitude: str = "" + map_url: str = "" @property def exit_code(self) -> int: - if self.kind in {"area", "skipped"}: + if self.kind in {"area", "precise", "skipped"}: return 0 return 1 @@ -92,6 +96,10 @@ def format_where(report: WhereReport) -> str: return f"state: {state}\nskipped: the vehicle is not online, so it was not woken" if report.kind == "scope": return SCOPE_HINT + if report.kind == "precise": + if not report.latitude or not report.longitude or not report.map_url: + return AREA_UNAVAILABLE + return f"latitude: {report.latitude}\nlongitude: {report.longitude}\nmap: {report.map_url}" if report.kind != "area": return AREA_UNAVAILABLE lines = [] @@ -112,6 +120,12 @@ def format_where_json(report: WhereReport) -> str: payload: dict = {"skipped": True, "state": report.state or "unknown"} elif report.kind == "scope": payload = {"error": SCOPE_HINT} + elif report.kind == "precise": + if not report.latitude or not report.longitude or not report.map_url: + payload = {"error": AREA_UNAVAILABLE} + else: + payload = {"latitude": report.latitude, "longitude": report.longitude, "map": report.map_url} + return json.dumps(payload, indent=2) elif report.kind != "area": payload = {"error": AREA_UNAVAILABLE} else: @@ -122,6 +136,58 @@ def format_where_json(report: WhereReport) -> str: return rendered +def precise_point(payload: object) -> tuple[str, str] | None: + """Formatted latitude and longitude, or None when the payload has no usable point.""" + if not isinstance(payload, dict): + return None + pair = _coordinates(payload) + if pair is None: + return None + try: + return format_latitude(pair[0]), format_longitude(pair[1]) + except ConfigError: + return None + + +def precise_from_vehicle_payload(payload: object) -> WhereReport: + """Precise point and a map link. This does not call Nominatim.""" + if isinstance(payload, dict) and location_scope_denied(payload): + return WhereReport(kind="scope") + point = precise_point(payload) + if point is None: + return WhereReport(kind="unavailable") + latitude, longitude = point + return WhereReport(kind="precise", latitude=latitude, longitude=longitude, map_url=map_link(latitude, longitude)) + + +def map_link(latitude: str, longitude: str) -> str: + return f"https://www.openstreetmap.org/?mlat={latitude}&mlon={longitude}#map=16/{latitude}/{longitude}" + + +def format_latitude(value) -> str: + return _format_axis(value, 90, "latitude must be from -90 to 90") + + +def format_longitude(value) -> str: + return _format_axis(value, 180, "longitude must be from -180 to 180") + + +def _format_axis(value, limit: int, message: str) -> str: + if isinstance(value, bool) or value is None: + raise ConfigError(message) + text = str(value).strip() + try: + number = float(text) + except (TypeError, ValueError) as exc: + raise ConfigError(message) from exc + if number != number or number in (float("inf"), float("-inf")) or number < -limit or number > limit: + raise ConfigError(message) + rendered = f"{number:.6f}".rstrip("0").rstrip(".") + if rendered in {"", "-", "-0"}: + return "0" + return rendered + + def _coordinates(payload: dict) -> tuple[object, object] | None: body = payload.get("response") if isinstance(payload.get("response"), dict) else payload if not isinstance(body, dict): diff --git a/core/fleet/departure.py b/core/fleet/departure.py new file mode 100644 index 0000000..07f6a6a --- /dev/null +++ b/core/fleet/departure.py @@ -0,0 +1,380 @@ +"""Departure preconditioning stored on the car. + +The read is Fleet API ``vehicle_data`` with ``preconditioning_schedule_data``. +Writes go through tesla-control ``precondition-schedule-add`` and +``precondition-schedule-remove``, which sign ``add_precondition_schedule`` +and ``remove_precondition_schedule``. + +The signed schedule is a cabin-ready time, on selected days, at a place. +``PreconditionSchedule`` has no battery switch and no off-peak time. +Tesla's command reference says ``set_scheduled_departure`` is not +recommended beginning with firmware 2024.26, and that +``add_precondition_schedule`` should be used instead. tesla-control does +not register the older command. This module does not send it, and it does +not send off-peak charging. +""" + +from __future__ import annotations + +import json +import math +from dataclasses import dataclass, replace + +from core.errors import ConfigError +from core.fleet.area import format_latitude, format_longitude +from core.fleet.charge_schedule import format_days, parse_days, parse_degree, parse_schedule_id + +# precondition-schedule-add optionals are positional: REPEAT, ID, ENABLED. +# REPEAT other than "once" leaves one_time false. "weekly" is only a +# placeholder so ID and ENABLED can be reached. It is not a protocol field. +# The tool copies ID when it is present. Omit it and the tool stores the +# current unix time. ENABLED defaults to true inside the tool. +_REPEAT_WEEKLY = "weekly" + + +@dataclass(frozen=True) +class DepartureWrite: + days: str + ready_time: str + latitude: str | None + longitude: str | None + once: bool + enabled: bool + days_mask: int + ready_minutes: int + schedule_id: str | None + + def with_location(self, latitude, longitude) -> DepartureWrite: + return replace(self, latitude=format_latitude(latitude), longitude=format_longitude(longitude)) + + def tool_args(self) -> list[str]: + if self.latitude is None or self.longitude is None: + raise ConfigError("latitude and longitude are required before the command is sent") + args = ["precondition-schedule-add", self.days, self.ready_time, self.latitude, self.longitude] + if self.schedule_id is None and self.enabled: + if self.once: + args.append("once") + return args + args.append("once" if self.once else _REPEAT_WEEKLY) + args.append(self.schedule_id or "") + if not self.enabled: + args.append("false") + return args + + +@dataclass(frozen=True) +class DepartureRow: + id: int | None + name: str | None + days_of_week: int | None + precondition_time: int | None + one_time: bool + enabled: bool + latitude: float | None + longitude: float | None + + def as_dict(self) -> dict: + body = { + "id": self.id, + "days_of_week": self.days_of_week, + "days": format_days(self.days_of_week), + "enabled": self.enabled, + "precondition_time": self.precondition_time, + "ready_by": _format_clock(self.precondition_time), + "one_time": self.one_time, + "latitude": self.latitude, + "longitude": self.longitude, + } + if self.name: + body["name"] = self.name + return body + + +@dataclass(frozen=True) +class DepartureReport: + state: str + skipped: bool + section_present: bool + list_present: bool + schedules: tuple[DepartureRow, ...] + + def as_dict(self) -> dict: + return { + "state": self.state, + "skipped": self.skipped, + "preconditioning_schedule_data": self.section_present, + "precondition_schedules": self.list_present, + "schedules": [row.as_dict() for row in self.schedules], + } + + +def parse_ready_time(value: str) -> tuple[str, int]: + text = str(value).strip() + pieces = text.split(":") + if len(pieces) != 2 or pieces[0] == "" or pieces[1] == "": + raise ConfigError(_TIME_ERROR) + if not pieces[0].isdigit() or not pieces[1].isdigit(): + raise ConfigError(_TIME_ERROR) + hours = int(pieces[0]) + minutes = int(pieces[1]) + if hours > 23 or minutes > 59: + raise ConfigError(_TIME_ERROR) + total = hours * 60 + minutes + return f"{hours:02d}:{minutes:02d}", total + + +def parse_departure_write( + days: str, + ready_time: str, + latitude: str | None = None, + longitude: str | None = None, + *, + once: bool, + enabled: bool, + schedule_id: str | None = None, +) -> DepartureWrite: + day_text, day_mask = parse_days(days) + clock, minutes = parse_ready_time(ready_time) + identified = None + if schedule_id is not None and str(schedule_id).strip() != "": + identified = parse_schedule_id(schedule_id) + if not enabled and identified is None: + raise ConfigError("a disabled departure schedule needs an id") + lat_text, lon_text = _optional_place(latitude, longitude) + return DepartureWrite( + days=day_text, + ready_time=clock, + latitude=lat_text, + longitude=lon_text, + once=bool(once), + enabled=bool(enabled), + days_mask=day_mask, + ready_minutes=minutes, + schedule_id=identified, + ) + + +def _optional_place(latitude, longitude) -> tuple[str | None, str | None]: + lat_missing = latitude is None or str(latitude).strip() == "" + lon_missing = longitude is None or str(longitude).strip() == "" + if lat_missing and lon_missing: + return None, None + if lat_missing or lon_missing: + raise ConfigError("pass both latitude and longitude, or omit both to use the car's current location") + return format_latitude(latitude), format_longitude(longitude) + + +def schedules_from_vehicle_data(payload: dict) -> tuple[bool, bool, tuple[DepartureRow, ...]]: + """Return section presence, list presence, and rows. + + The documented container is ``preconditioning_schedule_data``. Each row + uses the PreconditionSchedule fields. Latitude and longitude are also + accepted as ``lat`` and ``lon``. + """ + body = payload.get("response") if isinstance(payload.get("response"), dict) else payload + if not isinstance(body, dict): + return False, False, () + section = body.get("preconditioning_schedule_data") + if not isinstance(section, dict): + return False, False, () + rows = section.get("precondition_schedules") + if not isinstance(rows, list): + return True, False, () + parsed = tuple(_row(item) for item in rows if isinstance(item, dict)) + return True, True, parsed + + +def departure_satisfied(kind: str, observed, extra_args: list[str]) -> bool: + if not getattr(observed, "departure_schedule_list_present", False): + return False + rows = getattr(observed, "departure_schedules", ()) or () + if kind == "departure_schedule_set": + return any(_row_matches_add(row, extra_args) for row in rows) + if kind == "departure_schedule_remove": + if len(extra_args) < 2 or extra_args[0] != "id": + return False + target = extra_args[-1] + return all(str(row.id) != target for row in rows) + return False + + +def matching_ids(rows, extra_args: list[str]) -> list[str]: + found = [] + for row in rows: + if row.id is None: + continue + if _row_matches_add(row, extra_args): + found.append(str(row.id)) + return found + + +def format_departure_report(report: DepartureReport) -> str: + if report.skipped: + return f"state: {report.state}\nskipped: the vehicle is not online, so it was not woken" + if not report.section_present: + return "state: online\npreconditioning_schedule_data was not in the response" + if not report.list_present: + return "state: online\npreconditioning_schedule_data did not include precondition_schedules" + if not report.schedules: + return "state: online\nno departure schedules" + lines = ["state: online"] + lines.extend(_format_row(row) for row in report.schedules) + return "\n".join(lines) + + +def format_departure_report_json(report: DepartureReport) -> str: + return json.dumps(report.as_dict(), indent=2) + + +def _format_row(row: DepartureRow) -> str: + identity = f"id {row.id}" if row.id is not None else "id ?" + if row.name: + identity = f"{identity} {row.name}" + state = "enabled" if row.enabled else "disabled" + repeat = "once" if row.one_time else "weekly" + days = format_days(row.days_of_week) or "?" + place = "" + if row.latitude is not None or row.longitude is not None: + place = f", lat {_num(row.latitude)} lon {_num(row.longitude)}" + return f"{identity}: {state}, {days}, ready by {_format_clock(row.precondition_time)}, {repeat}{place}" + + +def _format_clock(minutes: int | None) -> str: + if minutes is None: + return "?" + if minutes < 0 or minutes > 1439: + return str(minutes) + return f"{minutes // 60:02d}:{minutes % 60:02d}" + + +def _row(raw: dict) -> DepartureRow: + return DepartureRow( + id=_whole(raw.get("id")), + name=_name(raw.get("name")), + days_of_week=_days_value(raw.get("days_of_week")), + precondition_time=_minutes_value(raw.get("precondition_time")) if "precondition_time" in raw else None, + one_time=_flag(raw, "one_time"), + enabled=_flag(raw, "enabled"), + latitude=_coord(raw, "latitude", "lat"), + longitude=_coord(raw, "longitude", "lon"), + ) + + +def _row_matches_add(row: DepartureRow, extra_args: list[str]) -> bool: + try: + expected = _expected_add(extra_args) + except ConfigError: + return False + if expected is None or row.days_of_week != expected.days_mask: + return False + if row.enabled != expected.enabled or row.one_time != expected.once: + return False + if row.precondition_time != expected.ready_minutes: + return False + if not _close(row.latitude, expected.latitude) or not _close(row.longitude, expected.longitude): + return False + return True + + +def _expected_add(extra_args: list[str]) -> DepartureWrite | None: + if len(extra_args) < 4: + return None + once = False + enabled = True + schedule_id = None + if len(extra_args) >= 5: + once = extra_args[4] == "once" + if len(extra_args) >= 6 and extra_args[5] != "": + schedule_id = extra_args[5] + if len(extra_args) >= 7: + enabled = extra_args[6] == "true" + return parse_departure_write( + extra_args[0], + extra_args[1], + extra_args[2], + extra_args[3], + once=once, + enabled=enabled, + schedule_id=schedule_id, + ) + + +def _close(actual: float | None, expected: str) -> bool: + if actual is None: + return False + try: + return abs(float(actual) - float(expected)) <= 0.0001 + except (TypeError, ValueError): + return False + + +def _flag(raw: dict, key: str) -> bool: + value = raw.get(key) + return value if isinstance(value, bool) else False + + +def _whole(value) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + if isinstance(value, str) and value.isdigit(): + return int(value) + return None + + +def _minutes_value(value) -> int | None: + number = _whole(value) + if number is None: + return None + return number + + +def _days_value(value) -> int | None: + if isinstance(value, str): + text = value.strip() + if not text: + return None + try: + return parse_days(text)[1] + except ConfigError: + return _whole(text) + return _whole(value) + + +def _coord(raw: dict, *keys: str) -> float | None: + for key in keys: + if key not in raw: + continue + value = raw.get(key) + if isinstance(value, bool) or value is None: + return None + try: + number = float(value) + except (TypeError, ValueError): + return None + if math.isfinite(number): + return number + return None + return None + + +def _name(value) -> str | None: + if not isinstance(value, str): + return None + text = " ".join(value.split()) + return text or None + + +def _num(value: float | None) -> str: + if value is None: + return "?" + try: + return parse_degree(str(value)) + except ConfigError: + return str(value) + + +_TIME_ERROR = "time must look like 07:30" diff --git a/core/fleet/snapshot.py b/core/fleet/snapshot.py index 7210b40..54ad275 100644 --- a/core/fleet/snapshot.py +++ b/core/fleet/snapshot.py @@ -1,8 +1,10 @@ """Normalize a vehicle_data payload into the fields the safety checks need. -Drive-state latitude and longitude are ignored on purpose so a readout cannot -be copied into assistant memory by accident. Charging-timetable coordinates -stay on the schedule rows, which only the charge-schedule command prints. +Drive-state latitude and longitude are not copied onto this observation. +status, check, and report do not request location_data. Precise coordinates +are read only when the caller asks: where --precise, or a departure place +taken from the car. History does not store them. Charging-timetable and +departure-schedule places stay on those rows, which only those commands print. """ from __future__ import annotations @@ -10,6 +12,8 @@ from dataclasses import dataclass from core.fleet.charge_schedule import ChargeScheduleRow, schedules_from_vehicle_data +from core.fleet.departure import DepartureRow +from core.fleet.departure import schedules_from_vehicle_data as departure_schedules_from_vehicle_data @dataclass @@ -25,6 +29,8 @@ class Observation: charging_state: str | None = None charge_limit_soc: int | None = None charge_amps: int | None = None + charge_current_request: int | None = None + charge_current_request_max: int | None = None tpms_soft_warning: bool | None = None tpms_hard_warning: bool | None = None tyre_fl: float | None = None @@ -44,6 +50,9 @@ class Observation: charge_schedules: tuple[ChargeScheduleRow, ...] = () charge_schedule_section_present: bool = False charge_schedule_list_present: bool = False + departure_schedules: tuple[DepartureRow, ...] = () + departure_schedule_section_present: bool = False + departure_schedule_list_present: bool = False def _section(payload: dict, name: str) -> dict: @@ -210,9 +219,13 @@ def from_vehicle_data(payload: dict) -> Observation: vehicle = _section(payload, "vehicle_state") climate = _section(payload, "climate_state") charge = _section(payload, "charge_state") - amps = charge.get("charge_amps", charge.get("charge_current_request")) + request = charge.get("charge_current_request") + amps = charge.get("charge_amps") + if amps is None: + amps = request shift = drive.get("shift_state") section_present, list_present, schedules = schedules_from_vehicle_data(payload) + departure_section, departure_list, departures = departure_schedules_from_vehicle_data(payload) return Observation( shift_state=str(shift) if shift not in (None, "") else None, speed=_optional_float(drive.get("speed")), @@ -225,6 +238,10 @@ def from_vehicle_data(payload: dict) -> Observation: charging_state=str(charge["charging_state"]) if charge.get("charging_state") else None, charge_limit_soc=_optional_int(charge.get("charge_limit_soc")), charge_amps=_optional_int(amps), + charge_current_request=_optional_int(request) if "charge_current_request" in charge else None, + charge_current_request_max=_optional_int(charge.get("charge_current_request_max")) + if "charge_current_request_max" in charge + else None, tpms_soft_warning=_flag_group(vehicle, _SOFT_WARNINGS), tpms_hard_warning=_flag_group(vehicle, _HARD_WARNINGS), tyre_fl=_optional_float(vehicle.get("tpms_pressure_fl")), @@ -244,4 +261,7 @@ def from_vehicle_data(payload: dict) -> Observation: charge_schedules=schedules, charge_schedule_section_present=section_present, charge_schedule_list_present=list_present, + departure_schedules=departures, + departure_schedule_section_present=departure_section, + departure_schedule_list_present=departure_list, ) diff --git a/core/ops.py b/core/ops.py index d8ce382..536b6ef 100644 --- a/core/ops.py +++ b/core/ops.py @@ -1,9 +1,9 @@ """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, and charge-schedule 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, and departure show +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 @@ -20,7 +20,7 @@ from adapters.template import TemplateAdapter from core.config import load_regions from core.errors import ConfigError, ConfirmationRequired, TokenError -from core.fleet.area import WhereReport, area_from_vehicle_payload +from core.fleet.area import WhereReport, area_from_vehicle_payload, precise_from_vehicle_payload, precise_point from core.fleet.charge_schedule import ( ScheduleReport, ScheduleWrite, @@ -28,6 +28,12 @@ parse_schedule_id, schedules_from_vehicle_data, ) +from core.fleet.departure import ( + DepartureReport, + DepartureWrite, + matching_ids as matching_departure_ids, + schedules_from_vehicle_data as departure_schedules_from_vehicle_data, +) from core.fleet.commands import TeslaControl from core.fleet.credentials import load_client_credentials from core.fleet.check_state import apply_rising_edge @@ -39,7 +45,7 @@ skipped_report, ) from core.fleet.http import HttpError, UrllibClient -from core.fleet.queries import FleetReader, QueryError, VehicleAsleep +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.tokens import AtomicTokenStore, FleetTokenRefresher, TokenManager, audience_for @@ -51,6 +57,9 @@ MILES_TO_KM = 1.609344 DATA_ENDPOINTS = ["charge_state", "climate_state", "vehicle_state", "drive_state"] CHARGE_SCHEDULE_ENDPOINT = "charge_schedule_data" +DEPARTURE_ENDPOINT = "preconditioning_schedule_data" +CHARGE_AMPS_MIN = 1 +CHARGE_AMPS_MAX = 80 LOCATION_ENDPOINTS = ["location_data"] PUBLIC_COMMANDS = { @@ -65,6 +74,7 @@ "charge-start": "charging_start", "charge-stop": "charging_stop", "charge-limit": "charging_set_limit", + "charge-amps": "charging_set_amps", "sentry-on": "sentry_on", "sentry-off": "sentry_off", "seat-heat": "seat_heater", @@ -121,6 +131,30 @@ def parse_limit(value: str) -> str: return str(number) +def parse_amps(value: str) -> str: + """Whole amps from 1 to 80. The car may still use less.""" + text = str(value).strip() + if not text.isdigit(): + raise ConfigError(f"amps must be a whole number from {CHARGE_AMPS_MIN} to {CHARGE_AMPS_MAX}") + number = int(text) + if number < CHARGE_AMPS_MIN or number > CHARGE_AMPS_MAX: + raise ConfigError(f"amps must be a whole number from {CHARGE_AMPS_MIN} to {CHARGE_AMPS_MAX}") + return str(number) + + +def charge_amps_result(requested: str, observed: Observation) -> str: + """Say what was asked, and when the car clamped it to the charger.""" + setting = observed.charge_current_request + if setting is None: + setting = observed.charge_amps + if setting is not None and str(setting) != str(requested): + return ( + f"OK: charge-amps {requested}\n" + f"The car kept {setting} A. It clamps to what the charger allows." + ) + return f"OK: charge-amps {requested}\nThe car may clamp this to what the charger allows." + + def vehicle_rows(payload: dict) -> list[dict]: body = payload.get("response", payload) if isinstance(body, list): @@ -264,8 +298,8 @@ def report(self, *, preferred: str | None) -> VehicleReport: secrets = [item for item in (str(chosen.get("vin") or ""), preferred or "") if item] return _redact_report(briefing, secrets) - def where(self, *, preferred: str | None) -> WhereReport: - """Coarse area. A sleeping or offline vehicle is skipped and is not woken.""" + def where(self, *, preferred: str | None, precise: bool = False) -> WhereReport: + """Area or a precise point. A sleeping or offline vehicle is not woken.""" try: chosen = select_vehicle(self.reader.list_vehicles(), preferred) except VehicleAsleep: @@ -283,6 +317,8 @@ def where(self, *, preferred: str | None) -> WhereReport: return WhereReport(kind="unavailable") except Exception: return WhereReport(kind="unavailable") + if precise: + return precise_from_vehicle_payload(payload) return area_from_vehicle_payload(self.reader.http, payload) def charge_schedule_show(self, *, preferred: str | None) -> ScheduleReport: @@ -367,7 +403,7 @@ def observe() -> Observation: payload = self.reader.vehicle_data(_key, DATA_ENDPOINTS) return from_vehicle_data(payload) - self.gateway.execute( + observed = self.gateway.execute( spec, shift_state=observation.shift_state, speed=observation.speed, @@ -378,6 +414,8 @@ def observe() -> Observation: observe=observe, extra_args=extra_args, ) + if name == "charge-amps" and extra_args: + return charge_amps_result(extra_args[0], observed) if extra_args: return f"OK: {name} {' '.join(extra_args)}" return f"OK: {name}" @@ -448,6 +486,121 @@ def observe() -> Observation: return "OK: charge-schedule set" return f"OK: charge-schedule remove {tool_args[-1]}" + def departure_show(self, *, preferred: str | None) -> DepartureReport: + """Read departure schedules. A car that is not online is left asleep.""" + chosen = select_vehicle(self.reader.list_vehicles(), preferred) + state = vehicle_state_name(chosen) or "unknown" + if state != "online": + return DepartureReport( + state=state, + skipped=True, + section_present=False, + list_present=False, + schedules=(), + ) + payload = self.reader.vehicle_data(vehicle_key(chosen), [DEPARTURE_ENDPOINT]) + section_present, list_present, schedules = departure_schedules_from_vehicle_data(payload) + return DepartureReport( + state="online", + skipped=False, + section_present=section_present, + list_present=list_present, + schedules=schedules, + ) + + def departure_set( + self, + spec: DepartureWrite, + *, + preferred: str | None, + statement: str | None, + sleep=time.sleep, + ) -> str: + return self._departure_command( + "departure_schedule_set", + None if spec.latitude is None or spec.longitude is None else spec.tool_args(), + spec=spec, + preferred=preferred, + statement=statement, + sleep=sleep, + ) + + def departure_remove( + self, + schedule_id: str, + *, + preferred: str | None, + statement: str | None, + sleep=time.sleep, + ) -> str: + identified = parse_schedule_id(schedule_id) + return self._departure_command( + "departure_schedule_remove", + ["precondition-schedule-remove", "id", identified], + preferred=preferred, + statement=statement, + sleep=sleep, + ) + + def _read_precise_location(self, key: str) -> tuple[str, str]: + """Read location_data. A 408 is not followed by another wake.""" + try: + payload = self.reader.vehicle_data(key, LOCATION_ENDPOINTS) + except VehicleAsleep as exc: + raise ConfigError( + "The car must be online to read its location. Wake it, or pass latitude and longitude." + ) from exc + if location_scope_denied(payload): + raise ConfigError( + "The token cannot read vehicle location. " + "Re-authorize and include the vehicle_location scope, or pass latitude and longitude." + ) + point = precise_point(payload) + if point is None: + raise ConfigError("The car did not return a location. Pass latitude and longitude.") + return point + + def _departure_command( + self, + spec_name: str, + tool_args: list[str] | None, + *, + preferred: str | None, + statement: str | None, + sleep, + spec: DepartureWrite | None = None, + ) -> str: + key, _observation = self._read_online(preferred, sleep=sleep) + if spec is not None and (spec.latitude is None or spec.longitude is None): + latitude, longitude = self._read_precise_location(key) + spec = spec.with_location(latitude, longitude) + tool_args = spec.tool_args() + if not tool_args: + raise ConfigError("latitude and longitude are required before the command is sent") + token = self.tokens.access_token() + + def observe() -> Observation: + payload = self.reader.vehicle_data(key, [*DATA_ENDPOINTS, DEPARTURE_ENDPOINT]) + return from_vehicle_data(payload) + + observed = self.gateway.execute( + spec_name, + shift_state=_observation.shift_state, + speed=_observation.speed, + adapter=_cli_adapter(statement), + via_schedule=False, + vin=key, + access_token=token, + observe=observe, + extra_args=tool_args[1:], + ) + if spec_name == "departure_schedule_set": + found = matching_departure_ids(observed.departure_schedules, tool_args[1:]) + if found: + return "OK: departure set id " + " ".join(found) + return "OK: departure set" + return f"OK: departure remove {tool_args[-1]}" + def _extra(name: str, argument: str | None) -> list[str] | None: if name == "seat-heat": @@ -462,6 +615,10 @@ def _extra(name: str, argument: str | None) -> list[str] | None: if argument is None: raise ConfigError("limit must be 50-100") return [parse_limit(argument)] + if name == "charge-amps": + if argument is None: + raise ConfigError(f"amps must be a whole number from {CHARGE_AMPS_MIN} to {CHARGE_AMPS_MAX}") + return [parse_amps(argument)] if argument is not None: raise ConfigError("unknown command") return None diff --git a/core/safety/catalog.py b/core/safety/catalog.py index b2a06e3..4f70807 100644 --- a/core/safety/catalog.py +++ b/core/safety/catalog.py @@ -12,6 +12,9 @@ # The rear trunk can open (`trunk-open`) and, on vehicles that can close it, `trunk-close`. # The front trunk can open. The signed tool has no command that closes it. # Cabin preconditioning now is `climate-on` (auto conditioning start). +# A later departure is `precondition-schedule-add` / `precondition-schedule-remove`. +# Those subcommands are upstream. They are not the older scheduled-departure command. +# 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. TOOL_ARGS: dict[str, tuple[str, ...]] = { @@ -39,6 +42,8 @@ "charging_set_amps": ("charging-set-amps",), "charging_schedule_set": ("charging-schedule-add",), "charging_schedule_remove": ("charging-schedule-remove",), + "departure_schedule_set": ("precondition-schedule-add",), + "departure_schedule_remove": ("precondition-schedule-remove",), } VERIFY_KIND = { @@ -66,6 +71,8 @@ "charging_set_amps": "charge_amps", "charging_schedule_set": "charge_schedule_set", "charging_schedule_remove": "charge_schedule_remove", + "departure_schedule_set": "departure_schedule_set", + "departure_schedule_remove": "departure_schedule_remove", } @@ -141,6 +148,9 @@ def schedule_blocks(command, sensitive: set[str] | None = None) -> bool: Health check (`check`) and the morning history (`history`) stay allowed. Trunk, frunk, and remote start are interactive owner commands. + Charging changes, including charge current and the charging timetable, + are blocked. Setting or removing a departure schedule is blocked. + `departure show` is a read and stays allowed. """ if not isinstance(command, str): return False @@ -153,7 +163,31 @@ def schedule_blocks(command, sensitive: set[str] | None = None) -> bool: text = _schedule_text(raw) if text in _SCHEDULE_BLOCKED or text.replace(" ", "_") in names: return True - if raw == "charge-schedule" or raw.startswith("charge-schedule ") or text == "charge schedule" or text.startswith("charge schedule "): + if _blocks_charge_schedule(raw, text) or _blocks_charge_amps(text) or _blocks_departure_change(text): return True head = _schedule_text(raw.split()[0]) return head in _SCHEDULE_BLOCKED or head.replace(" ", "_") in names + + +def _blocks_charge_schedule(raw: str, text: str) -> bool: + if raw == "charge-schedule" or raw.startswith("charge-schedule "): + return True + return text == "charge schedule" or text.startswith("charge schedule ") + + +def _blocks_charge_amps(text: str) -> bool: + if text in {"charge amps", "charging set amps"}: + return True + return text.startswith("charge amps ") or text.startswith("charging set amps ") + + +def _blocks_departure_change(text: str) -> bool: + """Block departure writes. A show read stays allowed.""" + parts = text.split() + if parts[:2] == ["departure", "show"] or parts[:3] == ["departure", "schedule", "show"]: + return False + if parts[:1] == ["departure"]: + return True + if text in {"precondition schedule add", "precondition schedule remove"}: + return True + return text.startswith("precondition schedule add ") or text.startswith("precondition schedule remove ") diff --git a/core/safety/gateway.py b/core/safety/gateway.py index 856583f..8376bbc 100644 --- a/core/safety/gateway.py +++ b/core/safety/gateway.py @@ -5,6 +5,7 @@ from core.errors import ConfigError, ScheduleBlocked, VerificationFailed 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.safety.catalog import CommandSpec, load_specs from core.safety.confirm import Confirmation, ConfirmationBroker, new_challenge @@ -98,14 +99,39 @@ def _verify(kind: str, observed: Observation, extra_args: list[str]) -> None: if kind == "charge_limit" and extra_args and observed.charge_limit_soc is not None: if str(observed.charge_limit_soc) == str(extra_args[0]): return - if kind == "charge_amps" and extra_args and observed.charge_amps is not None: - if str(observed.charge_amps) == str(extra_args[0]): - return + if kind == "charge_amps" and _charge_amps_match(observed, extra_args): + return if kind in {"charge_schedule_set", "charge_schedule_remove"} and charge_schedule_satisfied(kind, observed, extra_args): return + if kind in {"departure_schedule_set", "departure_schedule_remove"} and departure_satisfied(kind, observed, extra_args): + return raise VerificationFailed("The vehicle state after the command did not match the request. The command was not sent again.") +def _charge_amps_match(observed: Observation, extra_args: list[str]) -> bool: + """True when the car kept the requested current, or clamped down to its max. + + The setting is ``charge_current_request``. ``charge_amps`` is the live + draw and is only the fallback when the request field is absent. A lower + positive value counts only when it equals ``charge_current_request_max``. + """ + if not extra_args: + return False + try: + asked = int(str(extra_args[0])) + except (TypeError, ValueError): + return False + setting = observed.charge_current_request + if setting is None: + setting = observed.charge_amps + if setting is None: + return False + if setting == asked: + return True + cap = observed.charge_current_request_max + return cap is not None and setting == cap and 0 < cap < asked + + def _seat_matches(observed: Observation, extra_args: list[str]) -> bool: if len(extra_args) < 2: return False diff --git a/docs/00-before-you-start.md b/docs/00-before-you-start.md index bc4fc11..90ffb4b 100644 --- a/docs/00-before-you-start.md +++ b/docs/00-before-you-start.md @@ -33,7 +33,7 @@ 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 -schedule unlock, turning sentry off, the trunk, the front trunk, remote start, or a charging change. This check is separate from the one daily +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. agentdrive report is the live snapshot when I want the car's current status, including the skip line when the car is asleep. If I later ask for a different time or to turn the @@ -89,7 +89,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, or a charging change on it. AgentDrive does not run that job. It is separate from the daily morning briefing. +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 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 f3cad58..5a59231 100644 --- a/docs/01-install-and-safety.md +++ b/docs/01-install-and-safety.md @@ -28,11 +28,11 @@ Override a wrong detection with `bash setup/install.sh --adapter template`. ## Step 3 — What the safety gate will refuse -A state-changing command is sent only when the shift state is Park and the speed is 0. Unlock, remote start, opening the front trunk, opening or closing the rear trunk, sentry off, and charging changes each need an explicit confirmation for that action. The next action needs a new one. A schedule cannot supply it, and it cannot run remote start, the rear trunk, the front trunk, or sentry off. Sentry on, seat heat, steering-wheel heat, defrost, and preconditioning the cabin now use that park check and do not add a confirmation of their own. +A state-changing command is sent only when the shift state is Park and the speed is 0. Unlock, remote start, opening the front trunk, opening or closing the rear trunk, sentry off, charging changes, and setting or removing a departure schedule each need an explicit confirmation for that action. The next action needs a new one. A schedule cannot supply it, and it cannot run remote start, the rear trunk, the front trunk, sentry off, or a departure schedule change. Sentry on, seat heat, steering-wheel heat, defrost, and preconditioning the cabin now use that park check and do not add a confirmation of their own. ## 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, or a charging 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. 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. ## After install — daily report diff --git a/docs/06-queries-and-commands.md b/docs/06-queries-and-commands.md index 081d9a1..14c0cdd 100644 --- a/docs/06-queries-and-commands.md +++ b/docs/06-queries-and-commands.md @@ -4,7 +4,7 @@ ![Park check, one-time confirmation for sensitive actions, then one send and a fresh read](images/command-safety.svg) -Reads use the Fleet API: the vehicle list, `vehicle_data`, and wake. Location on firmware 2023.38 and later needs the `location_data` endpoint and the `vehicle_location` scope. `status` and `check` do not request that endpoint. `where` requests it only when the vehicle list already says the car is online. Coordinates are not copied into the AI agent's memory. +Reads use the Fleet API: the vehicle list, `vehicle_data`, and wake. Location on firmware 2023.38 and later needs the `location_data` endpoint and the `vehicle_location` scope. `status` and `check` do not request that endpoint. `where` and `where --precise` request it only when the vehicle list already says the car is online. Precise coordinates are used only when you ask for them (`where --precise`, or a departure place taken from the car) and are not written to the local history. `agentdrive status` reads charge, climate, and vehicle state, including tyre pressures and TPMS warnings. If the vehicle list says the car is asleep, status sends one wake and then polls for up to 45 seconds. The VIN is masked. Tokens are not printed. When the account has more than one vehicle, set `AGENTDRIVE_VIN` to choose one. Otherwise the first vehicle on the account is used. @@ -14,7 +14,15 @@ Reads use the Fleet API: the vehicle list, `vehicle_data`, and wake. Location on Before that point leaves the process, latitude and longitude are rounded to two decimal places, about 1 km. The command sends that rounded point to OpenStreetMap Nominatim for one reverse lookup at zoom 14, with the `AgentDrive/` User-Agent. It does not retry, and it does not send the precise point. Nominatim's usage policy allows this kind of light use and asks callers not to bulk-geocode. Place names are OpenStreetMap data. -The command prints a suburb or locality, a state or region, and a country. The text labels are `suburb`, `region`, and `country`. `suburb` is the suburb when Nominatim has one, and otherwise a neighbourhood, locality, village, town, or city. `--json` prints those three fields. Stdout, JSON, and errors do not contain coordinates. If the lookup fails, the command prints `area unavailable` and exits 1. +The default command prints a suburb or locality, a state or region, and a country. The text labels are `suburb`, `region`, and `country`. `suburb` is the suburb when Nominatim has one, and otherwise a neighbourhood, locality, village, town, or city. `--json` prints those three fields. That default text, JSON, and the errors do not contain coordinates. If the lookup fails, the command prints `area unavailable` and exits 1. + +`agentdrive where --precise` prints the precise latitude and longitude and an OpenStreetMap link when the car is already online. It uses the same non-waking read. It does not call Nominatim. If the car is asleep or offline, it skips and does not wake the car. `--json` prints `latitude`, `longitude`, and `map`. + +```bash +agentdrive where +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. @@ -37,6 +45,7 @@ Commands: | Command | What it sends | Confirmation | | --- | --- | --- | | `where` | no signed command; one `location_data` read when the car is already online | no | +| `where --precise` | no signed command; the same non-waking `location_data` read, then latitude, longitude, and a map link | no | | `wake` | signed `wake` | no, and it does not use the park check | | `flash` | signed `flash-lights` | no | | `honk` | signed `honk` | no | @@ -50,8 +59,12 @@ Commands: | `steering-heat on` or `off` | signed `steering-wheel-heater` | no | | `defrost-on`, `defrost-off` | signed `preconditioning-max` | no | | `precondition` | signed `climate-on` | no | +| `departure show` | read `preconditioning_schedule_data` | no, and it does not wake | +| `departure set