Files
we_monitor/server/data_model.py
T
jensandClaude Sonnet 4.6 f050de7001 collect: migrate from weconnect to CarConnectivity + VW connector
Replace the weconnect library with carconnectivity and
carconnectivity-connector-volkswagen.  The connector is configured via
a dict passed to CarConnectivity(); startup()/fetch_all() replace
login()/update().  All domain extractors in data_model.py are rewritten
for the new object model (vehicle.charging.*, vehicle.get_electric_drive(),
vehicle.climatization.*, etc.).  Remaining time fields are now derived
from estimated_date_reached DateAttributes.  Battery temperatures are
read directly in °C (no Kelvin conversion).  Auth error handling drops
the manual re-login since the connector manages token refresh internally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:02:34 +02:00

270 lines
9.0 KiB
Python

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