Refactor collect.py into focused modules

- data_model.py  : _str, _phys, load_store, save_store, auto_out_path
- we_connect.py  : domain extractors, ALL_DOMAINS, connect,
                   select_vehicle, collect_snapshot
- network.py     : TCP push server (start_push_server, broadcast)
- log_config.py  : logging setup
- collect.py     : run() loop and CLI, wired to the above modules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 19:41:54 +02:00
co-authored by Claude Sonnet 4.6
parent 3dae080520
commit bc016cd7cb
5 changed files with 389 additions and 399 deletions
+17 -399
View File
@@ -44,394 +44,30 @@ Credentials file format (JSON)
} }
All four keys are optional; domains may also be a comma-separated string. All four keys are optional; domains may also be a comma-separated string.
Explicit CLI flags and env vars take precedence. 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 argparse
import json import json
import logging import logging
import os import os
import socket
import sys import sys
import threading
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
try: import log_config
from weconnect import weconnect import network
except ImportError: import we_connect
print("weconnect is not installed. Run: pip install weconnect", file=sys.stderr) from data_model import auto_out_path, load_store, save_store
sys.exit(1) from we_connect import ALL_DOMAINS
log = logging.getLogger(__name__) 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 ──────────────────────────────────────────────────────────────── # ── 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: def run(args: argparse.Namespace) -> None:
logging.basicConfig( log_config.setup(args.verbose)
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(",")] raw = [d.strip() for d in args.domains.split(",")]
domains = list(ALL_DOMAINS) if raw == ["all"] else raw domains = list(ALL_DOMAINS) if raw == ["all"] else raw
@@ -441,32 +77,13 @@ def run(args: argparse.Namespace) -> None:
sys.exit(1) sys.exit(1)
log.info("Connecting to WeConnect…") log.info("Connecting to WeConnect…")
wc = weconnect.WeConnect( wc = we_connect.connect(args.username, args.password)
username=args.username,
password=args.password,
updateAfterLogin=False,
loginOnInit=True,
)
wc.login()
wc.update()
vehicles = list(wc.vehicles.values()) vehicle, err = we_connect.select_vehicle(wc, args.vin)
if not vehicles: if err:
log.error("No vehicles found in this WeConnect account") log.error(err)
sys.exit(1) 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 vin = vehicle.vin.value
log.info("Vehicle: %s", vin) log.info("Vehicle: %s", vin)
@@ -476,9 +93,9 @@ def run(args: argparse.Namespace) -> None:
else: else:
logs_dir = Path("logs") logs_dir = Path("logs")
logs_dir.mkdir(exist_ok=True) logs_dir.mkdir(exist_ok=True)
out = _auto_out_path(logs_dir, vin) out = auto_out_path(logs_dir, vin)
push_clients, push_lock = _start_push_server(args.host, args.port) push_clients, push_lock = network.start_push_server(args.host, args.port)
current_day = datetime.now(timezone.utc).date() current_day = datetime.now(timezone.utc).date()
store = load_store(out, vin, args.interval) store = load_store(out, vin, args.interval)
@@ -494,17 +111,17 @@ def run(args: argparse.Namespace) -> None:
if logs_dir is not None: if logs_dir is not None:
today = datetime.now(timezone.utc).date() today = datetime.now(timezone.utc).date()
if today != current_day: if today != current_day:
out = _auto_out_path(logs_dir, vin) out = auto_out_path(logs_dir, vin)
store = load_store(out, vin, args.interval) store = load_store(out, vin, args.interval)
current_day = today current_day = today
log.info("Midnight UTC rotation → %s", out) log.info("Midnight UTC rotation → %s", out)
try: try:
wc.update() wc.update()
snapshot = collect_snapshot(vehicle, domains) snapshot = we_connect.collect_snapshot(vehicle, domains)
store["records"].append(snapshot) store["records"].append(snapshot)
save_store(out, store, args.max_records) save_store(out, store, args.max_records)
_broadcast(snapshot, push_clients, push_lock) network.broadcast(snapshot, push_clients, push_lock)
log.info("Record #%d saved at %s", len(store["records"]), snapshot["ts"]) log.info("Record #%d saved at %s", len(store["records"]), snapshot["ts"])
except KeyboardInterrupt: except KeyboardInterrupt:
raise raise
@@ -578,6 +195,7 @@ def main() -> None:
metavar="N", metavar="N",
help="Keep only the last N records in the file (default: unlimited)", help="Keep only the last N records in the file (default: unlimited)",
) )
srv = p.add_argument_group("push server") srv = p.add_argument_group("push server")
srv.add_argument( srv.add_argument(
"--host", "--host",
@@ -638,4 +256,4 @@ def main() -> None:
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+43
View File
@@ -0,0 +1,43 @@
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
log = logging.getLogger(__name__)
def _str(v) -> str:
return str(v.value) if hasattr(v, "value") else str(v)
def _phys(value, unit: str) -> dict:
return {"value": value, "unit": unit}
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 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))
+8
View File
@@ -0,0 +1,8 @@
import logging
def setup(verbose: bool) -> None:
logging.basicConfig(
level=logging.DEBUG if verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
+49
View File
@@ -0,0 +1,49 @@
import json
import logging
import socket
import threading
log = logging.getLogger(__name__)
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
+272
View File
@@ -0,0 +1,272 @@
import logging
import sys
from datetime import datetime, timezone
from data_model import _phys, _str
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__)
# ── domain extractors ────────────────────────────────────────────────────────
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)
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 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,
}
# ── connection helpers ───────────────────────────────────────────────────────
def connect(username: str, password: str):
wc = weconnect.WeConnect(
username=username,
password=password,
updateAfterLogin=False,
loginOnInit=True,
)
wc.login()
wc.update()
return wc
def select_vehicle(wc, vin: str | None):
"""Return (vehicle, error_message). error_message is None on success."""
vehicles = list(wc.vehicles.values())
if not vehicles:
return None, "No vehicles found in this WeConnect account"
if vin:
vehicle = next((v for v in vehicles if v.vin.value == vin), None)
if not vehicle:
available = [v.vin.value for v in vehicles]
return None, f"VIN {vin} not found. Available: {available}"
return vehicle, None
return vehicles[0], None
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