#!/usr/bin/env python3 """ WeConnect vehicle data collector. Periodically fetches selected domains from a VW WeConnect vehicle and appends timestamped records to a JSON file. The JSON structure mirrors the WeConnect domain hierarchy: domain → status-object → field. Usage examples -------------- # Credentials from env vars, all defaults: export WC_USER=me@example.com WC_PASS=secret python collect.py # Credentials from a JSON file (output auto-named under ./logs/): python collect.py -c creds.json -i 600 # Credentials from a JSON file, explicit output path: python collect.py -c creds.json -i 600 -o id3.json # Custom domains, 10-minute interval: python collect.py -u me@example.com -p secret \\ -d charging,measurements,readiness -i 600 -o id3.json # Everything, verbose, keep last 1 000 records: python collect.py -d all -v --max-records 1000 Available domains: charging, climatisation, measurements, readiness, parking, access Push server ----------- The collector listens on --host/--port (default 0.0.0.0:9999). Each connected TCP client receives every new snapshot as a single line of JSON (newline-delimited) the moment it is collected. Connect with: nc localhost 9999 or any TCP client that reads lines. Credentials file format (JSON) ------------------------------- { "username": "me@example.com", "password": "secret", "vin": "WVWZZZE1ZMP123456", "domains": ["charging", "measurements", "readiness"] } All four keys are optional; domains may also be a comma-separated string. Explicit CLI flags and env vars take precedence. """ import argparse import json import logging import os import sys import time from datetime import datetime, timezone from pathlib import Path from server import log_config, network, we_connect from server.data_model import ALL_DOMAINS, collect_snapshot from server.storage_helpers import auto_out_path, load_store, load_today_records, save_store log = logging.getLogger(__name__) # ── main loop ──────────────────────────────────────────────────────────────── def run(args: argparse.Namespace) -> None: log_config.setup(args.verbose) raw = [d.strip() for d in args.domains.split(",")] domains = list(ALL_DOMAINS) if raw == ["all"] else raw unknown = [d for d in domains if d not in ALL_DOMAINS] if unknown: log.error("Unknown domain(s): %s. Available: %s", unknown, list(ALL_DOMAINS)) sys.exit(1) log.info("Connecting to WeConnect…") wc = we_connect.connect(args.username, args.password) vehicle, err = we_connect.select_vehicle(wc, args.vin) if err: log.error(err) sys.exit(1) vin = vehicle.vin.value log.info("Vehicle: %s", vin) if args.output: out = Path(args.output) logs_dir = None else: logs_dir = Path("logs") logs_dir.mkdir(exist_ok=True) out = auto_out_path(logs_dir, vin) push_clients, push_lock, push_store_ref = network.start_push_server(args.host, args.port) if logs_dir is not None: push_store_ref["preloaded"] = load_today_records(logs_dir, vin) if push_store_ref["preloaded"]: log.info("Pre-loaded %d record(s) from today's log files", len(push_store_ref["preloaded"])) current_day = datetime.now(timezone.utc).date() store = load_store(out, vin, args.interval) push_store_ref["current"] = store log.info( "Collecting [%s] every %ds → %s (Ctrl-C to stop)", ", ".join(domains), args.interval, out, ) try: while True: if logs_dir is not None: today = datetime.now(timezone.utc).date() if today != current_day: out = auto_out_path(logs_dir, vin) store = load_store(out, vin, args.interval) push_store_ref["current"] = store current_day = today log.info("Midnight UTC rotation → %s", out) try: wc.update() snapshot = collect_snapshot(vehicle, domains) store["records"].append(snapshot) save_store(out, store, args.max_records) network.broadcast(snapshot, push_clients, push_lock) log.info("Record #%d saved at %s", len(store["records"]), snapshot["ts"]) except KeyboardInterrupt: raise except Exception: log.exception("Collection failed — will retry at next interval") time.sleep(args.interval) except KeyboardInterrupt: log.info("Stopped by user. %d records in %s", len(store["records"]), out) # ── CLI ────────────────────────────────────────────────────────────────────── def main() -> None: p = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) creds = p.add_argument_group( "credentials (also accepted via WC_USER / WC_PASS env vars or -c FILE)" ) creds.add_argument( "-c", "--credentials", metavar="FILE", help=( "JSON file with keys: username, password, vin, domains. " "Values are overridden by explicit CLI flags and env vars." ), ) creds.add_argument( "-u", "--username", default=os.environ.get("WC_USER"), help="WeConnect / MyVolkswagen email", ) creds.add_argument( "-p", "--password", default=os.environ.get("WC_PASS"), help="WeConnect password", ) p.add_argument("--vin", help="Vehicle VIN (uses first vehicle when omitted)") p.add_argument( "-d", "--domains", default=None, metavar="DOMAIN[,DOMAIN…]", help=( "Comma-separated domains to collect, or 'all'. " f"Available: {', '.join(ALL_DOMAINS)}. " "Default: charging,measurements,readiness" ), ) p.add_argument( "-i", "--interval", type=int, default=300, metavar="SECONDS", help="Collection interval in seconds (default: 300)", ) p.add_argument( "-o", "--output", default=None, metavar="FILE", help="Output file (default: logs/YYYY_MM_DD_HH_MM_SS_.json)", ) p.add_argument( "--max-records", type=int, default=None, metavar="N", help="Keep only the last N records in the file (default: unlimited)", ) srv = p.add_argument_group("push server") srv.add_argument( "--host", default="0.0.0.0", help="Bind address for the push server (default: 0.0.0.0)", ) srv.add_argument( "--port", type=int, default=9999, help="TCP port for the push server (default: 9999)", ) p.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging") p.add_argument( "--list-domains", action="store_true", help="Print available domains and exit", ) args = p.parse_args() if args.list_domains: print("\n".join(ALL_DOMAINS)) sys.exit(0) if args.credentials: creds_path = Path(args.credentials) if not creds_path.exists(): p.error(f"Credentials file not found: {args.credentials}") try: creds_data = json.loads(creds_path.read_text()) except json.JSONDecodeError as exc: p.error(f"Invalid JSON in credentials file: {exc}") if args.username is None: args.username = creds_data.get("username") if args.password is None: args.password = creds_data.get("password") if args.vin is None: args.vin = creds_data.get("vin") if args.domains is None: raw_domains = creds_data.get("domains") if isinstance(raw_domains, list): args.domains = ",".join(raw_domains) else: args.domains = raw_domains if args.domains is None: args.domains = "charging,measurements,readiness" if not args.username or not args.password: p.error( "Username and password are required. " "Pass them with -u/-p or set WC_USER / WC_PASS environment variables." ) run(args) if __name__ == "__main__": main()