From bc424310b569c0f1b6bb54c98da94c98fbd78463 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Thu, 28 May 2026 22:50:57 +0200 Subject: [PATCH] server: replace carconnectivity with volkswagencarnet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches the VW API backend from carconnectivity + connector-volkswagen to volkswagencarnet (local fork with the cariad-hybrid-auth-fix patch applied), fixing authentication against the current CARIAD BFF endpoints. - requirements.txt: point at local volkswagencarnet git repo via git+file:// URL (editable path no longer needed now the patch is committed there) - we_connect.py: rewrite around the async volkswagencarnet Connection class; a persistent asyncio event loop held in _Session lets the rest of the code stay synchronous - data_model.py: remap all extractors to volkswagencarnet Vehicle properties and Paths constants; output shape preserved so apply_procedural and the GUI client continue to work unchanged - collect.py: swap carconnectivity error import, drop TemporaryAuthenticationError (no equivalent), fix vehicle.vin.value → vehicle.vin Co-Authored-By: Claude Sonnet 4.6 --- server/collect.py | 10 +- server/data_model.py | 274 +++++++++++++++++++++++++--------------- server/requirements.txt | 3 +- server/we_connect.py | 84 ++++++------ 4 files changed, 223 insertions(+), 148 deletions(-) diff --git a/server/collect.py b/server/collect.py index cd12d4a..20f9246 100644 --- a/server/collect.py +++ b/server/collect.py @@ -10,7 +10,7 @@ from .data_model import ALL_DOMAINS, collect_snapshot, apply_procedural from .storage_helpers import auto_out_path, load_last_24h_records, load_store, save_store from jaydiff.diff import diff_full as jay_diff_full -from carconnectivity.errors import AuthenticationError, TemporaryAuthenticationError +from volkswagencarnet.vw_exceptions import AuthenticationError log = logging.getLogger(__name__) @@ -26,18 +26,18 @@ def run(args: argparse.Namespace) -> None: sys.exit(1) if args.dry: - log.info("Dry-run mode — skipping CarConnectivity login") + log.info("Dry-run mode — skipping volkswagencarnet login") wc = None vehicle = None vin = args.vin or "UNKNOWN" else: - log.info("Connecting via CarConnectivity…") + log.info("Connecting via volkswagencarnet…") 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 + vin = vehicle.vin log.info("Vehicle: %s", vin) @@ -98,7 +98,7 @@ def run(args: argparse.Namespace) -> None: log.info("Record #%d saved at %s", len(store["records"]), snapshot["ts"]) except KeyboardInterrupt: raise - except (AuthenticationError, TemporaryAuthenticationError): + except AuthenticationError: log.warning("Authentication error — will retry at next interval") except Exception: log.exception("Collection failed — will retry at next interval") diff --git a/server/data_model.py b/server/data_model.py index 3ebdb31..199805f 100644 --- a/server/data_model.py +++ b/server/data_model.py @@ -2,68 +2,83 @@ import logging from collections.abc import Callable from datetime import datetime, timezone +from volkswagencarnet.vw_const import Paths +from volkswagencarnet.vw_utilities import find_path + log = logging.getLogger(__name__) # ── value helpers ───────────────────────────────────────────────────────────── -def _str(attr) -> str: - """Return the string value of a CarConnectivity attribute.""" - return str(attr) - - def _phys(value, unit: str) -> dict: return {"value": value, "unit": unit} -def _remaining_min(estimated_date_reached_attr) -> float | None: - """Compute remaining minutes from an estimated_date_reached DateAttribute.""" - edr = estimated_date_reached_attr.value - if edr is None: - return None - return max(0.0, (edr - datetime.now(timezone.utc)).total_seconds() / 60) - - # ── domain extractors ───────────────────────────────────────────────────────── def extract_charging(vehicle) -> dict | None: result: dict = {} try: - ch = vehicle.charging - result["state"] = _str(ch.state) - result["type"] = _str(ch.type) - try: - result["power"] = _phys(ch.power.value, "kW") - except Exception: - pass - try: - rem = _remaining_min(ch.estimated_date_reached) - if rem is not None: - result["remain"] = _phys(round(rem), "min") - except Exception: - pass - + result["state"] = vehicle.charging_state except Exception: - log.debug("vehicle.charging unavailable", exc_info=True) + log.debug("charging.state unavailable", exc_info=True) try: - cfg = vehicle.charging.settings - result["settings"] = { - "target_level": _phys(cfg.target_level.value, "%"), - "maximum_current": _phys(cfg.maximum_current.value, "A"), - "auto_unlock": _str(cfg.auto_unlock), - } + if vehicle.is_charger_type_supported: + result["type"] = vehicle.charger_type except Exception: - log.debug("vehicle.charging.settings unavailable", exc_info=True) + pass try: - conn = vehicle.charging.connector - result["plugStatus"] = { - "connection_state": _str(conn.connection_state), - "lock_state": _str(conn.lock_state), - "external_power": _str(conn.external_power), - } + power = vehicle.charging_power + if power is not None: + result["power"] = _phys(power, "kW") + except Exception: + log.debug("charging.power unavailable", exc_info=True) + + try: + remain = vehicle.charging_time_left + if remain is not None: + result["remain"] = _phys(remain, "min") + except Exception: + log.debug("charging.remain unavailable", exc_info=True) + + settings: dict = {} + try: + target = vehicle.battery_target_charge_level + if target is not None: + settings["target_level"] = _phys(target, "%") + except Exception: + pass + try: + max_a = vehicle.charge_max_ac_ampere + if max_a is not None: + settings["maximum_current"] = _phys(max_a, "A") + except Exception: + pass + try: + auto_unlock = vehicle.auto_release_ac_connector_state + if auto_unlock is not None: + settings["auto_unlock"] = auto_unlock + except Exception: + pass + if settings: + result["settings"] = settings + + try: + plug: dict = {} + conn_state = find_path(vehicle.attrs, Paths.PLUG_CONN) + if conn_state is not None: + plug["connection_state"] = conn_state + lock_state = find_path(vehicle.attrs, Paths.PLUG_LOCK) + if lock_state is not None: + plug["lock_state"] = lock_state + ext_pwr = find_path(vehicle.attrs, Paths.PLUG_EXT_PWR) + if ext_pwr is not None: + plug["external_power"] = ext_pwr + if plug: + result["plugStatus"] = plug except Exception: log.debug("plugStatus unavailable", exc_info=True) @@ -74,40 +89,39 @@ def extract_climatisation(vehicle) -> dict | None: result: dict = {} try: - cl = vehicle.climatization - result["state"] = _str(cl.state) - try: - rem = _remaining_min(cl.estimated_date_reached) - if rem is not None: - result["remain"] = _phys(round(rem), "min") - except Exception: - pass + state = vehicle.climatisation_state + if state is not None: + result["state"] = state except Exception: - log.debug("vehicle.climatization unavailable", exc_info=True) + log.debug("climatisation.state unavailable", exc_info=True) try: - cs = vehicle.climatization.settings - result["settings"] = { - "target_temperature": _phys(cs.target_temperature.value, "degC"), - } + remain = vehicle.electric_remaining_climatisation_time + if remain is not None: + result["remain"] = _phys(remain, "min") except Exception: - log.debug("climatization.settings unavailable", exc_info=True) + pass try: - wh = vehicle.window_heatings - result["window_heatings"] = { - name: _str(win.heating_state) - for name, win in wh.heatings.items() - } + target = vehicle.climatisation_target_temperature + if target is not None: + result["settings"] = {"target_temperature": _phys(target, "degC")} except Exception: - log.debug("vehicle.window_heatings unavailable", exc_info=True) + log.debug("climatisation.settings unavailable", exc_info=True) try: - result["environment"] = { - "temperature_outside": _phys(vehicle.outside_temperature.value, "degC"), - } + window_status = find_path(vehicle.attrs, Paths.CLIMATISATION_WINDOW_HEATING_STATUS) + if window_status: + heatings: dict = {} + for entry in window_status: + loc = entry.get("windowLocation") + state = entry.get("windowHeatingState") + if loc and state is not None: + heatings[loc] = state + if heatings: + result["window_heatings"] = heatings except Exception: - log.debug("vehicle.outside_temperature unavailable", exc_info=True) + log.debug("window_heatings unavailable", exc_info=True) return result or None @@ -116,23 +130,45 @@ def extract_electric_drive(vehicle) -> dict | None: result: dict = {} try: - ed = vehicle.get_electric_drive() - result["range"] = _phys(ed.range.value, "km") - result["range_full"] = _phys(round(float(ed.range_estimated_full.value), 1), "km") - try: - bat = ed.battery - result["battery"] = { - "soc": _phys(ed.level.value, "%"), - "temperature_max": _phys(round(float(bat.temperature_max.value) - 273.15, 1), "degC"), - "temperature_min": _phys(round(float(bat.temperature_min.value) - 273.15, 1), "degC"), - } - except Exception: - log.debug("electric_drive/battery unavailable", exc_info=True) + rng = vehicle.electric_range + if rng is not None: + result["range"] = _phys(rng, "km") except Exception: - log.debug("electric_drive unavailable", exc_info=True) + log.debug("electric_range unavailable", exc_info=True) try: - result["odometer"] = {"odometer": _phys(vehicle.odometer.value, "km")} + rng_full = vehicle.battery_cruising_range + if rng_full is not None: + result["range_full"] = _phys(round(float(rng_full), 1), "km") + except Exception: + log.debug("battery_cruising_range unavailable", exc_info=True) + + battery: dict = {} + try: + soc = vehicle.battery_level + if soc is not None: + battery["soc"] = _phys(soc, "%") + except Exception: + log.debug("battery.soc unavailable", exc_info=True) + try: + tmax = vehicle.hv_battery_max_temperature + if tmax is not None: + battery["temperature_max"] = _phys(round(tmax, 1), "degC") + except Exception: + pass + try: + tmin = vehicle.hv_battery_min_temperature + if tmin is not None: + battery["temperature_min"] = _phys(round(tmin, 1), "degC") + except Exception: + pass + if battery: + result["battery"] = battery + + try: + odo = vehicle.distance + if odo is not None: + result["odometer"] = {"odometer": _phys(odo, "km")} except Exception: log.debug("odometer unavailable", exc_info=True) @@ -143,32 +179,43 @@ def extract_connectivity(vehicle) -> dict | None: result: dict = {} try: - state = str(vehicle.connection_state) - result["state"] = state + is_online = vehicle.connection_state_is_online + is_active = vehicle.connection_state_is_active + if is_online: + result["state"] = "online" + elif is_active: + result["state"] = "active" + else: + result["state"] = "offline" except Exception: log.debug("connectivity.state unavailable", exc_info=True) return result or None + def extract_vehicle(vehicle) -> dict | None: result: dict = {} try: - state = str(vehicle.state) - result["state"] = state + overall = find_path(vehicle.attrs, Paths.ACCESS_OVERALL_STATUS) + if overall is not None: + result["state"] = overall except Exception: log.debug("vehicle.state unavailable", exc_info=True) return result or None + def extract_position(vehicle) -> dict | None: result: dict = {} try: pos = vehicle.position - result["lat"] = pos.latitude.value - result["lon"] = pos.longitude.value - + lat = pos.get("lat") + lng = pos.get("lng") + if lat is not None and lat != "?" and lng is not None and lng != "?": + result["lat"] = lat + result["lon"] = lng # volkswagencarnet uses "lng" internally; we keep "lon" for compat except Exception: log.debug("position unavailable", exc_info=True) @@ -179,20 +226,29 @@ def extract_doors(vehicle) -> dict | None: result: dict = {} try: - doors = vehicle.doors - if doors.lock_state.value is not None: - result["lock_state"] = _str(doors.lock_state) - if doors.open_state.value is not None: - result["open_state"] = _str(doors.open_state) - door_entries = {} - for name, door in doors.doors.items(): - entry = {} - if door.lock_state.value is not None: - entry["lock_state"] = _str(door.lock_state) - if door.open_state.value is not None: - entry["open_state"] = _str(door.open_state) + lock_state = find_path(vehicle.attrs, Paths.ACCESS_DOOR_LOCK) + if lock_state is not None: + result["lock_state"] = lock_state + + doors_list = find_path(vehicle.attrs, Paths.ACCESS_DOORS) or [] + door_entries: dict = {} + all_closed: bool | None = None + for door in doors_list: + name = door.get("name") + status = door.get("status") or [] + if not name: + continue + entry: dict = {} + if "closed" in status or "open" in status: + open_state = "closed" if "closed" in status else "open" + entry["open_state"] = open_state + all_closed = open_state == "closed" if all_closed is None else (all_closed and open_state == "closed") + if "locked" in status or "unlocked" in status: + entry["lock_state"] = "locked" if "locked" in status else "unlocked" if entry: door_entries[name] = entry + if all_closed is not None: + result["open_state"] = "closed" if all_closed else "open" if door_entries: result["doors"] = door_entries except Exception: @@ -205,12 +261,20 @@ def extract_windows(vehicle) -> dict | None: result: dict = {} try: - if vehicle.windows.open_state.value is not None: - result["open_state"] = _str(vehicle.windows.open_state) - window_entries = {} - for name, win in vehicle.windows.windows.items(): - if win.open_state.value is not None: - window_entries[name] = {"open_state": _str(win.open_state)} + windows_list = find_path(vehicle.attrs, Paths.ACCESS_WINDOWS) or [] + window_entries: dict = {} + all_closed: bool | None = None + for win in windows_list: + name = win.get("name") + status = win.get("status") or [] + if not name or "unsupported" in status: + continue + if "closed" in status or "open" in status: + open_state = "closed" if "closed" in status else "open" + window_entries[name] = {"open_state": open_state} + all_closed = open_state == "closed" if all_closed is None else (all_closed and open_state == "closed") + if all_closed is not None: + result["open_state"] = "closed" if all_closed else "open" if window_entries: result["windows"] = window_entries except Exception: @@ -278,4 +342,4 @@ def apply_procedural(record: dict) -> dict: if proc: record["procedural"] = proc - return record \ No newline at end of file + return record diff --git a/server/requirements.txt b/server/requirements.txt index b7423e6..2f70d0e 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -1,3 +1,2 @@ -carconnectivity -carconnectivity-connector-volkswagen +volkswagencarnet @ git+file:///home/jens/work/repos/volkswagencarnet jaydiff @ git+http://192.168.22.90:3001/jayfield/jaypy.git diff --git a/server/we_connect.py b/server/we_connect.py index 453e445..bda75de 100644 --- a/server/we_connect.py +++ b/server/we_connect.py @@ -1,52 +1,64 @@ +import asyncio import logging -import sys -try: - from carconnectivity.carconnectivity import CarConnectivity -except ImportError: - print( - "carconnectivity is not installed. Run: " - "pip install carconnectivity carconnectivity-connector-volkswagen", - file=sys.stderr, - ) - sys.exit(1) +import aiohttp +from volkswagencarnet.vw_connection import Connection +from volkswagencarnet.vw_exceptions import AuthenticationError # noqa: F401 — re-exported for collect.py log = logging.getLogger(__name__) -def connect(username: str, password: str) -> CarConnectivity: - config = { - "carConnectivity": { - "connectors": [ - { - "type": "volkswagen", - "config": { - "username": username, - "password": password, - }, - } - ] - } - } - cc = CarConnectivity(config=config) - cc.startup() - cc.fetch_all() - return cc +class _Session: + """Holds the asyncio event loop, aiohttp session, and Connection together.""" + + def __init__( + self, + loop: asyncio.AbstractEventLoop, + session: aiohttp.ClientSession, + connection: Connection, + ) -> None: + self._loop = loop + self._http_session = session + self.connection = connection + + def run(self, coro): + return self._loop.run_until_complete(coro) + + @property + def vehicles(self): + return self.connection.vehicles -def select_vehicle(cc: CarConnectivity, vin: str | None): +def connect(username: str, password: str) -> _Session: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + async def _do(): + session = aiohttp.ClientSession(headers={"Connection": "keep-alive"}) + conn = Connection(session, username, password) + ok = await conn.doLogin() + if not ok: + await session.close() + raise AuthenticationError("Login failed") + return session, conn + + session, conn = loop.run_until_complete(_do()) + return _Session(loop, session, conn) + + +def select_vehicle(wc: _Session, vin: str | None): """Return (vehicle, error_message). error_message is None on success.""" - vehicles = list(cc.get_garage().list_vehicles()) + vehicles = wc.vehicles if not vehicles: - return None, "No vehicles found in this CarConnectivity account" + return None, "No vehicles found in this account" if vin: - vehicle = next((v for v in vehicles if v.vin.value == vin), None) + vehicle = next((v for v in vehicles if v.vin == vin), None) if not vehicle: - available = [v.vin.value for v in vehicles] - return None, f"VIN {vin} not found. Available: {available}" + available = [v.vin for v in vehicles] + return None, f"VIN {vin} not found. Available: {available}" return vehicle, None return vehicles[0], None -def update(cc: CarConnectivity) -> None: - cc.fetch_all() \ No newline at end of file +def update(wc: _Session) -> None: + wc.run(wc.connection.update())