From 4330663c530299dccb257509531fc804760dd912 Mon Sep 17 00:00:00 2001 From: itbird Date: Sat, 14 Nov 2026 01:28:30 +0000 Subject: [PATCH] Add multi-car nicknames and owner approval for family requests Several cars can be named and selected with --car or a default. History, alerts, and briefings stay with each car. A family member's sensitive action becomes a pending request the owner approves once. --- .agents/skills/agentdrive/SKILL.md | 51 +- .../agentdrive/config/runtime.example.yaml | 6 + .agents/skills/agentdrive/prompts/install.md | 2 + .gitignore | 5 + README.md | 2 +- SECURITY.md | 4 +- config/runtime.example.yaml | 6 + core/cli.py | 380 ++++++++++++- core/family.py | 293 ++++++++++ core/fleet/check_state.py | 8 +- core/garage.py | 343 ++++++++++++ core/history/official.py | 10 +- core/history/service.py | 12 +- core/history/store.py | 5 + core/history/usage.py | 19 +- core/ops.py | 101 ++-- core/requests.py | 244 +++++++++ core/safety/catalog.py | 9 + core/safety/confirm.py | 18 +- core/safety/gateway.py | 5 +- docs/00-before-you-start.md | 4 + docs/01-install-and-safety.md | 2 +- docs/06-queries-and-commands.md | 57 +- docs/07-assistants.md | 4 +- docs/08-skills.md | 2 +- tests/test_cars_and_family.py | 510 ++++++++++++++++++ 26 files changed, 2014 insertions(+), 88 deletions(-) create mode 100644 core/family.py create mode 100644 core/garage.py create mode 100644 core/requests.py create mode 100644 tests/test_cars_and_family.py diff --git a/.agents/skills/agentdrive/SKILL.md b/.agents/skills/agentdrive/SKILL.md index fb201f6..8405362 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. 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. Also use it when the user has more than one car or a family member asks for an action the owner must approve. Do not sign vehicle commands in this skill. --- # AgentDrive @@ -55,6 +55,53 @@ Security trade-off of a shared key: every agent that can read the private key fi `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. +## Which car + +`agentdrive cars` lists the cars on the account. It does not wake a car and it does not print a full VIN. A masked tail is enough to tell them apart. + +With one car, do not ask for a nickname and do not pass `--car`. The commands stay as they are. + +With several cars, ask the owner for a nickname for each car, then run `agentdrive cars name `. The tail is the last four characters from `agentdrive cars`. Set a default only when the owner chooses one: `agentdrive cars default `. `agentdrive cars clear-default` clears it. + +Pass `--car ` on a command for one car. `--car` can sit before or after the command name. If several cars are saved and there is no default and no `--car`, a command that would act on one car refuses. Do not guess. + +When a nickname is in use, a confirmation-required command names that car. The sentence has to include `confirm`, the action, and the nickname. Example: `I confirm unlock on daily`. `I confirm unlock` is not enough for that car, and a sentence that names a different nickname does not apply. The result line names the car too, for example `OK: unlock on daily`. With one car and no `--car`, keep the existing sentence, such as `I confirm unlock`. + +Health checks, alert memory, briefings, and the local history are per car. The first car that is named keeps history that was already saved. With several cars, schedule `agentdrive check --json --car ` once per car. The same alert on two cars is two alerts. The morning job runs `agentdrive history due --car ` once per car and you send each briefing on its own. With one car, `agentdrive check --json` and `agentdrive history due` stay as they are. Those commands still do not wake the car. + +## Family members + +The owner registers a handle and the actions that handle may ask for. + +```bash +agentdrive family add rider +agentdrive family allow rider lock +agentdrive family allow rider status +agentdrive family allow rider unlock +agentdrive family allow rider charging +agentdrive family list +agentdrive family revoke rider lock +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. + +```bash +agentdrive --as rider --car daily unlock +``` + +The owner approves or denies with the id from that text: + +```bash +agentdrive request list +agentdrive request approve +agentdrive request deny +``` + +Approve runs the action once. The car must still be in Park at speed 0. A request expires 10 minutes after it is created. Expired, denied, and already used requests do not run. A family member cannot approve or deny their own request. Do not pass `--as` on `request approve` or `request deny`. + ## 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. @@ -71,4 +118,4 @@ The same job is used for every shell-capable AI agent. Do not invent a second mo ## Secrets -Never put a private key, token, client secret, authorization code, or VIN in this skill, in the AI agent's memory, or in chat. +Never put a private key, token, client secret, authorization code, or VIN in this skill, in the AI agent's memory, or in chat. Car nicknames and family handles live in the config home. Do not copy `cars.yaml`, `family.yaml`, or `requests.json` into chat or into the git repo. diff --git a/.agents/skills/agentdrive/config/runtime.example.yaml b/.agents/skills/agentdrive/config/runtime.example.yaml index 7ebdc00..de1e552 100644 --- a/.agents/skills/agentdrive/config/runtime.example.yaml +++ b/.agents/skills/agentdrive/config/runtime.example.yaml @@ -19,3 +19,9 @@ token_file: 'C:\Users\\agentdrive-key\tokens.json' # report_schedule: # enabled: true # time: "07:30" +# +# Car nicknames and family members are not stored in this file. +# `agentdrive cars name` writes $AGENTDRIVE_HOME/cars.yaml. +# `agentdrive family add` writes $AGENTDRIVE_HOME/family.yaml. +# Pending family requests are $AGENTDRIVE_HOME/requests.json. +# Do not commit those files. diff --git a/.agents/skills/agentdrive/prompts/install.md b/.agents/skills/agentdrive/prompts/install.md index eea53af..b97cbcd 100644 --- a/.agents/skills/agentdrive/prompts/install.md +++ b/.agents/skills/agentdrive/prompts/install.md @@ -44,3 +44,5 @@ If I ask to turn the briefing off, run `agentdrive report-schedule disable` and AgentDrive stores the time and does not run in the background. The same daily job is used for every shell-capable AI agent. Keep every secret in the config home or in the existing key and token files. +If several cars are on the account, ask for a nickname for each and pass `--car `. With one car, do not. A confirmation then names the car, for example `I confirm unlock on daily`. +If a family member asks for unlock or a charging change, run it with `--as ` and relay the pending-request text to the owner. The owner approves with `agentdrive request approve `. Do not approve it yourself. diff --git a/.gitignore b/.gitignore index 9dae45b..85d6e4d 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,11 @@ config/profile.local.yaml config/host.yaml config/runtime.yaml +# Private multi-car and family state. These belong in $AGENTDRIVE_HOME only. +/cars.yaml +/family.yaml +/requests.json + # Editor and OS noise .DS_Store .idea/ diff --git a/README.md b/README.md index c2a80c7..f96d020 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, 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. +**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. **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. diff --git a/SECURITY.md b/SECURITY.md index c81733a..da40221 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,7 +26,9 @@ 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, 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. +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. When more than one car is in use, the confirmation also names that car, so it cannot be applied to a different one. 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. + +A family member can be allowed to ask for an action. Unlock and charging changes are not run for them. AgentDrive saves a pending request for 10 minutes. You approve or deny it. A family member cannot approve their own request. An expired, denied, or already used request cannot run. 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/config/runtime.example.yaml b/config/runtime.example.yaml index 7ebdc00..de1e552 100644 --- a/config/runtime.example.yaml +++ b/config/runtime.example.yaml @@ -19,3 +19,9 @@ token_file: 'C:\Users\\agentdrive-key\tokens.json' # report_schedule: # enabled: true # time: "07:30" +# +# Car nicknames and family members are not stored in this file. +# `agentdrive cars name` writes $AGENTDRIVE_HOME/cars.yaml. +# `agentdrive family add` writes $AGENTDRIVE_HOME/family.yaml. +# Pending family requests are $AGENTDRIVE_HOME/requests.json. +# Do not commit those files. diff --git a/core/cli.py b/core/cli.py index 48c2111..62b31b9 100644 --- a/core/cli.py +++ b/core/cli.py @@ -14,9 +14,32 @@ from core.config import repo_root from core.doctor import run_doctor from core.errors import AgentDriveError, AmbiguousAdapter, ConfigError +from core.family import ( + add_member, + allow_actions, + allow_text, + classify, + format_family, + load_family, + normalize_handle, + owner_confirmation, + remove_member, + revoke_action, +) 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.garage import ( + clear_default, + format_cars, + load_garage, + remember_car, + set_default, + store_for_saved, + vehicle_rows, +) +from core.redact import mask_vin +from core.requests import approve_request, create_request, deny_request, list_requests from core.fleet.http import UrllibClient from core.fleet.report import format_report, format_report_json from core.history.official import render_news @@ -52,6 +75,8 @@ def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="agentdrive") parser.add_argument("--home", default="", help="Config home. Defaults to $AGENTDRIVE_HOME or ~/.config/agentdrive") + parser.add_argument("--car", default="", help="Nickname of the car this command uses") + parser.add_argument("--as", dest="actor", default="", help="Family member handle. The owner leaves this unset.") sub = parser.add_subparsers(dest="command", required=True) present_cmd = sub.add_parser("gate-present") @@ -208,12 +233,44 @@ def build_parser() -> argparse.ArgumentParser: service_status.add_argument("--json", action="store_true") sub.add_parser("update") + + cars = sub.add_parser("cars", help="List cars on this account, or save a nickname. Listing does not wake a car.") + cars_action = cars.add_subparsers(dest="cars_action") + cars_action.add_parser("list", help="List cars from the vehicle list. Does not wake a car.") + cars_name = cars_action.add_parser("name", help="Save a nickname for the car with this VIN tail.") + cars_name.add_argument("tail", help="Last four characters from `agentdrive cars`.") + cars_name.add_argument("nickname") + cars_default = cars_action.add_parser("default", help="Use this nickname when a command does not pass --car.") + cars_default.add_argument("nickname") + cars_action.add_parser("clear-default", help="Clear the default car. Several cars then need --car.") + + family = sub.add_parser("family", help="Register a family member and the actions they may request.") + family_action = family.add_subparsers(dest="family_action") + family_action.add_parser("list") + family_add = family_action.add_parser("add") + family_add.add_argument("handle") + family_remove = family_action.add_parser("remove") + family_remove.add_argument("handle") + family_allow = family_action.add_parser("allow") + family_allow.add_argument("handle") + family_allow.add_argument("action") + family_revoke = family_action.add_parser("revoke") + family_revoke.add_argument("handle") + family_revoke.add_argument("action") + + request = sub.add_parser("request", help="List, approve, or deny a family member's pending request.") + request_action = request.add_subparsers(dest="request_action") + request_action.add_parser("list") + request_approve = request_action.add_parser("approve") + request_approve.add_argument("request_id") + request_deny = request_action.add_parser("deny") + request_deny.add_argument("request_id") return parser def main(argv: list[str] | None = None) -> int: parser = build_parser() - args = parser.parse_args(argv) + args = parser.parse_args(_normalize_global(argv)) home = Path(args.home) if args.home else resolve_home() try: if args.command == "gate-present": @@ -249,6 +306,12 @@ def main(argv: list[str] | None = None) -> int: return _news(args, home) if args.command == "service": return _service(args, home) + if args.command == "cars": + return _cars(args, home) + if args.command == "family": + return _family(args, home) + if args.command == "request": + return _request(args, home) if args.command == "charge-schedule": return _charge_schedule(args, home) if args.command == "departure": @@ -338,7 +401,8 @@ def _check(args, home: Path) -> int: tyre_bar=args.tyre_threshold, low_battery_percent=args.battery_threshold, ) - report = build_app(home).check(preferred=preferred_vin(), tyre_bar=tyre, low_battery_percent=battery) + _guard_read(args, home, "check") + report = _open_app(home, args).check(preferred=preferred_vin(), tyre_bar=tyre, low_battery_percent=battery) if args.json: print(scrub(format_check_json(report))) else: @@ -347,7 +411,8 @@ def _check(args, home: Path) -> int: def _report(args, home: Path) -> int: - briefing = build_app(home).report(preferred=preferred_vin()) + _guard_read(args, home, "report") + briefing = _open_app(home, args).report(preferred=preferred_vin()) if args.json: print(scrub(format_report_json(briefing))) else: @@ -371,51 +436,60 @@ def _report_schedule(args, home: Path) -> int: def _where(args, home: Path) -> int: - report = build_app(home).where(preferred=preferred_vin(), precise=bool(args.precise)) + _guard_read(args, home, "where") + report = _open_app(home, args).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 def _history(args, home: Path) -> int: + _guard_read(args, home, "history") + store = store_for_saved(home, args.car or None) action = args.history_action if action in {"trips", "charges", "snapshots", "alerts", "config"}: if args.limit < 1 or args.limit > 200: raise ConfigError("The history limit must be from 1 to 200.") - print(scrub(render_list(home, action, limit=args.limit, as_json=args.json))) + print(scrub(render_list(home, action, limit=args.limit, as_json=args.json, store=store))) return 0 - print(scrub(render_briefing(home, action, as_json=args.json))) + print(scrub(render_briefing(home, action, as_json=args.json, store=store))) return 0 def _tips(args, home: Path) -> int: - print(scrub(render_tips(home, as_json=args.json))) + _guard_read(args, home, "tips") + print(scrub(render_tips(home, as_json=args.json, store=store_for_saved(home, args.car or None)))) return 0 def _news(args, home: Path) -> int: - text = render_news(home, as_json=args.json) + _guard_read(args, home, "news") + text = render_news(home, as_json=args.json, store=store_for_saved(home, args.car or None)) if text: print(scrub(text)) return 0 def _service(args, home: Path) -> int: + _guard_read(args, home, "service") + store = store_for_saved(home, args.car or None) if args.service_action == "log": - print(scrub(perform_log(home, args.kind, note=args.note, interval_km=args.interval_km))) + print(scrub(perform_log(home, args.kind, note=args.note, interval_km=args.interval_km, store=store))) return 0 as_json = bool(args.json) - print(scrub(render_service_status(home, as_json=as_json))) + print(scrub(render_service_status(home, as_json=as_json, store=store))) return 0 def _charge_schedule(args, home: Path) -> int: if args.schedule_action == "show": - report = build_app(home).charge_schedule_show(preferred=preferred_vin()) + _guard_read(args, home, "charge-schedule-show") + report = _open_app(home, args).charge_schedule_show(preferred=preferred_vin()) text = format_schedule_report_json(report) if args.json else format_schedule_report(report) print(scrub(text)) return 0 statement = getattr(args, "confirm", None) + action = f"charge-schedule-{args.schedule_action}" if args.schedule_action == "set": spec = parse_schedule_write( args.days, @@ -425,9 +499,27 @@ def _charge_schedule(args, home: Path) -> int: once=args.once, enabled=args.enabled == "true", ) + app = _open_app(home, args) + held = _family_hold( + args, + home, + app, + action, + detail={ + "days": args.days, + "time": args.time, + "latitude": args.latitude, + "longitude": args.longitude, + "once": bool(args.once), + "enabled": args.enabled == "true", + }, + ) + if held is not None: + print(scrub(held)) + return 0 print( scrub( - build_app(home).charge_schedule_set( + app.charge_schedule_set( spec, preferred=preferred_vin(), statement=statement, @@ -437,9 +529,14 @@ def _charge_schedule(args, home: Path) -> int: return 0 if args.schedule_action == "remove": schedule_id = parse_schedule_id(args.schedule_id) + app = _open_app(home, args) + held = _family_hold(args, home, app, action, detail={"schedule_id": schedule_id}) + if held is not None: + print(scrub(held)) + return 0 print( scrub( - build_app(home).charge_schedule_remove( + app.charge_schedule_remove( schedule_id, preferred=preferred_vin(), statement=statement, @@ -452,11 +549,13 @@ def _charge_schedule(args, home: Path) -> int: def _departure(args, home: Path) -> int: if args.departure_action == "show": - report = build_app(home).departure_show(preferred=preferred_vin()) + _guard_read(args, home, "departure-show") + report = _open_app(home, args).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) + action = f"departure-{args.departure_action}" if args.departure_action == "set": schedule_id = args.id if args.enabled == "false" and not schedule_id: @@ -470,9 +569,28 @@ def _departure(args, home: Path) -> int: enabled=args.enabled == "true", schedule_id=schedule_id, ) + app = _open_app(home, args) + held = _family_hold( + args, + home, + app, + action, + detail={ + "days": args.days, + "time": args.time, + "latitude": args.latitude, + "longitude": args.longitude, + "once": bool(args.once), + "enabled": args.enabled == "true", + "schedule_id": schedule_id, + }, + ) + if held is not None: + print(scrub(held)) + return 0 print( scrub( - build_app(home).departure_set( + app.departure_set( spec, preferred=preferred_vin(), statement=statement, @@ -482,9 +600,14 @@ def _departure(args, home: Path) -> int: return 0 if args.departure_action == "remove": schedule_id = parse_schedule_id(args.schedule_id) + app = _open_app(home, args) + held = _family_hold(args, home, app, action, detail={"schedule_id": schedule_id}) + if held is not None: + print(scrub(held)) + return 0 print( scrub( - build_app(home).departure_remove( + app.departure_remove( schedule_id, preferred=preferred_vin(), statement=statement, @@ -511,13 +634,19 @@ def _vehicle(args, home: Path) -> int: extra = list(parse_seat_heat(args.seat, args.level)) elif args.command == "steering-heat": extra = [parse_on_off(args.state, "steering heat")] - app = build_app(home) + app = _open_app(home, args) if args.command == "refresh": + _guard_read(args, home, "refresh") print(scrub(app.refresh())) return 0 if args.command == "status": + _guard_read(args, home, "status") print(scrub(app.status(preferred=preferred_vin()))) return 0 + held = _family_hold(args, home, app, args.command, argument=argument, extra=extra) + if held is not None: + print(scrub(held)) + return 0 statement = getattr(args, "confirm", None) print( scrub( @@ -533,5 +662,222 @@ def _vehicle(args, home: Path) -> int: return 0 +_GLOBAL = {"--home", "--car", "--as"} + + +def _normalize_global(argv: list[str] | None) -> list[str] | None: + """Move --home, --car, and --as in front of the subcommand.""" + if argv is None: + return None + pulled: list[str] = [] + rest: list[str] = [] + index = 0 + while index < len(argv): + token = argv[index] + name, sep, value = token.partition("=") + if sep and name in _GLOBAL: + pulled.extend([name, value]) + index += 1 + continue + if token in _GLOBAL: + if index + 1 >= len(argv) or str(argv[index + 1]).startswith("-"): + pulled.append(token) + index += 1 + continue + pulled.extend([token, argv[index + 1]]) + index += 2 + continue + rest.append(token) + index += 1 + return pulled + rest + + +def _open_app(home: Path, args): + app = build_app(home) + car = getattr(args, "car", "") or "" + if car: + app.car_name = car + return app + + +def _owner_only(args) -> None: + if getattr(args, "actor", ""): + raise ConfigError("Only the owner can change cars, family members, or requests.") + + +def _guard_read(args, home: Path, action: str) -> None: + actor = getattr(args, "actor", "") or "" + if not actor: + return + if classify(home, actor, action) != "direct": + raise ConfigError(f"{normalize_handle(actor)} cannot request {action}.") + + +def _family_hold(args, home: Path, app, action: str, *, argument=None, extra=None, detail=None): + """Return relay text when a family member must wait for the owner.""" + actor = getattr(args, "actor", "") or "" + if not actor: + return None + decision = classify(home, actor, action) + handle = normalize_handle(actor) + if decision == "deny": + raise ConfigError(f"{handle} cannot request {action}.") + if decision == "direct": + return None + selection = app.prepare(preferred_vin()) + car = selection.car + return create_request( + home, + member=handle, + car=car.nickname if car else "", + car_id=car.id if car else "", + label=selection.label, + command=action, + argument=argument, + extra=list(extra) if extra else None, + detail=detail, + ) + + +def _cars(args, home: Path) -> int: + action = args.cars_action or "list" + if action == "list": + _guard_read_if_actor(args, home, "cars") + app = _open_app(home, args) + rows = vehicle_rows(app.reader.list_vehicles()) + print(scrub(format_cars(rows, load_garage(home)))) + return 0 + _owner_only(args) + if action == "name": + app = _open_app(home, args) + rows = vehicle_rows(app.reader.list_vehicles()) + car = remember_car(home, rows, args.tail, args.nickname) + print(f"Saved {car.nickname} as {mask_vin(car.vin)}.") + return 0 + if action == "default": + garage = set_default(home, args.nickname) + print(f"Default car is {garage.default}.") + return 0 + if action == "clear-default": + clear_default(home) + print("No default car is set.") + return 0 + raise ConfigError("unknown command") + + +def _guard_read_if_actor(args, home: Path, action: str) -> None: + """Family members need a direct grant. The owner does not.""" + if getattr(args, "actor", ""): + _guard_read(args, home, action) + + +def _family(args, home: Path) -> int: + _owner_only(args) + action = args.family_action or "list" + if action == "list": + print(format_family(load_family(home))) + return 0 + if action == "add": + member = add_member(home, args.handle) + print(f"Added family member {member.handle}.") + return 0 + if action == "remove": + remove_member(home, args.handle) + print(f"Removed family member {normalize_handle(args.handle)}.") + return 0 + if action == "allow": + granted = allow_actions(home, args.handle, args.action) + print(allow_text(normalize_handle(args.handle), granted)) + return 0 + if action == "revoke": + names = revoke_action(home, args.handle, args.action) + handle = normalize_handle(args.handle) + print("\n".join(f"{handle} can no longer request {name}." for name in names)) + return 0 + raise ConfigError("unknown command") + + +def _request(args, home: Path) -> int: + action = args.request_action or "list" + if action == "list": + _owner_only(args) + print(scrub(list_requests(home))) + return 0 + if action == "approve": + print( + scrub( + approve_request( + home, + args.request_id, + actor=getattr(args, "actor", "") or "", + execute=lambda record: _execute_saved(home, record), + ready=lambda record: _ready_saved(home, record), + ) + ) + ) + return 0 + if action == "deny": + print(scrub(deny_request(home, args.request_id, actor=getattr(args, "actor", "") or ""))) + return 0 + raise ConfigError("unknown command") + + +def _ready_saved(home: Path, record: dict) -> None: + """Refuse a request whose car disappeared before it is spent.""" + car_id = str(record.get("car_id") or "") + if not car_id: + return + if load_garage(home).by_id(car_id) is None: + raise ConfigError("That car is no longer in the private config.") + + +def _execute_saved(home: Path, record: dict) -> str: + app = build_app(home) + garage = load_garage(home) + car = garage.by_id(str(record.get("car_id") or "")) if record.get("car_id") else None + preferred = car.vin if car is not None else None + label = str(record.get("label") or "") + if car is not None and (label or len(garage.cars) > 1): + label = car.nickname + app.car_name = car.nickname + else: + label = "" + statement = owner_confirmation(str(record.get("command") or ""), label) + command = str(record.get("command") or "") + detail = record.get("detail") if isinstance(record.get("detail"), dict) else {} + if command == "charge-schedule-set": + spec = parse_schedule_write( + str(detail.get("days") or ""), + str(detail.get("time") or ""), + str(detail.get("latitude") or ""), + str(detail.get("longitude") or ""), + once=bool(detail.get("once")), + enabled=bool(detail.get("enabled", True)), + ) + return app.charge_schedule_set(spec, preferred=preferred, statement=statement) + if command == "charge-schedule-remove": + return app.charge_schedule_remove(str(detail.get("schedule_id") or ""), preferred=preferred, statement=statement) + if command == "departure-set": + spec = parse_departure_write( + str(detail.get("days") or ""), + str(detail.get("time") or ""), + detail.get("latitude"), + detail.get("longitude"), + once=bool(detail.get("once")), + enabled=bool(detail.get("enabled", True)), + schedule_id=detail.get("schedule_id"), + ) + return app.departure_set(spec, preferred=preferred, statement=statement) + if command == "departure-remove": + return app.departure_remove(str(detail.get("schedule_id") or ""), preferred=preferred, statement=statement) + return app.command( + command, + preferred=preferred, + argument=record.get("argument"), + statement=statement, + extra=record.get("extra"), + ) + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/core/family.py b/core/family.py new file mode 100644 index 0000000..5b583e3 --- /dev/null +++ b/core/family.py @@ -0,0 +1,293 @@ +"""Family members the owner allows to ask for parked actions. + +The file is ``$AGENTDRIVE_HOME/family.yaml``. A member has a handle, the +low-risk actions they may run, and the sensitive actions they may request. +Unlock and every charging change stay requests: the command records them +and the owner approves them. A family member cannot approve. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +import yaml + +from core.atomic import write_atomic +from core.errors import ConfigError +from core.safety.catalog import load_specs + +FAMILY_NAME = "family.yaml" +_HANDLE = re.compile(r"^[a-z][a-z0-9-]{1,31}$") +_VIN = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$") + +# Low-risk reads and parked actions. The owner opts in per member. +DIRECT_ACTIONS = { + "cars", + "charge-schedule-show", + "check", + "climate-off", + "climate-on", + "defrost-off", + "defrost-on", + "departure-show", + "flash", + "history", + "honk", + "lock", + "news", + "precondition", + "report", + "seat-heat", + "sentry-on", + "service", + "set-temp", + "status", + "steering-heat", + "tips", + "wake", + "where", +} + +# Sensitive actions. These are requests even if the owner lists them. +CHARGING_ACTIONS = ( + "charge-amps", + "charge-limit", + "charge-schedule-remove", + "charge-schedule-set", + "charge-start", + "charge-stop", +) +REQUEST_ACTIONS = { + *CHARGING_ACTIONS, + "departure-remove", + "departure-set", + "frunk-open", + "remote-start", + "sentry-off", + "trunk-close", + "trunk-open", + "unlock", +} + + +@dataclass +class Member: + handle: str + direct: list[str] = field(default_factory=list) + request: list[str] = field(default_factory=list) + + +@dataclass +class Family: + members: list[Member] = field(default_factory=list) + + def by_handle(self, handle: str) -> Member | None: + key = str(handle or "").strip().lower() + for member in self.members: + if member.handle == key: + return member + return None + + +def normalize_handle(value: str) -> str: + text = " ".join(str(value or "").split()).lower() + if not _HANDLE.fullmatch(text) or _VIN.fullmatch(text.upper()): + raise ConfigError("A family handle must be 2 to 32 letters, digits, or hyphens.") + return text + + +def load_family(home) -> Family: + from pathlib import Path + + file_path = Path(home) / FAMILY_NAME + if not file_path.is_file(): + return Family() + try: + data = yaml.safe_load(file_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, yaml.YAMLError) as exc: + raise ConfigError("family.yaml could not be read.") from exc + if data is None: + return Family() + if not isinstance(data, dict): + raise ConfigError("family.yaml must be a mapping.") + if set(data) - {"version", "members"}: + raise ConfigError("family.yaml contains a field this toolkit will not store.") + members: list[Member] = [] + seen: set[str] = set() + for item in data.get("members") or []: + member = _member(item) + if member.handle in seen: + raise ConfigError("family.yaml has two members with the same handle.") + seen.add(member.handle) + members.append(member) + return Family(members=members) + + +def save_family(home, family: Family) -> None: + from pathlib import Path + + payload = { + "version": 1, + "members": [ + {"handle": member.handle, "direct": list(member.direct), "request": list(member.request)} + for member in family.members + ], + } + text = yaml.safe_dump(payload, sort_keys=False, allow_unicode=False) + write_atomic(Path(home) / FAMILY_NAME, text) + + +def add_member(home, handle: str) -> Member: + name = normalize_handle(handle) + family = load_family(home) + if family.by_handle(name) is not None: + raise ConfigError("That family member is already registered.") + member = Member(handle=name) + family.members.append(member) + save_family(home, family) + return member + + +def remove_member(home, handle: str) -> None: + name = normalize_handle(handle) + family = load_family(home) + if family.by_handle(name) is None: + raise ConfigError("No family member is registered with that handle.") + family.members = [member for member in family.members if member.handle != name] + save_family(home, family) + + +def allow_actions(home, handle: str, token: str) -> list[tuple[str, str]]: + """Grant a handle an action. Sensitive names are stored as requests.""" + name = normalize_handle(handle) + family = load_family(home) + member = family.by_handle(name) + if member is None: + raise ConfigError("No family member is registered with that handle.") + granted = expand_action(token) + for action, kind in granted: + if kind == "request": + if action not in member.request: + member.request.append(action) + if action in member.direct: + member.direct.remove(action) + elif action not in member.direct: + member.direct.append(action) + member.direct.sort() + member.request.sort() + save_family(home, family) + return granted + + +def revoke_action(home, handle: str, token: str) -> list[str]: + name = normalize_handle(handle) + family = load_family(home) + member = family.by_handle(name) + if member is None: + raise ConfigError("No family member is registered with that handle.") + names = [action for action, _kind in expand_action(token)] + for action in names: + if action in member.direct: + member.direct.remove(action) + if action in member.request: + member.request.remove(action) + save_family(home, family) + return names + + +def classify(home, actor: str, action: str) -> str: + """Return ``direct``, ``request``, or ``deny``.""" + name = normalize_handle(actor) + member = load_family(home).by_handle(name) + if member is None: + raise ConfigError("No family member is registered with that handle.") + if action in REQUEST_ACTIONS: + if action in member.request or action in member.direct: + return "request" + return "deny" + if action in member.direct: + return "direct" + return "deny" + + +def expand_action(token: str) -> list[tuple[str, str]]: + text = " ".join(str(token or "").split()).lower() + if text == "charging": + return [(name, "request") for name in CHARGING_ACTIONS] + if text in REQUEST_ACTIONS: + return [(text, "request")] + if text in DIRECT_ACTIONS: + return [(text, "direct")] + raise ConfigError("That action is not one a family member can be given.") + + +def format_family(family: Family) -> str: + if not family.members: + return "No family members are registered." + lines = ["Family members."] + for member in family.members: + direct = ", ".join(member.direct) or "none" + request = ", ".join(member.request) or "none" + lines.append(f"{member.handle}") + lines.append(f" direct: {direct}") + lines.append(f" request: {request}") + return "\n".join(lines) + + +def allow_text(handle: str, granted: list[tuple[str, str]]) -> str: + lines = [] + for action, kind in granted: + if kind == "request": + lines.append(f"{handle} may request {action}. It is not run until the owner approves it.") + else: + lines.append(f"{handle} may run {action} directly.") + return "\n".join(lines) + + +def action_phrase(command: str) -> str: + """Confirmation phrase for a saved request. Matches the safety catalog.""" + from core.ops import PUBLIC_COMMANDS + + spec_name = { + "charge-schedule-remove": "charging_schedule_remove", + "charge-schedule-set": "charging_schedule_set", + "departure-remove": "departure_schedule_remove", + "departure-set": "departure_schedule_set", + }.get(command, PUBLIC_COMMANDS.get(command, "")) + spec = load_specs().get(spec_name) + if spec is None: + raise ConfigError("That request cannot be run.") + return spec.action + + +def owner_confirmation(command: str, label: str) -> str: + phrase = action_phrase(command) + if label: + return f"I confirm {phrase} on {label}" + return f"I confirm {phrase}" + + +def _member(item) -> Member: + if not isinstance(item, dict): + raise ConfigError("A family member must be a mapping.") + if set(item) - {"handle", "direct", "request"}: + raise ConfigError("A family member has a field this toolkit will not store.") + handle = normalize_handle(str(item.get("handle") or "")) + direct = _names(item.get("direct") or [], DIRECT_ACTIONS, "direct") + request = _names(item.get("request") or [], REQUEST_ACTIONS, "request") + return Member(handle=handle, direct=direct, request=request) + + +def _names(value, allowed: set[str], label: str) -> list[str]: + if not isinstance(value, list): + raise ConfigError(f"A family member {label} list must be a list.") + names: list[str] = [] + for item in value: + text = str(item or "").strip().lower() + if text not in allowed: + raise ConfigError(f"A family member {label} list has an unknown action.") + if text not in names: + names.append(text) + names.sort() + return names diff --git a/core/fleet/check_state.py b/core/fleet/check_state.py index 4bdc3a0..0d46e1f 100644 --- a/core/fleet/check_state.py +++ b/core/fleet/check_state.py @@ -1,8 +1,10 @@ """Remember which health alerts are already active. -The file lives in the private config home, next to ``runtime.yaml``. It stores -alert codes only: 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. +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 +read or write it. AgentDrive does not run the health check itself. """ from __future__ import annotations diff --git a/core/garage.py b/core/garage.py new file mode 100644 index 0000000..ee7d42f --- /dev/null +++ b/core/garage.py @@ -0,0 +1,343 @@ +"""Nicknames for the cars on one account. + +The file is ``$AGENTDRIVE_HOME/cars.yaml``. It is private. A nickname is +chosen by the owner. The VIN stays in that file so a later command can +pick the same car, and it is never printed. + +With one car, history and alert files stay in the config home. The first +car that is named keeps those files. Another car uses +``$AGENTDRIVE_HOME/cars//``. +""" + +from __future__ import annotations + +import re +import secrets +from dataclasses import dataclass +from pathlib import Path + +import yaml + +from core.atomic import write_atomic +from core.errors import ConfigError +from core.fleet.queries import QueryError +from core.redact import mask_vin + +CARS_NAME = "cars.yaml" +_NICK = re.compile(r"^[a-z][a-z0-9-]{1,31}$") +_VIN = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$") +_RESERVED = { + "approve", + "car", + "confirm", + "default", + "deny", + "off", + "on", + "owner", + "request", +} + + +@dataclass(frozen=True) +class Car: + id: str + nickname: str + vin: str + + +@dataclass(frozen=True) +class Garage: + cars: tuple[Car, ...] = () + default: str = "" + legacy_id: str = "" + + def by_nickname(self, name: str) -> Car | None: + key = str(name or "").strip().lower() + for car in self.cars: + if car.nickname == key: + return car + return None + + def by_vin(self, vin: str) -> Car | None: + key = str(vin or "").strip().upper() + for car in self.cars: + if car.vin == key: + return car + return None + + def by_id(self, car_id: str) -> Car | None: + for car in self.cars: + if car.id == str(car_id or ""): + return car + return None + + +@dataclass(frozen=True) +class Selection: + """The car a command will use, and where its local data lives.""" + + row: dict + car: Car | None + store: Path + label: str + + +def normalize_nickname(value: str) -> str: + """Return a stored nickname, or refuse a VIN-shaped or reserved word.""" + text = " ".join(str(value or "").split()).lower() + if not _NICK.fullmatch(text) or text in _RESERVED or _VIN.fullmatch(text.upper()): + raise ConfigError("A car nickname must be 2 to 32 letters, digits, or hyphens.") + return text + + +def load_garage(home: Path) -> Garage: + path = Path(home) / CARS_NAME + if not path.is_file(): + return Garage() + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, yaml.YAMLError) as exc: + raise ConfigError("cars.yaml could not be read.") from exc + if data is None: + return Garage() + if not isinstance(data, dict): + raise ConfigError("cars.yaml must be a mapping.") + unknown = set(data) - {"version", "default", "legacy_id", "cars"} + if unknown: + raise ConfigError("cars.yaml contains a field this toolkit will not store.") + cars: list[Car] = [] + seen_names: set[str] = set() + seen_vins: set[str] = set() + for item in data.get("cars") or []: + car = _car(item) + if car.nickname in seen_names or car.vin in seen_vins: + raise ConfigError("cars.yaml has two cars with the same nickname or VIN.") + seen_names.add(car.nickname) + seen_vins.add(car.vin) + cars.append(car) + default = str(data.get("default") or "").strip().lower() + if default and default not in seen_names: + raise ConfigError("The default car is not in cars.yaml.") + legacy = str(data.get("legacy_id") or "").strip() + if legacy and all(car.id != legacy for car in cars): + raise ConfigError("The saved history car is not in cars.yaml.") + return Garage(cars=tuple(cars), default=default, legacy_id=legacy) + + +def save_garage(home: Path, garage: Garage) -> None: + payload = { + "version": 1, + "default": garage.default, + "legacy_id": garage.legacy_id, + "cars": [{"id": car.id, "nickname": car.nickname, "vin": car.vin} for car in garage.cars], + } + text = yaml.safe_dump(payload, sort_keys=False, allow_unicode=False) + write_atomic(Path(home) / CARS_NAME, text) + + +def store_for(home: Path, garage: Garage, car: Car | None) -> Path: + """Directory that holds this car's history and alert file. + + One car, or the car that already had history, keeps the config home. + """ + root = Path(home) + if car is None or len(garage.cars) <= 1: + return root + if garage.legacy_id and car.id == garage.legacy_id: + return root + return root / "cars" / car.id + + +def store_for_saved(home: Path, car_name: str | None) -> Path: + """Pick a history directory without contacting the car. + + Zero or one saved car keeps the config home, which is where an older + install already wrote ``history.db``. + """ + garage = load_garage(home) + if len(garage.cars) <= 1: + if car_name: + nickname = normalize_nickname(car_name) + if garage.cars and garage.by_nickname(nickname) is None: + raise ConfigError("No car is saved under that nickname.") + if not garage.cars: + raise ConfigError("No car is saved under that nickname.") + return Path(home) + if car_name: + car = garage.by_nickname(normalize_nickname(car_name)) + if car is None: + raise ConfigError("No car is saved under that nickname.") + return store_for(home, garage, car) + if garage.default: + car = garage.by_nickname(garage.default) + if car is None: + raise ConfigError("The default car is not in cars.yaml.") + return store_for(home, garage, car) + raise ConfigError( + "Several cars are saved and no default is set. " + "Pass --car , or set one with `agentdrive cars default `." + ) + + +def resolve_vehicle(home: Path, payload: dict, *, car_name: str | None, preferred: str | None) -> Selection: + """Choose one car. Several cars and no explicit choice is a refusal.""" + rows = vehicle_rows(payload) + if not rows: + raise QueryError("no vehicles") + garage = load_garage(home) + explicit = str(car_name or "").strip() + if explicit: + car = garage.by_nickname(normalize_nickname(explicit)) + if car is None: + raise ConfigError("No car is saved under that nickname. Run `agentdrive cars` and `agentdrive cars name`.") + row = _row_for_vin(rows, car.vin) + return Selection(row=row, car=car, store=store_for(home, garage, car), label=car.nickname) + if len(rows) == 1: + row = rows[0] + vin = str(row.get("vin") or "") + if preferred and vin.upper() != str(preferred).upper(): + raise QueryError("The requested vehicle is not on this account") + car = garage.by_vin(vin) + return Selection(row=row, car=car, store=store_for(home, garage, car), label="") + if preferred: + car = garage.by_vin(preferred) + if car is None: + raise ConfigError( + "Several cars are on this account. " + "Name this car with `agentdrive cars name ` and pass `--car `." + ) + row = _row_for_vin(rows, car.vin) + return Selection(row=row, car=car, store=store_for(home, garage, car), label=car.nickname) + if garage.default: + car = garage.by_nickname(garage.default) + if car is None: + raise ConfigError("The default car is not in cars.yaml.") + row = _row_for_vin(rows, car.vin) + return Selection(row=row, car=car, store=store_for(home, garage, car), label=car.nickname) + raise ConfigError( + "Several cars are on this account and no default is set. " + "Pass --car , or set one with `agentdrive cars default `. " + "Commands will not guess a car." + ) + + +def remember_car(home: Path, rows: list[dict], tail: str, nickname: str) -> Car: + """Attach a nickname to the car whose VIN ends with ``tail``.""" + row = match_tail(rows, tail) + vin = str(row.get("vin") or "").strip().upper() + if not _VIN.fullmatch(vin): + raise ConfigError("That vehicle has no usable VIN.") + name = normalize_nickname(nickname) + garage = load_garage(home) + current = garage.by_vin(vin) + others = [car for car in garage.cars if car.vin != vin] + if any(car.nickname == name for car in others): + raise ConfigError("That nickname is already used by another car.") + if current is None: + car = Car(id=secrets.token_hex(4), nickname=name, vin=vin) + legacy = garage.legacy_id or car.id + else: + car = Car(id=current.id, nickname=name, vin=vin) + legacy = garage.legacy_id or current.id + default = garage.default + if current is not None and default == current.nickname: + default = name + save_garage(home, Garage(cars=tuple(others + [car]) if current is None else _replace(garage.cars, car), default=default, legacy_id=legacy)) + return car + + +def set_default(home: Path, nickname: str) -> Garage: + name = normalize_nickname(nickname) + garage = load_garage(home) + if garage.by_nickname(name) is None: + raise ConfigError("No car is saved under that nickname.") + updated = Garage(cars=garage.cars, default=name, legacy_id=garage.legacy_id) + save_garage(home, updated) + return updated + + +def clear_default(home: Path) -> Garage: + garage = load_garage(home) + updated = Garage(cars=garage.cars, default="", legacy_id=garage.legacy_id) + save_garage(home, updated) + return updated + + +def match_tail(rows: list[dict], token: str) -> dict: + """Match a VIN tail or a full VIN. The token is never copied into an error.""" + text = "".join(str(token or "").split()).upper() + if len(text) == 4: + matches = [row for row in rows if str(row.get("vin") or "").upper().endswith(text)] + elif _VIN.fullmatch(text): + matches = [row for row in rows if str(row.get("vin") or "").upper() == text] + else: + raise ConfigError("Name a car by the last four characters shown in `agentdrive cars`.") + if len(matches) == 1: + return matches[0] + if not matches: + raise ConfigError("No car on this account has that VIN tail.") + raise ConfigError("That VIN tail matches more than one car.") + + +def format_cars(rows: list[dict], garage: Garage) -> str: + """List cars from the vehicle list. The full VIN is not included.""" + lines = ["Cars on this account. This list does not wake them."] + if not rows: + lines.append("No cars on this account.") + return "\n".join(lines) + for row in rows: + vin = str(row.get("vin") or "") + car = garage.by_vin(vin) + nickname = car.nickname if car else "(unnamed)" + default = " default" if car and garage.default == car.nickname else "" + state = str(row.get("state") or "unknown").strip().lower() or "unknown" + lines.append(f"{nickname} {_public_name(row, vin)} {mask_vin(vin)}{default} {state}") + if len(rows) > 1 and not garage.default: + lines.append( + "No default is set. A command will not guess a car until you pass --car or set a default." + ) + return "\n".join(lines) + + +def vehicle_rows(payload: dict) -> list[dict]: + body = payload.get("response", payload) if isinstance(payload, dict) else payload + if isinstance(body, list): + return [item for item in body if isinstance(item, dict)] + if isinstance(body, dict): + return [body] + return [] + + +def _replace(cars: tuple[Car, ...], updated: Car) -> tuple[Car, ...]: + return tuple(updated if car.id == updated.id else car for car in cars) + + +def _car(item) -> Car: + if not isinstance(item, dict): + raise ConfigError("A car in cars.yaml must be a mapping.") + unknown = set(item) - {"id", "nickname", "vin"} + if unknown: + raise ConfigError("A car in cars.yaml has a field this toolkit will not store.") + nickname = normalize_nickname(str(item.get("nickname") or "")) + vin = str(item.get("vin") or "").strip().upper() + car_id = str(item.get("id") or "").strip().lower() + if not _VIN.fullmatch(vin): + raise ConfigError("A car in cars.yaml needs a VIN.") + if not re.fullmatch(r"[0-9a-f]{8}", car_id): + raise ConfigError("A car in cars.yaml needs a local id.") + return Car(id=car_id, nickname=nickname, vin=vin) + + +def _row_for_vin(rows: list[dict], vin: str) -> dict: + for row in rows: + if str(row.get("vin") or "").upper() == vin.upper(): + return row + raise QueryError("The requested vehicle is not on this account") + + +def _public_name(row: dict, vin: str) -> str: + name = " ".join(str(row.get("display_name") or "").split()) + if vin and name: + name = name.replace(vin, mask_vin(vin)) + return name or "Vehicle" diff --git a/core/history/official.py b/core/history/official.py index 0a82857..877cbe0 100644 --- a/core/history/official.py +++ b/core/history/official.py @@ -15,7 +15,7 @@ from core.history.clock import local_today, resolve_timezone from core.history.locale import locale_for, locale_name, site_root -from core.history.store import history_session, load_notices, save_notice +from core.history.store import history_root, history_session, load_notices, save_notice from core.history.text import dumps from core.history.tips import on_cooldown @@ -60,7 +60,7 @@ def fetch_text(url: str) -> str: return text -def take_official(home, today: date, *, tz=None, fetch=None) -> dict | None: +def take_official(home, today: date, *, tz=None, fetch=None, store=None) -> dict | None: """Return one new official item, or None when the slot should be omitted.""" zone = tz or resolve_timezone(home) locale = locale_for(home, zone) @@ -68,7 +68,7 @@ def take_official(home, today: date, *, tz=None, fetch=None) -> dict | None: found = _collect(locale, today, getter) if not found: return None - with history_session(home, write=True) as conn: + with history_session(history_root(home, store), write=True) as conn: notices = load_notices(conn) if _shown_this_week(notices, today): return None @@ -88,10 +88,10 @@ def take_official(home, today: date, *, tz=None, fetch=None) -> dict | None: return _public(item, locale) -def render_news(home, *, as_json: bool, on: date | None = None, tz=None, fetch=None) -> str: +def render_news(home, *, as_json: bool, on: date | None = None, tz=None, fetch=None, store=None) -> str: zone = tz or resolve_timezone(home) today = on or local_today(zone) - item = take_official(home, today, tz=zone, fetch=fetch) + item = take_official(home, today, tz=zone, fetch=fetch, store=store) if as_json: return dumps({"item": item}) if item is None: diff --git a/core/history/service.py b/core/history/service.py index 635dd84..127cf96 100644 --- a/core/history/service.py +++ b/core/history/service.py @@ -12,6 +12,7 @@ from core.errors import ConfigError, HistoryError from core.history.clock import local_today, resolve_timezone from core.history.store import ( + history_root, history_session, insert_service_event, isoformat_utc, @@ -58,7 +59,7 @@ def clean_interval(value) -> float | None: return number -def perform_log(home, kind: str, *, note: str = "", interval_km: float | None = None, now: datetime | None = None) -> str: +def perform_log(home, kind: str, *, note: str = "", interval_km: float | None = None, now: datetime | None = None, store=None) -> str: """Record one service event. Raises when the odometer has not been saved.""" if kind not in _KINDS: raise ConfigError("Service kind must be rotation, replacement, or maintenance.") @@ -66,10 +67,11 @@ def perform_log(home, kind: str, *, note: str = "", interval_km: float | None = requested = clean_interval(interval_km) basis, stored_interval = _resolve_interval(kind, requested) logged_at = isoformat_utc(now or datetime.now(timezone.utc)) - with history_session(home, write=False) as existing: + root = history_root(home, store) + with history_session(root, write=False) as existing: if existing is None or latest_odometer(existing) is None: raise HistoryError(NO_ODOMETER) - with history_session(home, write=True) as conn: + with history_session(root, write=True) as conn: odometer = latest_odometer(conn) if odometer is None: raise HistoryError(NO_ODOMETER) @@ -85,10 +87,10 @@ def perform_log(home, kind: str, *, note: str = "", interval_km: float | None = return _logged_text(kind, odometer, basis, stored_interval) -def render_service_status(home, *, as_json: bool, on=None, tz=None) -> str: +def render_service_status(home, *, as_json: bool, on=None, tz=None, store=None) -> str: zone = tz or resolve_timezone(home) today = on or local_today(zone) - with history_session(home, write=False) as conn: + with history_session(history_root(home, store), write=False) as conn: if conn is None: view = service_view([], None, {}, today) else: diff --git a/core/history/store.py b/core/history/store.py index 4266f36..e896f1a 100644 --- a/core/history/store.py +++ b/core/history/store.py @@ -247,6 +247,11 @@ def database_exists(home: Path) -> bool: return database_paths(home)[1].is_file() +def history_root(home: Path, store: Path | None = None) -> Path: + """Directory that holds history.db. ``store`` overrides the config home.""" + return Path(home if store is None else store) + + def latest_odometer(conn) -> float | None: row = conn.execute( "SELECT odometer_km FROM snapshots WHERE odometer_km IS NOT NULL ORDER BY last_seen DESC, id DESC LIMIT 1" diff --git a/core/history/usage.py b/core/history/usage.py index f25ebb8..3baa956 100644 --- a/core/history/usage.py +++ b/core/history/usage.py @@ -11,7 +11,7 @@ from core.errors import ConfigError from core.history.clock import local_today, resolve_timezone, timezone_name from core.history.service import take_service_reminders -from core.history.store import database_exists, history_session, isoformat_utc, list_named, range_summary +from core.history.store import database_exists, history_root, history_session, isoformat_utc, list_named, range_summary from core.history.text import dumps, fmt_km, fmt_kwh, fmt_pct, yes_no from core.history.official import take_official from core.history.tips import choose_tip, is_tip_day @@ -60,10 +60,10 @@ def covered(period: str, day: date) -> tuple[date, date]: raise ConfigError("Unknown history period.") -def render_list(home, kind: str, *, limit: int, as_json: bool) -> str: +def render_list(home, kind: str, *, limit: int, as_json: bool, store=None) -> str: if kind not in _LISTS: raise ConfigError("Unknown history list.") - with history_session(home, write=False) as conn: + with history_session(history_root(home, store), write=False) as conn: if conn is None: rows, total = [], 0 else: @@ -79,13 +79,13 @@ def render_list(home, kind: str, *, limit: int, as_json: bool) -> str: return "\n".join(lines) -def render_tips(home, *, as_json: bool, on: date | None = None, tz=None) -> str: +def render_tips(home, *, as_json: bool, on: date | None = None, tz=None, store=None) -> str: zone = tz or resolve_timezone(home) today = on or local_today(zone) if not is_tip_day(today): tip, reason = None, "not_today" else: - with history_session(home, write=True) as conn: + with history_session(history_root(home, store), write=True) as conn: tip, reason = choose_tip(conn, today) if as_json: payload = {"due": tip is not None, "reason": reason, "tip": None if tip is None else tip["text"]} @@ -97,7 +97,7 @@ def render_tips(home, *, as_json: bool, on: date | None = None, tz=None) -> str: return f"Tip: {tip['text']}" -def render_briefing(home, period: str, *, as_json: bool, on: date | None = None, tz=None) -> str: +def render_briefing(home, period: str, *, as_json: bool, on: date | None = None, tz=None, store=None) -> str: zone = tz or resolve_timezone(home) today = on or local_today(zone) selected = due_period(today) if period == "due" else period @@ -105,19 +105,20 @@ def render_briefing(home, period: str, *, as_json: bool, on: date | None = None, raise ConfigError("Unknown history period.") start, end = covered(selected, today) start_iso, end_iso = _bounds(start, end, zone) - if not database_exists(home) and not is_tip_day(today): + root = history_root(home, store) + if not database_exists(root) and not is_tip_day(today): summary = {"trips": [], "charges": [], "snapshots": 0, "alerts": []} tip = None reminders: list[dict] = [] else: - with history_session(home, write=True) as conn: + with history_session(root, write=True) as conn: summary = range_summary(conn, start_iso, end_iso) tip, _reason = choose_tip(conn, today) reminders = take_service_reminders(conn, today) official = None if selected == "weekly": try: - official = take_official(home, today, tz=zone) + official = take_official(home, today, tz=zone, store=root) except Exception: official = None payload = _payload( diff --git a/core/ops.py b/core/ops.py index 536b6ef..f43fc0c 100644 --- a/core/ops.py +++ b/core/ops.py @@ -49,6 +49,7 @@ 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 +from core.garage import resolve_vehicle from core.paths import resolve_key, resolve_token_file from core.profile.owner import load_profile from core.redact import mask_vin, redact @@ -246,18 +247,24 @@ def __init__( self.region = region self.history = history self.gateway = Gateway(control) + self.car_name = "" def refresh(self) -> str: self.tokens.access_token(force=True) return "refreshed; tokens saved" + def prepare(self, preferred: str | None): + """Resolve the car from the vehicle list. This does not wake the car.""" + return self._bind(preferred) + def status(self, *, preferred: str | None, sleep=time.sleep) -> str: - chosen, key, observation = self._online(preferred, sleep=sleep) + _selection, chosen, key, observation = self._online(preferred, sleep=sleep) 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: - chosen = select_vehicle(self.reader.list_vehicles(), preferred) + selection = self._bind(preferred) + chosen = selection.row state = vehicle_state_name(chosen) or "unknown" if state != "online": return skipped_report(state, tyre_bar=tyre_bar, low_battery_percent=low_battery_percent) @@ -266,8 +273,8 @@ def check(self, *, preferred: str | None, tyre_bar: float, low_battery_percent: report = report_from_observation(observation, tyre_bar=tyre_bar, low_battery_percent=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) - return apply_rising_edge(self.home, report) + self._remember(payload, observation, tyre_bar=tyre_bar, low_battery_percent=low_battery_percent, store=selection.store) + return apply_rising_edge(selection.store, report) def report(self, *, preferred: str | None) -> VehicleReport: """Read-only briefing. A car that is not online, a timeout, or HTTP 408 is a skip. @@ -275,7 +282,8 @@ def report(self, *, preferred: str | None) -> VehicleReport: This never calls wake. """ try: - chosen = select_vehicle(self.reader.list_vehicles(), preferred) + selection = self._bind(preferred) + chosen = selection.row except (VehicleAsleep, TimeoutError, HttpError, QueryError) as exc: state = _report_skip_state(exc) if state is None: @@ -294,14 +302,14 @@ def report(self, *, preferred: str | None) -> VehicleReport: observation = from_vehicle_data(payload) name = str(chosen.get("display_name") or "Vehicle") briefing = vehicle_report(name, observation) - self._remember(payload, observation) + self._remember(payload, observation, store=selection.store) 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, 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) + chosen = self._bind(preferred).row except VehicleAsleep: return WhereReport(kind="skipped", state="asleep") state = vehicle_state_name(chosen) or "unknown" @@ -323,7 +331,7 @@ def where(self, *, preferred: str | None, precise: bool = False) -> WhereReport: def charge_schedule_show(self, *, preferred: str | None) -> ScheduleReport: """Read the in-car timetable. A car that is not online is left asleep.""" - chosen = select_vehicle(self.reader.list_vehicles(), preferred) + chosen = self._bind(preferred).row state = vehicle_state_name(chosen) or "unknown" if state != "online": return ScheduleReport( @@ -391,12 +399,12 @@ def command( raise ConfigError("unknown command") extra_args = list(extra) if extra is not None else _extra(name, argument) if name == "wake": - chosen = select_vehicle(self.reader.list_vehicles(), preferred) - key = vehicle_key(chosen) + selection = self._bind(preferred) + key = vehicle_key(selection.row) token = self.tokens.access_token() self.control.run(vin=key, access_token=token, args=["wake"]) - return "OK: wake" - _key, observation = self._read_online(preferred, sleep=sleep) + return _tagged("OK: wake", selection.label) + _key, observation, label = self._read_online(preferred, sleep=sleep) token = self.tokens.access_token() def observe() -> Observation: @@ -413,14 +421,23 @@ def observe() -> Observation: access_token=token, observe=observe, extra_args=extra_args, + car=label, ) if name == "charge-amps" and extra_args: - return charge_amps_result(extra_args[0], observed) + return _tagged(charge_amps_result(extra_args[0], observed), label) if extra_args: - return f"OK: {name} {' '.join(extra_args)}" - return f"OK: {name}" + return _tagged(f"OK: {name} {' '.join(extra_args)}", label) + return _tagged(f"OK: {name}", label) + + def _bind(self, preferred: str | None): + return resolve_vehicle( + self.home, + self.reader.list_vehicles(), + car_name=self.car_name or None, + preferred=preferred, + ) - def _remember(self, payload, observation, *, tyre_bar=None, low_battery_percent=None) -> None: + def _remember(self, payload, observation, *, tyre_bar=None, low_battery_percent=None, store=None) -> None: """Save a parked snapshot. A failure is printed and does not change the command.""" if not self.history: return @@ -430,7 +447,7 @@ def _remember(self, payload, observation, *, tyre_bar=None, low_battery_percent= from core.history.store import record_parked_snapshot record_parked_snapshot( - self.home, + Path(store) if store is not None else self.home, payload if isinstance(payload, dict) else {}, observation, tyre_bar=float(tyre_bar), @@ -439,18 +456,19 @@ def _remember(self, payload, observation, *, tyre_bar=None, low_battery_percent= except Exception as exc: _remember_failure(exc) - def _online(self, preferred: str | None, *, sleep) -> tuple[dict, str, Observation]: - chosen = select_vehicle(self.reader.list_vehicles(), preferred) + def _online(self, preferred: str | None, *, sleep): + selection = self._bind(preferred) + chosen = selection.row key = vehicle_key(chosen) if vehicle_state_name(chosen) != "online": woken = self.reader.wake_until_online(key, sleep=sleep) chosen = {**chosen, **woken, "state": woken.get("state") or "online"} payload = self.reader.vehicle_data(key, DATA_ENDPOINTS) - return chosen, key, from_vehicle_data(payload) + return selection, chosen, key, from_vehicle_data(payload) - def _read_online(self, preferred: str | None, *, sleep) -> tuple[str, Observation]: - _chosen, key, observation = self._online(preferred, sleep=sleep) - return key, observation + def _read_online(self, preferred: str | None, *, sleep) -> tuple[str, Observation, str]: + selection, _chosen, key, observation = self._online(preferred, sleep=sleep) + return key, observation, selection.label def _charge_schedule_command( self, @@ -461,7 +479,7 @@ def _charge_schedule_command( statement: str | None, sleep, ) -> str: - key, _observation = self._read_online(preferred, sleep=sleep) + key, _observation, label = self._read_online(preferred, sleep=sleep) token = self.tokens.access_token() def observe() -> Observation: @@ -478,17 +496,18 @@ def observe() -> Observation: access_token=token, observe=observe, extra_args=tool_args[1:], + car=label, ) if spec_name == "charging_schedule_set": found = matching_ids(observed.charge_schedules, tool_args[1:]) if found: - return "OK: charge-schedule set id " + " ".join(found) - return "OK: charge-schedule set" - return f"OK: charge-schedule remove {tool_args[-1]}" + return _tagged("OK: charge-schedule set id " + " ".join(found), label) + return _tagged("OK: charge-schedule set", label) + return _tagged(f"OK: charge-schedule remove {tool_args[-1]}", label) 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) + chosen = self._bind(preferred).row state = vehicle_state_name(chosen) or "unknown" if state != "online": return DepartureReport( @@ -570,7 +589,7 @@ def _departure_command( sleep, spec: DepartureWrite | None = None, ) -> str: - key, _observation = self._read_online(preferred, sleep=sleep) + key, _observation, label = 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) @@ -593,13 +612,23 @@ def observe() -> Observation: access_token=token, observe=observe, extra_args=tool_args[1:], + car=label, ) 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]}" + return _tagged("OK: departure set id " + " ".join(found), label) + return _tagged("OK: departure set", label) + return _tagged(f"OK: departure remove {tool_args[-1]}", label) + + +def _tagged(text: str, label: str) -> str: + """Name the car on the first line when a confirmation has to name it.""" + if not label: + return text + head, sep, tail = text.partition("\n") + named = f"{head} on {label}" + return named + sep + tail if sep else named def _extra(name: str, argument: str | None) -> list[str] | None: @@ -636,10 +665,18 @@ def prompter(challenge) -> str: phrase = _action_phrase(challenge) if statement is not None: return statement + car = str(getattr(challenge, "car", "") or "").strip() if not sys.stdin.isatty(): + if car: + raise ConfirmationRequired( + "This action needs an explicit confirmation. " + f"Pass --confirm with a sentence that includes confirm, the car, and {phrase}." + ) raise ConfirmationRequired( f"This action needs an explicit confirmation. Pass --confirm with a sentence that includes confirm and {phrase}." ) + if car: + return input(f"Confirm {phrase}. Type a sentence that names the action and the car and includes confirm: ") return input(f"Confirm {phrase}. Type a sentence that names the action and includes confirm: ") return TemplateAdapter(prompter) diff --git a/core/requests.py b/core/requests.py new file mode 100644 index 0000000..172eb1e --- /dev/null +++ b/core/requests.py @@ -0,0 +1,244 @@ +"""Pending family requests stored in the private config home. + +A request records who asked, which car, the action, and its parameters. +It expires after 10 minutes. Approve runs it once. Deny, expiry, and a +second approve never run it. This module does not send a message. +""" + +from __future__ import annotations + +import json +import secrets +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from core.atomic import write_atomic +from core.errors import ConfigError + +REQUESTS_NAME = "requests.json" +REQUEST_MINUTES = 10 + +_OPEN = "pending" +_APPROVED = "approved" +_DENIED = "denied" +_EXPIRED = "expired" + + +def create_request( + home: Path, + *, + member: str, + car: str, + car_id: str, + label: str, + command: str, + argument: str | None, + extra: list[str] | None, + detail: dict | None, + now: datetime | None = None, +) -> str: + """Save one pending request and return the text an assistant should relay.""" + moment = _clock(now) + record = { + "id": _new_id(home), + "member": member, + "car": car, + "car_id": car_id, + "label": label, + "command": command, + "argument": argument, + "extra": list(extra) if extra else None, + "detail": detail, + "summary": summary_for(command, argument, extra, detail), + "created": _iso(moment), + "expires": _iso(moment + timedelta(minutes=REQUEST_MINUTES)), + "status": _OPEN, + } + _reject_vin(record) + records = _load(home) + records.append(record) + _save(home, records) + return relay_text(record) + + +def list_requests(home: Path, *, now: datetime | None = None) -> str: + records = _refresh(home, _clock(now)) + if not records: + return "No requests." + lines = ["Requests saved on this computer."] + for record in records: + car = record.get("car") or "-" + lines.append( + f"{record['id']} {record['status']} {record['member']} {car} {record['summary']} " + f"created {record['created']} expires {record['expires']}" + ) + return "\n".join(lines) + + +def approve_request(home: Path, request_id: str, *, actor: str, execute, now: datetime | None = None, ready=None) -> str: + """Mark the request used, then run it once. A failure still spends it.""" + record = _take(home, request_id, actor=actor, now=now, verb="approve") + if ready is not None: + ready(record) + _set_status(home, record["id"], _APPROVED) + record["status"] = _APPROVED + try: + result = execute(record) + except Exception: + raise + text = str(result or "").rstrip() + if text: + return f"Approved {record['id']}.\n{text}" + return f"Approved {record['id']}." + + +def deny_request(home: Path, request_id: str, *, actor: str, now: datetime | None = None) -> str: + record = _take(home, request_id, actor=actor, now=now, verb="deny") + _set_status(home, record["id"], _DENIED) + return f"Denied {record['id']}. It will not run." + + +def relay_text(record: dict) -> str: + car_bit = f" on {record['car']}" if record.get("car") else "" + summary = record.get("summary") or record.get("command") or "the action" + return ( + f"Pending request {record['id']} from {record['member']}{car_bit}: {summary}.\n" + f"Created {record['created']}. Expires {record['expires']} ({REQUEST_MINUTES} minutes).\n" + "This action was not run.\n" + f"Tell the owner: {record['member']} asked to {summary}{car_bit}. " + f"Approve with `agentdrive request approve {record['id']}` " + f"or deny with `agentdrive request deny {record['id']}`." + ) + + +def summary_for(command: str, argument: str | None, extra: list[str] | None, detail: dict | None) -> str: + if command == "charge-schedule-set" and isinstance(detail, dict): + return f"charge-schedule set {detail.get('days', '')} {detail.get('time', '')}".strip() + if command == "charge-schedule-remove" and isinstance(detail, dict): + return f"charge-schedule remove {detail.get('schedule_id', '')}".strip() + if command == "departure-set" and isinstance(detail, dict): + return f"departure set {detail.get('days', '')} {detail.get('time', '')}".strip() + if command == "departure-remove" and isinstance(detail, dict): + return f"departure remove {detail.get('schedule_id', '')}".strip() + if extra: + return command + " " + " ".join(str(item) for item in extra) + if argument: + return f"{command} {argument}" + return command + + +def _take(home: Path, request_id: str, *, actor: str, now: datetime, verb: str) -> dict: + moment = _clock(now) + records = _refresh(home, moment) + record = _find(records, request_id) + _reject_actor(actor, record, verb) + status = record["status"] + if status == _EXPIRED: + raise ConfigError("That request has expired and will not run.") + if status == _DENIED: + raise ConfigError("That request was denied and will not run.") + if status == _APPROVED: + raise ConfigError("That request was already used and will not run.") + if status != _OPEN: + raise ConfigError("That request cannot be run.") + return record + + +def _reject_actor(actor: str, record: dict, verb: str) -> None: + handle = str(actor or "").strip().lower() + if not handle: + return + if handle == str(record.get("member") or ""): + if verb == "deny": + raise ConfigError("A family member cannot deny their own request.") + raise ConfigError("A family member cannot approve their own request.") + raise ConfigError("Only the owner can approve a request." if verb == "approve" else "Only the owner can deny a request.") + + +def _refresh(home: Path, moment: datetime) -> list[dict]: + records = _load(home) + changed = False + for record in records: + if record.get("status") == _OPEN and _expired(record, moment): + record["status"] = _EXPIRED + changed = True + if changed: + _save(home, records) + return records + + +def _expired(record: dict, moment: datetime) -> bool: + try: + expires = datetime.strptime(str(record.get("expires") or ""), "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + except ValueError: + return True + return moment >= expires + + +def _find(records: list[dict], request_id: str) -> dict: + key = str(request_id or "").strip().lower() + if not key: + raise ConfigError("Name the request id.") + for record in records: + if str(record.get("id") or "") == key: + return record + raise ConfigError("No request has that id.") + + +def _set_status(home: Path, request_id: str, status: str) -> None: + records = _load(home) + for record in records: + if record.get("id") == request_id: + record["status"] = status + break + _save(home, records) + + +def _load(home: Path) -> list[dict]: + path = Path(home) / REQUESTS_NAME + if not path.is_file(): + return [] + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ConfigError("requests.json could not be read.") from exc + if not isinstance(data, dict) or not isinstance(data.get("requests"), list): + raise ConfigError("requests.json must contain a requests list.") + records = [] + for item in data["requests"]: + if isinstance(item, dict): + records.append(item) + return records + + +def _save(home: Path, records: list[dict]) -> None: + for record in records: + _reject_vin(record) + payload = json.dumps({"requests": records}, indent=2) + "\n" + write_atomic(Path(home) / REQUESTS_NAME, payload) + + +def _new_id(home: Path) -> str: + used = {str(item.get("id") or "") for item in _load(home)} + for _ in range(8): + candidate = secrets.token_hex(4) + if candidate not in used: + return candidate + raise ConfigError("A request id could not be chosen.") + + +def _reject_vin(record: dict) -> None: + """Requests keep a car id, never a VIN.""" + if "vin" in record: + raise ConfigError("A request cannot store a VIN.") + + +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).replace(microsecond=0) + + +def _iso(moment: datetime) -> str: + return moment.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") diff --git a/core/safety/catalog.py b/core/safety/catalog.py index 4f70807..b37ebc4 100644 --- a/core/safety/catalog.py +++ b/core/safety/catalog.py @@ -165,6 +165,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_request_approval(text): + return True head = _schedule_text(raw.split()[0]) return head in _SCHEDULE_BLOCKED or head.replace(" ", "_") in names @@ -181,6 +183,13 @@ def _blocks_charge_amps(text: str) -> bool: return text.startswith("charge amps ") or text.startswith("charging set amps ") +def _blocks_request_approval(text: str) -> bool: + """A schedule must not approve or deny a family request.""" + if text in {"request approve", "request deny"}: + return True + return text.startswith("request approve ") or text.startswith("request deny ") + + def _blocks_departure_change(text: str) -> bool: """Block departure writes. A show read stays allowed.""" parts = text.split() diff --git a/core/safety/confirm.py b/core/safety/confirm.py index 6c6c5ff..b9b2088 100644 --- a/core/safety/confirm.py +++ b/core/safety/confirm.py @@ -15,6 +15,7 @@ class Challenge: action_id: str command: str summary: str + car: str = "" @dataclass(frozen=True) @@ -26,8 +27,8 @@ class Confirmation: statement: str -def new_challenge(command: str, summary: str) -> Challenge: - return Challenge(action_id=uuid4().hex, command=command, summary=summary) +def new_challenge(command: str, summary: str, car: str = "") -> Challenge: + return Challenge(action_id=uuid4().hex, command=command, summary=summary, car=car) class ConfirmationBroker: @@ -55,6 +56,10 @@ def authorize( statement = " ".join(response.statement.lower().split()) if not _names_command(statement, spec): raise ConfirmationRequired("Name the action and confirm it. A plain yes is not enough.") + if challenge.car and not _has_phrase(statement, challenge.car.lower()): + raise ConfirmationRequired( + "Name the car in the confirmation. A confirmation for one car cannot be used on another." + ) self.used.add(response.action_id) @@ -62,7 +67,8 @@ def _names_command(statement: str, spec: CommandSpec) -> bool: if "confirm" not in statement.split(): return False phrases = {spec.name, spec.name.replace("_", " "), spec.action} - for phrase in phrases: - if phrase and re.search(rf"(? bool: + return re.search(rf"(? Observation: spec = self.specs.get(command) if spec is None: @@ -39,7 +40,9 @@ def execute( if spec.tier == "sensitive": if via_schedule: raise ScheduleBlocked("A schedule cannot run a sensitive command.") - challenge = new_challenge(command, spec.action) + label = " ".join(str(car or "").split()) + summary = spec.action if not label else f"{spec.action} on {label}" + challenge = new_challenge(command, summary, car=label) response = adapter.confirm(challenge) self.broker.authorize(spec, challenge, response, via_schedule=False) args = list(spec.tool_args) diff --git a/docs/00-before-you-start.md b/docs/00-before-you-start.md index 90ffb4b..2addf8b 100644 --- a/docs/00-before-you-start.md +++ b/docs/00-before-you-start.md @@ -35,6 +35,8 @@ 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, 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`. +If I add a family member, a sensitive request such as unlock or a charging change is not run. Use `--as ` so AgentDrive records a pending request, then relay that text to me. I approve with `agentdrive request approve ` or deny with `agentdrive request deny `. Do not approve it yourself. A family member cannot approve their own request. 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 briefing off, update the stored schedule and update that job. @@ -93,6 +95,8 @@ After install, the AI agent schedules `agentdrive check --json` every 30 minutes 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. +If you have more than one car, tell the assistant a nickname for each and which one is the usual car. The confirmation for a sensitive action then names that car. When a family member asks to unlock a car or change charging, the assistant shows you the request. You approve or deny it. The request expires after 10 minutes. + ## Full step-by-step guide These pages are the longer version. Your AI agent follows them. You only do the six steps above. diff --git a/docs/01-install-and-safety.md b/docs/01-install-and-safety.md index 5a59231..f3c147b 100644 --- a/docs/01-install-and-safety.md +++ b/docs/01-install-and-safety.md @@ -28,7 +28,7 @@ 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, 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. +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. When you have named more than one car, that confirmation also names the car. 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. A family member's request for a sensitive action waits until you approve it. ## After install — health check diff --git a/docs/06-queries-and-commands.md b/docs/06-queries-and-commands.md index 14c0cdd..67e55fa 100644 --- a/docs/06-queries-and-commands.md +++ b/docs/06-queries-and-commands.md @@ -6,7 +6,11 @@ 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. +`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. + +`agentdrive cars` lists the cars on the account from the vehicle list. It does not wake a car. Each line shows the nickname, the name on the account, a masked VIN tail, and whether the car is the default. `agentdrive cars name ` saves a nickname you choose. The tail is the last four characters from that list. `agentdrive cars default ` sets the car used when a command does not pass `--car`. `agentdrive cars clear-default` clears it. With one car, leave the nickname unset and the commands stay as they are. With several cars and no default, a command does not guess: pass `--car ` or set a default. `AGENTDRIVE_VIN` still selects a car when that car already has a nickname. The full VIN is not printed. + +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. @@ -78,7 +82,56 @@ 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. 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, 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. + +## Several cars + +```bash +agentdrive cars +agentdrive cars name 0001 daily +agentdrive cars name 0002 weekend +agentdrive cars default daily +agentdrive --car weekend lock +agentdrive unlock --car weekend --confirm "I confirm unlock on weekend" +``` + +`0001` and `0002` stand in for the last four characters printed by `cars`. The nickname is yours. `daily` and `weekend` are examples. + +## 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. + +```bash +agentdrive family add rider +agentdrive family allow rider lock +agentdrive family allow rider status +agentdrive family allow rider unlock +agentdrive family allow rider charging +agentdrive family list +``` + +`charging` covers start, stop, the charge limit, the charging current, and the in-car timetable changes. A family member's assistant runs the command with `--as`: + +```bash +agentdrive --as rider --car daily unlock +``` + +That prints a pending request and does not unlock the car, even if a confirmation sentence is passed. You approve or deny it: + +```bash +agentdrive request list +agentdrive request approve ab12cd34 +agentdrive request deny ab12cd34 +``` + +The id is the short id in the relay text. A request lasts 10 minutes. Approve runs it once, and the car must still be in Park at speed 0. A second approve, a deny, an expired request, or an approve from the same family member does not run it. Only you can approve or deny. The relay text looks like this: + +```text +Pending request ab12cd34 from rider on daily: unlock. +Created 2026-11-14T01:00:00Z. Expires 2026-11-14T01:10:00Z (10 minutes). +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`. +``` ## Sentry and cabin climate diff --git a/docs/07-assistants.md b/docs/07-assistants.md index 237a351..05454b0 100644 --- a/docs/07-assistants.md +++ b/docs/07-assistants.md @@ -31,7 +31,7 @@ Copy `adapters/template.py` for a host that is not listed. The same five operati 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. ```text -Schedule a routine named health-check that runs `agentdrive check --json` every 30 minutes. 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. 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. 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 @@ -47,7 +47,7 @@ When you ask for a different time, the AI agent runs `agentdrive report-schedule A host that uses cron can copy the example line printed by `agentdrive report-schedule`. AgentDrive does not install that line or a systemd timer. This is the recommended setup. It is not zero-setup: something outside AgentDrive has to run the command. ```text -Read `agentdrive report-schedule`. If it is enabled, create a daily job at that local time that runs `agentdrive history due` and send me that briefing every day. On Monday send the previous week, on the 1st the previous month, and on 1 January the previous year. If I change the time or turn the briefing off, update the job to match the command. Do not wake the car from that job. +Read `agentdrive report-schedule`. If it is enabled, create a daily job at that local time that runs `agentdrive history due` and send me that briefing every day. With several cars, run `agentdrive history due --car ` once per car and send each briefing on its own. On Monday send the previous week, on the 1st the previous month, and on 1 January the previous year. If I change the time or turn the briefing off, update the job to match the command. Do not wake the car from that job. ``` ## Using more than one AI agent diff --git a/docs/08-skills.md b/docs/08-skills.md index e412408..dc1995a 100644 --- a/docs/08-skills.md +++ b/docs/08-skills.md @@ -9,7 +9,7 @@ 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. Notify only when it exits 1. The same alert exits 0 until it clears and later returns. 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. 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. 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 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. diff --git a/tests/test_cars_and_family.py b/tests/test_cars_and_family.py new file mode 100644 index 0000000..d4c3d27 --- /dev/null +++ b/tests/test_cars_and_family.py @@ -0,0 +1,510 @@ +"""Several cars on one account, and family requests the owner approves.""" + +import json +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +from core.cli import main +from core.config import load_regions +from core.errors import ConfigError, ConfirmationRequired +from core.family import allow_actions, classify +from core.fleet.check_state import CHECK_ALERTS_NAME, load_codes +from core.fleet.queries import FleetReader +from core.fleet.snapshot import Observation +from core.garage import load_garage, remember_car, store_for +from core.history.store import record_parked_snapshot +from core.history.usage import render_list +from core.ops import App +from core.requests import REQUESTS_NAME, approve_request, create_request +from core.safety.catalog import schedule_blocks +from core.safety.confirm import Confirmation +from core.safety.gateway import Gateway + +VIN_A = "TESTVN00000000001" +VIN_B = "TESTVN00000000002" +TOKEN = "access-token" +NOW = datetime(2026, 11, 14, 1, 0, tzinfo=timezone.utc) + + +class Control: + def __init__(self): + self.calls = [] + + def run(self, *, vin, access_token, args, timeout=90): + self.calls.append({"vin": vin, "args": list(args)}) + + +class Http: + def __init__(self, rows, data): + self.rows = rows + self.data = data + self.calls = [] + + def request_json(self, method, url, headers, timeout=30, body=None): + self.calls.append(url) + if url.rstrip("/").endswith("/vehicles"): + return 200, {"response": self.rows} + if "vehicle_data" in url: + return 200, self.data + if url.endswith("/wake_up"): + return 200, {"response": {"state": "online"}} + return 500, {} + + +def row(vin, state="online", name="One"): + return {"vin": vin, "state": state, "display_name": name, "id": "veh-" + vin[-4:]} + + +def payload(**vehicle_state): + state = {"locked": True, "sentry_mode": True, "odometer": 10} + state.update(vehicle_state) + return { + "response": { + "drive_state": {"shift_state": "P", "speed": 0}, + "charge_state": {"battery_level": 80, "charging_state": "Disconnected"}, + "vehicle_state": state, + } + } + + +def make_app(home, http, control=None, *, history=False): + tokens = type("Tokens", (), {"access_token": staticmethod(lambda force=False: TOKEN)})() + reader = FleetReader(http, load_regions(), "na", tokens) + return App( + home, + reader=reader, + tokens=tokens, + control=control or Control(), + region="na", + history=history, + ) + + +def name_both(home, http): + remember_car(home, http.rows, "0001", "daily") + remember_car(home, http.rows, "0002", "weekend") + + +def test_vin_fixtures_are_seventeen_characters(): + assert len(VIN_A) == 17 + assert len(VIN_B) == 17 + assert VIN_A != VIN_B + + +def test_one_car_keeps_the_old_confirmation_and_ok_line(tmp_path): + http = Http([row(VIN_A)], payload(locked=False)) + control = Control() + app = make_app(tmp_path, http, control) + text = app.command("unlock", preferred=None, argument=None, statement="I confirm unlock", sleep=lambda _s: None) + assert text == "OK: unlock" + assert " on " not in text + assert control.calls[0]["vin"] == VIN_A + assert control.calls[0]["args"] == ["unlock"] + + +def test_several_cars_refuse_without_a_default_or_nickname(tmp_path): + http = Http([row(VIN_A, name="One"), row(VIN_B, name="Two")], payload(locked=False)) + control = Control() + app = make_app(tmp_path, http, control) + name_both(tmp_path, http) + with pytest.raises(ConfigError) as caught: + app.command("unlock", preferred=None, argument=None, statement="I confirm unlock", sleep=lambda _s: None) + assert "no default" in str(caught.value) + assert "will not guess" in str(caught.value) + assert control.calls == [] + assert VIN_A not in str(caught.value) + assert VIN_B not in str(caught.value) + + +def test_car_flag_selects_one_car_and_the_confirmation_names_it(tmp_path, monkeypatch, capsys): + http = Http([row(VIN_A, name=VIN_A), row(VIN_B, name="Two")], payload(locked=False)) + control = Control() + app = make_app(tmp_path, http, control) + name_both(tmp_path, http) + monkeypatch.setattr("core.cli.build_app", lambda home: app) + monkeypatch.delenv("AGENTDRIVE_VIN", raising=False) + monkeypatch.delenv("TESLA_VIN", raising=False) + + missing = main(["--home", str(tmp_path), "--car", "daily", "unlock", "--confirm", "I confirm unlock"]) + assert missing == 2 + assert control.calls == [] + assert "Name the car" in capsys.readouterr().err + + ok = main( + [ + "unlock", + "--car", + "daily", + "--home", + str(tmp_path), + "--confirm", + "I confirm unlock on daily", + ] + ) + assert ok == 0 + output = capsys.readouterr().out + assert output.strip() == "OK: unlock on daily" + assert control.calls[0]["vin"] == VIN_A + assert VIN_A not in output + assert VIN_B not in output + + +def test_confirmation_for_the_other_car_does_not_run(tmp_path): + http = Http([row(VIN_A), row(VIN_B)], payload(locked=False)) + control = Control() + app = make_app(tmp_path, http, control) + name_both(tmp_path, http) + app.car_name = "weekend" + with pytest.raises(ConfirmationRequired): + app.command( + "unlock", + preferred=None, + argument=None, + statement="I confirm unlock on daily", + sleep=lambda _s: None, + ) + assert control.calls == [] + + +def test_default_car_is_used_when_the_flag_is_omitted(tmp_path): + http = Http([row(VIN_A), row(VIN_B)], payload(locked=False)) + control = Control() + app = make_app(tmp_path, http, control) + name_both(tmp_path, http) + from core.garage import set_default + + set_default(tmp_path, "weekend") + text = app.command("unlock", preferred=None, argument=None, statement="I confirm unlock on weekend", sleep=lambda _s: None) + assert text == "OK: unlock on weekend" + assert control.calls[0]["vin"] == VIN_B + + +def test_car_list_does_not_wake_and_masks_the_vin(tmp_path, monkeypatch, capsys): + http = Http([row(VIN_A, state="asleep", name=VIN_A), row(VIN_B, state="online", name="Two")], payload()) + app = make_app(tmp_path, http) + name_both(tmp_path, http) + monkeypatch.setattr("core.cli.build_app", lambda home: app) + assert main(["--home", str(tmp_path), "cars"]) == 0 + output = capsys.readouterr().out + assert "does not wake" in output + assert "daily" in output + assert "weekend" in output + assert "****0001" in output + assert "****0002" in output + assert VIN_A not in output + assert VIN_B not in output + assert "asleep" in output + assert not any(url.endswith("/wake_up") for url in http.calls) + assert not any("vehicle_data" in url for url in http.calls) + + +def test_history_and_alerts_stay_with_the_car_they_were_saved_for(tmp_path): + record_parked_snapshot( + tmp_path, + {"response": {"vehicle_state": {"odometer": 12}, "drive_state": {"shift_state": "P", "speed": 0}}}, + Observation(shift_state="P", speed=0, battery_level=55, locked=True), + tyre_bar=2.5, + low_battery_percent=30, + now=NOW, + ) + from core.fleet.check_state import save_codes + + save_codes(tmp_path, ["unlocked"]) + rows = [row(VIN_A), row(VIN_B)] + remember_car(tmp_path, rows, "0001", "daily") + remember_car(tmp_path, rows, "0002", "weekend") + garage = load_garage(tmp_path) + daily = garage.by_nickname("daily") + weekend = garage.by_nickname("weekend") + daily_store = store_for(tmp_path, garage, daily) + weekend_store = store_for(tmp_path, garage, weekend) + assert daily_store == tmp_path + assert weekend_store != tmp_path + + daily_rows = json.loads(render_list(tmp_path, "snapshots", limit=5, as_json=True, store=daily_store)) + weekend_rows = json.loads(render_list(tmp_path, "snapshots", limit=5, as_json=True, store=weekend_store)) + assert daily_rows["total"] == 1 + assert weekend_rows["total"] == 0 + assert load_codes(tmp_path) == {"unlocked"} + assert load_codes(weekend_store) == set() + assert VIN_A not in json.dumps(daily_rows) + + record_parked_snapshot( + weekend_store, + {"response": {"vehicle_state": {"odometer": 40}, "drive_state": {"shift_state": "P", "speed": 0}}}, + Observation(shift_state="P", speed=0, battery_level=70, locked=True), + tyre_bar=2.5, + low_battery_percent=30, + now=NOW + timedelta(hours=1), + ) + assert json.loads(render_list(tmp_path, "snapshots", limit=5, as_json=True, store=daily_store))["total"] == 1 + assert json.loads(render_list(tmp_path, "snapshots", limit=5, as_json=True, store=weekend_store))["total"] == 1 + + +def test_alert_dedupe_is_per_car(tmp_path): + http = Http([row(VIN_A), row(VIN_B)], payload(locked=False)) + name_both(tmp_path, http) + app = make_app(tmp_path, http, history=False) + app.car_name = "daily" + first = app.check(preferred=None, tyre_bar=2.5, low_battery_percent=30) + assert first.exit_code == 1 + assert [item.code for item in first.alerts] == ["unlocked"] + again = app.check(preferred=None, tyre_bar=2.5, low_battery_percent=30) + assert again.exit_code == 0 + app.car_name = "weekend" + other = app.check(preferred=None, tyre_bar=2.5, low_battery_percent=30) + assert other.exit_code == 1 + garage = load_garage(tmp_path) + weekend_store = store_for(tmp_path, garage, garage.by_nickname("weekend")) + assert (tmp_path / CHECK_ALERTS_NAME).is_file() + assert (weekend_store / CHECK_ALERTS_NAME).is_file() + assert load_codes(tmp_path) == {"unlocked"} + assert load_codes(weekend_store) == {"unlocked"} + assert not any(url.endswith("/wake_up") for url in http.calls) + + +def test_history_selection_does_not_contact_the_car(tmp_path, monkeypatch, capsys): + rows = [row(VIN_A), row(VIN_B)] + remember_car(tmp_path, rows, "0001", "daily") + remember_car(tmp_path, rows, "0002", "weekend") + + def explode(home): + raise AssertionError("history must not contact the car") + + monkeypatch.setattr("core.cli.build_app", explode) + assert main(["--home", str(tmp_path), "history", "snapshots", "--json"]) == 2 + assert "no default" in capsys.readouterr().err + assert main(["--home", str(tmp_path), "history", "--car", "weekend", "snapshots", "--json"]) == 0 + assert json.loads(capsys.readouterr().out)["total"] == 0 + + +def test_family_request_is_not_run_until_the_owner_approves_it_once(tmp_path, monkeypatch, capsys): + http = Http([row(VIN_A), row(VIN_B)], payload(locked=False)) + control = Control() + app = make_app(tmp_path, http, control) + name_both(tmp_path, http) + 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", "unlock"]) == 0 + assert main(["--home", str(tmp_path), "family", "allow", "rider", "charging"]) == 0 + allowed = capsys.readouterr().out + assert "may request unlock" in allowed + assert "not run until the owner approves" in allowed + assert "may request charge-amps" in allowed + assert classify(tmp_path, "rider", "unlock") == "request" + + created = main( + [ + "--home", + str(tmp_path), + "--as", + "rider", + "--car", + "daily", + "unlock", + "--confirm", + "I confirm unlock on daily", + ] + ) + assert created == 0 + relay = capsys.readouterr().out + assert "Pending request" in relay + assert "from rider on daily: unlock" in relay + assert "This action was not run." in relay + assert "Tell the owner:" in relay + assert "request approve" in relay + assert "request deny" in relay + assert VIN_A not in relay + assert control.calls == [] + saved = json.loads((tmp_path / REQUESTS_NAME).read_text(encoding="utf-8")) + assert VIN_A not in json.dumps(saved) + assert "vin" not in json.dumps(saved) + request_id = saved["requests"][0]["id"] + + listed = main(["--home", str(tmp_path), "request", "list"]) + assert listed == 0 + listing = capsys.readouterr().out + assert request_id in listing + assert "pending" in listing + assert "rider" in listing + assert VIN_A not in listing + + approved = main(["--home", str(tmp_path), "request", "approve", request_id]) + assert approved == 0 + assert "Approved" in capsys.readouterr().out + assert control.calls == [{"vin": VIN_A, "args": ["unlock"]}] + + control.calls.clear() + again = main(["--home", str(tmp_path), "request", "approve", request_id]) + assert again == 2 + assert "already used" in capsys.readouterr().err + assert control.calls == [] + + +def test_deny_expiry_and_self_approval_never_run_the_command(tmp_path, monkeypatch, capsys): + http = Http([row(VIN_A)], payload(locked=False)) + control = Control() + app = make_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", "unlock"]) == 0 + capsys.readouterr() + + assert main(["--home", str(tmp_path), "--as", "rider", "unlock"]) == 0 + denied_id = json.loads((tmp_path / REQUESTS_NAME).read_text(encoding="utf-8"))["requests"][0]["id"] + assert main(["--home", str(tmp_path), "request", "deny", denied_id]) == 0 + assert "Denied" in capsys.readouterr().out + assert main(["--home", str(tmp_path), "request", "approve", denied_id]) == 2 + assert "denied" in capsys.readouterr().err + assert control.calls == [] + + car_id = load_garage(tmp_path).by_nickname("daily").id + expired = create_request( + tmp_path, + member="rider", + car="daily", + car_id=car_id, + label="daily", + command="unlock", + argument=None, + extra=None, + detail=None, + now=NOW, + ) + expired_id = expired.split()[2] + with pytest.raises(ConfigError) as caught: + approve_request( + tmp_path, + expired_id, + actor="", + execute=lambda record: (_ for _ in ()).throw(AssertionError("expired request ran")), + now=NOW + timedelta(minutes=10), + ) + assert "expired" in str(caught.value) + + fresh = create_request( + tmp_path, + member="rider", + car="daily", + car_id=car_id, + label="daily", + command="unlock", + argument=None, + extra=None, + detail=None, + now=NOW, + ) + fresh_id = fresh.split()[2] + with pytest.raises(ConfigError) as own: + approve_request(tmp_path, fresh_id, actor="rider", execute=lambda record: "ran", now=NOW) + assert "cannot approve their own request" in str(own.value) + with pytest.raises(ConfigError) as other: + approve_request(tmp_path, fresh_id, actor="guest", execute=lambda record: "ran", now=NOW) + assert "Only the owner" in str(other.value) + + assert main(["--home", str(tmp_path), "--as", "rider", "unlock"]) == 0 + own_id = json.loads((tmp_path / REQUESTS_NAME).read_text(encoding="utf-8"))["requests"][-1]["id"] + assert main(["--as", "rider", "--home", str(tmp_path), "request", "approve", own_id]) == 2 + assert "cannot approve their own request" in capsys.readouterr().err + assert control.calls == [] + + +def test_a_low_risk_grant_runs_and_a_missing_grant_does_not(tmp_path, monkeypatch): + http = Http([row(VIN_A)], payload()) + control = Control() + app = make_app(tmp_path, http, control) + 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), "--as", "rider", "lock"]) == 2 + assert control.calls == [] + allow_actions(tmp_path, "rider", "lock") + assert main(["--home", str(tmp_path), "--as", "rider", "lock"]) == 0 + assert control.calls[0]["args"] == ["lock"] + assert not (tmp_path / REQUESTS_NAME).exists() + + +def test_approval_still_refuses_a_car_that_is_not_parked(tmp_path): + http = Http( + [row(VIN_A)], + {"response": {"drive_state": {"shift_state": "D", "speed": 4}, "vehicle_state": {"locked": True}}}, + ) + control = Control() + app = make_app(tmp_path, http, control) + remember_car(tmp_path, http.rows, "0001", "daily") + car = load_garage(tmp_path).by_nickname("daily") + text = create_request( + tmp_path, + member="rider", + car="daily", + car_id=car.id, + label="daily", + command="unlock", + argument=None, + extra=None, + detail=None, + now=NOW, + ) + request_id = text.split()[2] + + def execute(record): + app.car_name = "daily" + return app.command("unlock", preferred=car.vin, argument=None, statement="I confirm unlock on daily", sleep=lambda _s: None) + + with pytest.raises(Exception): + approve_request(tmp_path, request_id, actor="", execute=execute, now=NOW) + assert control.calls == [] + with pytest.raises(ConfigError) as caught: + approve_request(tmp_path, request_id, actor="", execute=execute, now=NOW) + assert "already used" in str(caught.value) + assert control.calls == [] + + +def test_a_schedule_cannot_approve_a_request(): + assert schedule_blocks("request approve ab12cd34") + assert schedule_blocks("request deny ab12cd34") + assert schedule_blocks("check") is False + + +def test_gateway_challenge_names_the_car(): + class Runner: + def __init__(self): + self.calls = [] + + def run(self, *, vin, access_token, args, timeout=45): + self.calls.append(args) + + class Adapter: + def __init__(self, statement): + self.statement = statement + self.last = None + + def confirm(self, challenge): + self.last = challenge + return Confirmation( + action_id=challenge.action_id, + command=challenge.command, + accepted=True, + source="owner", + statement=self.statement, + ) + + runner = Runner() + adapter = Adapter("I confirm unlock") + with pytest.raises(ConfirmationRequired): + Gateway(runner).execute( + "unlock", + shift_state="P", + speed=0, + adapter=adapter, + via_schedule=False, + vin="VIN", + access_token="token", + observe=lambda: Observation(shift_state="P", speed=0, locked=False), + car="daily", + ) + assert adapter.last.summary == "unlock on daily" + assert adapter.last.car == "daily" + assert runner.calls == []