Vergangene Daten aus dem HA-Verlauf importierbar, Auslieferstand bereinigt

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>
This commit is contained in:
2026-08-23 20:42:54 +02:00
parent f115af50ab
commit 3b99f1f332
25 changed files with 2375 additions and 69 deletions
+41 -2
View File
@@ -128,12 +128,38 @@ reicht `scp`/`rsync` vom gewohnten Terminal aus.
`configuration_snippet.yaml` aus diesem Ordner öffnen. Die zwei Blöcke
(`pyscript:` und `panel_custom:`) in die bestehende `configuration.yaml`
übernehmen — **nicht** die Datei komplett ersetzen. Falls dort schon ein
`pyscript:`-Block existiert, nur die Zeile `allow_all_imports: true` darin
ergänzen statt einen zweiten Block anzulegen.
`pyscript:`-Block existiert, nur die beiden Zeilen `allow_all_imports: true`
und `hass_is_global: true` darin ergänzen statt einen zweiten Block anzulegen.
`hass_is_global: true` braucht nur der nachträgliche Datenimport
(Schritt 8) — ohne die Zeile läuft alles andere unverändert, der Import
meldet dann aber, dass er den Verlauf nicht lesen kann.
Der `panel_custom:`-Block kann schon jetzt mit rein; er wird erst mit dem
Frontend-Baustein wirksam und stört bis dahin nicht.
### Datenaufbewahrung — je früher, desto mehr ist zu retten
Zusätzlich `recorder_snippet.yaml` aus demselben Ordner übernehmen. Home
Assistant löscht Sensor-Verläufe **standardmäßig nach 10 Tagen**; der Block
hebt das auf ein Jahr an.
Das ist zeitkritisch, anders als der Rest dieser Anleitung: was der recorder
einmal gelöscht hat, ist endgültig weg — auch für den Import in Schritt 8.
Wer diesen Block erst in vier Wochen einbaut, kann die dazwischen liegenden
Fahrten nicht mehr nachtragen.
Nicht betroffen sind die Bestände der App selbst (`fahrten.jsonl`,
`tankvorgaenge.jsonl`, `batteriespannung.jsonl`, `fahrzeugprofil.json` unter
`/config/audi_dashboard/`): die werden nirgends automatisch gekürzt und
bleiben dauerhaft erhalten. Die 10-Tage-Grenze betrifft nur den **rohen**
Sensor-Verlauf, aus dem die App ihre Datensätze erst ableitet.
Zum Platzbedarf (an der Testinstanz gemessen: rund 5.300 Zustandsänderungen
pro Tag, hochgerechnet grob 11,5 GB für ein Jahr) steht alles im Kopf von
`recorder_snippet.yaml`, samt einer auskommentierten `exclude:`-Liste zum
Kürzen, falls die Datenbank zu groß wird.
## Schritt 4 — die Sensoren zuordnen
Anders als früher wird dafür **nicht** mehr `pyscript/modules/
@@ -265,6 +291,19 @@ Sensor suchen, Zustand testweise auf `on`/`off` setzen). Danach in
- **Tailscale „VPN On Demand"** in der Tailscale-App selbst einrichten
(Regel „Only On", gebunden an `Audi_MMI_2804_5GHz`) — unabhängig von
Home Assistant, kann jederzeit parallel erledigt werden
- **Vergangene Daten nachtragen:** Fahrterkennung, Tankerkennung und
Batterieverlauf laufen erst ab der Installation mit. Was Home Assistant
vorher schon aufgezeichnet hat, holt **Einstellungen → Einrichten → „Daten
importieren aus Home Assistant"** nach: Zeitraum wählen, „Importieren", und
es entstehen dieselben Fahrten, Tankvorgänge und Spannungswerte, die die
Live-Erkennung erzeugt hätte. Der Vorgang ist gefahrlos wiederholbar —
überschneidet sich ein Zeitraum mit bereits erfassten Fahrten, wird er
übersprungen statt doppelt angelegt.
Wie weit er zurückreicht, hängt allein an der Aufbewahrung aus Schritt 3
(Standard 10 Tage, mit `recorder_snippet.yaml` ein Jahr). Kommt „0 Fahrten
angelegt" zurück, nennt das Fenster den frühesten Zeitpunkt, zu dem
überhaupt noch etwas aufgezeichnet ist.
## Schritt 9 — Frontend prüfen
+14
View File
@@ -3,13 +3,27 @@
# vorhandenen Top-Level-Schlüsseln einfügen (falls z. B. schon ein
# `pyscript:`-Block existiert, dort zusammenführen statt duplizieren).
# Datenaufbewahrung: Home Assistant löscht Sensor-Verläufe standardmäßig nach
# 10 Tagen - das begrenzt, wie weit "Daten aus Home Assistant importieren"
# zurückreichen kann. Der zugehörige `recorder:`-Block steht mit ausführlicher
# Begründung und gemessenem Platzbedarf in recorder_snippet.yaml daneben;
# von dort übernehmen.
# pyscript-Backend: Fahrterkennung, Fahrtabschluss, Reifenzähler,
# Belegverarbeitung (siehe pyscript/-Ordner). allow_all_imports ist nötig,
# weil die Skripte os, subprocess, urllib.request, base64 und uuid nutzen -
# pyscript erlaubt standardmäßig gar keine Importe, auch keine aus der
# Standardbibliothek.
#
# hass_is_global stellt den `hass`-Namen in pyscript bereit. Gebraucht wird er
# ausschließlich von historienimport.py ("Daten importieren aus Home
# Assistant"): der Verlauf wird über Home Assistants eigene recorder-API
# gelesen (get_significant_states), die das hass-Objekt als ersten Parameter
# erwartet. Ohne diese Zeile bleibt der Rest der App voll funktionsfähig, nur
# der Import meldet, dass er den Verlauf nicht lesen kann.
pyscript:
allow_all_imports: true
hass_is_global: true
# Frontend: bindet den umgebauten Prototyp als eigenen Sidebar-Eintrag ein
# (§10 Punkt 1). module_url zeigt auf /config/www/audi-dashboard-panel.js
@@ -3,13 +3,27 @@
# vorhandenen Top-Level-Schlüsseln einfügen (falls z. B. schon ein
# `pyscript:`-Block existiert, dort zusammenführen statt duplizieren).
# Datenaufbewahrung: Home Assistant löscht Sensor-Verläufe standardmäßig nach
# 10 Tagen - das begrenzt, wie weit "Daten aus Home Assistant importieren"
# zurückreichen kann. Der zugehörige `recorder:`-Block steht mit ausführlicher
# Begründung und gemessenem Platzbedarf in recorder_snippet.yaml daneben;
# von dort übernehmen.
# pyscript-Backend: Fahrterkennung, Fahrtabschluss, Reifenzähler,
# Belegverarbeitung (siehe pyscript/-Ordner). allow_all_imports ist nötig,
# weil die Skripte os, subprocess, urllib.request, base64 und uuid nutzen -
# pyscript erlaubt standardmäßig gar keine Importe, auch keine aus der
# Standardbibliothek.
#
# hass_is_global stellt den `hass`-Namen in pyscript bereit. Gebraucht wird er
# ausschließlich von historienimport.py ("Daten importieren aus Home
# Assistant"): der Verlauf wird über Home Assistants eigene recorder-API
# gelesen (get_significant_states), die das hass-Objekt als ersten Parameter
# erwartet. Ohne diese Zeile bleibt der Rest der App voll funktionsfähig, nur
# der Import meldet, dass er den Verlauf nicht lesen kann.
pyscript:
allow_all_imports: true
hass_is_global: true
# Frontend: bindet den umgebauten Prototyp als eigenen Sidebar-Eintrag ein
# (§10 Punkt 1). module_url zeigt auf /config/www/audi-dashboard-panel.js
+80 -5
View File
@@ -12,6 +12,35 @@
4. configuration_snippet.yaml wird in <Ziel>\configuration.yaml eingefügt
5. ha_token.txt wird geschrieben, wenn -Token übergeben wurde
SICHERHEIT GEGENÜBER DER BESTEHENDEN HA-INSTALLATION
----------------------------------------------------
Das Skript ist so gebaut, dass es eine laufende Home-Assistant-Installation
(auch HA OS) nicht gefährden kann. Konkret und nachprüfbar:
- Es LÖSCHT NIE etwas. Im gesamten Skript kommt kein Remove-Item vor, und
kopiert wird bewusst ohne robocopy /MIR - fremde Dateien in pyscript\
und www\ bleiben liegen, es wird nur ergänzt.
- Es fasst .storage\ nicht an: keine Integrationen, keine Geräte, keine
Entitäten, keine Benutzer, keine Automatisierungen. automations.yaml,
scripts.yaml, scenes.yaml und secrets.yaml werden nicht gelesen und
nicht geschrieben.
- Es startet Home Assistant nicht neu und greift nicht in den laufenden
Betrieb ein. Bis zum manuellen Neustart ändert sich am Verhalten der
Instanz nichts.
- Die einzige Datei außerhalb der eigenen Ordner, die überhaupt verändert
wird, ist configuration.yaml - und das ist der einzige Weg, auf dem
dieses Skript HA am Starten hindern könnte. Deshalb dort drei Netze:
eine zeitgestempelte Sicherung vor jeder Änderung, ein Abbruch ohne
jede Änderung falls dort schon fremde pyscript:/panel_custom:-Blöcke
stehen, und eine Rücklese-Prüfung nach dem Schreiben, die bei der
kleinsten Abweichung automatisch zurückrollt.
- -Pruefen zeigt den kompletten Ablauf, ohne irgendetwas zu schreiben.
Im Zweifel damit anfangen.
Was das Skript NICHT kann: pyscript installieren (das macht HACS),
pypdf nachinstallieren (braucht eine Shell auf der HA-Maschine) und HA
neu starten. Diese Schritte stehen am Ende als Restliste.
Grundsatz: nichts kaputtmachen, was schon da ist. Das Skript ist darauf
ausgelegt, gefahrlos mehrfach zu laufen (etwa nach einem Abbruch):
@@ -214,30 +243,70 @@ if ($fremdePyscript -or $fremdePanel) {
Write-Host ""
Write-Host " Bitte von Hand zusammenführen (Inhalt aus configuration_snippet.yaml):" -ForegroundColor Yellow
Write-Host " - unter pyscript: allow_all_imports: true"
Write-Host " - hass_is_global: true"
Write-Host " - unter panel_custom: den Listeneintrag 'audi-dashboard-panel'"
[void]$restliste.Add("configuration.yaml von Hand ergänzen (siehe Hinweis oben) - zwei gleiche Top-Level-Schlüssel wären ungültiges YAML.")
} elseif ($Pruefen) {
if ($schonDa) { Info "würde den vorhandenen Mein-Audi-Block ersetzen" }
else { Info "würde den Mein-Audi-Block anhängen" }
} else {
Copy-Item $configYaml "$configYaml.bak" -Force
Info "Sicherung: configuration.yaml.bak"
# Zeitgestempelte Sicherung statt einer festen .bak: die feste Datei wurde
# bei einem zweiten Lauf von der bereits geänderten Fassung überschrieben -
# ausgerechnet das Original, auf das man im Notfall zurückwill, war dann weg.
$sicherung = "$configYaml.bak"
$stempel = Get-Date -Format "yyyyMMdd-HHmmss"
$sicherungZeit = "$configYaml.$stempel.bak"
Copy-Item $configYaml $sicherungZeit -Force
if (-not (Test-Path $sicherung)) { Copy-Item $configYaml $sicherung -Force }
Info "Sicherung: $(Split-Path $sicherungZeit -Leaf)"
if ($schonDa) {
$neu = [regex]::Replace(
$inhalt,
"(?ms)" + [regex]::Escape($markeAuf) + ".*?" + [regex]::Escape($markeZu) + "\r?\n?",
[System.Text.RegularExpressions.MatchEvaluator]{ param($m) $block }
)
Gut "vorhandenen Block ersetzt"
} else {
$trenner = ""
if ($inhalt.Length -gt 0 -and -not $inhalt.EndsWith("`n")) { $trenner = "`r`n" }
$neu = $inhalt + $trenner + "`r`n" + $block
Gut "Block angehängt"
}
# Ohne BOM schreiben - HA liest die Datei als YAML, ein BOM hat da nichts
# zu suchen (Out-File -Encoding utf8 setzt in PowerShell 5.1 eines).
[System.IO.File]::WriteAllText($configYaml, $neu, (New-Object System.Text.UTF8Encoding($false)))
# Rücklesen und prüfen, bevor wir die Datei so stehen lassen. Eine kaputte
# configuration.yaml ist der einzige Weg, auf dem dieses Skript eine
# laufende Home-Assistant-Installation lahmlegen könnte (HA startet dann
# nicht mehr) - deshalb wird das Ergebnis hier verifiziert und im
# Zweifelsfall sofort zurückgerollt, statt es dem Nutzer zu überlassen.
$kontrolle = [System.IO.File]::ReadAllText($configYaml, $utf8OhneBom)
$bestandNoch = [regex]::Replace(
$kontrolle,
"(?ms)" + [regex]::Escape($markeAuf) + ".*?" + [regex]::Escape($markeZu) + "\r?\n?",
""
)
$treffer = ([regex]::Matches($kontrolle, [regex]::Escape($markeAuf))).Count
$heil = $true
$grund = ""
if ($kontrolle.Length -lt $inhalt.Length) {
$heil = $false; $grund = "die Datei ist kürzer geworden als vorher"
} elseif ($treffer -ne 1) {
$heil = $false; $grund = "der Mein-Audi-Block steht $treffer mal statt genau einmal darin"
} elseif ($bestandNoch.Trim() -ne $ohneUnserenBlock.Trim()) {
$heil = $false; $grund = "der bisherige Inhalt der Datei hat sich verändert"
}
if (-not $heil) {
Copy-Item $sicherungZeit $configYaml -Force
Warnung "configuration.yaml wurde zurückgerollt - $grund."
Write-Host " Die Datei ist unverändert wie vor dem Start dieses Skripts." -ForegroundColor Yellow
[void]$restliste.Add("configuration.yaml von Hand ergänzen (Inhalt aus configuration_snippet.yaml) - der automatische Weg wurde aus Sicherheitsgründen zurückgenommen.")
} elseif ($schonDa) {
Gut "vorhandenen Block ersetzt (geprüft)"
} else {
Gut "Block angehängt (geprüft)"
}
}
# --------------------------------------------------------------- 5. Token
@@ -258,9 +327,15 @@ if (Test-Path $tokenDatei) {
}
# --------------------------------------------------------------- Restliste
# Bewusst nicht automatisch eingefügt: der recorder-Block ist eine Entscheidung
# über den Plattenplatz der Instanz (ein Jahr Fahrzeugverlauf sind grob 1-1,5 GB,
# siehe Kopf von recorder_snippet.yaml), und viele Instanzen haben bereits einen
# eigenen recorder:-Block mit anderen Einstellungen. Zusammenführen ist Handarbeit.
[void]$restliste.Add("ZEITKRITISCH - Datenaufbewahrung: den Block aus recorder_snippet.yaml in die configuration.yaml übernehmen. Home Assistant löscht Sensor-Verläufe sonst nach 10 Tagen; was weg ist, lässt sich auch mit 'Daten importieren aus Home Assistant' nicht mehr nachtragen. Je früher der Block drin ist, desto mehr Vergangenheit bleibt erhalten. VORHER Einstellungen -> System -> Speicher prüfen: ein Jahr Verlauf braucht grob 1-1,5 GB. Bei wenig freiem Platz (SD-Karte, kleine eMMC) mit purge_keep_days: 90 anfangen - Details im Kopf der Datei.")
[void]$restliste.Add("Home Assistant neu starten: Einstellungen -> System -> Neu starten.")
[void]$restliste.Add("Nur für Tankbeleg-Upload: auf der HA-Maschine 'pip install pypdf' ausführen (Terminal-&-SSH-Add-on oder docker exec).")
[void]$restliste.Add("Nach dem Neustart in der App: Einstellungen -> Fahrzeug einrichten -> Setup - Sensoren zuordnen. Zwingend für die Fahrterkennung ist der Zündungs-/ACC-Sensor (binary_sensor, meist vom Teltonika FMM003).")
[void]$restliste.Add("Nach dem Neustart in der App: Einstellungen -> Fahrzeug einrichten -> Setup - Sensoren zuordnen. Im Auslieferstand ist KEIN Sensor vorbelegt; zwingend für die Fahrterkennung ist der Zündungs-/ACC-Sensor (binary_sensor, meist vom Teltonika FMM003). Danach einmal neu starten - die drei trigger-gebundenen Felder (Zündung, Kilometerstand, Tankfüllstand) werden erst dann wirksam.")
[void]$restliste.Add("Optional, sobald die Sensoren zugeordnet sind: Einstellungen -> Einrichten -> 'Daten importieren aus Home Assistant' holt Fahrten, Tankvorgänge und Spannungswerte aus dem bereits aufgezeichneten HA-Verlauf nach.")
Write-Host ""
if ($Pruefen) {
@@ -0,0 +1,511 @@
"""Nachträglicher Import vergangener Zeiträume aus dem Home-Assistant-Verlauf
(Einstellungen -> "Daten importieren aus Home Assistant").
WOZU
----
Fahrterkennung (fahrterkennung.py), Tankerkennung (tankerkennung.py) und
Batterieverlauf (batterieverlauf.py) arbeiten alle nur ab dem Moment, in dem
sie laufen: sie hängen an @state_trigger bzw. an einem 5-Minuten-Takt. Alles,
was das Fahrzeug gemeldet hat, BEVOR die App lief (oder während Home
Assistant aus war, oder bevor ein Sensor überhaupt zugeordnet war), taucht in
den Beständen der App deshalb nie auf - obwohl der recorder von Home
Assistant es längst aufgezeichnet hat.
Dieser Import schließt genau diese Lücke: er liest denselben Verlauf, den die
Live-Trigger sonst in Echtzeit sehen, und leitet daraus rückwirkend dieselben
Datensätze ab.
GRENZE, DIE MAN KENNEN MUSS
---------------------------
Weiter zurück als der recorder aufbewahrt, geht es nicht - was dort gelöscht
ist, ist endgültig weg. Home Assistant löscht standardmäßig nach 10 Tagen;
recorder_snippet.yaml hebt das auf ein Jahr an. Der Import meldet deshalb im
Ergebnis mit, ab wann im gewählten Zeitraum überhaupt Daten vorlagen
(`ab_wann_daten`), damit ein leeres Ergebnis nicht wie ein Fehler aussieht.
WIE DER VERLAUF GELESEN WIRD
----------------------------
Über Home Assistants eigene recorder-API
(`homeassistant.components.recorder.history.get_significant_states`), nicht
über direkten SQL-Zugriff auf home-assistant_v2.db. Der Umweg über die API
ist Absicht: das Datenbankschema des recorders ist HA-intern und ändert sich
zwischen Versionen, die Funktion dagegen ist die von HA selbst benutzte und
stabile Schnittstelle.
Voraussetzung dafür ist `hass_is_global: true` im pyscript-Block der
configuration.yaml (siehe configuration_snippet.yaml) - ohne das existiert
der `hass`-Name hier nicht. Der Aufruf läuft über task.executor(), weil es
eine echte externe Funktion ist und ein Datenbankzugriff nichts im
Event-Loop verloren hat (dieselbe pyscript-Einschränkung wie bei io.open in
profil.py, siehe dortiger Kopfkommentar).
`significant_changes_only=False` ist wichtig: bei numerischen Sensoren
(Kilometerstand, Tankfüllstand) liefert der Standardmodus nur "auffällige"
Änderungen und verschluckt genau die kleinen Schritte, aus denen sich
Fahrstrecke und Tankvorgänge zusammensetzen.
DOPPELTE DATENSÄTZE
-------------------
Der Import ist absichtlich mehrfach ausführbar (überlappende Zeiträume,
zweiter Versuch nach einem Abbruch): jede erzeugte Fahrt wird gegen die
bereits vorhandenen geprüft und übersprungen, wenn sich ihr Zeitraum mit
einer bestehenden Fahrt überschneidet - egal ob die live erkannt, von Hand
angelegt oder aus einem früheren Import stammt. Tankvorgänge werden über ein
Zeitfenster (TANK_DUBLETTE_MIN) entdoppelt, Batteriewerte über den Tag als
Schlüssel (dort führt profil.batterieverlauf_tageswert_aktualisieren() min/
max ohnehin zusammen, statt Zeilen zu vervielfachen).
Erzeugte Datensätze tragen `source: "import"` - dieselbe Rolle wie "ha"
(live erkannt), "manual" (von Hand) und "auto" (Tankerkennung), damit später
nachvollziehbar bleibt, woher ein Eintrag stammt.
"""
import datetime
from homeassistant.components.recorder.history import get_significant_states
import einstellungen
import frontend_veroeffentlichung
import profil
# Ein Anstieg des Tankfüllstands gilt ab denselben Schwellen als Tankvorgang
# wie in der Live-Erkennung - bewusst dieselben Zahlen wie in
# tankerkennung.py, damit ein importierter Zeitraum dieselben Vorgänge
# erzeugt, die die Live-Erkennung erzeugt hätte.
LITER_SCHWELLE = 5
PROZENT_SCHWELLE = 9
STANDARD_TANKVOLUMEN_LITER = 58
# Zwei Tankvorgänge innerhalb dieser Spanne gelten als derselbe - schützt
# gegen Dubletten, wenn derselbe Zeitraum zweimal importiert wird oder sich
# Import und Live-Erkennung am Rand überschneiden.
TANK_DUBLETTE_MIN = 90
# Fahrten, die kürzer sind, sind Zündung-an-ohne-Fahrt (Radio, Tür öffnen mit
# Zündung, Diagnose) - die Live-Erkennung legt sie zwar an, im Rückblick
# fluten sie den Bestand aber mit Nulleinträgen. Bewusst konservativ.
MINDESTDAUER_S = 60
def _status(zustand, daten=None):
"""Fortschritt/Ergebnis für die Oberfläche. Gleiches Muster wie
_status_veroeffentlichen() in updateverwaltung.py."""
state.set(
"pyscript.audi_dashboard_import_status",
zustand,
new_attributes={"daten": daten or {}},
)
def _als_zeit(wert):
"""Akzeptiert, was die Oberfläche schickt: ISO mit oder ohne Zeitzone.
Ohne Zeitzone gilt die lokale Zeit von Home Assistant - der Nutzer wählt
im Formular schließlich Ortszeit, keine UTC."""
if not wert:
return None
ts = datetime.datetime.fromisoformat(str(wert))
if ts.tzinfo is None:
ts = ts.astimezone()
return ts.astimezone(datetime.timezone.utc)
def _zahl(wert):
try:
return float(wert)
except (TypeError, ValueError):
return None
def _verlauf(entity_id, start, ende):
"""Zustandsverlauf einer Entität als Liste von (Zeitpunkt, Rohwert),
aufsteigend. Leere Liste, wenn die Entität nicht zugeordnet ist oder im
Zeitraum nichts vorliegt.
Die Zustände "unknown"/"unavailable" werden verworfen: sie bedeuten
"keine Meldung", nicht "Wert 0" - würden sie durchgereicht, ergäbe ein
Ausfall der Integration eine Fahrt mit absurder Kilometerdifferenz."""
if not entity_id:
return []
roh = task.executor(
get_significant_states, hass, start, ende, [entity_id], None, True, False
)
reihen = roh.get(entity_id) or []
ergebnis = []
for s in reihen:
if s.state in ("unknown", "unavailable", None, ""):
continue
ergebnis.append((s.last_updated, s.state))
ergebnis.sort(key=lambda p: p[0])
return ergebnis
def _wert_bei(verlauf, zeitpunkt):
"""Der zuletzt vor `zeitpunkt` gemeldete Zahlenwert, sonst der erste
danach, sonst None. "Zuletzt davor" ist die richtige Wahl für einen
Zählerstand: der Kilometerstand bei Fahrtbeginn ist der, der zuletzt
gemeldet wurde, nicht der nächste (der schon Strecke enthält)."""
davor = None
for ts, wert in verlauf:
zahl = _zahl(wert)
if zahl is None:
continue
if ts <= zeitpunkt:
davor = zahl
else:
return davor if davor is not None else zahl
return davor
def _pausenzeit_sekunden():
p = profil.profil_lesen()
if p is None:
return 15 * 60
return p.get("einstellungen", {}).get("fahrten_pausenzeit_min", 15) * 60
def _fahrtfenster(zuendung_verlauf, pausenzeit_s):
"""Aus dem Zündungsverlauf die Zeiträume, in denen gefahren wurde.
Zwei Schritte, die zusammen die Pausenregel aus §7.1 nachbilden:
erst jeden zusammenhängenden "on"-Abschnitt sammeln, dann benachbarte
Abschnitte verschmelzen, deren Lücke kürzer als die Pausenzeit ist. Genau
das tut die Live-Erkennung über task.unique() + task.sleep(), nur eben
im Nachhinein und ohne Warten."""
roh = []
offen = None
for ts, wert in zuendung_verlauf:
an = str(wert).lower() in ("on", "true", "1")
if an and offen is None:
offen = ts
elif not an and offen is not None:
roh.append((offen, ts))
offen = None
# Ein am Ende des Zeitraums noch offener Abschnitt wird verworfen: die
# Fahrt ist zu diesem Zeitpunkt noch nicht beendet, ihr Ende läge hinter
# dem gewählten Fenster. Sie beim Fensterende abzuschneiden würde eine
# Fahrt mit erfundener Endzeit erzeugen.
if not roh:
return []
verschmolzen = [roh[0]]
for start, ende in roh[1:]:
vorheriger_start, vorheriges_ende = verschmolzen[-1]
if (start - vorheriges_ende).total_seconds() < pausenzeit_s:
verschmolzen[-1] = (vorheriger_start, ende)
else:
verschmolzen.append((start, ende))
return verschmolzen
def _ueberschneidet(start, ende, bestehende):
"""True, wenn sich [start, ende] mit einer bereits erfassten Fahrt
überschneidet. Verhindert Dubletten beim wiederholten Import."""
for f in bestehende:
try:
f_start = datetime.datetime.fromisoformat(f.get("ts_start"))
f_ende = datetime.datetime.fromisoformat(f.get("ts_end"))
except (TypeError, ValueError):
continue
if f_start.tzinfo is None or f_ende.tzinfo is None:
continue
if start < f_ende and f_start < ende:
return True
return False
def _fahrten_importieren(start, ende, verlaeufe):
"""Fahrten aus dem Zündungsverlauf, mit Kilometerstand und Start-/
Zielkoordinaten aus den übrigen Verläufen ergänzt."""
fenster = _fahrtfenster(verlaeufe["zuendung"], _pausenzeit_sekunden())
if not fenster:
return {"angelegt": 0, "uebersprungen": 0, "zu_kurz": 0}
bestehende = profil.fahrten_lesen()
km_verlauf = verlaeufe["km"]
lat_verlauf = verlaeufe["lat"]
lon_verlauf = verlaeufe["lon"]
angelegt = 0
uebersprungen = 0
zu_kurz = 0
neue = []
for f_start, f_ende in fenster:
dauer_s = int((f_ende - f_start).total_seconds())
if dauer_s < MINDESTDAUER_S:
zu_kurz += 1
continue
if _ueberschneidet(f_start, f_ende, bestehende):
uebersprungen += 1
continue
odo_start = _wert_bei(km_verlauf, f_start)
odo_end = _wert_bei(km_verlauf, f_ende)
distanz = None
if odo_start is not None and odo_end is not None and odo_end >= odo_start:
distanz = round(odo_end - odo_start, 1)
durchschnitt = None
if distanz is not None and dauer_s > 0:
durchschnitt = round(distanz / (dauer_s / 3600.0), 1)
fahrt = {
"trip_id": profil.neue_id("t"),
"ts_start": f_start.isoformat(),
"ts_end": f_ende.isoformat(),
"duration_s": dauer_s,
"distance_km": distanz,
"km_quelle": "sensor" if distanz is not None else None,
"odo_start": odo_start,
"odo_end": odo_end,
"avg_speed_kmh": durchschnitt,
"start_lat": _wert_bei(lat_verlauf, f_start),
"start_lon": _wert_bei(lon_verlauf, f_start),
"end_lat": _wert_bei(lat_verlauf, f_ende),
"end_lon": _wert_bei(lon_verlauf, f_ende),
"start_address": None,
"end_address": None,
"art": "privat",
"route": None,
"pausen": [],
"source": "import",
"status": "vollständig" if distanz is not None else "offen",
"edited_fields": [],
}
neue.append(fahrt)
bestehende.append(fahrt)
angelegt += 1
# Alle neuen Fahrten in einem Rutsch anhängen statt je Fahrt einmal die
# Datei zu öffnen - bei einem Jahr Verlauf sind das sonst hunderte
# Einzelschreibvorgänge.
if neue:
alle = profil.fahrten_lesen() + neue
alle.sort(key=lambda f: f.get("ts_start") or "")
profil.fahrten_schreiben(alle)
return {"angelegt": angelegt, "uebersprungen": uebersprungen, "zu_kurz": zu_kurz}
def _schwelle_prozent():
p = profil.profil_lesen()
if p is None:
tankvolumen = STANDARD_TANKVOLUMEN_LITER
else:
tankvolumen = p.get("fahrzeug", {}).get("tankvolumen_liter") or STANDARD_TANKVOLUMEN_LITER
return min((LITER_SCHWELLE / tankvolumen) * 100, PROZENT_SCHWELLE)
def _tankvorgaenge_importieren(verlaeufe):
"""Tankvorgänge aus dem Füllstandsverlauf - dieselbe Tiefststand-Logik
wie tankerkennung.py (siehe dortiger Kopfkommentar): jeder Anstieg über
die Schwelle gegen den zuletzt gesehenen Tiefststand ist ein Tankvorgang,
nicht jeder Anstieg gegen den unmittelbar vorherigen Wert."""
verlauf = verlaeufe["tank"]
if not verlauf:
return {"angelegt": 0, "uebersprungen": 0}
schwelle = _schwelle_prozent()
km_verlauf = verlaeufe["km"]
bestehende = profil.tankvorgaenge_lesen()
fenster = datetime.timedelta(minutes=TANK_DUBLETTE_MIN)
bekannte_zeiten = []
for t in bestehende:
try:
ts = datetime.datetime.fromisoformat(t.get("ts"))
except (TypeError, ValueError):
continue
if ts.tzinfo is not None:
bekannte_zeiten.append(ts)
angelegt = 0
uebersprungen = 0
neue = []
tiefststand = None
for ts, wert in verlauf:
aktuell = _zahl(wert)
if aktuell is None:
continue
if tiefststand is None or aktuell <= tiefststand:
tiefststand = aktuell
continue
if aktuell - tiefststand < schwelle:
continue
dublette = False
for bekannt in bekannte_zeiten:
if abs((bekannt - ts).total_seconds()) < fenster.total_seconds():
dublette = True
break
if dublette:
uebersprungen += 1
tiefststand = aktuell
continue
odometer_km = _wert_bei(km_verlauf, ts)
# "Gefahren seit der letzten Tankung" heißt: seit der letzten Tankung
# VOR dieser hier - nicht seit der zeitlich jüngsten überhaupt. Beim
# Import eines vergangenen Zeitraums liegen im Bestand regelmäßig
# bereits neuere Tankvorgänge; die als Bezug zu nehmen ergäbe eine
# negative Strecke (und damit, nach der Prüfung unten, gar keine).
eigene_ts = ts.isoformat()
vorheriger = None
for t in neue + bestehende:
t_ts = t.get("ts") or ""
if t.get("odometer_km") is None or not t_ts or t_ts >= eigene_ts:
continue
if vorheriger is None or t_ts > vorheriger[0]:
vorheriger = (t_ts, t["odometer_km"])
distanz = None
if odometer_km is not None and vorheriger is not None:
distanz = round(odometer_km - vorheriger[1], 1)
if distanz < 0:
distanz = None
tankvorgang = {
"tank_id": profil.neue_id("f"),
"receipt_key": None,
"ts": ts.isoformat(),
"liters": None,
"fuel_total_eur": None,
"price_per_l": None,
"discount": None,
"station_name": None,
"odometer_km": odometer_km,
"distance_km": distanz,
"fuel_type": None,
"source": "import",
"status": "unvollständig",
"receipt_file": None,
"edited_fields": [],
}
neue.append(tankvorgang)
bekannte_zeiten.append(ts)
angelegt += 1
tiefststand = aktuell
if neue:
alle = profil.tankvorgaenge_lesen() + neue
alle.sort(key=lambda t: t.get("ts") or "")
profil.tankvorgaenge_schreiben(alle)
return {"angelegt": angelegt, "uebersprungen": uebersprungen}
def _batterie_importieren(verlaeufe):
"""Tagesminimum/-maximum der 12V-Spannung je Tag des Zeitraums.
profil.batterieverlauf_tageswert_aktualisieren() führt bestehende und
neue Werte pro Tag zusammen (min bleibt min, max bleibt max), deshalb
braucht es hier keine eigene Dubletten-Prüfung: ein zweiter Import
desselben Zeitraums verändert die Einträge nicht mehr."""
verlauf = verlaeufe["batterie"]
if not verlauf:
return {"tage": 0}
tage = {}
for ts, wert in verlauf:
spannung = _zahl(wert)
if spannung is None:
continue
tag = ts.date().isoformat()
eintrag = tage.get(tag)
if eintrag is None:
tage[tag] = {"min": spannung, "min_ts": ts, "max": spannung, "max_ts": ts}
continue
if spannung < eintrag["min"]:
eintrag["min"] = spannung
eintrag["min_ts"] = ts
if spannung > eintrag["max"]:
eintrag["max"] = spannung
eintrag["max_ts"] = ts
for tag in sorted(tage):
werte = tage[tag]
profil.batterieverlauf_tageswert_aktualisieren(
tag, werte["min_ts"].isoformat(), werte["min"]
)
profil.batterieverlauf_tageswert_aktualisieren(
tag, werte["max_ts"].isoformat(), werte["max"]
)
return {"tage": len(tage)}
@service
def audi_dashboard_historie_importieren(start=None, ende=None):
"""Liest den Home-Assistant-Verlauf im gewählten Zeitraum und leitet
daraus Fahrten, Tankvorgänge und Batteriewerte ab.
start/ende sind ISO-Zeitstempel aus der Oberfläche (Ortszeit ohne
Zeitzone ist zulässig). Aufruf als
pyscript.audi_dashboard_historie_importieren."""
profil.ordner_sicherstellen()
try:
von = _als_zeit(start)
bis = _als_zeit(ende)
except ValueError as fehler:
log.error(f"audi_dashboard: Import mit unlesbarem Zeitraum aufgerufen ({fehler})")
_status("fehler", {"meldung": "Zeitraum nicht lesbar"})
return
if von is None or bis is None or von >= bis:
log.warning("audi_dashboard: Import ohne gültigen Zeitraum aufgerufen")
_status("fehler", {"meldung": "Bitte einen Zeitraum wählen, dessen Ende nach dem Start liegt."})
return
_status("laeuft", {"von": von.isoformat(), "bis": bis.isoformat()})
log.info(f"audi_dashboard: Import gestartet für {von.isoformat()} bis {bis.isoformat()}")
try:
verlaeufe = {
"zuendung": _verlauf(einstellungen.ZUENDUNG_SENSOR, von, bis),
"km": _verlauf(einstellungen.KM_SENSOR, von, bis),
"tank": _verlauf(einstellungen.TANK_SENSOR, von, bis),
"batterie": _verlauf(einstellungen.BATTERIE_SENSOR, von, bis),
"lat": _verlauf(einstellungen.STANDORT_LAT_SENSOR, von, bis),
"lon": _verlauf(einstellungen.STANDORT_LON_SENSOR, von, bis),
}
except Exception as fehler:
log.error(f"audi_dashboard: Verlauf nicht lesbar ({type(fehler).__name__}: {fehler})")
_status("fehler", {"meldung": (
"Der Verlauf konnte nicht gelesen werden. Steht hass_is_global: true "
"im pyscript-Block der configuration.yaml?"
)})
return
# Frühester Zeitpunkt, zu dem im gewählten Fenster überhaupt etwas
# aufgezeichnet war - damit ein leeres Ergebnis erklärbar wird
# ("recorder reicht nur bis ...") statt wie ein Fehler auszusehen.
frueheste = None
for name in verlaeufe:
if verlaeufe[name]:
erster = verlaeufe[name][0][0]
if frueheste is None or erster < frueheste:
frueheste = erster
fahrten = _fahrten_importieren(von, bis, verlaeufe)
tank = _tankvorgaenge_importieren(verlaeufe)
batterie = _batterie_importieren(verlaeufe)
frontend_veroeffentlichung.fahrten_veroeffentlichen()
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
frontend_veroeffentlichung.batterieverlauf_veroeffentlichen()
ergebnis = {
"von": von.isoformat(),
"bis": bis.isoformat(),
"ab_wann_daten": frueheste.isoformat() if frueheste else None,
"fahrten_angelegt": fahrten["angelegt"],
"fahrten_uebersprungen": fahrten["uebersprungen"],
"fahrten_zu_kurz": fahrten["zu_kurz"],
"tankvorgaenge_angelegt": tank["angelegt"],
"tankvorgaenge_uebersprungen": tank["uebersprungen"],
"batterie_tage": batterie["tage"],
}
_status("fertig", ergebnis)
log.info(f"audi_dashboard: Import abgeschlossen - {ergebnis}")
@@ -13,33 +13,52 @@ entitaeten.py) - Änderungen von dort werden zur Laufzeit auf diese Variablen
angewendet (überschreiben also die hier hinterlegten Standardwerte), ohne
diese Datei anzufassen.
2026-08-12: Die HACS-Integration TommiG1/HA_VAG-EU-Data-Act (bisherige
Quelle für Kilometerstand, Tankfüllstand, Türen/Fenster/Schlösser,
Ölwechsel-/Inspektionsdaten) wird nicht mehr verwendet - die zugehörigen
Entity-IDs sind deshalb unten bewusst leer. Neue Datenquelle ist der
Teltonika FMM003 (GPS-Tracker mit CAN-Anbindung, siehe AGENTS.md Abschnitt
B); er liefert Standort, Zündungsstatus und Batteriespannung, aber keinen
Tankfüllstand, keine Tür-/Fenster-/Schlossdaten und keine Ölwechsel-/
Inspektionstermine - die entsprechenden Kacheln zeigen deshalb bis auf
Weiteres "unbekannt" statt eines falschen Werts (siehe zustand_oder_none()
in frontend_veroeffentlichung.py). Bleibt ein Wert leer ("") oder passt eine
Entity-ID nicht zur tatsächlichen Integration, liefert zustand_oder_none()
für das jeweilige Feld None statt abzustürzen.
**Im Auslieferstand sind alle Felder hier leer.** Das ist Absicht, kein
unfertiger Zustand: welche Entity-IDs richtig sind, hängt an der jeweiligen
Instanz und ihren Integrationen. Die Zuordnung passiert nach der Installation
im Setup-Menü der App (Einstellungen -> Fahrzeug einrichten -> Setup), das
sie nach data/entitaeten.json schreibt; diese Datei bleibt dabei unangetastet.
Ein leeres Feld ist der sichere Zustand: die betroffene Kachel zeigt
"unbekannt" statt eines falschen Werts (siehe zustand_oder_none() in
frontend_veroeffentlichung.py), und die trigger-gebundenen Felder
(ZUENDUNG_SENSOR, KM_SENSOR, TANK_SENSOR) registrieren gar keinen Trigger,
statt einen gegen eine nicht existierende Entität zu registrieren. Eine
gesetzte, aber falsche Entity-ID ist deshalb schlechter als eine leere.
Zwei typische Quellen auf dieser Instanz: der Teltonika FMM003 (GPS-Tracker
mit CAN-Anbindung, über flespi angebunden - Standort, Zündungsstatus,
Bordnetzspannung, CAN-Werte wie Kilometerstand und Tankfüllstand) und eine
EU-Data-Act-Integration des Herstellers (Kilometerstand, Tankfüllstand,
Türen/Fenster/Schlösser, Reifendrücke, Ölwechsel-/Inspektionstermine). Welche
davon welche Rolle bedient, entscheidet das Setup-Menü - nicht diese Datei.
"""
# Fahrterkennung (§7.1): Start/Ende einer Fahrt wird über den Zündungs-/ACC-
# Status des FMM003 erkannt (on = Fahrt läuft), nicht mehr über die WLAN-
# Verbindung des iPhones zum Fahrzeug (siehe fahrterkennung.py).
ZUENDUNG_SENSOR = "binary_sensor.testzone_fmm003_engine_ignition_or_acc_status"
#
# Leer im Auslieferstand, wie alle Felder hier. Bis 2026-08-23 stand hier die
# Entity-ID einer längst abgeräumten Testinstanz
# ("binary_sensor.testzone_fmm003_..."). Auf einer frischen Installation
# zeigte sie ins Leere - und richtete dabei mehr an, als nur nutzlos zu sein:
# fahrterkennung.py registriert seinen @state_trigger nur, WENN dieses Feld
# belegt ist ("if einstellungen.ZUENDUNG_SENSOR:", ausdrücklich als Schutz
# gegen eine leere Entity-ID gebaut). Ein gesetzter, aber nicht existierender
# Wert hebelt genau diesen Schutz aus. Dasselbe galt für BATTERIE_SENSOR
# weiter unten. Zuordnung gehört ins Setup-Menü, nicht in den Auslieferstand.
ZUENDUNG_SENSOR = ""
# Kilometerstand - bisher aus der TommiG1-Integration, aktuell keine Quelle
# vorhanden. Der FMM003 liefert unter sensor.testzone_fmm003_total_calculated_mileage
# einen selbst berechneten Wert, der aber auf einer anderen Zählbasis beruht
# als der echte Fahrzeug-Kilometerstand (GPS-Streckenberechnung statt
# Tacho) - bewusst NICHT automatisch übernommen, um Reifenzähler,
# Ölwechsel-Prognose und Fahrtabschluss-Screening nicht mit einem
# inkonsistenten Basiswert zu verfälschen. Bei Bedarf über das Setup-Menü
# gezielt zuordnen.
# Kilometerstand - für Fahrtabschluss-Screening, Reifenzähler und
# Ölwechsel-Prognose.
#
# Beim FMM003 hier NICHT den selbst berechneten Gesamtkilometerstand
# (*_total_calculated_mileage) zuordnen: der beruht auf GPS-Streckenrechnung
# statt auf dem Tacho und damit auf einer anderen Zählbasis als der echte
# Fahrzeug-Kilometerstand. Wer ihn einträgt, verfälscht alle drei genannten
# Auswertungen mit einem inkonsistenten Basiswert. Der vom CAN gelesene Wert
# (*_total_vehicle_mileage_read_from_can) bzw. der Kilometerstand der
# EU-Data-Act-Integration ist der richtige.
KM_SENSOR = ""
# Tankfüllstand (Prozent) - keine Quelle mehr vorhanden (der FMM003 ist kein
@@ -49,11 +68,12 @@ TANK_SENSOR = ""
# Reichweite (§5.1 Übersicht) - keine Quelle mehr vorhanden.
RANGE_SENSOR = ""
# 12V-Batteriespannung (Mein Audi -> Zustand). Vom FMM003 geliefert -
# external_power_voltage ist die vom Gerät gemessene Bordnetzspannung des
# Fahrzeugs, NICHT battery_voltage (das ist die interne Pufferbatterie des
# 12V-Batteriespannung (Mein Audi -> Zustand). Beim FMM003 ist das
# external_power_voltage - die vom Gerät gemessene Bordnetzspannung des
# Fahrzeugs -, NICHT battery_voltage (das ist die interne Pufferbatterie des
# Trackers selbst und hat mit der Fahrzeugbatterie nichts zu tun).
BATTERIE_SENSOR = "sensor.testzone_fmm003_external_power_voltage"
# Leer im Auslieferstand, siehe ZUENDUNG_SENSOR oben.
BATTERIE_SENSOR = ""
# Knopf für eine sofortige Neuabfrage beim Fahrzeug - kam aus der
# TommiG1-Integration, keine Entsprechung beim FMM003 vorhanden.
@@ -282,6 +282,13 @@ def batterieverlauf_tageswert_aktualisieren(datum, ts, spannung):
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)
@@ -0,0 +1,103 @@
# Datenaufbewahrung (recorder) — Ergänzung zur bestehenden configuration.yaml.
#
# WOFÜR DAS DA IST
# ----------------
# Home Assistant löscht Sensor-Verlaufsdaten standardmäßig nach 10 Tagen
# (recorder.purge_keep_days, Standardwert). Das ist die Obergrenze dafür, wie
# weit ein späterer Import "Daten aus Home Assistant importieren" überhaupt
# zurückreichen KANN — was der recorder gelöscht hat, ist unwiederbringlich
# weg, auch für die App.
#
# Die App selbst speichert dagegen bereits unbegrenzt: fahrten.jsonl,
# tankvorgaenge.jsonl, batteriespannung.jsonl und fahrzeugprofil.json unter
# /config/audi_dashboard/ werden nirgends automatisch gekürzt oder gelöscht
# (siehe profil.py — es gibt keine Purge-Logik). Betroffen ist also nur der
# ROHE Sensor-Verlauf in Home Assistants eigener Datenbank, aus dem die App
# ihre Datensätze erst ableitet.
#
# Dieser Block hebt die Aufbewahrung auf ein Jahr an. Das ist die
# Übergangslösung, bis die App die Daten selbst dauerhaft übernimmt: fährt
# der Import einmal über einen Zeitraum, liegen die daraus erzeugten Fahrten/
# Tankvorgänge/Spannungswerte dauerhaft in den .jsonl-Beständen und sind vom
# recorder unabhängig.
#
# EINBAU
# ------
# Diesen Block in die bestehende configuration.yaml übernehmen (nicht die
# Datei ersetzen). Existiert dort schon ein `recorder:`-Block, die Werte dort
# zusammenführen statt einen zweiten anzulegen. Danach Home Assistant neu
# starten (Entwicklerwerkzeuge -> YAML -> Neu starten).
#
# PLATZBEDARF — an der Testinstanz gemessen, nicht geschätzt
# ---------------------------------------------------------
# Stand 2026-08-23: 90.858 Zustandsänderungen in 17 Tagen = rund 5.300 pro
# Tag, Datenbank 64,7 MB. Hochgerechnet auf 365 Tage sind das grob 1,9 Mio.
# Zeilen und in der Größenordnung 11,5 GB.
#
# ACHTUNG bei HA OS auf kleinem Datenträger (Raspberry Pi mit SD-Karte,
# 32-GB-eMMC): das ist der einzige Punkt dieser ganzen App, der einer sonst
# gesunden Installation gefährlich werden kann. Läuft der Datenträger voll,
# startet Home Assistant nicht mehr sauber - unabhängig von dieser App.
#
# Vor dem Einbau deshalb einmal nachsehen, wie viel Platz frei ist:
# Einstellungen -> System -> Speicher. Faustregel: mindestens 5 GB frei,
# sonst besser mit einem kürzeren Zeitraum anfangen (z. B.
# purge_keep_days: 90) und die auskommentierte exclude:-Liste unten gleich
# mit aktivieren. 90 Tage reichen für den Zweck "Vergangenheit nachtragen"
# in aller Regel aus - der Import holt die Daten ja dauerhaft in die
# .jsonl-Bestände der App, die danach vom recorder unabhängig sind.
#
# Nach ein paar Wochen noch einmal unter Einstellungen -> System -> Speicher
# nachsehen und bei Bedarf nachjustieren.
#
# 96 % dieser Zeilen stammen aus der EU-Data-Act-Integration
# (`cupra_eu_data_act`, ~1.737 Zustände je Entität in 17 Tagen, also etwa
# alle 14 Minuten ein Update über rund 50 Entitäten), 1 % vom FMM003 über
# flespi, 2 % alles Übrige. Wer den Platzbedarf drücken will, kürzt also bei
# der EU-Data-Act-Integration — die auskommentierte `exclude`-Liste unten ist
# ein Vorschlag dafür, welche Entitäten historisch nichts hergeben.
recorder:
# 365 Tage statt der 10 Tage Standardaufbewahrung.
purge_keep_days: 365
# Bewusst KEIN `include:`-Block. Ein `include` würde bedeuten, dass Home
# Assistant *nur noch* die dort genannten Entitäten aufzeichnet und für
# alles andere gar keinen Verlauf mehr führt — auch nicht für die 10 Tage,
# die es vorher hatte. Da auf dieser Instanz ohnehin 97 % der Aufzeichnung
# auf Fahrzeugdaten entfällt, spart das kaum Platz, kostet aber den Verlauf
# jeder anderen Integration. Deshalb: global ein Jahr aufbewahren.
# Standardmäßig 1 Sekunde. 30 Sekunden bündelt die Schreibvorgänge, was vor
# allem SD-Karten schont; der Preis ist, dass die letzten bis zu 30
# Sekunden bei einem harten Stromausfall fehlen können. Für Fahrzeugdaten,
# die ohnehin nur alle paar Minuten aktualisiert werden, ist das
# unerheblich.
commit_interval: 30
# Nächtliches Aufräumen (Standard 04:12) mit Neuaufbau der Datenbankdatei.
# repack gibt den durch gelöschte Zeilen frei gewordenen Platz auch
# tatsächlich ans Dateisystem zurück, statt die Datei nur intern als "leer"
# zu markieren — bei einem Jahr Aufbewahrung sonst der übliche Grund, warum
# die Datei nur noch wächst.
auto_purge: true
auto_repack: true
# OPTIONAL — nur einkommentieren, wenn die Datenbank zu groß wird.
# Diese Entitäten liefern über die Zeit keinen Erkenntniswert: reine
# Diagnose-/Netzwerkwerte des Trackers und abgeleitete Momentanwerte der
# EU-Data-Act-Integration, die sich jederzeit neu berechnen lassen.
# exclude:
# entity_globs:
# - sensor.*_tire_pressure_diff_*
# - sensor.*fmm003*_mobile_network_*
# - sensor.*fmm003*_lte_*
# - sensor.*fmm003*_network_*
# - sensor.*fmm003*_id_of_*
# - sensor.*fmm003*_*dilution_of_precision
# - sensor.*fmm003*_satellites
# entities:
# - sensor.audi_rs_4_avant_ei_t1302_echo
# - sensor.audi_rs_4_avant_ei_t1302_trueness
# - sensor.audi_rs_4_avant_uncurated_fields
# - sensor.audi_rs_4_avant_minutes_since_last_snapshot
@@ -2137,6 +2137,12 @@ function vEinst() {
<div class="feld" style="border-bottom:0"><label for="oelM">Nach Zeit</label>
<select id="oelM" data-oelm>${[12, 24].map((m) => `<option value="${m}" ${m === CAR.oel.monate ? "selected" : ""}>${m === 12 ? "1 Jahr" : "2 Jahre"}</option>`).join("")}</select></div>`}
<button class="aktion" style="margin-top:20px" data-setup-oeffnen>Setup Sensoren zuordnen</button>
<button class="aktion" data-import-oeffnen>Daten importieren aus Home Assistant</button>
<span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Legt Fahrten, Tankvorgänge und Spannungswerte für einen vergangenen Zeitraum
nachträglich an aus dem Verlauf, den Home Assistant zu den oben zugeordneten
Sensoren bereits aufgezeichnet hat.</span>
` : ""}
<button class="aktion ${einrichtenOffen ? "primaer" : ""}" style="margin-top:16px" data-einrichten-auf>${einrichtenOffen ? "Fertig" : "Einrichten"}</button>
</div>
@@ -3315,6 +3321,77 @@ function zwischenablageKnopfSinnvoll() {
return window.matchMedia && window.matchMedia("(pointer: coarse)").matches;
}
/* ------------------------------------------------- Historien-Import-Popup
Zeitraum wählen, importieren lassen, Ergebnis zeigen. Der eigentliche
Import läuft im Backend (pyscript/historienimport.py) und meldet seinen
Stand über die Entität pyscript.audi_dashboard_import_status - dasselbe
Muster wie beim Update-Status (UPDATE_STATUS oben). */
let importPopup = null;
/* Vorbelegung: die letzten 30 Tage. Wert im Format, das <input
type="datetime-local"> erwartet (Ortszeit, ohne Zeitzone, minutengenau) -
genau das, was der Dienst als Ortszeit interpretiert. */
function importZeitraumVorgabe() {
const jetzt = new Date();
const vor30 = new Date(jetzt.getTime() - 30 * 86400000);
const alsFeld = (d) => new Date(d.getTime() - d.getTimezoneOffset() * 60000)
.toISOString().slice(0, 16);
return { von: alsFeld(vor30), bis: alsFeld(jetzt) };
}
function importErgebnisText(d) {
const anzahl = (n, eins, viele) => `${de(n)} ${n === 1 ? eins : viele}`;
const teile = [anzahl(d.fahrten_angelegt || 0, "Fahrt", "Fahrten") + " angelegt"];
if (d.fahrten_uebersprungen) teile.push(`${de(d.fahrten_uebersprungen)} bereits vorhanden`);
if (d.fahrten_zu_kurz) teile.push(anzahl(d.fahrten_zu_kurz, "Zündung zu kurz für eine Fahrt", "Zündungen zu kurz für eine Fahrt"));
if (d.tankvorgaenge_angelegt) teile.push(anzahl(d.tankvorgaenge_angelegt, "Tankvorgang", "Tankvorgänge"));
if (d.batterie_tage) teile.push(anzahl(d.batterie_tage, "Tag Batteriespannung", "Tage Batteriespannung"));
return teile.join(" · ");
}
function vImportPopup() {
if (!importPopup) return "";
const p = importPopup;
const fertig = p.ergebnis && !p.laeuft;
// Nach dem Lauf zeigt dasselbe Fenster das Ergebnis statt der Felder - der
// Nutzer soll sehen, was passiert ist, bevor er es wegklickt (HIG
// "Feedback"), statt das Fenster wortlos verschwinden zu lassen.
const koerper = fertig
? `<div style="margin-top:14px;font-size:14px;line-height:1.6;color:var(--fg)">
${esc(importErgebnisText(p.ergebnis))}</div>
${p.ergebnis.fahrten_angelegt === 0 && p.ergebnis.ab_wann_daten
? `<span class="label" style="margin-top:12px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Aufgezeichnet ist in diesem Zeitraum ab ${dedat(new Date(p.ergebnis.ab_wann_daten))} ·
${new Date(p.ergebnis.ab_wann_daten).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr.
Weiter zurück bewahrt Home Assistant nichts mehr auf.</span>`
: ""}
${p.ergebnis.fahrten_angelegt === 0 && !p.ergebnis.ab_wann_daten
? `<span class="label" style="margin-top:12px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Für diesen Zeitraum liegt in Home Assistant kein Verlauf mehr vor.</span>`
: ""}`
: `<div class="feld" style="margin-top:12px"><label for="impVon">Von</label>
<input id="impVon" type="datetime-local" style="width:196px" value="${esc(p.von)}" data-import-von></div>
<div class="feld" style="border-bottom:0"><label for="impBis">Bis</label>
<input id="impBis" type="datetime-local" style="width:196px" value="${esc(p.bis)}" data-import-bis></div>
${p.fehler ? `<span class="label" style="margin-top:10px;color:var(--red)">${esc(p.fehler)}</span>` : ""}
<span class="label" style="margin-top:12px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Bereits erfasste Fahrten bleiben unangetastet überschneidet sich ein
Zeitraum, wird er übersprungen statt doppelt angelegt.</span>`;
return `<div class="beleg-catcher" data-import-zu></div>
<div class="beleg-popup" role="dialog" aria-modal="true" aria-label="Daten importieren">
<span class="label">Daten importieren aus Home Assistant</span>
${koerper}
${fertig
? `<button class="aktion primaer" data-import-zu>Fertig</button>`
: `<button class="aktion primaer" data-import-start ${p.laeuft ? "disabled" : ""}>${p.laeuft ? "Importiere …" : "Importieren"}</button>
<button class="aktion" data-import-zu ${p.laeuft ? "disabled" : ""}>Abbrechen</button>`}
</div>`;
}
function vBelegPopup() {
if (!belegPopup) return "";
// Am Telefon ist Einfügen der Hauptweg (PDF aus der Mail kopiert), am
@@ -3500,7 +3577,7 @@ function render() {
// faellt sonst bei jedem render(), z. B. beim Umschalten des Filters,
// auf 0 zurueck).
const setupKoerperScroll = setupOffen ? (ROOT.querySelector(".setup-koerper") || {}).scrollTop : 0;
ROOT.getElementById("overlay").innerHTML = sheetMarkup() + vSetupPopup() + vBelegPopup();
ROOT.getElementById("overlay").innerHTML = sheetMarkup() + vSetupPopup() + vBelegPopup() + vImportPopup();
if (setupOffen) {
const koerper = ROOT.querySelector(".setup-koerper");
if (koerper) koerper.scrollTop = setupKoerperScroll || 0;
@@ -3600,6 +3677,84 @@ function randwischenVerdrahten() {
Schreibende Aktionen: lokale Anzeige sofort aktualisieren (render()),
Persistenz über hass.callService im Hintergrund - dieselbe Reihenfolge
wie im Prototyp, nur dass jetzt zusätzlich gespeichert wird. */
/* Import anstoßen und auf sein Ergebnis warten.
Bewusst NICHT über serviceRufen(): der Import ist die eine Aktion, bei der
das Fenster offen bleibt und das Ergebnis zeigt, statt optimistisch zu
schließen. Das Backend meldet seinen Stand über
pyscript.audi_dashboard_import_status; hier wird dieselbe Entität gepollt,
bis sie "fertig" oder "fehler" meldet. Ein Poll statt eines Abwartens des
callService-Versprechens, weil pyscript-Dienste sofort zurückkehren, wenn
sie im Hintergrund weiterlaufen. */
async function importStatusLesen() {
try {
const states = await HASS.callWS({ type: "get_states" });
return states.find((x) => x.entity_id === "pyscript.audi_dashboard_import_status") || null;
} catch (e) {
return null;
}
}
async function importAusloesen() {
if (!importPopup || importPopup.laeuft) return;
const von = importPopup.von;
const bis = importPopup.bis;
if (!von || !bis) { importPopup.fehler = "Bitte Start und Ende wählen."; render(); return; }
if (von >= bis) { importPopup.fehler = "Das Ende muss nach dem Start liegen."; render(); return; }
importPopup.laeuft = true;
importPopup.fehler = null;
render();
// Zeitstempel des letzten Laufs merken: nur ein danach geschriebener Stand
// ist die Antwort auf DIESEN Aufruf und nicht das Ergebnis von gestern.
const vorher = await importStatusLesen();
const vorherStand = vorher ? vorher.last_updated : null;
try {
await HASS.callService("pyscript", "audi_dashboard_historie_importieren", { start: von, ende: bis });
} catch (err) {
console.error("audi_dashboard: Import", err);
if (importPopup) {
importPopup.laeuft = false;
importPopup.fehler = (err && err.message) || "Der Import konnte nicht gestartet werden.";
render();
}
return;
}
// Bis zu zwei Minuten - ein Jahr Verlauf über sechs Sensoren braucht seine
// Zeit, und ein zu früher Abbruch sähe wie ein Fehlschlag aus, obwohl der
// Import im Hintergrund sauber zu Ende läuft.
for (let versuch = 0; versuch < 120; versuch++) {
await new Promise((r) => setTimeout(r, 1000));
if (!importPopup) return;
const s = await importStatusLesen();
if (!s || s.last_updated === vorherStand) continue;
if (s.state === "fertig") {
importPopup.laeuft = false;
importPopup.ergebnis = (s.attributes && s.attributes.daten) || {};
render();
// Fahrten-/Tankvorgangslisten im Frontend nachziehen - das Backend hat
// sie bereits neu veröffentlicht, die App muss sie nur neu lesen.
datenAktualisieren();
return;
}
if (s.state === "fehler") {
importPopup.laeuft = false;
importPopup.fehler = (s.attributes && s.attributes.daten && s.attributes.daten.meldung)
|| "Der Import ist fehlgeschlagen.";
render();
return;
}
}
if (importPopup) {
importPopup.laeuft = false;
importPopup.fehler = "Der Import dauert ungewöhnlich lange — er läuft im Hintergrund weiter.";
render();
}
}
function serviceRufen(dienst, daten) {
// HIG "Feedback": ein fehlgeschlagener Aufruf darf nicht nur in der Konsole
// landen - die aufrufende Stelle hat ihr Popup meist schon geschlossen und
@@ -3657,6 +3812,9 @@ function ereignisseVerdrahten() {
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
if (belegPopup) { belegPopup = null; render(); e.preventDefault(); return; }
// Während der Import läuft nicht wegklickbar - er läuft im Backend
// weiter, das Fenster ist die einzige Stelle, die sein Ergebnis zeigt.
if (importPopup) { if (!importPopup.laeuft) { importPopup = null; render(); } e.preventDefault(); return; }
if (sheet) { sheetSchliessen(); e.preventDefault(); return; }
if (setupSucheOffen) { setupSucheOffen = null; setupSuchtext = ""; render(); e.preventDefault(); return; }
if (setupOffen) { setupSchliessen(); e.preventDefault(); return; }
@@ -4142,6 +4300,17 @@ function ereignisseVerdrahten() {
eingabe.click();
return;
}
if (e.target.closest("[data-import-oeffnen]")) {
const vorgabe = importZeitraumVorgabe();
importPopup = { von: vorgabe.von, bis: vorgabe.bis, laeuft: false, ergebnis: null, fehler: null };
render();
return;
}
if (e.target.closest("[data-import-zu]")) {
if (importPopup && importPopup.laeuft) return;
importPopup = null; render(); return;
}
if (e.target.closest("[data-import-start]")) { importAusloesen(); return; }
const del = e.target.closest("[data-loeschen]");
if (del) { delete CAR.service.vereinbart[del.dataset.loeschen]; profilSpeichern(); render(); return; }
const vseldel = e.target.closest("[data-vselbstdel]");
@@ -4402,6 +4571,11 @@ function ereignisseVerdrahten() {
CAR.versicherung.beitrag = sum(CAR.versicherung.teile, (t) => t.betrag || 0);
profilSpeichern(); render(); return;
}
// Kein render() bei den beiden Import-Feldern: das Popup wird bei jedem
// render() neu erzeugt, ein Neuzeichnen mitten in der Eingabe risse dem
// Nutzer den gerade offenen Datumswähler weg.
if (e.target.dataset.importVon !== undefined) { if (importPopup) importPopup.von = e.target.value; return; }
if (e.target.dataset.importBis !== undefined) { if (importPopup) importPopup.bis = e.target.value; return; }
if (e.target.dataset.vgueltigab !== undefined) { CAR.versicherung.gueltigAb = e.target.value; profilSpeichern(); render(); return; }
const vnotruf = e.target.dataset.vnotruf;
if (vnotruf) { CAR.versicherung[vnotruf] = e.target.value; profilSpeichern(); render(); return; }
@@ -1 +1 @@
{"version": 1787014000}
{"version": 1787015002}
@@ -950,13 +950,30 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
.standortmenu-werte { display: flex; align-items: center; gap: 10px; margin-top: 8px; font-size: 13.5px; color: var(--fg2); }
.standortmenu-werte svg { color: var(--fg3); flex: 0 0 auto; }
.standortmenu-aktionen { display: flex; gap: 10px; margin-top: 18px; padding-bottom: 4px; }
/* Beide Pillen folgen der Knopfsprache des restlichen Panels (.aktion /
.aktion.primaer weiter oben): Umriss fuer die Nebenaktion, gefuellt fuer
die Hauptaktion.
Vorher stand hier background: var(--tile) fuer "Teilen" und
background: var(--line-strong) fuer "Route". Beides ging im Tagmodus
schief: das Blatt darunter ist var(--tile-deckend), und die iOS-Auflage
(audi-dashboard-ios.css) setzt im Tagmodus --tile UND --tile-deckend
beide auf #FFFFFF - "Teilen" war damit weiss auf weiss, Kontrast 1,0:1,
also schlicht unsichtbar. --line-strong als Flaeche war ausserdem eine
Linienfarbe in der Rolle einer Fuellung.
Der Umriss haengt jetzt an denselben Tokens wie jeder andere Knopf und
traegt deshalb in beiden Themes und auch unter der iOS-Auflage, ohne dass
hier eine eigene Farbe gepflegt werden muss. */
.standort-pille {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
height: 40px; padding: 0 20px; border-radius: var(--r-pill);
border: none; background: var(--tile); color: var(--fg);
border: 1px solid var(--line-strong); background: none; color: var(--fg);
font-family: inherit; font-size: 14px; text-decoration: none; cursor: pointer;
}
.standort-pille.primaer { background: var(--line-strong); }
.standort-pille.primaer {
background: var(--fg); border-color: var(--fg); color: var(--canvas);
}
.standort-pille:active { transform: scale(.97); }
.standort-pille[disabled] { opacity: .4; pointer-events: none; }
+511
View File
@@ -0,0 +1,511 @@
"""Nachträglicher Import vergangener Zeiträume aus dem Home-Assistant-Verlauf
(Einstellungen -> "Daten importieren aus Home Assistant").
WOZU
----
Fahrterkennung (fahrterkennung.py), Tankerkennung (tankerkennung.py) und
Batterieverlauf (batterieverlauf.py) arbeiten alle nur ab dem Moment, in dem
sie laufen: sie hängen an @state_trigger bzw. an einem 5-Minuten-Takt. Alles,
was das Fahrzeug gemeldet hat, BEVOR die App lief (oder während Home
Assistant aus war, oder bevor ein Sensor überhaupt zugeordnet war), taucht in
den Beständen der App deshalb nie auf - obwohl der recorder von Home
Assistant es längst aufgezeichnet hat.
Dieser Import schließt genau diese Lücke: er liest denselben Verlauf, den die
Live-Trigger sonst in Echtzeit sehen, und leitet daraus rückwirkend dieselben
Datensätze ab.
GRENZE, DIE MAN KENNEN MUSS
---------------------------
Weiter zurück als der recorder aufbewahrt, geht es nicht - was dort gelöscht
ist, ist endgültig weg. Home Assistant löscht standardmäßig nach 10 Tagen;
recorder_snippet.yaml hebt das auf ein Jahr an. Der Import meldet deshalb im
Ergebnis mit, ab wann im gewählten Zeitraum überhaupt Daten vorlagen
(`ab_wann_daten`), damit ein leeres Ergebnis nicht wie ein Fehler aussieht.
WIE DER VERLAUF GELESEN WIRD
----------------------------
Über Home Assistants eigene recorder-API
(`homeassistant.components.recorder.history.get_significant_states`), nicht
über direkten SQL-Zugriff auf home-assistant_v2.db. Der Umweg über die API
ist Absicht: das Datenbankschema des recorders ist HA-intern und ändert sich
zwischen Versionen, die Funktion dagegen ist die von HA selbst benutzte und
stabile Schnittstelle.
Voraussetzung dafür ist `hass_is_global: true` im pyscript-Block der
configuration.yaml (siehe configuration_snippet.yaml) - ohne das existiert
der `hass`-Name hier nicht. Der Aufruf läuft über task.executor(), weil es
eine echte externe Funktion ist und ein Datenbankzugriff nichts im
Event-Loop verloren hat (dieselbe pyscript-Einschränkung wie bei io.open in
profil.py, siehe dortiger Kopfkommentar).
`significant_changes_only=False` ist wichtig: bei numerischen Sensoren
(Kilometerstand, Tankfüllstand) liefert der Standardmodus nur "auffällige"
Änderungen und verschluckt genau die kleinen Schritte, aus denen sich
Fahrstrecke und Tankvorgänge zusammensetzen.
DOPPELTE DATENSÄTZE
-------------------
Der Import ist absichtlich mehrfach ausführbar (überlappende Zeiträume,
zweiter Versuch nach einem Abbruch): jede erzeugte Fahrt wird gegen die
bereits vorhandenen geprüft und übersprungen, wenn sich ihr Zeitraum mit
einer bestehenden Fahrt überschneidet - egal ob die live erkannt, von Hand
angelegt oder aus einem früheren Import stammt. Tankvorgänge werden über ein
Zeitfenster (TANK_DUBLETTE_MIN) entdoppelt, Batteriewerte über den Tag als
Schlüssel (dort führt profil.batterieverlauf_tageswert_aktualisieren() min/
max ohnehin zusammen, statt Zeilen zu vervielfachen).
Erzeugte Datensätze tragen `source: "import"` - dieselbe Rolle wie "ha"
(live erkannt), "manual" (von Hand) und "auto" (Tankerkennung), damit später
nachvollziehbar bleibt, woher ein Eintrag stammt.
"""
import datetime
from homeassistant.components.recorder.history import get_significant_states
import einstellungen
import frontend_veroeffentlichung
import profil
# Ein Anstieg des Tankfüllstands gilt ab denselben Schwellen als Tankvorgang
# wie in der Live-Erkennung - bewusst dieselben Zahlen wie in
# tankerkennung.py, damit ein importierter Zeitraum dieselben Vorgänge
# erzeugt, die die Live-Erkennung erzeugt hätte.
LITER_SCHWELLE = 5
PROZENT_SCHWELLE = 9
STANDARD_TANKVOLUMEN_LITER = 58
# Zwei Tankvorgänge innerhalb dieser Spanne gelten als derselbe - schützt
# gegen Dubletten, wenn derselbe Zeitraum zweimal importiert wird oder sich
# Import und Live-Erkennung am Rand überschneiden.
TANK_DUBLETTE_MIN = 90
# Fahrten, die kürzer sind, sind Zündung-an-ohne-Fahrt (Radio, Tür öffnen mit
# Zündung, Diagnose) - die Live-Erkennung legt sie zwar an, im Rückblick
# fluten sie den Bestand aber mit Nulleinträgen. Bewusst konservativ.
MINDESTDAUER_S = 60
def _status(zustand, daten=None):
"""Fortschritt/Ergebnis für die Oberfläche. Gleiches Muster wie
_status_veroeffentlichen() in updateverwaltung.py."""
state.set(
"pyscript.audi_dashboard_import_status",
zustand,
new_attributes={"daten": daten or {}},
)
def _als_zeit(wert):
"""Akzeptiert, was die Oberfläche schickt: ISO mit oder ohne Zeitzone.
Ohne Zeitzone gilt die lokale Zeit von Home Assistant - der Nutzer wählt
im Formular schließlich Ortszeit, keine UTC."""
if not wert:
return None
ts = datetime.datetime.fromisoformat(str(wert))
if ts.tzinfo is None:
ts = ts.astimezone()
return ts.astimezone(datetime.timezone.utc)
def _zahl(wert):
try:
return float(wert)
except (TypeError, ValueError):
return None
def _verlauf(entity_id, start, ende):
"""Zustandsverlauf einer Entität als Liste von (Zeitpunkt, Rohwert),
aufsteigend. Leere Liste, wenn die Entität nicht zugeordnet ist oder im
Zeitraum nichts vorliegt.
Die Zustände "unknown"/"unavailable" werden verworfen: sie bedeuten
"keine Meldung", nicht "Wert 0" - würden sie durchgereicht, ergäbe ein
Ausfall der Integration eine Fahrt mit absurder Kilometerdifferenz."""
if not entity_id:
return []
roh = task.executor(
get_significant_states, hass, start, ende, [entity_id], None, True, False
)
reihen = roh.get(entity_id) or []
ergebnis = []
for s in reihen:
if s.state in ("unknown", "unavailable", None, ""):
continue
ergebnis.append((s.last_updated, s.state))
ergebnis.sort(key=lambda p: p[0])
return ergebnis
def _wert_bei(verlauf, zeitpunkt):
"""Der zuletzt vor `zeitpunkt` gemeldete Zahlenwert, sonst der erste
danach, sonst None. "Zuletzt davor" ist die richtige Wahl für einen
Zählerstand: der Kilometerstand bei Fahrtbeginn ist der, der zuletzt
gemeldet wurde, nicht der nächste (der schon Strecke enthält)."""
davor = None
for ts, wert in verlauf:
zahl = _zahl(wert)
if zahl is None:
continue
if ts <= zeitpunkt:
davor = zahl
else:
return davor if davor is not None else zahl
return davor
def _pausenzeit_sekunden():
p = profil.profil_lesen()
if p is None:
return 15 * 60
return p.get("einstellungen", {}).get("fahrten_pausenzeit_min", 15) * 60
def _fahrtfenster(zuendung_verlauf, pausenzeit_s):
"""Aus dem Zündungsverlauf die Zeiträume, in denen gefahren wurde.
Zwei Schritte, die zusammen die Pausenregel aus §7.1 nachbilden:
erst jeden zusammenhängenden "on"-Abschnitt sammeln, dann benachbarte
Abschnitte verschmelzen, deren Lücke kürzer als die Pausenzeit ist. Genau
das tut die Live-Erkennung über task.unique() + task.sleep(), nur eben
im Nachhinein und ohne Warten."""
roh = []
offen = None
for ts, wert in zuendung_verlauf:
an = str(wert).lower() in ("on", "true", "1")
if an and offen is None:
offen = ts
elif not an and offen is not None:
roh.append((offen, ts))
offen = None
# Ein am Ende des Zeitraums noch offener Abschnitt wird verworfen: die
# Fahrt ist zu diesem Zeitpunkt noch nicht beendet, ihr Ende läge hinter
# dem gewählten Fenster. Sie beim Fensterende abzuschneiden würde eine
# Fahrt mit erfundener Endzeit erzeugen.
if not roh:
return []
verschmolzen = [roh[0]]
for start, ende in roh[1:]:
vorheriger_start, vorheriges_ende = verschmolzen[-1]
if (start - vorheriges_ende).total_seconds() < pausenzeit_s:
verschmolzen[-1] = (vorheriger_start, ende)
else:
verschmolzen.append((start, ende))
return verschmolzen
def _ueberschneidet(start, ende, bestehende):
"""True, wenn sich [start, ende] mit einer bereits erfassten Fahrt
überschneidet. Verhindert Dubletten beim wiederholten Import."""
for f in bestehende:
try:
f_start = datetime.datetime.fromisoformat(f.get("ts_start"))
f_ende = datetime.datetime.fromisoformat(f.get("ts_end"))
except (TypeError, ValueError):
continue
if f_start.tzinfo is None or f_ende.tzinfo is None:
continue
if start < f_ende and f_start < ende:
return True
return False
def _fahrten_importieren(start, ende, verlaeufe):
"""Fahrten aus dem Zündungsverlauf, mit Kilometerstand und Start-/
Zielkoordinaten aus den übrigen Verläufen ergänzt."""
fenster = _fahrtfenster(verlaeufe["zuendung"], _pausenzeit_sekunden())
if not fenster:
return {"angelegt": 0, "uebersprungen": 0, "zu_kurz": 0}
bestehende = profil.fahrten_lesen()
km_verlauf = verlaeufe["km"]
lat_verlauf = verlaeufe["lat"]
lon_verlauf = verlaeufe["lon"]
angelegt = 0
uebersprungen = 0
zu_kurz = 0
neue = []
for f_start, f_ende in fenster:
dauer_s = int((f_ende - f_start).total_seconds())
if dauer_s < MINDESTDAUER_S:
zu_kurz += 1
continue
if _ueberschneidet(f_start, f_ende, bestehende):
uebersprungen += 1
continue
odo_start = _wert_bei(km_verlauf, f_start)
odo_end = _wert_bei(km_verlauf, f_ende)
distanz = None
if odo_start is not None and odo_end is not None and odo_end >= odo_start:
distanz = round(odo_end - odo_start, 1)
durchschnitt = None
if distanz is not None and dauer_s > 0:
durchschnitt = round(distanz / (dauer_s / 3600.0), 1)
fahrt = {
"trip_id": profil.neue_id("t"),
"ts_start": f_start.isoformat(),
"ts_end": f_ende.isoformat(),
"duration_s": dauer_s,
"distance_km": distanz,
"km_quelle": "sensor" if distanz is not None else None,
"odo_start": odo_start,
"odo_end": odo_end,
"avg_speed_kmh": durchschnitt,
"start_lat": _wert_bei(lat_verlauf, f_start),
"start_lon": _wert_bei(lon_verlauf, f_start),
"end_lat": _wert_bei(lat_verlauf, f_ende),
"end_lon": _wert_bei(lon_verlauf, f_ende),
"start_address": None,
"end_address": None,
"art": "privat",
"route": None,
"pausen": [],
"source": "import",
"status": "vollständig" if distanz is not None else "offen",
"edited_fields": [],
}
neue.append(fahrt)
bestehende.append(fahrt)
angelegt += 1
# Alle neuen Fahrten in einem Rutsch anhängen statt je Fahrt einmal die
# Datei zu öffnen - bei einem Jahr Verlauf sind das sonst hunderte
# Einzelschreibvorgänge.
if neue:
alle = profil.fahrten_lesen() + neue
alle.sort(key=lambda f: f.get("ts_start") or "")
profil.fahrten_schreiben(alle)
return {"angelegt": angelegt, "uebersprungen": uebersprungen, "zu_kurz": zu_kurz}
def _schwelle_prozent():
p = profil.profil_lesen()
if p is None:
tankvolumen = STANDARD_TANKVOLUMEN_LITER
else:
tankvolumen = p.get("fahrzeug", {}).get("tankvolumen_liter") or STANDARD_TANKVOLUMEN_LITER
return min((LITER_SCHWELLE / tankvolumen) * 100, PROZENT_SCHWELLE)
def _tankvorgaenge_importieren(verlaeufe):
"""Tankvorgänge aus dem Füllstandsverlauf - dieselbe Tiefststand-Logik
wie tankerkennung.py (siehe dortiger Kopfkommentar): jeder Anstieg über
die Schwelle gegen den zuletzt gesehenen Tiefststand ist ein Tankvorgang,
nicht jeder Anstieg gegen den unmittelbar vorherigen Wert."""
verlauf = verlaeufe["tank"]
if not verlauf:
return {"angelegt": 0, "uebersprungen": 0}
schwelle = _schwelle_prozent()
km_verlauf = verlaeufe["km"]
bestehende = profil.tankvorgaenge_lesen()
fenster = datetime.timedelta(minutes=TANK_DUBLETTE_MIN)
bekannte_zeiten = []
for t in bestehende:
try:
ts = datetime.datetime.fromisoformat(t.get("ts"))
except (TypeError, ValueError):
continue
if ts.tzinfo is not None:
bekannte_zeiten.append(ts)
angelegt = 0
uebersprungen = 0
neue = []
tiefststand = None
for ts, wert in verlauf:
aktuell = _zahl(wert)
if aktuell is None:
continue
if tiefststand is None or aktuell <= tiefststand:
tiefststand = aktuell
continue
if aktuell - tiefststand < schwelle:
continue
dublette = False
for bekannt in bekannte_zeiten:
if abs((bekannt - ts).total_seconds()) < fenster.total_seconds():
dublette = True
break
if dublette:
uebersprungen += 1
tiefststand = aktuell
continue
odometer_km = _wert_bei(km_verlauf, ts)
# "Gefahren seit der letzten Tankung" heißt: seit der letzten Tankung
# VOR dieser hier - nicht seit der zeitlich jüngsten überhaupt. Beim
# Import eines vergangenen Zeitraums liegen im Bestand regelmäßig
# bereits neuere Tankvorgänge; die als Bezug zu nehmen ergäbe eine
# negative Strecke (und damit, nach der Prüfung unten, gar keine).
eigene_ts = ts.isoformat()
vorheriger = None
for t in neue + bestehende:
t_ts = t.get("ts") or ""
if t.get("odometer_km") is None or not t_ts or t_ts >= eigene_ts:
continue
if vorheriger is None or t_ts > vorheriger[0]:
vorheriger = (t_ts, t["odometer_km"])
distanz = None
if odometer_km is not None and vorheriger is not None:
distanz = round(odometer_km - vorheriger[1], 1)
if distanz < 0:
distanz = None
tankvorgang = {
"tank_id": profil.neue_id("f"),
"receipt_key": None,
"ts": ts.isoformat(),
"liters": None,
"fuel_total_eur": None,
"price_per_l": None,
"discount": None,
"station_name": None,
"odometer_km": odometer_km,
"distance_km": distanz,
"fuel_type": None,
"source": "import",
"status": "unvollständig",
"receipt_file": None,
"edited_fields": [],
}
neue.append(tankvorgang)
bekannte_zeiten.append(ts)
angelegt += 1
tiefststand = aktuell
if neue:
alle = profil.tankvorgaenge_lesen() + neue
alle.sort(key=lambda t: t.get("ts") or "")
profil.tankvorgaenge_schreiben(alle)
return {"angelegt": angelegt, "uebersprungen": uebersprungen}
def _batterie_importieren(verlaeufe):
"""Tagesminimum/-maximum der 12V-Spannung je Tag des Zeitraums.
profil.batterieverlauf_tageswert_aktualisieren() führt bestehende und
neue Werte pro Tag zusammen (min bleibt min, max bleibt max), deshalb
braucht es hier keine eigene Dubletten-Prüfung: ein zweiter Import
desselben Zeitraums verändert die Einträge nicht mehr."""
verlauf = verlaeufe["batterie"]
if not verlauf:
return {"tage": 0}
tage = {}
for ts, wert in verlauf:
spannung = _zahl(wert)
if spannung is None:
continue
tag = ts.date().isoformat()
eintrag = tage.get(tag)
if eintrag is None:
tage[tag] = {"min": spannung, "min_ts": ts, "max": spannung, "max_ts": ts}
continue
if spannung < eintrag["min"]:
eintrag["min"] = spannung
eintrag["min_ts"] = ts
if spannung > eintrag["max"]:
eintrag["max"] = spannung
eintrag["max_ts"] = ts
for tag in sorted(tage):
werte = tage[tag]
profil.batterieverlauf_tageswert_aktualisieren(
tag, werte["min_ts"].isoformat(), werte["min"]
)
profil.batterieverlauf_tageswert_aktualisieren(
tag, werte["max_ts"].isoformat(), werte["max"]
)
return {"tage": len(tage)}
@service
def audi_dashboard_historie_importieren(start=None, ende=None):
"""Liest den Home-Assistant-Verlauf im gewählten Zeitraum und leitet
daraus Fahrten, Tankvorgänge und Batteriewerte ab.
start/ende sind ISO-Zeitstempel aus der Oberfläche (Ortszeit ohne
Zeitzone ist zulässig). Aufruf als
pyscript.audi_dashboard_historie_importieren."""
profil.ordner_sicherstellen()
try:
von = _als_zeit(start)
bis = _als_zeit(ende)
except ValueError as fehler:
log.error(f"audi_dashboard: Import mit unlesbarem Zeitraum aufgerufen ({fehler})")
_status("fehler", {"meldung": "Zeitraum nicht lesbar"})
return
if von is None or bis is None or von >= bis:
log.warning("audi_dashboard: Import ohne gültigen Zeitraum aufgerufen")
_status("fehler", {"meldung": "Bitte einen Zeitraum wählen, dessen Ende nach dem Start liegt."})
return
_status("laeuft", {"von": von.isoformat(), "bis": bis.isoformat()})
log.info(f"audi_dashboard: Import gestartet für {von.isoformat()} bis {bis.isoformat()}")
try:
verlaeufe = {
"zuendung": _verlauf(einstellungen.ZUENDUNG_SENSOR, von, bis),
"km": _verlauf(einstellungen.KM_SENSOR, von, bis),
"tank": _verlauf(einstellungen.TANK_SENSOR, von, bis),
"batterie": _verlauf(einstellungen.BATTERIE_SENSOR, von, bis),
"lat": _verlauf(einstellungen.STANDORT_LAT_SENSOR, von, bis),
"lon": _verlauf(einstellungen.STANDORT_LON_SENSOR, von, bis),
}
except Exception as fehler:
log.error(f"audi_dashboard: Verlauf nicht lesbar ({type(fehler).__name__}: {fehler})")
_status("fehler", {"meldung": (
"Der Verlauf konnte nicht gelesen werden. Steht hass_is_global: true "
"im pyscript-Block der configuration.yaml?"
)})
return
# Frühester Zeitpunkt, zu dem im gewählten Fenster überhaupt etwas
# aufgezeichnet war - damit ein leeres Ergebnis erklärbar wird
# ("recorder reicht nur bis ...") statt wie ein Fehler auszusehen.
frueheste = None
for name in verlaeufe:
if verlaeufe[name]:
erster = verlaeufe[name][0][0]
if frueheste is None or erster < frueheste:
frueheste = erster
fahrten = _fahrten_importieren(von, bis, verlaeufe)
tank = _tankvorgaenge_importieren(verlaeufe)
batterie = _batterie_importieren(verlaeufe)
frontend_veroeffentlichung.fahrten_veroeffentlichen()
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
frontend_veroeffentlichung.batterieverlauf_veroeffentlichen()
ergebnis = {
"von": von.isoformat(),
"bis": bis.isoformat(),
"ab_wann_daten": frueheste.isoformat() if frueheste else None,
"fahrten_angelegt": fahrten["angelegt"],
"fahrten_uebersprungen": fahrten["uebersprungen"],
"fahrten_zu_kurz": fahrten["zu_kurz"],
"tankvorgaenge_angelegt": tank["angelegt"],
"tankvorgaenge_uebersprungen": tank["uebersprungen"],
"batterie_tage": batterie["tage"],
}
_status("fertig", ergebnis)
log.info(f"audi_dashboard: Import abgeschlossen - {ergebnis}")
+45 -25
View File
@@ -13,33 +13,52 @@ entitaeten.py) - Änderungen von dort werden zur Laufzeit auf diese Variablen
angewendet (überschreiben also die hier hinterlegten Standardwerte), ohne
diese Datei anzufassen.
2026-08-12: Die HACS-Integration TommiG1/HA_VAG-EU-Data-Act (bisherige
Quelle für Kilometerstand, Tankfüllstand, Türen/Fenster/Schlösser,
Ölwechsel-/Inspektionsdaten) wird nicht mehr verwendet - die zugehörigen
Entity-IDs sind deshalb unten bewusst leer. Neue Datenquelle ist der
Teltonika FMM003 (GPS-Tracker mit CAN-Anbindung, siehe AGENTS.md Abschnitt
B); er liefert Standort, Zündungsstatus und Batteriespannung, aber keinen
Tankfüllstand, keine Tür-/Fenster-/Schlossdaten und keine Ölwechsel-/
Inspektionstermine - die entsprechenden Kacheln zeigen deshalb bis auf
Weiteres "unbekannt" statt eines falschen Werts (siehe zustand_oder_none()
in frontend_veroeffentlichung.py). Bleibt ein Wert leer ("") oder passt eine
Entity-ID nicht zur tatsächlichen Integration, liefert zustand_oder_none()
für das jeweilige Feld None statt abzustürzen.
**Im Auslieferstand sind alle Felder hier leer.** Das ist Absicht, kein
unfertiger Zustand: welche Entity-IDs richtig sind, hängt an der jeweiligen
Instanz und ihren Integrationen. Die Zuordnung passiert nach der Installation
im Setup-Menü der App (Einstellungen -> Fahrzeug einrichten -> Setup), das
sie nach data/entitaeten.json schreibt; diese Datei bleibt dabei unangetastet.
Ein leeres Feld ist der sichere Zustand: die betroffene Kachel zeigt
"unbekannt" statt eines falschen Werts (siehe zustand_oder_none() in
frontend_veroeffentlichung.py), und die trigger-gebundenen Felder
(ZUENDUNG_SENSOR, KM_SENSOR, TANK_SENSOR) registrieren gar keinen Trigger,
statt einen gegen eine nicht existierende Entität zu registrieren. Eine
gesetzte, aber falsche Entity-ID ist deshalb schlechter als eine leere.
Zwei typische Quellen auf dieser Instanz: der Teltonika FMM003 (GPS-Tracker
mit CAN-Anbindung, über flespi angebunden - Standort, Zündungsstatus,
Bordnetzspannung, CAN-Werte wie Kilometerstand und Tankfüllstand) und eine
EU-Data-Act-Integration des Herstellers (Kilometerstand, Tankfüllstand,
Türen/Fenster/Schlösser, Reifendrücke, Ölwechsel-/Inspektionstermine). Welche
davon welche Rolle bedient, entscheidet das Setup-Menü - nicht diese Datei.
"""
# Fahrterkennung (§7.1): Start/Ende einer Fahrt wird über den Zündungs-/ACC-
# Status des FMM003 erkannt (on = Fahrt läuft), nicht mehr über die WLAN-
# Verbindung des iPhones zum Fahrzeug (siehe fahrterkennung.py).
ZUENDUNG_SENSOR = "binary_sensor.testzone_fmm003_engine_ignition_or_acc_status"
#
# Leer im Auslieferstand, wie alle Felder hier. Bis 2026-08-23 stand hier die
# Entity-ID einer längst abgeräumten Testinstanz
# ("binary_sensor.testzone_fmm003_..."). Auf einer frischen Installation
# zeigte sie ins Leere - und richtete dabei mehr an, als nur nutzlos zu sein:
# fahrterkennung.py registriert seinen @state_trigger nur, WENN dieses Feld
# belegt ist ("if einstellungen.ZUENDUNG_SENSOR:", ausdrücklich als Schutz
# gegen eine leere Entity-ID gebaut). Ein gesetzter, aber nicht existierender
# Wert hebelt genau diesen Schutz aus. Dasselbe galt für BATTERIE_SENSOR
# weiter unten. Zuordnung gehört ins Setup-Menü, nicht in den Auslieferstand.
ZUENDUNG_SENSOR = ""
# Kilometerstand - bisher aus der TommiG1-Integration, aktuell keine Quelle
# vorhanden. Der FMM003 liefert unter sensor.testzone_fmm003_total_calculated_mileage
# einen selbst berechneten Wert, der aber auf einer anderen Zählbasis beruht
# als der echte Fahrzeug-Kilometerstand (GPS-Streckenberechnung statt
# Tacho) - bewusst NICHT automatisch übernommen, um Reifenzähler,
# Ölwechsel-Prognose und Fahrtabschluss-Screening nicht mit einem
# inkonsistenten Basiswert zu verfälschen. Bei Bedarf über das Setup-Menü
# gezielt zuordnen.
# Kilometerstand - für Fahrtabschluss-Screening, Reifenzähler und
# Ölwechsel-Prognose.
#
# Beim FMM003 hier NICHT den selbst berechneten Gesamtkilometerstand
# (*_total_calculated_mileage) zuordnen: der beruht auf GPS-Streckenrechnung
# statt auf dem Tacho und damit auf einer anderen Zählbasis als der echte
# Fahrzeug-Kilometerstand. Wer ihn einträgt, verfälscht alle drei genannten
# Auswertungen mit einem inkonsistenten Basiswert. Der vom CAN gelesene Wert
# (*_total_vehicle_mileage_read_from_can) bzw. der Kilometerstand der
# EU-Data-Act-Integration ist der richtige.
KM_SENSOR = ""
# Tankfüllstand (Prozent) - keine Quelle mehr vorhanden (der FMM003 ist kein
@@ -49,11 +68,12 @@ TANK_SENSOR = ""
# Reichweite (§5.1 Übersicht) - keine Quelle mehr vorhanden.
RANGE_SENSOR = ""
# 12V-Batteriespannung (Mein Audi -> Zustand). Vom FMM003 geliefert -
# external_power_voltage ist die vom Gerät gemessene Bordnetzspannung des
# Fahrzeugs, NICHT battery_voltage (das ist die interne Pufferbatterie des
# 12V-Batteriespannung (Mein Audi -> Zustand). Beim FMM003 ist das
# external_power_voltage - die vom Gerät gemessene Bordnetzspannung des
# Fahrzeugs -, NICHT battery_voltage (das ist die interne Pufferbatterie des
# Trackers selbst und hat mit der Fahrzeugbatterie nichts zu tun).
BATTERIE_SENSOR = "sensor.testzone_fmm003_external_power_voltage"
# Leer im Auslieferstand, siehe ZUENDUNG_SENSOR oben.
BATTERIE_SENSOR = ""
# Knopf für eine sofortige Neuabfrage beim Fahrzeug - kam aus der
# TommiG1-Integration, keine Entsprechung beim FMM003 vorhanden.
+7
View File
@@ -282,6 +282,13 @@ def batterieverlauf_tageswert_aktualisieren(datum, ts, spannung):
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)
+103
View File
@@ -0,0 +1,103 @@
# Datenaufbewahrung (recorder) — Ergänzung zur bestehenden configuration.yaml.
#
# WOFÜR DAS DA IST
# ----------------
# Home Assistant löscht Sensor-Verlaufsdaten standardmäßig nach 10 Tagen
# (recorder.purge_keep_days, Standardwert). Das ist die Obergrenze dafür, wie
# weit ein späterer Import "Daten aus Home Assistant importieren" überhaupt
# zurückreichen KANN — was der recorder gelöscht hat, ist unwiederbringlich
# weg, auch für die App.
#
# Die App selbst speichert dagegen bereits unbegrenzt: fahrten.jsonl,
# tankvorgaenge.jsonl, batteriespannung.jsonl und fahrzeugprofil.json unter
# /config/audi_dashboard/ werden nirgends automatisch gekürzt oder gelöscht
# (siehe profil.py — es gibt keine Purge-Logik). Betroffen ist also nur der
# ROHE Sensor-Verlauf in Home Assistants eigener Datenbank, aus dem die App
# ihre Datensätze erst ableitet.
#
# Dieser Block hebt die Aufbewahrung auf ein Jahr an. Das ist die
# Übergangslösung, bis die App die Daten selbst dauerhaft übernimmt: fährt
# der Import einmal über einen Zeitraum, liegen die daraus erzeugten Fahrten/
# Tankvorgänge/Spannungswerte dauerhaft in den .jsonl-Beständen und sind vom
# recorder unabhängig.
#
# EINBAU
# ------
# Diesen Block in die bestehende configuration.yaml übernehmen (nicht die
# Datei ersetzen). Existiert dort schon ein `recorder:`-Block, die Werte dort
# zusammenführen statt einen zweiten anzulegen. Danach Home Assistant neu
# starten (Entwicklerwerkzeuge -> YAML -> Neu starten).
#
# PLATZBEDARF — an der Testinstanz gemessen, nicht geschätzt
# ---------------------------------------------------------
# Stand 2026-08-23: 90.858 Zustandsänderungen in 17 Tagen = rund 5.300 pro
# Tag, Datenbank 64,7 MB. Hochgerechnet auf 365 Tage sind das grob 1,9 Mio.
# Zeilen und in der Größenordnung 11,5 GB.
#
# ACHTUNG bei HA OS auf kleinem Datenträger (Raspberry Pi mit SD-Karte,
# 32-GB-eMMC): das ist der einzige Punkt dieser ganzen App, der einer sonst
# gesunden Installation gefährlich werden kann. Läuft der Datenträger voll,
# startet Home Assistant nicht mehr sauber - unabhängig von dieser App.
#
# Vor dem Einbau deshalb einmal nachsehen, wie viel Platz frei ist:
# Einstellungen -> System -> Speicher. Faustregel: mindestens 5 GB frei,
# sonst besser mit einem kürzeren Zeitraum anfangen (z. B.
# purge_keep_days: 90) und die auskommentierte exclude:-Liste unten gleich
# mit aktivieren. 90 Tage reichen für den Zweck "Vergangenheit nachtragen"
# in aller Regel aus - der Import holt die Daten ja dauerhaft in die
# .jsonl-Bestände der App, die danach vom recorder unabhängig sind.
#
# Nach ein paar Wochen noch einmal unter Einstellungen -> System -> Speicher
# nachsehen und bei Bedarf nachjustieren.
#
# 96 % dieser Zeilen stammen aus der EU-Data-Act-Integration
# (`cupra_eu_data_act`, ~1.737 Zustände je Entität in 17 Tagen, also etwa
# alle 14 Minuten ein Update über rund 50 Entitäten), 1 % vom FMM003 über
# flespi, 2 % alles Übrige. Wer den Platzbedarf drücken will, kürzt also bei
# der EU-Data-Act-Integration — die auskommentierte `exclude`-Liste unten ist
# ein Vorschlag dafür, welche Entitäten historisch nichts hergeben.
recorder:
# 365 Tage statt der 10 Tage Standardaufbewahrung.
purge_keep_days: 365
# Bewusst KEIN `include:`-Block. Ein `include` würde bedeuten, dass Home
# Assistant *nur noch* die dort genannten Entitäten aufzeichnet und für
# alles andere gar keinen Verlauf mehr führt — auch nicht für die 10 Tage,
# die es vorher hatte. Da auf dieser Instanz ohnehin 97 % der Aufzeichnung
# auf Fahrzeugdaten entfällt, spart das kaum Platz, kostet aber den Verlauf
# jeder anderen Integration. Deshalb: global ein Jahr aufbewahren.
# Standardmäßig 1 Sekunde. 30 Sekunden bündelt die Schreibvorgänge, was vor
# allem SD-Karten schont; der Preis ist, dass die letzten bis zu 30
# Sekunden bei einem harten Stromausfall fehlen können. Für Fahrzeugdaten,
# die ohnehin nur alle paar Minuten aktualisiert werden, ist das
# unerheblich.
commit_interval: 30
# Nächtliches Aufräumen (Standard 04:12) mit Neuaufbau der Datenbankdatei.
# repack gibt den durch gelöschte Zeilen frei gewordenen Platz auch
# tatsächlich ans Dateisystem zurück, statt die Datei nur intern als "leer"
# zu markieren — bei einem Jahr Aufbewahrung sonst der übliche Grund, warum
# die Datei nur noch wächst.
auto_purge: true
auto_repack: true
# OPTIONAL — nur einkommentieren, wenn die Datenbank zu groß wird.
# Diese Entitäten liefern über die Zeit keinen Erkenntniswert: reine
# Diagnose-/Netzwerkwerte des Trackers und abgeleitete Momentanwerte der
# EU-Data-Act-Integration, die sich jederzeit neu berechnen lassen.
# exclude:
# entity_globs:
# - sensor.*_tire_pressure_diff_*
# - sensor.*fmm003*_mobile_network_*
# - sensor.*fmm003*_lte_*
# - sensor.*fmm003*_network_*
# - sensor.*fmm003*_id_of_*
# - sensor.*fmm003*_*dilution_of_precision
# - sensor.*fmm003*_satellites
# entities:
# - sensor.audi_rs_4_avant_ei_t1302_echo
# - sensor.audi_rs_4_avant_ei_t1302_trueness
# - sensor.audi_rs_4_avant_uncurated_fields
# - sensor.audi_rs_4_avant_minutes_since_last_snapshot
+175 -1
View File
@@ -2137,6 +2137,12 @@ function vEinst() {
<div class="feld" style="border-bottom:0"><label for="oelM">Nach Zeit</label>
<select id="oelM" data-oelm>${[12, 24].map((m) => `<option value="${m}" ${m === CAR.oel.monate ? "selected" : ""}>${m === 12 ? "1 Jahr" : "2 Jahre"}</option>`).join("")}</select></div>`}
<button class="aktion" style="margin-top:20px" data-setup-oeffnen>Setup Sensoren zuordnen</button>
<button class="aktion" data-import-oeffnen>Daten importieren aus Home Assistant</button>
<span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Legt Fahrten, Tankvorgänge und Spannungswerte für einen vergangenen Zeitraum
nachträglich an aus dem Verlauf, den Home Assistant zu den oben zugeordneten
Sensoren bereits aufgezeichnet hat.</span>
` : ""}
<button class="aktion ${einrichtenOffen ? "primaer" : ""}" style="margin-top:16px" data-einrichten-auf>${einrichtenOffen ? "Fertig" : "Einrichten"}</button>
</div>
@@ -3315,6 +3321,77 @@ function zwischenablageKnopfSinnvoll() {
return window.matchMedia && window.matchMedia("(pointer: coarse)").matches;
}
/* ------------------------------------------------- Historien-Import-Popup
Zeitraum wählen, importieren lassen, Ergebnis zeigen. Der eigentliche
Import läuft im Backend (pyscript/historienimport.py) und meldet seinen
Stand über die Entität pyscript.audi_dashboard_import_status - dasselbe
Muster wie beim Update-Status (UPDATE_STATUS oben). */
let importPopup = null;
/* Vorbelegung: die letzten 30 Tage. Wert im Format, das <input
type="datetime-local"> erwartet (Ortszeit, ohne Zeitzone, minutengenau) -
genau das, was der Dienst als Ortszeit interpretiert. */
function importZeitraumVorgabe() {
const jetzt = new Date();
const vor30 = new Date(jetzt.getTime() - 30 * 86400000);
const alsFeld = (d) => new Date(d.getTime() - d.getTimezoneOffset() * 60000)
.toISOString().slice(0, 16);
return { von: alsFeld(vor30), bis: alsFeld(jetzt) };
}
function importErgebnisText(d) {
const anzahl = (n, eins, viele) => `${de(n)} ${n === 1 ? eins : viele}`;
const teile = [anzahl(d.fahrten_angelegt || 0, "Fahrt", "Fahrten") + " angelegt"];
if (d.fahrten_uebersprungen) teile.push(`${de(d.fahrten_uebersprungen)} bereits vorhanden`);
if (d.fahrten_zu_kurz) teile.push(anzahl(d.fahrten_zu_kurz, "Zündung zu kurz für eine Fahrt", "Zündungen zu kurz für eine Fahrt"));
if (d.tankvorgaenge_angelegt) teile.push(anzahl(d.tankvorgaenge_angelegt, "Tankvorgang", "Tankvorgänge"));
if (d.batterie_tage) teile.push(anzahl(d.batterie_tage, "Tag Batteriespannung", "Tage Batteriespannung"));
return teile.join(" · ");
}
function vImportPopup() {
if (!importPopup) return "";
const p = importPopup;
const fertig = p.ergebnis && !p.laeuft;
// Nach dem Lauf zeigt dasselbe Fenster das Ergebnis statt der Felder - der
// Nutzer soll sehen, was passiert ist, bevor er es wegklickt (HIG
// "Feedback"), statt das Fenster wortlos verschwinden zu lassen.
const koerper = fertig
? `<div style="margin-top:14px;font-size:14px;line-height:1.6;color:var(--fg)">
${esc(importErgebnisText(p.ergebnis))}</div>
${p.ergebnis.fahrten_angelegt === 0 && p.ergebnis.ab_wann_daten
? `<span class="label" style="margin-top:12px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Aufgezeichnet ist in diesem Zeitraum ab ${dedat(new Date(p.ergebnis.ab_wann_daten))} ·
${new Date(p.ergebnis.ab_wann_daten).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr.
Weiter zurück bewahrt Home Assistant nichts mehr auf.</span>`
: ""}
${p.ergebnis.fahrten_angelegt === 0 && !p.ergebnis.ab_wann_daten
? `<span class="label" style="margin-top:12px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Für diesen Zeitraum liegt in Home Assistant kein Verlauf mehr vor.</span>`
: ""}`
: `<div class="feld" style="margin-top:12px"><label for="impVon">Von</label>
<input id="impVon" type="datetime-local" style="width:196px" value="${esc(p.von)}" data-import-von></div>
<div class="feld" style="border-bottom:0"><label for="impBis">Bis</label>
<input id="impBis" type="datetime-local" style="width:196px" value="${esc(p.bis)}" data-import-bis></div>
${p.fehler ? `<span class="label" style="margin-top:10px;color:var(--red)">${esc(p.fehler)}</span>` : ""}
<span class="label" style="margin-top:12px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Bereits erfasste Fahrten bleiben unangetastet überschneidet sich ein
Zeitraum, wird er übersprungen statt doppelt angelegt.</span>`;
return `<div class="beleg-catcher" data-import-zu></div>
<div class="beleg-popup" role="dialog" aria-modal="true" aria-label="Daten importieren">
<span class="label">Daten importieren aus Home Assistant</span>
${koerper}
${fertig
? `<button class="aktion primaer" data-import-zu>Fertig</button>`
: `<button class="aktion primaer" data-import-start ${p.laeuft ? "disabled" : ""}>${p.laeuft ? "Importiere …" : "Importieren"}</button>
<button class="aktion" data-import-zu ${p.laeuft ? "disabled" : ""}>Abbrechen</button>`}
</div>`;
}
function vBelegPopup() {
if (!belegPopup) return "";
// Am Telefon ist Einfügen der Hauptweg (PDF aus der Mail kopiert), am
@@ -3500,7 +3577,7 @@ function render() {
// faellt sonst bei jedem render(), z. B. beim Umschalten des Filters,
// auf 0 zurueck).
const setupKoerperScroll = setupOffen ? (ROOT.querySelector(".setup-koerper") || {}).scrollTop : 0;
ROOT.getElementById("overlay").innerHTML = sheetMarkup() + vSetupPopup() + vBelegPopup();
ROOT.getElementById("overlay").innerHTML = sheetMarkup() + vSetupPopup() + vBelegPopup() + vImportPopup();
if (setupOffen) {
const koerper = ROOT.querySelector(".setup-koerper");
if (koerper) koerper.scrollTop = setupKoerperScroll || 0;
@@ -3600,6 +3677,84 @@ function randwischenVerdrahten() {
Schreibende Aktionen: lokale Anzeige sofort aktualisieren (render()),
Persistenz über hass.callService im Hintergrund - dieselbe Reihenfolge
wie im Prototyp, nur dass jetzt zusätzlich gespeichert wird. */
/* Import anstoßen und auf sein Ergebnis warten.
Bewusst NICHT über serviceRufen(): der Import ist die eine Aktion, bei der
das Fenster offen bleibt und das Ergebnis zeigt, statt optimistisch zu
schließen. Das Backend meldet seinen Stand über
pyscript.audi_dashboard_import_status; hier wird dieselbe Entität gepollt,
bis sie "fertig" oder "fehler" meldet. Ein Poll statt eines Abwartens des
callService-Versprechens, weil pyscript-Dienste sofort zurückkehren, wenn
sie im Hintergrund weiterlaufen. */
async function importStatusLesen() {
try {
const states = await HASS.callWS({ type: "get_states" });
return states.find((x) => x.entity_id === "pyscript.audi_dashboard_import_status") || null;
} catch (e) {
return null;
}
}
async function importAusloesen() {
if (!importPopup || importPopup.laeuft) return;
const von = importPopup.von;
const bis = importPopup.bis;
if (!von || !bis) { importPopup.fehler = "Bitte Start und Ende wählen."; render(); return; }
if (von >= bis) { importPopup.fehler = "Das Ende muss nach dem Start liegen."; render(); return; }
importPopup.laeuft = true;
importPopup.fehler = null;
render();
// Zeitstempel des letzten Laufs merken: nur ein danach geschriebener Stand
// ist die Antwort auf DIESEN Aufruf und nicht das Ergebnis von gestern.
const vorher = await importStatusLesen();
const vorherStand = vorher ? vorher.last_updated : null;
try {
await HASS.callService("pyscript", "audi_dashboard_historie_importieren", { start: von, ende: bis });
} catch (err) {
console.error("audi_dashboard: Import", err);
if (importPopup) {
importPopup.laeuft = false;
importPopup.fehler = (err && err.message) || "Der Import konnte nicht gestartet werden.";
render();
}
return;
}
// Bis zu zwei Minuten - ein Jahr Verlauf über sechs Sensoren braucht seine
// Zeit, und ein zu früher Abbruch sähe wie ein Fehlschlag aus, obwohl der
// Import im Hintergrund sauber zu Ende läuft.
for (let versuch = 0; versuch < 120; versuch++) {
await new Promise((r) => setTimeout(r, 1000));
if (!importPopup) return;
const s = await importStatusLesen();
if (!s || s.last_updated === vorherStand) continue;
if (s.state === "fertig") {
importPopup.laeuft = false;
importPopup.ergebnis = (s.attributes && s.attributes.daten) || {};
render();
// Fahrten-/Tankvorgangslisten im Frontend nachziehen - das Backend hat
// sie bereits neu veröffentlicht, die App muss sie nur neu lesen.
datenAktualisieren();
return;
}
if (s.state === "fehler") {
importPopup.laeuft = false;
importPopup.fehler = (s.attributes && s.attributes.daten && s.attributes.daten.meldung)
|| "Der Import ist fehlgeschlagen.";
render();
return;
}
}
if (importPopup) {
importPopup.laeuft = false;
importPopup.fehler = "Der Import dauert ungewöhnlich lange — er läuft im Hintergrund weiter.";
render();
}
}
function serviceRufen(dienst, daten) {
// HIG "Feedback": ein fehlgeschlagener Aufruf darf nicht nur in der Konsole
// landen - die aufrufende Stelle hat ihr Popup meist schon geschlossen und
@@ -3657,6 +3812,9 @@ function ereignisseVerdrahten() {
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
if (belegPopup) { belegPopup = null; render(); e.preventDefault(); return; }
// Während der Import läuft nicht wegklickbar - er läuft im Backend
// weiter, das Fenster ist die einzige Stelle, die sein Ergebnis zeigt.
if (importPopup) { if (!importPopup.laeuft) { importPopup = null; render(); } e.preventDefault(); return; }
if (sheet) { sheetSchliessen(); e.preventDefault(); return; }
if (setupSucheOffen) { setupSucheOffen = null; setupSuchtext = ""; render(); e.preventDefault(); return; }
if (setupOffen) { setupSchliessen(); e.preventDefault(); return; }
@@ -4142,6 +4300,17 @@ function ereignisseVerdrahten() {
eingabe.click();
return;
}
if (e.target.closest("[data-import-oeffnen]")) {
const vorgabe = importZeitraumVorgabe();
importPopup = { von: vorgabe.von, bis: vorgabe.bis, laeuft: false, ergebnis: null, fehler: null };
render();
return;
}
if (e.target.closest("[data-import-zu]")) {
if (importPopup && importPopup.laeuft) return;
importPopup = null; render(); return;
}
if (e.target.closest("[data-import-start]")) { importAusloesen(); return; }
const del = e.target.closest("[data-loeschen]");
if (del) { delete CAR.service.vereinbart[del.dataset.loeschen]; profilSpeichern(); render(); return; }
const vseldel = e.target.closest("[data-vselbstdel]");
@@ -4402,6 +4571,11 @@ function ereignisseVerdrahten() {
CAR.versicherung.beitrag = sum(CAR.versicherung.teile, (t) => t.betrag || 0);
profilSpeichern(); render(); return;
}
// Kein render() bei den beiden Import-Feldern: das Popup wird bei jedem
// render() neu erzeugt, ein Neuzeichnen mitten in der Eingabe risse dem
// Nutzer den gerade offenen Datumswähler weg.
if (e.target.dataset.importVon !== undefined) { if (importPopup) importPopup.von = e.target.value; return; }
if (e.target.dataset.importBis !== undefined) { if (importPopup) importPopup.bis = e.target.value; return; }
if (e.target.dataset.vgueltigab !== undefined) { CAR.versicherung.gueltigAb = e.target.value; profilSpeichern(); render(); return; }
const vnotruf = e.target.dataset.vnotruf;
if (vnotruf) { CAR.versicherung[vnotruf] = e.target.value; profilSpeichern(); render(); return; }
@@ -1 +1 @@
{"version": 1787014000}
{"version": 1787015002}
+19 -2
View File
@@ -950,13 +950,30 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
.standortmenu-werte { display: flex; align-items: center; gap: 10px; margin-top: 8px; font-size: 13.5px; color: var(--fg2); }
.standortmenu-werte svg { color: var(--fg3); flex: 0 0 auto; }
.standortmenu-aktionen { display: flex; gap: 10px; margin-top: 18px; padding-bottom: 4px; }
/* Beide Pillen folgen der Knopfsprache des restlichen Panels (.aktion /
.aktion.primaer weiter oben): Umriss fuer die Nebenaktion, gefuellt fuer
die Hauptaktion.
Vorher stand hier background: var(--tile) fuer "Teilen" und
background: var(--line-strong) fuer "Route". Beides ging im Tagmodus
schief: das Blatt darunter ist var(--tile-deckend), und die iOS-Auflage
(audi-dashboard-ios.css) setzt im Tagmodus --tile UND --tile-deckend
beide auf #FFFFFF - "Teilen" war damit weiss auf weiss, Kontrast 1,0:1,
also schlicht unsichtbar. --line-strong als Flaeche war ausserdem eine
Linienfarbe in der Rolle einer Fuellung.
Der Umriss haengt jetzt an denselben Tokens wie jeder andere Knopf und
traegt deshalb in beiden Themes und auch unter der iOS-Auflage, ohne dass
hier eine eigene Farbe gepflegt werden muss. */
.standort-pille {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
height: 40px; padding: 0 20px; border-radius: var(--r-pill);
border: none; background: var(--tile); color: var(--fg);
border: 1px solid var(--line-strong); background: none; color: var(--fg);
font-family: inherit; font-size: 14px; text-decoration: none; cursor: pointer;
}
.standort-pille.primaer { background: var(--line-strong); }
.standort-pille.primaer {
background: var(--fg); border-color: var(--fg); color: var(--canvas);
}
.standort-pille:active { transform: scale(.97); }
.standort-pille[disabled] { opacity: .4; pointer-events: none; }