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:
@@ -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 1–1,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 1–1,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; }
|
||||
|
||||
|
||||
@@ -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 1–1,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; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user