import logging from datetime import datetime, timezone log = logging.getLogger(__name__) # ── value helpers ───────────────────────────────────────────────────────────── 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} # ── 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, } 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 # ── procedural fields ───────────────────────────────────────────────────────── def _get(data: dict, *keys): """Navigate nested dicts safely; returns None if any key is missing.""" for k in keys: if not isinstance(data, dict): return None data = data.get(k) return data def apply_procedural(record: dict) -> dict: """Compute derived fields and store them under record['procedural']. Called for every new snapshot before storage and broadcast, and for historical records that lack the 'procedural' key before they are sent to clients. Add computed fields to the proc dict below. """ proc: dict = {} range_at_soc = _get(record, "measurements", "rangeStatus", "totalRange", "value") soc = _get(record, "charging", "batteryStatus", "currentSOC", "value") if range_at_soc is not None and soc: proc["range_at_100"] = _phys(round(100 * float(range_at_soc) / float(soc), 1), "km") # ── add computed fields here ────────────────────────────────────────── # Use _get(record, "domain", "statusObject", "field", "value") to # safely read any nested value. Always use {"value": ..., "unit": ...} # format so the GUI picks up the field automatically. # # Example — usable energy estimated from SOC and battery capacity: # soc = _get(record, "charging", "batteryStatus", "currentSOC", "value") # if soc is not None: # proc["energy_stored"] = {"value": round(soc / 100 * 77.0, 1), "unit": "kWh"} # ───────────────────────────────────────────────────────────────────── if proc: record["procedural"] = proc return record