Files
we_monitor/collect.py
T
jensandClaude Sonnet 4.6 81ebcd8e0f Add TCP push server: broadcast new snapshots to connected clients
Each connected TCP client receives every snapshot as a newline-
delimited JSON line the moment it is collected (no polling needed).
The server binds on --host/--port (default 0.0.0.0:9999).
Client connections are accepted in a daemon thread; dead connections
are pruned on the next broadcast.

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

642 lines
20 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.
Sample output record
--------------------
{
"ts": "2026-05-25T10:05:00+00:00",
"charging": {
"chargingStatus": { "chargingState": "readyForCharging",
"chargePower": {"value": 9.5, "unit": "kW"}, ... },
"batteryStatus": { "currentSOC": {"value": 80, "unit": "%"},
"cruisingRangeElectric": {"value": 295, "unit": "km"} },
"chargingSettings": { "targetSOC": {"value": 80, "unit": "%"}, ... },
"plugStatus": { "plugConnectionState": "disconnected", ... }
},
"measurements": {
"odometerStatus": { "odometer": {"value": 12345, "unit": "km"} },
"rangeStatus": { "electricRange": {"value": 295, "unit": "km"},
"totalRange": {"value": 295, "unit": "km"} },
"temperatureBatteryStatus": { "temperatureHvBatteryMax": {"value": 22.0, "unit": "degC"},
"temperatureHvBatteryMin": {"value": 20.0, "unit": "degC"} },
"temperatureOutsideStatus": { "temperatureOutside": {"value": 18.0, "unit": "degC"} }
},
"readiness": {
"readinessStatus": {
"connectionState": { "isOnline": true, "isActive": true },
"connectionWarning": { "insufficientBatteryLevelWarning": false }
}
},
"parking": {
"parkingPosition": { "lat": 52.123, "lon": 13.456 }
},
"access": {
"accessStatus": {
"overallStatus": "safe",
"doors": { "frontLeft": { "lockState": "locked", "openState": "closed" } },
"windows": { "frontLeft": { "openState": "closed" } }
}
}
}
"""
import argparse
import json
import logging
import os
import socket
import sys
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
try:
from weconnect import weconnect
except ImportError:
print("weconnect is not installed. Run: pip install weconnect", file=sys.stderr)
sys.exit(1)
log = logging.getLogger(__name__)
# ── helpers ──────────────────────────────────────────────────────────────────
def _str(v) -> str:
"""Convert an enum-like WeConnect value to a plain string."""
return str(v.value) if hasattr(v, "value") else str(v)
def _phys(value, unit: str) -> dict:
return {"value": value, "unit": unit}
# ── domain extractors ────────────────────────────────────────────────────────
# Each function returns a dict whose keys are the WeConnect status-object names
# and whose values are dicts of field-name → value — mirroring the API layout.
# Returns None when the domain is absent from the API response.
def extract_charging(vehicle) -> dict | None:
try:
d = vehicle.domains["charging"]
except KeyError:
return None
result: dict = {}
try:
cs = d["chargingStatus"]
result["chargingStatus"] = {
"chargingState": _str(cs.chargingState),
"chargePower": _phys(cs.chargePower_kW.value, "kW"),
"remainingChargingTimeToComplete": _phys(cs.remainingChargingTimeToComplete_min.value, "min"),
"chargeType": _str(cs.chargeType),
}
except Exception:
log.debug("chargingStatus unavailable", exc_info=True)
try:
bs = d["batteryStatus"]
result["batteryStatus"] = {
"currentSOC": _phys(bs.currentSOC_pct.value, "%"),
"cruisingRangeElectric": _phys(bs.cruisingRangeElectric_km.value, "km"),
}
except Exception:
log.debug("charging/batteryStatus unavailable", exc_info=True)
try:
cfg = d["chargingSettings"]
result["chargingSettings"] = {
"targetSOC": _phys(cfg.targetSOC_pct.value, "%"),
"maxChargeCurrentAC": _str(cfg.maxChargeCurrentAC),
"autoUnlockPlugWhenCharged": _str(cfg.autoUnlockPlugWhenCharged),
}
except Exception:
log.debug("chargingSettings unavailable", exc_info=True)
try:
ps = d["plugStatus"]
result["plugStatus"] = {
"plugConnectionState": _str(ps.plugConnectionState),
"plugLockState": _str(ps.plugLockState),
"externalPower": _str(ps.externalPower),
}
except Exception:
log.debug("plugStatus unavailable", exc_info=True)
return result or None
def extract_climatisation(vehicle) -> dict | None:
try:
d = vehicle.domains["climatisation"]
except KeyError:
return None
result: dict = {}
try:
cl = d["climatisationStatus"]
result["climatisationStatus"] = {
"climatisationState": _str(cl.climatisationState),
"remainingClimatisationTime": _phys(cl.remainingClimatisationTime_min.value, "min"),
}
except Exception:
log.debug("climatisationStatus unavailable", exc_info=True)
try:
cs = d["climatisationSettings"]
result["climatisationSettings"] = {
"targetTemperature": _phys(cs.targetTemperature_C.value, "degC"),
"unitInCar": _str(cs.unitInCar),
}
except Exception:
log.debug("climatisationSettings unavailable", exc_info=True)
try:
wh = d["windowHeatingStatus"]
result["windowHeatingStatus"] = {
name: _str(win.windowHeatingState)
for name, win in wh.windows.items()
}
except Exception:
log.debug("windowHeatingStatus unavailable", exc_info=True)
return result or None
def extract_measurements(vehicle) -> dict | None:
try:
d = vehicle.domains["measurements"]
except KeyError:
return None
result: dict = {}
try:
od = d["odometerStatus"]
result["odometerStatus"] = {"odometer": _phys(od.odometer.value, "km")}
except Exception:
log.debug("odometerStatus unavailable", exc_info=True)
try:
rs = d["rangeStatus"]
result["rangeStatus"] = {
"electricRange": _phys(rs.electricRange.value, "km"),
"totalRange": _phys(rs.totalRange_km.value, "km"),
}
except Exception:
log.debug("rangeStatus unavailable", exc_info=True)
# Battery temperature — reported in Kelvin by the API
try:
bs = d["temperatureBatteryStatus"]
result["temperatureBatteryStatus"] = {
"temperatureHvBatteryMax": _phys(float(bs.temperatureHvBatteryMax_K.value - 273.15), "degC"),
"temperatureHvBatteryMin": _phys(float(bs.temperatureHvBatteryMin_K.value - 273.15), "degC"),
}
except Exception:
log.debug("measurements/temperatureBatteryStatus (temperature) unavailable", exc_info=True)
try:
temp = d["temperatureOutsideStatus"]
result["temperatureOutsideStatus"] = {
"temperatureOutside": _phys(temp.temperatureOutside_C.value, "degC"),
}
except Exception:
log.debug("temperatureOutsideStatus unavailable", exc_info=True)
return result or None
def extract_readiness(vehicle) -> dict | None:
try:
d = vehicle.domains["readiness"]
except KeyError:
return None
result: dict = {}
try:
rs = d["readinessStatus"]
conn = rs.connectionState
warn = rs.connectionWarning
result["readinessStatus"] = {
"connectionState": {
"isOnline": conn.isOnline.value,
"isActive": conn.isActive.value,
},
"connectionWarning": {
"insufficientBatteryLevelWarning": warn.insufficientBatteryLevelWarning.value,
"insufficientBatteryLevelError": warn.insufficientBatteryLevelError.value,
},
}
except Exception:
log.debug("readinessStatus unavailable", exc_info=True)
return result or None
def extract_parking(vehicle) -> dict | None:
try:
d = vehicle.domains["parking"]
except KeyError:
return None
result: dict = {}
try:
pos = d["parkingPosition"]
result["parkingPosition"] = {
"lat": pos.lat.value,
"lon": pos.lon.value,
}
except Exception:
log.debug("parkingPosition unavailable", exc_info=True)
return result or None
def extract_access(vehicle) -> dict | None:
try:
d = vehicle.domains["access"]
except KeyError:
return None
result: dict = {}
try:
acc = d["accessStatus"]
result["accessStatus"] = {
"overallStatus": _str(acc.overallStatus),
"doors": {
name: {
"lockState": _str(door.lockState),
"openState": _str(door.openState),
}
for name, door in acc.doors.items()
},
"windows": {
name: {"openState": _str(win.openState)}
for name, win in acc.windows.items()
},
}
except Exception:
log.debug("accessStatus unavailable", exc_info=True)
return result or None
ALL_DOMAINS: dict[str, callable] = {
"charging": extract_charging,
"climatisation": extract_climatisation,
"measurements": extract_measurements,
"readiness": extract_readiness,
"parking": extract_parking,
"access": extract_access,
}
# ── snapshot / store helpers ─────────────────────────────────────────────────
def collect_snapshot(vehicle, domains: list[str]) -> dict:
snapshot: dict = {"ts": datetime.now(timezone.utc).isoformat()}
for domain in domains:
data = ALL_DOMAINS[domain](vehicle)
if data is not None:
snapshot[domain] = data
return snapshot
def load_store(path: Path, vin: str, interval: int) -> dict:
if path.exists():
try:
data = json.loads(path.read_text())
if isinstance(data, dict) and "records" in data:
return data
except json.JSONDecodeError:
log.warning("Output file is corrupt — starting fresh")
return {
"meta": {
"vin": vin,
"created_at": datetime.now(timezone.utc).isoformat(),
"interval_seconds": interval,
},
"records": [],
}
def save_store(path: Path, store: dict, max_records: int | None) -> None:
if max_records and len(store["records"]) > max_records:
store["records"] = store["records"][-max_records:]
path.write_text(json.dumps(store, indent=2, default=str))
# ── push server ──────────────────────────────────────────────────────────────
def _accept_loop(
server_sock: socket.socket,
clients: list,
lock: threading.Lock,
) -> None:
while True:
try:
conn, addr = server_sock.accept()
log.info("Push client connected from %s", addr)
with lock:
clients.append(conn)
except OSError:
break
def _broadcast(snapshot: dict, clients: list, lock: threading.Lock) -> None:
line = (json.dumps(snapshot, default=str) + "\n").encode()
with lock:
dead = []
for conn in clients:
try:
conn.sendall(line)
except OSError:
dead.append(conn)
for conn in dead:
clients.remove(conn)
conn.close()
log.info("Push client disconnected")
def _start_push_server(host: str, port: int) -> tuple:
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_sock.bind((host, port))
server_sock.listen()
clients: list = []
lock = threading.Lock()
t = threading.Thread(target=_accept_loop, args=(server_sock, clients, lock), daemon=True)
t.start()
log.info("Push server listening on %s:%d", host, port)
return clients, lock
# ── main loop ────────────────────────────────────────────────────────────────
def _auto_out_path(logs_dir: Path, vin: str) -> Path:
stamp = datetime.now(timezone.utc).strftime("%Y_%m_%d_%H_%M_%S")
return logs_dir / f"{stamp}_{vin}.json"
def run(args: argparse.Namespace) -> None:
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
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 = weconnect.WeConnect(
username=args.username,
password=args.password,
updateAfterLogin=False,
loginOnInit=True,
)
wc.login()
wc.update()
vehicles = list(wc.vehicles.values())
if not vehicles:
log.error("No vehicles found in this WeConnect account")
sys.exit(1)
if args.vin:
vehicle = next((v for v in vehicles if v.vin.value == args.vin), None)
if not vehicle:
log.error(
"VIN %s not found. Available: %s",
args.vin,
[v.vin.value for v in vehicles],
)
sys.exit(1)
else:
vehicle = vehicles[0]
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 = _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)
_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()