Initialer Import: HA-Panel, Design-System, Companion-App
Drei zusammengehörige Teile in einem Repository: - homeassistant/ Das fertige, im Einsatz befindliche Home-Assistant-Panel (panel_custom Custom Element + pyscript-Backend). Echte Fahrzeug- und Personendaten (fahrzeugprofil.json, fahrten.jsonl, tankvorgaenge.jsonl, Tankbelege) bleiben per .gitignore außen vor; die anonymisierte Vorlage fahrzeugprofil.example.json ist mit dabei. - design-system/ Eigenständige React-Komponentenbibliothek (@audi-dash/ui), die die visuelle Sprache des Panels nachbildet - ohne Audi-Markenzeichen und ohne die lizenzierte Hausschrift. Dient als Grundlage für Claude Design. War bis hierher ein eigenes Repository und ist in dieses eingeschmolzen worden. - companion-app/ Datenschicht der neuen App DataMetric360 (iOS/Android via Capacitor, zusätzlich als Iframe im HA-Dashboard). Noch ohne Oberfläche: REST- und WebSocket-Zugriff auf Home Assistant plus Warteschlange für Änderungen ohne Netz. Ersetzt das eingespritzte hass-Objekt, das nur innerhalb des HA-Frontends existiert. Dazu die Projektdokumentation: SPECIFICATION.md (Ist-Stand des Panels), COMPANION_APP_ARCHITECTURE.md (Architekturentscheidungen der neuen App), AUDIT_2026-08-10.md, DESIGN_BRIEF_DATAMETRIC360.md und der ursprüngliche Bauauftrag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
"""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():
|
||||
f = task.executor(io.open, PROFIL_PFAD, "r")
|
||||
inhalt = f.read()
|
||||
f.close()
|
||||
return json.loads(inhalt)
|
||||
|
||||
|
||||
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):
|
||||
"""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."""
|
||||
geaendert = False
|
||||
for d in zeilen:
|
||||
if d.get(id_feld) == id_wert:
|
||||
geschuetzt = set(d.get("edited_fields", []))
|
||||
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_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})
|
||||
_zeilen_schreiben(BATTERIEVERLAUF_PFAD, verlauf)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- IDs
|
||||
|
||||
def neue_id(praefix):
|
||||
return f"{praefix}-{uuid.uuid4().hex[:12]}"
|
||||
Reference in New Issue
Block a user