Files
we_monitor/collect.py
T
jensandClaude Sonnet 4.6 9d2d742368 Fall back to disk when in-memory store is empty on client connect
storage_helpers: load_today_records() scans logs/ for all files
matching today's UTC date and VIN, merges and sorts their records.

network: _accept_loop receives an optional load_today callable;
if store_ref["current"] has no records it calls load_today() to
replay the day's history from disk before adding the client.

collect: passes a load_today lambda (None when -o is used).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:21:17 +02:00

262 lines
8.3 KiB
Python

#!/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)
load_today = (lambda: load_today_records(logs_dir, vin)) if logs_dir else None
push_clients, push_lock, push_store_ref = network.start_push_server(
args.host, args.port, load_today=load_today
)
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_<VIN>.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()