pyscript-Backend zur echten HA-Integration umgebaut (HACS-fähig)
Das Backend liegt jetzt als custom_components/audi_dashboard/ vor - eine normale Home-Assistant-Integration mit Config-Flow, einer sensor-Plattform und 18 Diensten. Damit ist die App über HACS installierbar; bis das Repo auf GitHub gespiegelt ist (HACS spricht ausschließlich mit GitHub), installiert homeassistant/installationspaket/install.ps1 denselben Ordner ohne HACS. Fünf Installationsschritte entfallen ersatzlos: der pyscript:-Block, der panel_custom:-Block, das Kopieren der Oberfläche nach www/, das langlebige Zugriffstoken (der Verlauf wird direkt über die recorder-API gelesen) und "pip install pypdf" (steht in manifest.json). Das Fahrzeugprofil legt die Integration beim ersten Start aus ihrer Vorlage an. Drei alte Schwächen sind dabei mit erledigt: - Die Nutzlast landet nicht mehr in der Recorder-Datenbank (_unrecorded_attributes - das kann nur eine echte Entität). - Eine laufende Fahrt überlebt einen Neustart (Store statt Arbeitsspeicher); fiel sie während eines Ausfalls ins Ende, schließt nach_neustart_fortsetzen() sie beim letzten aufgezeichneten Zeitpunkt. - Sensor-Zuordnungen wirken sofort - die Zustandsbeobachter werden neu gebunden, der Neustart-Hinweis und der Neustart-Dienst sind weg. Namensvertrag geändert, beide Oberflächen mitgezogen: pyscript.audi_dashboard_x -> sensor.audi_dashboard_x, pyscript.audi_dashboard_y -> audi_dashboard.y. Eine Companion-App vom alten Stand findet nach dem Umstieg nichts mehr und muss neu gebaut werden; das Panel liegt in der Integration und kann nicht driften. Der selbstgebaute Updater entfällt - HACS ist die Update-Mechanik, die Home Assistant kennt. Die Versionierung schrumpft auf eine Quelle: manifest.json. Geprüft am laufenden Testcontainer (Container byteweise identisch mit dem Repo): alle 18 Dienste, Panel, Config-Entry neu laden, Historienimport, echter Shell-Beleg in-process, Neuinstallation im Wegwerf-Container blank mit automatisch nachinstalliertem pypdf. Companion-App: tsc sauber, 112/112 Tests, beide Rauchtests gegen das laufende Backend grün. Belegparser 8/8. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
"""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}), ä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."""
|
||||
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
|
||||
) -> 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."""
|
||||
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
|
||||
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}
|
||||
)
|
||||
# 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),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------- 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]}"
|
||||
Reference in New Issue
Block a user