Files
we_monitor/server/data_model.py
T
jensandClaude Sonnet 4.6 cdc01aaa76 data_model: implement range_at_100 procedural field
Compute estimated full-charge range from totalRange and current SOC.
Guard against None values (_get returns None for missing keys) and
SOC = 0 to avoid TypeError / ZeroDivisionError on incomplete records.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:49:12 +02:00

287 lines
9.3 KiB
Python

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