Files
we_monitor/collect.py
T
jensandClaude Sonnet 4.6 b57e2a9cc2 collect: send last 24 h of history on client connect (not same-day only)
Replace load_today_records with load_last_24h_records which computes a
rolling 24 h cutoff, globs log files for both the current and previous
calendar day, and filters records by timestamp — so clients always
receive a full 24 h window regardless of when midnight falls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 13:20:41 +02:00

263 lines
8.5 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_last_24h_records, 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_store_ref = network.start_push_server(args.host, args.port)
if logs_dir is not None:
push_store_ref["preloaded"] = load_last_24h_records(logs_dir, vin)
if push_store_ref["preloaded"]:
log.info("Pre-loaded %d record(s) from the last 24 h", 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_<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()