import logging from datetime import datetime, timezone 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 status: dict = { "chargingState": _str(ch.state), "chargeType": _str(ch.type), } try: status["chargePower"] = _phys(ch.power.value, "kW") except Exception: pass try: rem = _remaining_min(ch.estimated_date_reached) if rem is not None: status["remainingChargingTimeToComplete"] = _phys(round(rem), "min") except Exception: pass result["chargingStatus"] = status except Exception: log.debug("chargingStatus unavailable", exc_info=True) try: ed = vehicle.get_electric_drive() result["batteryStatus"] = { "currentSOC": _phys(ed.level.value, "%"), "cruisingRangeElectric": _phys(ed.range.value, "km"), } except Exception: log.debug("charging/batteryStatus unavailable", exc_info=True) try: cfg = vehicle.charging.settings result["chargingSettings"] = { "targetSOC": _phys(cfg.target_level.value, "%"), "maxChargeCurrentAC": _phys(cfg.maximum_current.value, "A"), "autoUnlockPlugWhenCharged": _str(cfg.auto_unlock), } except Exception: log.debug("chargingSettings unavailable", exc_info=True) try: conn = vehicle.charging.connector result["plugStatus"] = { "plugConnectionState": _str(conn.connection_state), "plugLockState": _str(conn.lock_state), "externalPower": _str(conn.external_power), } except Exception: log.debug("plugStatus unavailable", exc_info=True) return result or None def extract_climatisation(vehicle) -> dict | None: result: dict = {} try: cl = vehicle.climatization status: dict = {"climatisationState": _str(cl.state)} try: rem = _remaining_min(cl.estimated_date_reached) if rem is not None: status["remainingClimatisationTime"] = _phys(round(rem), "min") except Exception: pass result["climatisationStatus"] = status except Exception: log.debug("climatisationStatus unavailable", exc_info=True) try: cs = vehicle.climatization.settings result["climatisationSettings"] = { "targetTemperature": _phys(cs.target_temperature.value, "degC"), } except Exception: log.debug("climatisationSettings unavailable", exc_info=True) try: wh = vehicle.window_heatings result["windowHeatingStatus"] = { name: _str(win.heating_state) for name, win in wh.heatings.items() } except Exception: log.debug("windowHeatingStatus unavailable", exc_info=True) return result or None def extract_measurements(vehicle) -> dict | None: result: dict = {} try: result["odometerStatus"] = {"odometer": _phys(vehicle.odometer.value, "km")} except Exception: log.debug("odometerStatus unavailable", exc_info=True) try: ed = vehicle.get_electric_drive() electric_range = ed.range.value result["rangeStatus"] = { "electricRange": _phys(electric_range, "km"), "totalRange": _phys(electric_range, "km"), } except Exception: log.debug("rangeStatus unavailable", exc_info=True) try: bat = vehicle.get_electric_drive().battery result["temperatureBatteryStatus"] = { "temperatureHvBatteryMax": _phys(bat.temperature_max.value, "degC"), "temperatureHvBatteryMin": _phys(bat.temperature_min.value, "degC"), } except Exception: log.debug("measurements/temperatureBatteryStatus unavailable", exc_info=True) try: result["temperatureOutsideStatus"] = { "temperatureOutside": _phys(vehicle.outside_temperature.value, "degC"), } except Exception: log.debug("temperatureOutsideStatus unavailable", exc_info=True) return result or None def extract_readiness(vehicle) -> dict | None: result: dict = {} try: conn_state = str(vehicle.connection_state) result["readinessStatus"] = { "connectionState": { "isOnline": conn_state == "online", "isActive": conn_state in ("online", "reachable"), }, } except Exception: log.debug("readinessStatus unavailable", exc_info=True) return result or None def extract_parking(vehicle) -> dict | None: result: dict = {} try: pos = vehicle.position result["parkingPosition"] = { "lat": pos.latitude.value, "lon": pos.longitude.value, } except Exception: log.debug("parkingPosition unavailable", exc_info=True) return result or None def extract_access(vehicle) -> dict | None: result: dict = {} try: doors = vehicle.doors result["accessStatus"] = { "overallStatus": _str(doors.lock_state), "doors": { name: { "lockState": _str(door.lock_state), "openState": _str(door.open_state), } for name, door in doors.doors.items() }, "windows": { name: {"openState": _str(win.open_state)} for name, win in vehicle.windows.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