3b99f1f332
Datenaufbewahrung (recorder_snippet.yaml, neu): Home Assistant loescht Sensor-Verlaeufe standardmaessig nach 10 Tagen - das war die stille Obergrenze dafuer, wie weit sich ueberhaupt je etwas rekonstruieren laesst, denn geloeschte recorder-Zeilen sind endgueltig weg. Der Block hebt das auf 365 Tage. Die Bestaende der App selbst (fahrten.jsonl, tankvorgaenge.jsonl, batteriespannung.jsonl, fahrzeugprofil.json) waren davon nie betroffen, die kennen ohnehin keine Purge-Logik. Platzbedarf an der Testinstanz gemessen statt geschaetzt: 90.858 Zeilen in 17 Tagen, hochgerechnet grob 1,9 Mio. Zeilen und 1-1,5 GB im Jahr, davon 96 % aus der EU-Data-Act-Integration - dafuer liegt eine auskommentierte exclude:-Liste bei. Bewusst kein include:-Block, der wuerde jeder anderen Integration den Verlauf nehmen und dabei kaum Platz sparen. Historienimport (pyscript/historienimport.py, neu): neuer Dienst audi_dashboard_historie_importieren(start, ende) liest denselben Verlauf, den die Live-Trigger in Echtzeit sehen, und leitet rueckwirkend dieselben Datensaetze ab - Fahrten aus dem Zuendungsverlauf (inklusive der Pausenregel, kurze Unterbrechungen verschmelzen zu einer Fahrt), Tankvorgaenge nach derselben Tiefststand-Logik und denselben Schwellen wie tankerkennung.py, Tagesmin/-max der Batteriespannung. Mehrfach ausfuehrbar: ueberschneidet sich eine Fahrt mit einer bereits erfassten, wird sie uebersprungen statt doppelt angelegt. Erzeugte Datensaetze tragen source: "import". Gelesen wird ueber HAs eigene recorder-API (get_significant_states), nicht per direktem SQL auf home-assistant_v2.db: das Schema ist HA-intern und aendert sich zwischen Versionen, die Funktion ist die stabile Schnittstelle. Preis dafuer ist hass_is_global: true im pyscript-Block; ohne die Zeile laeuft alles andere unveraendert weiter, nur der Import meldet, dass er den Verlauf nicht lesen kann. Oberflaeche in beiden Codebasen: Knopf "Daten importieren aus Home Assistant" unter Einstellungen -> Einrichten, dahinter ein Fenster mit Von/Bis (Vorbelegung: letzte 30 Tage), "Importieren" als Hauptaktion und "Abbrechen" als zweite Wahl. Das Fenster bleibt offen und zeigt das Ergebnis, statt optimistisch zu schliessen - hier ist das Ergebnis der Zweck des Aufrufs. Fortschritt ueber die Entitaet pyscript.audi_dashboard_import_status, weil pyscript-Dienste sofort zurueckkehren. In companion-app bewusst NICHT ueber die Offline-Warteschlange: ein spaeter aus dem Nichts abgefeuerter Importlauf waere fuer den Nutzer nicht nachvollziehbar. Beim Testen gefunden und mitgefixt: profil.batterieverlauf_tageswert_ aktualisieren() haengte in Einfuegereihenfolge an. Solange nur die Live-Aufzeichnung schrieb, war das dasselbe wie sortiert (sie traegt immer den heutigen Tag ein) - der Import traegt vergangene Tage nach, die waeren hinter den neueren gelandet und das Diagramm haette zeitlich rueckwaerts gelaufen. Sortiert jetzt nach Datum, wie der eigene Docstring es ohnehin versprach. Auslieferstand bereinigt: einstellungen.py brachte zwei Entity-IDs einer laengst abgeraeumten Testinstanz mit (testzone_fmm003_...). Beim Testen unsichtbar, weil die Testinstanz sie ueber entitaeten.json ueberschreibt - auf einer Neuinstallation waeren sie die wirksamen Werte gewesen. Und schlimmer als nur wirkungslos: fahrterkennung.py registriert seinen @state_trigger nur, wenn das Feld belegt ist, ausdruecklich als Schutz gegen eine leere Entity-ID. Ein gesetzter, aber nicht existierender Wert hebelt genau diesen Schutz aus. Beide Felder jetzt leer wie alle anderen; Kopfkommentar der Datei ueberarbeitet, er behauptete noch, die EU-Data-Act-Integration werde nicht mehr verwendet. install.ps1 abgesichert, da die erste echte Installation auf HA OS ansteht: die feste configuration.yaml.bak wurde bei jedem Lauf ueberschrieben, ausgerechnet das Original war nach dem zweiten Lauf weg - Sicherungen jetzt zeitgestempelt. Nach dem Schreiben wird die Datei zurueckgelesen und geprueft (Laenge, genau ein Markerblock, bisheriger Inhalt unveraendert); bei der kleinsten Abweichung rollt das Skript automatisch zurueck. Die Sicherheitseigenschaften stehen jetzt im Kopf der Datei, statt dass man ihnen glauben muss. Standort-Blatt: "Teilen" war im Tagmodus unsichtbar - .standort-pille nutzte var(--tile), das Blatt darunter var(--tile-deckend), und die iOS-Auflage setzt im Tagmodus beide auf #FFFFFF. Gemessener Kontrast 1,00:1, weiss auf weiss. Beide Pillen folgen jetzt der Knopfsprache des Panels (.aktion / .aktion.primaer): Umriss fuer die Nebenaktion, var(--fg) gefuellt fuer "Route". Danach 17-21:1 Textkontrast in beiden Themes. Nebenbei ist damit --line-strong - eine Linienfarbe - nicht laenger als Knopffuellung im Einsatz. Geprueft: Import zweimal ueber die echte Oberflaeche im Browser gegen eine in die recorder-DB eingespielte Kunsthistorie (der Testcontainer laeuft nicht, waehrend das Auto faehrt, echte Fahrten liegen dort also nicht vor) - drei Fahrten wie erwartet, die 5-Minuten-Unterbrechung korrekt zu einer 50-Minuten-Fahrt verschmolzen, die 30-Sekunden-Zuendung verworfen, Strecken kilometergenau; zweiter Lauf legte 0 an und meldete 3 als vorhanden. Neuinstallation in einem Wegwerf-Container: nur Profilvorlage und Parser, keine Fahrten-/Tank-/Batteriedatei, null Fehler im Log. install.ps1 gegen Attrappen: -Pruefen schreibt nichts, echter Lauf laesst automations.yaml bytegleich (SHA-256) und fremde Bloecke stehen, Ergebnis parst als gueltiges HA-YAML, zweiter Lauf idempotent, bei fremdem pyscript:/panel_custom: bleibt die Datei bytegleich. tsc --noEmit sauber, companion-app-Tests 106/106, vite build sauber, HA-Configcheck und Start ohne Fehler. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
299 lines
11 KiB
Python
299 lines
11 KiB
Python
"""Datenzugriff für Fahrzeugprofil, Fahrten und Tankvorgänge (Bauauftrag §6).
|
|
|
|
Importierbar aus anderen pyscript-Dateien mit `import profil`.
|
|
|
|
Drei getrennte Bestände, wie in §6.1 festgelegt:
|
|
- Fahrzeugprofil: eine JSON-Datei, alles Fahrzeugspezifische
|
|
- Fahrten: JSON Lines, eine Zeile je Fahrt
|
|
- Tankvorgänge: JSON Lines
|
|
|
|
Drei Eigenheiten von pyscript, alle an einer echten Testinstanz beobachtet,
|
|
nicht nur aus der Dokumentation übernommen:
|
|
|
|
1. Das eingebaute `open()` existiert in pyscript nicht (NameError) - aus
|
|
Sicherheitsgründen bewusst nicht freigegeben. Der funktionierende Weg ist
|
|
`task.executor(io.open, pfad, modus)`: `io.open` ist eine echte externe
|
|
Funktion aus der Standardbibliothek, kein im pyscript-Ordner selbst
|
|
definierter Code.
|
|
2. task.executor() akzeptiert generell nur solche echten externen Funktionen
|
|
- eigene, im pyscript-Ordner definierte Funktionen weist es mit "pyscript
|
|
functions can't be called from task.executor" zurück. Deshalb hier nur
|
|
`io.open` selbst über den Executor, Lesen/Schreiben/Schließen auf dem
|
|
zurückgegebenen Datei-Objekt direkt (das ist kein bare-name-Aufruf mehr,
|
|
sondern ein Methodenaufruf auf einem bereits vorhandenen Objekt).
|
|
3. Variablen, die innerhalb eines `with ... as f:`-Blocks zugewiesen werden,
|
|
waren danach außerhalb nicht mehr auffindbar (NameError), deshalb kein
|
|
`with` - offen/lesen/schließen nacheinander.
|
|
"""
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import uuid
|
|
|
|
BASIS = "/config/audi_dashboard"
|
|
PROFIL_PFAD = f"{BASIS}/fahrzeugprofil.json"
|
|
FAHRTEN_PFAD = f"{BASIS}/fahrten.jsonl"
|
|
TANKVORGAENGE_PFAD = f"{BASIS}/tankvorgaenge.jsonl"
|
|
BATTERIEVERLAUF_PFAD = f"{BASIS}/batteriespannung.jsonl"
|
|
BELEGE_ORDNER = f"{BASIS}/belege"
|
|
|
|
|
|
# ---------------------------------------------------------------- Ordner ---
|
|
|
|
def ordner_sicherstellen():
|
|
os.makedirs(BASIS, exist_ok=True)
|
|
os.makedirs(BELEGE_ORDNER, exist_ok=True)
|
|
|
|
|
|
# ----------------------------------------------------------- Fahrzeugprofil
|
|
|
|
def profil_lesen():
|
|
"""Liest das Fahrzeugprofil, oder None wenn es fehlt bzw. beschädigt ist.
|
|
|
|
Ohne diese Prüfung reißt eine fehlende Datei (Installation unvollständig,
|
|
siehe INSTALL.md Schritt 2) jeden Trigger und jeden Service mit, der das
|
|
Profil braucht — bei laufenden Zeittriggern also im Minutentakt. Jeder
|
|
Aufrufer muss den None-Fall abfangen."""
|
|
if not os.path.exists(PROFIL_PFAD):
|
|
log.error(
|
|
f"audi_dashboard: {PROFIL_PFAD} fehlt. Siehe INSTALL.md Schritt 2 — "
|
|
"bis dahin bleiben alle Funktionen aus, die das Profil brauchen."
|
|
)
|
|
return None
|
|
f = task.executor(io.open, PROFIL_PFAD, "r")
|
|
inhalt = f.read()
|
|
f.close()
|
|
try:
|
|
return json.loads(inhalt)
|
|
except ValueError as fehler:
|
|
log.error(
|
|
f"audi_dashboard: {PROFIL_PFAD} ist kein gültiges JSON ({fehler}). "
|
|
"Letztes Backup aus audi_dashboard/backups/ zurückspielen."
|
|
)
|
|
return None
|
|
|
|
|
|
def profil_schreiben(profil):
|
|
tmp = PROFIL_PFAD + ".tmp"
|
|
text = json.dumps(profil, ensure_ascii=False, indent=2)
|
|
f = task.executor(io.open, tmp, "w")
|
|
f.write(text)
|
|
f.close()
|
|
os.replace(tmp, PROFIL_PFAD)
|
|
|
|
|
|
# --------------------------------------------------------- JSON-Lines-Basis
|
|
|
|
def _zeilen_lesen(pfad):
|
|
if not os.path.exists(pfad):
|
|
return []
|
|
f = task.executor(io.open, pfad, "r")
|
|
inhalt = f.read()
|
|
f.close()
|
|
datensaetze = []
|
|
for zeile in inhalt.splitlines():
|
|
zeile = zeile.strip()
|
|
if zeile:
|
|
datensaetze.append(json.loads(zeile))
|
|
return datensaetze
|
|
|
|
|
|
def _zeilen_schreiben(pfad, datensaetze):
|
|
tmp = pfad + ".tmp"
|
|
zeilen = [json.dumps(d, ensure_ascii=False) for d in datensaetze]
|
|
text = "\n".join(zeilen)
|
|
if zeilen:
|
|
text += "\n"
|
|
f = task.executor(io.open, tmp, "w")
|
|
f.write(text)
|
|
f.close()
|
|
os.replace(tmp, pfad)
|
|
|
|
|
|
def _zeile_anhaengen(pfad, datensatz):
|
|
f = task.executor(io.open, pfad, "a")
|
|
f.write(json.dumps(datensatz, ensure_ascii=False) + "\n")
|
|
f.close()
|
|
|
|
|
|
# ------------------------------------------------------------------ Fahrten
|
|
|
|
def fahrten_lesen():
|
|
return _zeilen_lesen(FAHRTEN_PFAD)
|
|
|
|
|
|
def fahrten_schreiben(fahrten):
|
|
_zeilen_schreiben(FAHRTEN_PFAD, fahrten)
|
|
|
|
|
|
def fahrt_anhaengen(fahrt):
|
|
_zeile_anhaengen(FAHRTEN_PFAD, fahrt)
|
|
|
|
|
|
def offene_fahrten():
|
|
"""Alle Fahrten mit status == 'offen', ältere zuerst."""
|
|
fahrten = fahrten_lesen()
|
|
offen = [f for f in fahrten if f.get("status") == "offen"]
|
|
return sorted(offen, key=lambda f: f.get("ts_start", ""))
|
|
|
|
|
|
def _datensatz_aktualisieren(zeilen, id_feld, id_wert, aenderungen, schreiben, schutz=True):
|
|
"""Ersetzt ausgewählte Felder eines Datensatzes anhand seiner ID und
|
|
schreibt den gesamten Bestand neu. Manuell geänderte Felder
|
|
(edited_fields, analog zu §7.7 Regel 3) werden dabei nie überschrieben.
|
|
Gemeinsame Grundlage für Fahrten und Tankvorgänge - beide Archive
|
|
funktionieren nach demselben Muster.
|
|
|
|
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."""
|
|
geaendert = False
|
|
for d in zeilen:
|
|
if d.get(id_feld) == id_wert:
|
|
geschuetzt = set(d.get("edited_fields", [])) if schutz else set()
|
|
for feld, wert in aenderungen.items():
|
|
if feld not in geschuetzt:
|
|
d[feld] = wert
|
|
geaendert = True
|
|
break
|
|
if geaendert:
|
|
schreiben(zeilen)
|
|
return geaendert
|
|
|
|
|
|
def fahrt_aktualisieren(trip_id, aenderungen):
|
|
return _datensatz_aktualisieren(fahrten_lesen(), "trip_id", trip_id, aenderungen, fahrten_schreiben)
|
|
|
|
|
|
def fahrt_bearbeiten(trip_id, aenderungen):
|
|
"""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 (siehe schutz-Parameter oben)."""
|
|
return _datensatz_aktualisieren(fahrten_lesen(), "trip_id", trip_id, aenderungen, fahrten_schreiben, schutz=False)
|
|
|
|
|
|
def fahrt_loeschen(trip_id):
|
|
fahrten = fahrten_lesen()
|
|
uebrig = [f for f in fahrten if f.get("trip_id") != trip_id]
|
|
if len(uebrig) == len(fahrten):
|
|
return False
|
|
fahrten_schreiben(uebrig)
|
|
return True
|
|
|
|
|
|
# ------------------------------------------------------------ Tankvorgänge
|
|
|
|
def tankvorgaenge_lesen():
|
|
return _zeilen_lesen(TANKVORGAENGE_PFAD)
|
|
|
|
|
|
def tankvorgaenge_schreiben(tankvorgaenge):
|
|
_zeilen_schreiben(TANKVORGAENGE_PFAD, tankvorgaenge)
|
|
|
|
|
|
def tankvorgang_anhaengen(tankvorgang):
|
|
_zeile_anhaengen(TANKVORGAENGE_PFAD, tankvorgang)
|
|
|
|
|
|
def tankvorgang_aktualisieren(tank_id, aenderungen):
|
|
return _datensatz_aktualisieren(tankvorgaenge_lesen(), "tank_id", tank_id, aenderungen, tankvorgaenge_schreiben)
|
|
|
|
|
|
def tankvorgang_loeschen(tank_id):
|
|
tankvorgaenge = tankvorgaenge_lesen()
|
|
uebrig = [t for t in tankvorgaenge if t.get("tank_id") != tank_id]
|
|
if len(uebrig) == len(tankvorgaenge):
|
|
return False
|
|
tankvorgaenge_schreiben(uebrig)
|
|
return True
|
|
|
|
|
|
def letzter_tankvorgang():
|
|
"""Der zeitlich jüngste bereits erfasste Tankvorgang (nach ts), oder None,
|
|
falls noch keiner existiert. Grundlage für die Gefahrene-Distanz-Berechnung
|
|
beim Anlegen eines neuen Tankvorgangs (§5.5)."""
|
|
tankvorgaenge = tankvorgaenge_lesen()
|
|
if not tankvorgaenge:
|
|
return None
|
|
return max(tankvorgaenge, key=lambda t: t.get("ts") or "")
|
|
|
|
|
|
def distanz_seit_letzter_tankung(aktueller_km):
|
|
"""Gefahrene Distanz seit dem vorherigen Tankvorgang, als Vorschlag für
|
|
das gleichnamige Formularfeld (§5.5) - frei überschreibbar, genau wie
|
|
odometer_km selbst. None, wenn kein Kilometerstand oder kein vorheriger
|
|
Tankvorgang vorliegt (erster Eintrag überhaupt). Gemeinsame Grundlage für
|
|
Beleg-Erfassung (belegverarbeitung.py) und automatische Tankerkennung
|
|
(tankerkennung.py)."""
|
|
if aktueller_km is None:
|
|
return None
|
|
letzter = letzter_tankvorgang()
|
|
if not letzter or letzter.get("odometer_km") is None:
|
|
return None
|
|
return round(aktueller_km - letzter["odometer_km"], 1)
|
|
|
|
|
|
def tankvorgang_nach_id(tank_id):
|
|
for t in tankvorgaenge_lesen():
|
|
if t.get("tank_id") == tank_id:
|
|
return t
|
|
return None
|
|
|
|
|
|
def tankvorgang_nach_receipt_key(receipt_key):
|
|
"""Für §7.7 Regel: derselbe Beleg (receipt_key, minutengenau) erzeugt
|
|
keinen zweiten Datensatz."""
|
|
for t in tankvorgaenge_lesen():
|
|
if t.get("receipt_key") == receipt_key:
|
|
return t
|
|
return None
|
|
|
|
|
|
# ------------------------------------------------------- Batteriespannung
|
|
|
|
def batterieverlauf_lesen():
|
|
"""Ein Eintrag pro Tag ({datum, min, min_ts, max, max_ts}), älteste
|
|
zuerst - siehe batterieverlauf.py für die Aufzeichnungslogik. min_ts/
|
|
max_ts sind die Zeitstempel (ISO, UTC) der jeweiligen Einzelmessung, für
|
|
die Datum/Uhrzeit-Anzeige beim Antippen des Diagrammpunkts im Frontend -
|
|
der Punkt selbst zeigt nur den Minimalwert (siehe dortiger Kommentar,
|
|
warum der aussagekräftig für die Entladung ist)."""
|
|
return _zeilen_lesen(BATTERIEVERLAUF_PFAD)
|
|
|
|
|
|
def batterieverlauf_tageswert_aktualisieren(datum, ts, spannung):
|
|
"""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. Das Fahrzeug meldet die
|
|
Spannung künftig mehrfach pro Stunde (aktive Fahrt) statt nur einmal
|
|
täglich - der komplette Bestand wird deshalb bei jeder Messung neu
|
|
geschrieben (wie bei den übrigen JSON-Lines-Beständen hier), was bei
|
|
einem Eintrag pro Tag über Jahre hinweg unproblematisch bleibt."""
|
|
verlauf = batterieverlauf_lesen()
|
|
for eintrag in verlauf:
|
|
if eintrag.get("datum") == datum:
|
|
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, also stets den jüngsten). Der nachträgliche Import
|
|
# (historienimport.py) 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 "")
|
|
_zeilen_schreiben(BATTERIEVERLAUF_PFAD, verlauf)
|
|
|
|
|
|
# --------------------------------------------------------------------- IDs
|
|
|
|
def neue_id(praefix):
|
|
return f"{praefix}-{uuid.uuid4().hex[:12]}"
|