Files
we_monitor/collect.py
T
jensandClaude Sonnet 4.6 3ab7795248 Push last snapshot immediately to newly connected clients
The server keeps the most recent snapshot in memory (under the same
lock as the client list). When a client connects it receives that
snapshot right away, then continues to receive live updates on each
collection cycle.

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

259 lines
8.1 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
import log_config
import network
import we_connect
from data_model import ALL_DOMAINS, collect_snapshot
from storage_helpers import auto_out_path, load_store, 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_last = network.start_push_server(args.host, args.port)
current_day = datetime.now(timezone.utc).date()
store = load_store(out, vin, args.interval)
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)
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, push_last)
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()