172db54e7a
- Statistik-Tab-Icon per zentrierter Stauchung an die Fuellflaeche der anderen Tab-Icons angeglichen (Panel + companion-app), auf Nutzerwunsch anschliessend 10% groesser. - Batteriediagramm neu gezeichnet: dynamische viewBox (1 Einheit = 1 Pixel, kein preserveAspectRatio-Verzerren mehr), ein Punkt je Ruhespannungsmessung statt Datumsbeschriftung, Tageshoechstwert nur noch im Tooltip. Generator- spannungen (> AGM_RUHE_MAX_V = 13,0 V, vom Nutzer festgelegt) bleiben aus dem Diagramm aussen vor; der SOC-Kurve-Eckwert "100% / voll" bleibt bei den ursprünglich recherchierten 12,8 V. companion-app auf dieselbe lineare SOC-Interpolation umgestellt (vorher eine abweichende Stufenkurve). - Außentemperatur wird jetzt auch beim Batterieverlauf-Import nachgetragen (vorher nur live) - Kopplung an die naechstgelegene Messung innerhalb NEBENWERT_MAX_ABSTAND_S (verlauf.py), vom Nutzer nach Live-Daten-Analyse auf 300s festgelegt. Merge-Logik in ablage.py ergaenzt: ein erneuter Import kann eine zuvor fehlende Temperatur jetzt tatsaechlich nachtragen, auch wenn sich der Tagesminimalwert selbst nicht aendert. - "Mein Audi"/Zustand zeigt bei unplausibler Live-Messung (z. B. Dongle offline) die zuletzt gemessene Spannung statt "unbekannt". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
447 lines
19 KiB
Python
447 lines
19 KiB
Python
"""Datenzugriff für Fahrzeugprofil, Fahrten, Tankvorgänge und Batterieverlauf.
|
|
|
|
Drei getrennte Bestände, wie in SPECIFICATION.md §6.1 festgelegt:
|
|
- Fahrzeugprofil: eine JSON-Datei, alles Fahrzeugspezifische
|
|
- Fahrten: JSON Lines, eine Zeile je Fahrt
|
|
- Tankvorgänge: JSON Lines
|
|
- Batterieverlauf: JSON Lines, ein Eintrag je Tag
|
|
|
|
Dateiformat und Ordnername sind identisch mit der pyscript-Fassung
|
|
(/config/audi_dashboard/). Eine bestehende Installation läuft damit nach dem
|
|
Umstieg einfach weiter - es gibt keine Datenmigration, und deshalb auch
|
|
keinen Weg, bei der Migration etwas zu verlieren.
|
|
|
|
ZWEI DINGE, DIE HIER ANDERS SIND ALS IN DER PYSCRIPT-FASSUNG
|
|
------------------------------------------------------------
|
|
1. Ganz normales `open()`. Die dortige Verrenkung `task.executor(io.open, ...)`
|
|
war eine reine pyscript-Eigenheit (das eingebaute open() existiert dort
|
|
nicht). Hier läuft jede Dateioperation stattdessen über
|
|
`hass.async_add_executor_job` - ein Datei-Zugriff hat im Event-Loop nichts
|
|
verloren, und diese Klasse ist die einzige Stelle, die das kapselt: alle
|
|
öffentlichen Methoden sind `async` und schalten selbst in den Executor.
|
|
|
|
2. Eine Sperre um die verändernden Zugriffe. Fast jede Änderung ist ein
|
|
Lesen-Ändern-Schreiben über den kompletten Bestand (eine Fahrt ändern
|
|
heißt: alle Fahrten lesen, eine anfassen, alle zurückschreiben). Liefen
|
|
zwei davon verschränkt, gewänne die zuletzt schreibende und die andere
|
|
Änderung wäre spurlos weg. In der pyscript-Fassung war das eine offene
|
|
Flanke; hier kostet die Absicherung eine `asyncio.Lock`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import uuid
|
|
from collections.abc import Callable
|
|
from typing import Any
|
|
|
|
from homeassistant.core import HomeAssistant
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
class Ablage:
|
|
"""Alle Dateizugriffe der App an einer Stelle."""
|
|
|
|
def __init__(self, hass: HomeAssistant, basis: str) -> None:
|
|
self._hass = hass
|
|
self.basis = basis
|
|
self.profil_pfad = os.path.join(basis, "fahrzeugprofil.json")
|
|
self.fahrten_pfad = os.path.join(basis, "fahrten.jsonl")
|
|
self.tankvorgaenge_pfad = os.path.join(basis, "tankvorgaenge.jsonl")
|
|
self.batterieverlauf_pfad = os.path.join(basis, "batteriespannung.jsonl")
|
|
self.zuordnung_pfad = os.path.join(basis, "entitaeten.json")
|
|
self.belege_ordner = os.path.join(basis, "belege")
|
|
self.backup_ordner = os.path.join(basis, "backups")
|
|
self._sperre = asyncio.Lock()
|
|
|
|
async def _im_executor(self, funktion: Callable, *args: Any) -> Any:
|
|
return await self._hass.async_add_executor_job(funktion, *args)
|
|
|
|
# ------------------------------------------------------------- Ordner
|
|
|
|
def _ordner_anlegen(self) -> None:
|
|
os.makedirs(self.basis, exist_ok=True)
|
|
os.makedirs(self.belege_ordner, exist_ok=True)
|
|
|
|
async def ordner_sicherstellen(self) -> None:
|
|
await self._im_executor(self._ordner_anlegen)
|
|
|
|
def _vorlage_anlegen(self, vorlage_pfad: str) -> bool:
|
|
if os.path.exists(self.profil_pfad):
|
|
return False
|
|
with open(vorlage_pfad, encoding="utf-8") as datei:
|
|
inhalt = datei.read()
|
|
self._text_schreiben(self.profil_pfad, inhalt)
|
|
return True
|
|
|
|
async def vorlage_anlegen(self, vorlage_pfad: str) -> bool:
|
|
"""Legt bei der allerersten Einrichtung ein Fahrzeugprofil aus der
|
|
mitgelieferten Vorlage an. True, wenn dabei wirklich etwas entstanden
|
|
ist.
|
|
|
|
Vorher war das ein eigener Installationsschritt ("die Datei aus data/
|
|
nach /config/audi_dashboard/ kopieren"). Wurde er vergessen, startete
|
|
die App in einen Zustand, den man von einem Fehler nicht unterscheiden
|
|
konnte: das Panel blieb leer, im Protokoll stand nur eine Zeile über
|
|
eine fehlende Datei.
|
|
|
|
Ein bereits vorhandenes Profil wird nie angefasst - deshalb die
|
|
Existenzprüfung im selben Executor-Aufruf wie das Schreiben, nicht
|
|
davor: sonst läge zwischen Prüfen und Schreiben ein Zeitfenster."""
|
|
return await self._im_executor(self._vorlage_anlegen, vorlage_pfad)
|
|
|
|
# ------------------------------------------------------ Rohe Dateiarbeit
|
|
|
|
@staticmethod
|
|
def _text_lesen(pfad: str) -> str | None:
|
|
if not os.path.exists(pfad):
|
|
return None
|
|
with open(pfad, encoding="utf-8") as datei:
|
|
return datei.read()
|
|
|
|
@staticmethod
|
|
def _text_schreiben(pfad: str, text: str) -> None:
|
|
"""Atomar: erst in eine .tmp-Datei, dann umbenennen.
|
|
|
|
os.replace ist auf allen unterstützten Systemen atomar. Damit kann ein
|
|
Absturz mitten im Schreiben keine halb geschriebene Datei hinterlassen
|
|
- der alte Stand bleibt vollständig, bis der neue vollständig da ist."""
|
|
os.makedirs(os.path.dirname(pfad), exist_ok=True)
|
|
tmp = pfad + ".tmp"
|
|
with open(tmp, "w", encoding="utf-8") as datei:
|
|
datei.write(text)
|
|
os.replace(tmp, pfad)
|
|
|
|
@staticmethod
|
|
def _zeilen_aus_text(text: str | None, pfad: str) -> list[dict]:
|
|
if not text:
|
|
return []
|
|
datensaetze: list[dict] = []
|
|
for nummer, zeile in enumerate(text.splitlines(), start=1):
|
|
zeile = zeile.strip()
|
|
if not zeile:
|
|
continue
|
|
try:
|
|
datensaetze.append(json.loads(zeile))
|
|
except ValueError as fehler:
|
|
# Eine kaputte Zeile darf nicht den ganzen Bestand mitreißen:
|
|
# der Rest ist gültig und die App bleibt benutzbar.
|
|
_LOGGER.error(
|
|
"%s Zeile %s ist kein gültiges JSON (%s) - Zeile übersprungen",
|
|
pfad, nummer, fehler,
|
|
)
|
|
return datensaetze
|
|
|
|
@staticmethod
|
|
def _text_aus_zeilen(datensaetze: list[dict]) -> str:
|
|
zeilen = [json.dumps(d, ensure_ascii=False) for d in datensaetze]
|
|
return "\n".join(zeilen) + ("\n" if zeilen else "")
|
|
|
|
# ------------------------------------------------------- Fahrzeugprofil
|
|
|
|
def _profil_lesen(self) -> dict | None:
|
|
text = self._text_lesen(self.profil_pfad)
|
|
if text is None:
|
|
_LOGGER.error(
|
|
"%s fehlt - bis dahin bleiben alle Funktionen aus, die das Profil brauchen",
|
|
self.profil_pfad,
|
|
)
|
|
return None
|
|
try:
|
|
return json.loads(text)
|
|
except ValueError as fehler:
|
|
_LOGGER.error(
|
|
"%s ist kein gültiges JSON (%s). Letztes Backup aus %s zurückspielen.",
|
|
self.profil_pfad, fehler, self.backup_ordner,
|
|
)
|
|
return None
|
|
|
|
async def profil_lesen(self) -> dict | None:
|
|
"""Das Fahrzeugprofil, oder None wenn es fehlt bzw. beschädigt ist.
|
|
|
|
Jeder Aufrufer muss den None-Fall abfangen: ohne diese Prüfung reißt
|
|
eine fehlende Datei jeden Zeittakt und jeden Dienst mit, der das
|
|
Profil braucht - bei laufenden Zeittriggern also im Minutentakt."""
|
|
return await self._im_executor(self._profil_lesen)
|
|
|
|
async def profil_schreiben(self, profil: dict) -> None:
|
|
async with self._sperre:
|
|
await self._im_executor(
|
|
self._text_schreiben,
|
|
self.profil_pfad,
|
|
json.dumps(profil, ensure_ascii=False, indent=2),
|
|
)
|
|
|
|
# -------------------------------------------------------------- Fahrten
|
|
|
|
async def fahrten_lesen(self) -> list[dict]:
|
|
text = await self._im_executor(self._text_lesen, self.fahrten_pfad)
|
|
return self._zeilen_aus_text(text, self.fahrten_pfad)
|
|
|
|
async def fahrten_schreiben(self, fahrten: list[dict]) -> None:
|
|
await self._im_executor(
|
|
self._text_schreiben, self.fahrten_pfad, self._text_aus_zeilen(fahrten)
|
|
)
|
|
|
|
async def fahrt_anhaengen(self, fahrt: dict) -> None:
|
|
async with self._sperre:
|
|
fahrten = await self.fahrten_lesen()
|
|
fahrten.append(fahrt)
|
|
await self.fahrten_schreiben(fahrten)
|
|
|
|
async def fahrten_ergaenzen(self, neue: list[dict]) -> None:
|
|
"""Mehrere Fahrten in einem Rutsch, nach Startzeit sortiert.
|
|
|
|
Für den Historienimport: bei einem Jahr Verlauf wären das sonst
|
|
hunderte einzelne Schreibvorgänge."""
|
|
if not neue:
|
|
return
|
|
async with self._sperre:
|
|
alle = await self.fahrten_lesen()
|
|
alle.extend(neue)
|
|
alle.sort(key=lambda f: f.get("ts_start") or "")
|
|
await self.fahrten_schreiben(alle)
|
|
|
|
@staticmethod
|
|
def _datensatz_aendern(
|
|
zeilen: list[dict], id_feld: str, id_wert: str, aenderungen: dict, schutz: bool
|
|
) -> bool:
|
|
"""Ersetzt ausgewählte Felder eines Datensatzes anhand seiner ID.
|
|
|
|
Manuell geänderte Felder (edited_fields) werden dabei nie
|
|
überschrieben. schutz=False hebt genau diese Sperre auf - nötig für
|
|
Eingaben aus der Oberfläche: edited_fields schützt gegen die
|
|
automatische Ergänzung, nicht gegen den Menschen, der das Feld gerade
|
|
selbst korrigiert."""
|
|
for d in zeilen:
|
|
if d.get(id_feld) != id_wert:
|
|
continue
|
|
geschuetzt = set(d.get("edited_fields", [])) if schutz else set()
|
|
for feld, wert in aenderungen.items():
|
|
if feld not in geschuetzt:
|
|
d[feld] = wert
|
|
return True
|
|
return False
|
|
|
|
async def fahrt_aktualisieren(self, trip_id: str, aenderungen: dict) -> bool:
|
|
async with self._sperre:
|
|
fahrten = await self.fahrten_lesen()
|
|
if not self._datensatz_aendern(fahrten, "trip_id", trip_id, aenderungen, True):
|
|
return False
|
|
await self.fahrten_schreiben(fahrten)
|
|
return True
|
|
|
|
async def fahrt_bearbeiten(self, trip_id: str, aenderungen: dict) -> bool:
|
|
"""Wie fahrt_aktualisieren(), aber für Eingaben aus der Oberfläche:
|
|
eine von Hand gesetzte Angabe sticht auch dann, wenn dasselbe Feld
|
|
schon einmal von Hand gesetzt wurde."""
|
|
async with self._sperre:
|
|
fahrten = await self.fahrten_lesen()
|
|
if not self._datensatz_aendern(fahrten, "trip_id", trip_id, aenderungen, False):
|
|
return False
|
|
await self.fahrten_schreiben(fahrten)
|
|
return True
|
|
|
|
async def fahrt_loeschen(self, trip_id: str) -> bool:
|
|
async with self._sperre:
|
|
fahrten = await self.fahrten_lesen()
|
|
uebrig = [f for f in fahrten if f.get("trip_id") != trip_id]
|
|
if len(uebrig) == len(fahrten):
|
|
return False
|
|
await self.fahrten_schreiben(uebrig)
|
|
return True
|
|
|
|
# --------------------------------------------------------- Tankvorgänge
|
|
|
|
async def tankvorgaenge_lesen(self) -> list[dict]:
|
|
text = await self._im_executor(self._text_lesen, self.tankvorgaenge_pfad)
|
|
return self._zeilen_aus_text(text, self.tankvorgaenge_pfad)
|
|
|
|
async def tankvorgaenge_schreiben(self, tankvorgaenge: list[dict]) -> None:
|
|
await self._im_executor(
|
|
self._text_schreiben,
|
|
self.tankvorgaenge_pfad,
|
|
self._text_aus_zeilen(tankvorgaenge),
|
|
)
|
|
|
|
async def tankvorgang_anhaengen(self, tankvorgang: dict) -> None:
|
|
async with self._sperre:
|
|
alle = await self.tankvorgaenge_lesen()
|
|
alle.append(tankvorgang)
|
|
await self.tankvorgaenge_schreiben(alle)
|
|
|
|
async def tankvorgaenge_ergaenzen(self, neue: list[dict]) -> None:
|
|
if not neue:
|
|
return
|
|
async with self._sperre:
|
|
alle = await self.tankvorgaenge_lesen()
|
|
alle.extend(neue)
|
|
alle.sort(key=lambda t: t.get("ts") or "")
|
|
await self.tankvorgaenge_schreiben(alle)
|
|
|
|
async def tankvorgang_aktualisieren(self, tank_id: str, aenderungen: dict) -> bool:
|
|
async with self._sperre:
|
|
alle = await self.tankvorgaenge_lesen()
|
|
if not self._datensatz_aendern(alle, "tank_id", tank_id, aenderungen, True):
|
|
return False
|
|
await self.tankvorgaenge_schreiben(alle)
|
|
return True
|
|
|
|
async def tankvorgang_loeschen(self, tank_id: str) -> bool:
|
|
async with self._sperre:
|
|
alle = await self.tankvorgaenge_lesen()
|
|
uebrig = [t for t in alle if t.get("tank_id") != tank_id]
|
|
if len(uebrig) == len(alle):
|
|
return False
|
|
await self.tankvorgaenge_schreiben(uebrig)
|
|
return True
|
|
|
|
async def tankvorgang_nach_id(self, tank_id: str) -> dict | None:
|
|
for t in await self.tankvorgaenge_lesen():
|
|
if t.get("tank_id") == tank_id:
|
|
return t
|
|
return None
|
|
|
|
async def tankvorgang_nach_receipt_key(self, receipt_key: str) -> dict | None:
|
|
"""Derselbe Beleg (receipt_key, minutengenau) darf keinen zweiten
|
|
Datensatz erzeugen (§7.7 Regel 3)."""
|
|
for t in await self.tankvorgaenge_lesen():
|
|
if t.get("receipt_key") == receipt_key:
|
|
return t
|
|
return None
|
|
|
|
async def distanz_seit_letzter_tankung(self, aktueller_km: float | None) -> float | None:
|
|
"""Gefahrene Distanz seit dem vorherigen Tankvorgang, als Vorschlag
|
|
für das gleichnamige Formularfeld - frei überschreibbar, genau wie
|
|
odometer_km selbst. None, wenn kein Kilometerstand oder kein
|
|
vorheriger Tankvorgang vorliegt (erster Eintrag überhaupt)."""
|
|
if aktueller_km is None:
|
|
return None
|
|
alle = await self.tankvorgaenge_lesen()
|
|
if not alle:
|
|
return None
|
|
letzter = max(alle, key=lambda t: t.get("ts") or "")
|
|
if letzter.get("odometer_km") is None:
|
|
return None
|
|
return round(aktueller_km - letzter["odometer_km"], 1)
|
|
|
|
# ------------------------------------------------------ Batteriespannung
|
|
|
|
async def batterieverlauf_lesen(self) -> list[dict]:
|
|
"""Ein Eintrag pro Tag ({datum, min, min_ts, max, max_ts,
|
|
min_temp_c}), älteste zuerst. min_ts/max_ts sind die Zeitstempel
|
|
(ISO, UTC) der jeweiligen Einzelmessung, für die Datum/Uhrzeit-Anzeige
|
|
beim Antippen des Diagrammpunkts - der Punkt selbst zeigt nur den
|
|
Minimalwert. min_temp_c ist die Außentemperatur zum Zeitpunkt genau
|
|
dieser Minimalmessung (None ohne zugeordneten Temperatursensor oder
|
|
bei älteren, vor dessen Einführung geschriebenen Einträgen)."""
|
|
text = await self._im_executor(self._text_lesen, self.batterieverlauf_pfad)
|
|
return self._zeilen_aus_text(text, self.batterieverlauf_pfad)
|
|
|
|
async def batterieverlauf_tageswert_aktualisieren(
|
|
self, datum: str, ts: str, spannung: float, aussentemp: float | None = None
|
|
) -> None:
|
|
"""Trägt eine neue Messung in den Tageseintrag für `datum` ein: legt
|
|
ihn beim ersten Wert des Tages an, erweitert sonst nur min/max samt
|
|
dem Zeitstempel der jeweils neuen Extremmessung. min_temp_c wird nur
|
|
mitgeschrieben, wenn diese Messung auch den Minimalwert setzt - sie
|
|
gilt für genau diesen Zeitpunkt, nicht für den Tag insgesamt.
|
|
|
|
Sonderfall, ohne den ein erneuter Import nichts nachliefern könnte:
|
|
meldet ein späterer Aufruf exakt denselben (bereits gespeicherten)
|
|
Minimalwert erneut, aber mit einer jetzt verfügbaren Temperatur, wo
|
|
vorher keine lag - z. B. weil NEBENWERT_MAX_ABSTAND_S seither
|
|
aufgeweitet wurde und die damals zu weit entfernte Temperaturmessung
|
|
jetzt innerhalb der Grenze liegt -, wird min_temp_c nachträglich
|
|
gefüllt. `spannung < eintrag["min"]` allein hätte das nie erlaubt:
|
|
derselbe Wert ist nie *kleiner* als sich selbst, also griff die
|
|
Aktualisierung nur beim allerersten Mal."""
|
|
async with self._sperre:
|
|
verlauf = await self.batterieverlauf_lesen()
|
|
for eintrag in verlauf:
|
|
if eintrag.get("datum") != datum:
|
|
continue
|
|
if spannung < eintrag["min"]:
|
|
eintrag["min"] = spannung
|
|
eintrag["min_ts"] = ts
|
|
eintrag["min_temp_c"] = aussentemp
|
|
elif spannung == eintrag["min"] and eintrag.get("min_temp_c") is None and aussentemp is not None:
|
|
eintrag["min_temp_c"] = aussentemp
|
|
if spannung > eintrag["max"]:
|
|
eintrag["max"] = spannung
|
|
eintrag["max_ts"] = ts
|
|
break
|
|
else:
|
|
verlauf.append({
|
|
"datum": datum, "min": spannung, "min_ts": ts,
|
|
"max": spannung, "max_ts": ts, "min_temp_c": aussentemp,
|
|
})
|
|
# Nach Datum sortiert schreiben, nicht in Einfügereihenfolge.
|
|
# Solange nur die Live-Aufzeichnung schrieb, war beides dasselbe
|
|
# (sie trägt immer den heutigen Tag ein). Der nachträgliche Import
|
|
# trägt dagegen vergangene Tage ein - ohne diese Zeile stünden sie
|
|
# hinter den neueren, und das Diagramm im Frontend, das die Datei
|
|
# in Dateireihenfolge zeichnet, liefe zeitlich rückwärts.
|
|
verlauf.sort(key=lambda e: e.get("datum") or "")
|
|
await self._im_executor(
|
|
self._text_schreiben,
|
|
self.batterieverlauf_pfad,
|
|
self._text_aus_zeilen(verlauf),
|
|
)
|
|
|
|
async def batterieverlauf_eintrag_loeschen(self, datum: str) -> bool:
|
|
async with self._sperre:
|
|
verlauf = await self.batterieverlauf_lesen()
|
|
uebrig = [e for e in verlauf if e.get("datum") != datum]
|
|
if len(uebrig) == len(verlauf):
|
|
return False
|
|
await self._im_executor(
|
|
self._text_schreiben,
|
|
self.batterieverlauf_pfad,
|
|
self._text_aus_zeilen(uebrig),
|
|
)
|
|
return True
|
|
|
|
# ---------------------------------------------------- Sensor-Zuordnung
|
|
|
|
async def zuordnung_lesen(self) -> dict:
|
|
"""Die im Setup-Menü gespeicherten Zuordnungen, oder {} wenn die
|
|
Datei fehlt bzw. beschädigt ist.
|
|
|
|
Der Fehlerfall ist nicht Vorsicht um ihrer selbst willen: das Anwenden
|
|
läuft beim Start VOR der ersten Veröffentlichung. Ohne die Absicherung
|
|
reißt eine einzige unlesbare Zeile den gesamten Startvorgang mit - das
|
|
Panel bliebe komplett leer, ohne dass irgendetwas auf die Ursache
|
|
hindeutet."""
|
|
text = await self._im_executor(self._text_lesen, self.zuordnung_pfad)
|
|
if not text or not text.strip():
|
|
return {}
|
|
try:
|
|
gelesen = json.loads(text)
|
|
except ValueError as fehler:
|
|
_LOGGER.error(
|
|
"%s ist kein gültiges JSON (%s). Die eingebauten Standardwerte gelten "
|
|
"weiter; die Zuordnung lässt sich im Setup-Menü neu speichern.",
|
|
self.zuordnung_pfad, fehler,
|
|
)
|
|
return {}
|
|
if not isinstance(gelesen, dict):
|
|
_LOGGER.error("%s enthält kein Objekt - wird ignoriert.", self.zuordnung_pfad)
|
|
return {}
|
|
return gelesen
|
|
|
|
async def zuordnung_schreiben(self, mapping: dict) -> None:
|
|
await self._im_executor(
|
|
self._text_schreiben,
|
|
self.zuordnung_pfad,
|
|
json.dumps(mapping, ensure_ascii=False, indent=2),
|
|
)
|
|
|
|
|
|
def neue_id(praefix: str) -> str:
|
|
return f"{praefix}-{uuid.uuid4().hex[:12]}"
|