Vergangene Daten aus dem HA-Verlauf importierbar, Auslieferstand bereinigt

Datenaufbewahrung (recorder_snippet.yaml, neu): Home Assistant loescht
Sensor-Verlaeufe standardmaessig nach 10 Tagen - das war die stille
Obergrenze dafuer, wie weit sich ueberhaupt je etwas rekonstruieren
laesst, denn geloeschte recorder-Zeilen sind endgueltig weg. Der Block
hebt das auf 365 Tage. Die Bestaende der App selbst (fahrten.jsonl,
tankvorgaenge.jsonl, batteriespannung.jsonl, fahrzeugprofil.json) waren
davon nie betroffen, die kennen ohnehin keine Purge-Logik. Platzbedarf
an der Testinstanz gemessen statt geschaetzt: 90.858 Zeilen in 17 Tagen,
hochgerechnet grob 1,9 Mio. Zeilen und 1-1,5 GB im Jahr, davon 96 % aus
der EU-Data-Act-Integration - dafuer liegt eine auskommentierte
exclude:-Liste bei. Bewusst kein include:-Block, der wuerde jeder
anderen Integration den Verlauf nehmen und dabei kaum Platz sparen.

Historienimport (pyscript/historienimport.py, neu): neuer Dienst
audi_dashboard_historie_importieren(start, ende) liest denselben
Verlauf, den die Live-Trigger in Echtzeit sehen, und leitet rueckwirkend
dieselben Datensaetze ab - Fahrten aus dem Zuendungsverlauf (inklusive
der Pausenregel, kurze Unterbrechungen verschmelzen zu einer Fahrt),
Tankvorgaenge nach derselben Tiefststand-Logik und denselben Schwellen
wie tankerkennung.py, Tagesmin/-max der Batteriespannung. Mehrfach
ausfuehrbar: ueberschneidet sich eine Fahrt mit einer bereits erfassten,
wird sie uebersprungen statt doppelt angelegt. Erzeugte Datensaetze
tragen source: "import".

Gelesen wird ueber HAs eigene recorder-API (get_significant_states),
nicht per direktem SQL auf home-assistant_v2.db: das Schema ist
HA-intern und aendert sich zwischen Versionen, die Funktion ist die
stabile Schnittstelle. Preis dafuer ist hass_is_global: true im
pyscript-Block; ohne die Zeile laeuft alles andere unveraendert weiter,
nur der Import meldet, dass er den Verlauf nicht lesen kann.

Oberflaeche in beiden Codebasen: Knopf "Daten importieren aus Home
Assistant" unter Einstellungen -> Einrichten, dahinter ein Fenster mit
Von/Bis (Vorbelegung: letzte 30 Tage), "Importieren" als Hauptaktion und
"Abbrechen" als zweite Wahl. Das Fenster bleibt offen und zeigt das
Ergebnis, statt optimistisch zu schliessen - hier ist das Ergebnis der
Zweck des Aufrufs. Fortschritt ueber die Entitaet
pyscript.audi_dashboard_import_status, weil pyscript-Dienste sofort
zurueckkehren. In companion-app bewusst NICHT ueber die
Offline-Warteschlange: ein spaeter aus dem Nichts abgefeuerter
Importlauf waere fuer den Nutzer nicht nachvollziehbar.

Beim Testen gefunden und mitgefixt: profil.batterieverlauf_tageswert_
aktualisieren() haengte in Einfuegereihenfolge an. Solange nur die
Live-Aufzeichnung schrieb, war das dasselbe wie sortiert (sie traegt
immer den heutigen Tag ein) - der Import traegt vergangene Tage nach,
die waeren hinter den neueren gelandet und das Diagramm haette zeitlich
rueckwaerts gelaufen. Sortiert jetzt nach Datum, wie der eigene
Docstring es ohnehin versprach.

Auslieferstand bereinigt: einstellungen.py brachte zwei Entity-IDs einer
laengst abgeraeumten Testinstanz mit (testzone_fmm003_...). Beim Testen
unsichtbar, weil die Testinstanz sie ueber entitaeten.json ueberschreibt
- auf einer Neuinstallation waeren sie die wirksamen Werte gewesen. Und
schlimmer als nur wirkungslos: fahrterkennung.py registriert seinen
@state_trigger nur, wenn das Feld belegt ist, ausdruecklich als Schutz
gegen eine leere Entity-ID. Ein gesetzter, aber nicht existierender Wert
hebelt genau diesen Schutz aus. Beide Felder jetzt leer wie alle
anderen; Kopfkommentar der Datei ueberarbeitet, er behauptete noch, die
EU-Data-Act-Integration werde nicht mehr verwendet.

install.ps1 abgesichert, da die erste echte Installation auf HA OS
ansteht: die feste configuration.yaml.bak wurde bei jedem Lauf
ueberschrieben, ausgerechnet das Original war nach dem zweiten Lauf weg
- Sicherungen jetzt zeitgestempelt. Nach dem Schreiben wird die Datei
zurueckgelesen und geprueft (Laenge, genau ein Markerblock, bisheriger
Inhalt unveraendert); bei der kleinsten Abweichung rollt das Skript
automatisch zurueck. Die Sicherheitseigenschaften stehen jetzt im Kopf
der Datei, statt dass man ihnen glauben muss.

Standort-Blatt: "Teilen" war im Tagmodus unsichtbar - .standort-pille
nutzte var(--tile), das Blatt darunter var(--tile-deckend), und die
iOS-Auflage setzt im Tagmodus beide auf #FFFFFF. Gemessener Kontrast
1,00:1, weiss auf weiss. Beide Pillen folgen jetzt der Knopfsprache des
Panels (.aktion / .aktion.primaer): Umriss fuer die Nebenaktion, var(--fg)
gefuellt fuer "Route". Danach 17-21:1 Textkontrast in beiden Themes.
Nebenbei ist damit --line-strong - eine Linienfarbe - nicht laenger als
Knopffuellung im Einsatz.

Geprueft: Import zweimal ueber die echte Oberflaeche im Browser gegen
eine in die recorder-DB eingespielte Kunsthistorie (der Testcontainer
laeuft nicht, waehrend das Auto faehrt, echte Fahrten liegen dort also
nicht vor) - drei Fahrten wie erwartet, die 5-Minuten-Unterbrechung
korrekt zu einer 50-Minuten-Fahrt verschmolzen, die 30-Sekunden-Zuendung
verworfen, Strecken kilometergenau; zweiter Lauf legte 0 an und meldete
3 als vorhanden. Neuinstallation in einem Wegwerf-Container: nur
Profilvorlage und Parser, keine Fahrten-/Tank-/Batteriedatei, null
Fehler im Log. install.ps1 gegen Attrappen: -Pruefen schreibt nichts,
echter Lauf laesst automations.yaml bytegleich (SHA-256) und fremde
Bloecke stehen, Ergebnis parst als gueltiges HA-YAML, zweiter Lauf
idempotent, bei fremdem pyscript:/panel_custom: bleibt die Datei
bytegleich. tsc --noEmit sauber, companion-app-Tests 106/106, vite build
sauber, HA-Configcheck und Start ohne Fehler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 20:42:54 +02:00
parent f115af50ab
commit 3b99f1f332
25 changed files with 2375 additions and 69 deletions
+152 -1
View File
@@ -1,6 +1,6 @@
# AGENTS.md — Project state, review findings, open items, and working rules
**Last updated: 2026-08-19** (merged the `umsetzung-datametric360` branch — companion app phases
**Last updated: 2026-08-23** (merged the `umsetzung-datametric360` branch — companion app phases
110 done, see `UMSETZUNGSPLAN.md`; 2026-08-13: cleaned up remaining EU Data Act residue, fixed
oversized toggle switches, and fixed a desktop-layout audit (settings button / rings logo / popups
overflowing past the capped content column) — see `DESIGN_AUDIT_2026-08-13.md`; 2026-08-16: fixed
@@ -87,6 +87,11 @@ Verified live in the Docker test container (panel: add/edit/delete a Selbstbetei
values persisted through profilSpeichern()+re-render) and via `tsc --noEmit` + the companion-app test
suite (100/100 incl. the render-every-page smoke test, which now also covers `vertrag`) + `vite
build`, since companion-app needs a live backend connection the local dev server doesn't have.
2026-08-23: **retroactive data import** — see section F below for the full entry. In short: HA's
recorder keeps sensor history only 10 days by default, which silently capped how far back anything
could ever be reconstructed; `recorder_snippet.yaml` raises that to a year, and a new
`pyscript/historienimport.py` + "Daten importieren aus Home Assistant" button (both codebases)
rebuilds trips, refuels and battery history for any past window out of that recorder history.
This file is the entry point for every new agent
session: what this repo is, what is finished, what is missing, and how to work here. Detail lives in
the linked documents — this file points, it does not duplicate.
@@ -1705,6 +1710,152 @@ Not planned/scoped in either codebase yet — start with clarifying questions on
and the summary-box redesign goals before touching code, per this project's mockup-first convention
for open-ended redesign asks (see the "Anstehende Termine" 3-round mockup process, section C).
### F) Retroactive data import from the HA recorder (built 2026-08-23)
**The retention answer, because it was the owner's actual question.** Two different lifetimes were
being conflated:
- **The app's own records live forever already.** `fahrten.jsonl`, `tankvorgaenge.jsonl`,
`batteriespannung.jsonl` and `fahrzeugprofil.json` under `/config/audi_dashboard/` have no purge
path anywhere in `profil.py` — nothing trims them, ever. That was already true before this change.
- **HA's raw sensor history did not.** `recorder.purge_keep_days` defaults to **10 days**, and the
test instance had no `recorder:` block at all. That default was the real ceiling: it caps how far
back *any* reconstruction can ever reach, because deleted recorder rows are gone permanently.
`homeassistant/recorder_snippet.yaml` (new) raises it to 365 days with `auto_repack: true`. Measured
on the test instance rather than guessed: 90.858 state rows over 17 days ≈ 5.300/day, 64,7 MB DB →
roughly 1,9 M rows and 11,5 GB for a year. 96 % of that traffic is the `cupra_eu_data_act`
integration (~1.737 states per entity per 17 days across ~50 entities), 1 % flespi/FMM003, 2 % the
rest — so the file carries a commented-out `exclude:` list targeting the EU-Data-Act side for anyone
who needs to shrink it. Deliberately **no `include:` block**: an include would stop HA recording
everything else entirely, and since 97 % of this instance is vehicle data it would save almost
nothing while costing every other integration its history.
**The import itself.** `pyscript/historienimport.py` (new) exposes
`audi_dashboard_historie_importieren(start, ende)`. It reads the same sensors the live triggers
watch and derives the same records they would have produced:
- trips from the ignition sensor — contiguous "on" stretches, then merged where the gap is shorter
than `fahrten_pausenzeit_min`, which reproduces the live pause rule (`task.unique()` +
`task.sleep()` in `fahrterkennung.py`) after the fact; odometer, GPS start/end filled from the
other histories; stretches under 60 s dropped as ignition-without-a-drive
- refuels from the tank sensor using the same low-water-mark logic and the same thresholds as
`tankerkennung.py` (5 l / 9 pp, whichever is more sensitive)
- daily min/max battery voltage, same shape as `batterieverlauf.py`
Re-running is safe by design: trips whose window overlaps an existing trip are skipped (whatever
their origin), refuels dedupe on a 90-minute window, battery days merge in `profil`'s own
min/max update. Created records carry `source: "import"` alongside the existing `ha`/`manual`/`auto`.
**How the history is read, and why.** Via HA's own
`homeassistant.components.recorder.history.get_significant_states` through `task.executor()`, not by
opening `home-assistant_v2.db` with sqlite3 directly. Direct SQL was the tempting option
(`allow_all_imports` is already on, no config change needed) but the recorder schema is HA-internal
and changes between versions; the function is the interface HA itself uses. The cost is one new
config line, **`hass_is_global: true`** in the pyscript block (`configuration_snippet.yaml`), without
which the `hass` name does not exist in pyscript — everything else in the app works fine without it,
only the import reports that it cannot read the history. `significant_changes_only=False` matters:
the default mode drops exactly the small numeric steps that odometer distance and refuel detection
are built from.
**Frontend, both codebases.** Panel: `vImportPopup()` reusing the existing `.beleg-popup` overlay
pattern, opened from a new button under Einstellungen → Einrichten, with Von/Bis
`datetime-local` fields defaulting to the last 30 days, "Importieren" primary and "Abbrechen"
second. The dialog stays open and shows the result rather than closing optimistically — the one
place in the app where that is right, because the result *is* the point of the action. Progress
comes from polling `pyscript.audi_dashboard_import_status` (same pattern as the update status),
since pyscript services return immediately while the work continues in the background.
Companion: `screens/HistorienImport.tsx` using the design system's `Popup variant="form"`, plus
`api.historieImportieren()` / `api.importStatusLesen()`. Deliberately **not** routed through the
offline queue like every other write: the import is a long-running operation whose result is the
reason you invoked it, and a queued run firing later from a dead session would surprise the user —
offline, it simply should not be offered.
**One real bug found and fixed while testing:** `profil.batterieverlauf_tageswert_aktualisieren()`
appended in insertion order. That was invisible while only the live path wrote it (it always adds
today, i.e. always the newest), but the import adds *past* days — they landed after newer ones and
the frontend chart, which draws in file order, would have run backwards. It now sorts by date,
restoring the invariant its own docstring already promised.
**Verification.** The test container is not running while the car is driven (owner's note), so its
recorder holds no real trips. Instead a synthetic history was seeded straight into the recorder DB —
two drives, one with a 5-minute mid-drive ignition gap, one 30-second ignition burst, a two-step
refuel, and two days of voltage — and the import reproduced it exactly: 3 trips (the gap correctly
merged into one 50-minute/40 km trip), 1 rejected as too short, 1 refuel, 2 battery days, distances
matching the seeded odometer to the kilometre. A second run over the same window created 0 and
reported 3 as already present, confirming dedup. Both runs were driven through the real UI in the
browser, not by calling the service directly. `tsc --noEmit` clean, companion tests 106/106 (6 new
for the result-sentence pluralisation), `vite build` clean, HA config check and startup clean.
**Still open / worth knowing:** the import has only ever run against seeded data, never against a
real multi-week recorder history on the production instance — the first real run is the honest test,
especially for how long a full year takes (the UI waits up to two minutes before saying it continues
in the background). Address reverse-geocoding is not attempted for imported trips (`start_address`/
`end_address` stay null, same as live-detected ones before the user edits them).
### G) Fresh-install audit + installer hardening (2026-08-23, before the first real deployment)
Owner is about to install on the real HA OS instance and asked for two things: that the one-click
package produce a genuinely blank app, and that it carry no risk to the existing HA system.
**The one real contamination bug.** `pyscript/modules/einstellungen.py` shipped with two hardcoded
entity IDs from a long-gone test instance — `ZUENDUNG_SENSOR =
"binary_sensor.testzone_fmm003_engine_ignition_or_acc_status"` and `BATTERIE_SENSOR =
"sensor.testzone_fmm003_external_power_voltage"` (note `testzone_`, older even than the current test
container's `testcar_b9_…`). Invisible during all testing, because the test instance's own
`entitaeten.json` overrides both. On a fresh install with no overrides they would have been the
*effective* values — and worse than merely dangling: `fahrterkennung.py` guards trigger registration
with `if einstellungen.ZUENDUNG_SENSOR:`, explicitly built so an unset sensor registers no trigger at
all. A set-but-nonexistent value defeats exactly that guard. Both are now `""` like every other field,
and the file header was rewritten — it still claimed the EU-Data-Act integration was gone, which
stopped being true when `cupra_eu_data_act` appeared.
Everything else in the package was already clean, verified rather than assumed: no `fahrten.jsonl`,
no `tankvorgaenge.jsonl`, no `batteriespannung.jsonl`, no `entitaeten.json`, no real tokens (the
`ha_token` hits are path constants), and `fahrzeugprofil.example.json` carries only model specs —
FIN, Kennzeichen, Erstzulassung, HU, insurance numbers and all counters are empty or zero.
**Verified by actually doing it**, not by reading the installer: a throwaway `ghcr.io/home-assistant/
home-assistant:stable` container, the package copied in exactly as `install.ps1` does it, pyscript
config entry added, restarted. Result: `/config/audi_dashboard/` held only `fahrzeugprofil.json`,
`shell_beleg_parser.py` and an empty `belege/`; no trip, fuel or battery file existed at all; zero
ERROR lines in the log.
**Installer safety, audited and then hardened.** The audit found the good news first: `install.ps1`
contains no `Remove-Item` anywhere, no `robocopy /MIR`, never touches `.storage/`, never reads or
writes `automations.yaml`/`scripts.yaml`/`secrets.yaml`, and never restarts HA. The only file it
changes outside its own folders is `configuration.yaml` — which is also the only way it could stop HA
booting. Three gaps closed there:
- the fixed `configuration.yaml.bak` was overwritten on every run, so a second run destroyed the
pristine original. Backups are now timestamped, and the plain `.bak` is written only once
- after writing, the file is read back and checked (still at least as long as before, marker block
present exactly once, prior content byte-identical); any deviation **rolls back automatically**
from the timestamped backup and moves the step to the manual to-do list
- the header now states the safety properties explicitly, so they can be checked rather than trusted
Tested end to end against mock config dirs: `-Pruefen` wrote nothing; a real run preserved a foreign
`light:` group and left `automations.yaml` byte-identical (SHA-256 compared) while appending the
block; the output parses as valid HA YAML with all foreign keys intact; a second run left exactly one
block and preserved `fahrzeugprofil.json`; and against a config that already had its own `pyscript:`
and `panel_custom:` the file came back byte-identical, as designed.
**The genuine remaining risk is disk space, not the installer.** `recorder_snippet.yaml`'s 365 days
is ~11,5 GB, and on HA OS with an SD card or small eMMC a full disk stops HA from starting — nothing
to do with this app. The snippet header and the installer's to-do list now say so plainly, tell the
user to check Einstellungen → System → Speicher first, and recommend `purge_keep_days: 90` when space
is tight (enough for the "catch up on the past" purpose, since the import moves data into the app's
own permanent `.jsonl` files anyway).
**Also fixed this round (panel-only):** the "Teilen" pill on the location sheet was invisible in
day mode — `.standort-pille` used `background: var(--tile)` while the sheet under it uses
`var(--tile-deckend)`, and the iOS overlay sets *both* to `#FFFFFF` in day mode. Measured contrast
was 1.00:1, white on white. Both pills now follow the app's own button language (`.aktion` /
`.aktion.primaer`): outline for the secondary, `var(--fg)` fill for "Route". Text contrast measured
1721:1 in both themes afterwards. This also retired `--line-strong` — a *line* colour — being used
as a button fill. No companion-app port: the location sheet's share/route pills do not exist there,
which the parity rule exempts as panel-only.
---
## Working conventions (observed — keep them)
+27 -1
View File
@@ -14,7 +14,7 @@ export * from "./warteschlange.ts";
export * from "./ablageNativ.ts";
import { ENTITAETEN } from "./types.ts";
import type { Fahrt, Fahrzeugstatus, Profil, Tankvorgang } from "./types.ts";
import type { Fahrt, Fahrzeugstatus, ImportErgebnis, Profil, Tankvorgang } from "./types.ts";
import { HassRest } from "./rest.ts";
import { HassLive } from "./live.ts";
import { Warteschlange } from "./warteschlange.ts";
@@ -171,4 +171,30 @@ export class DataMetricApi {
jetztAktualisieren(): Promise<unknown> {
return this.rest.dienstAufrufen("pyscript", "audi_dashboard_jetzt_aktualisieren");
}
/* ------------------------------------------- Import aus dem HA-Verlauf
Bewusst NICHT über die Warteschlange, anders als die übrigen
schreibenden Vorgänge: der Import ist keine Eingabe, die man im Funkloch
absetzt und später ausgeführt haben will. Er dauert lange, sein Ergebnis
ist der eigentliche Zweck des Aufrufs, und ein nachträglich aus der
Warteschlange abgefeuerter Lauf käme für den Nutzer aus dem Nichts.
Ohne Netz gehört er schlicht nicht angeboten. */
/** Startet den Import. Kehrt zurück, sobald das Backend den Auftrag
angenommen hat nicht, wenn er fertig ist; dafür importStatusLesen(). */
historieImportieren(start: string, ende: string): Promise<unknown> {
return this.rest.dienstAufrufen("pyscript", "audi_dashboard_historie_importieren", {
start,
ende,
});
}
/** Stand des laufenden bzw. letzten Imports: "laeuft" | "fertig" |
"fehler", plus Zählwerte im Attribut "daten". */
async importStatusLesen(): Promise<{ zustand: string; daten: ImportErgebnis }> {
const zustand = await this.rest.zustandLesen<{ daten: ImportErgebnis }>(
ENTITAETEN.importStatus,
);
return { zustand: zustand.state, daten: zustand.attributes?.daten ?? {} };
}
}
+19
View File
@@ -131,6 +131,24 @@ export interface Profil {
[weitere: string]: unknown;
}
/** Ergebnis eines Historien-Imports (pyscript/historienimport.py). Die
Zählwerte fehlen, solange der Lauf noch nicht fertig ist; `meldung` steht
nur im Fehlerfall. */
export interface ImportErgebnis {
von?: string;
bis?: string;
/** Frühester Zeitpunkt mit Aufzeichnung im gewählten Fenster erklärt ein
leeres Ergebnis (weiter zurück bewahrt HA nichts mehr auf"). */
ab_wann_daten?: string | null;
fahrten_angelegt?: number;
fahrten_uebersprungen?: number;
fahrten_zu_kurz?: number;
tankvorgaenge_angelegt?: number;
tankvorgaenge_uebersprungen?: number;
batterie_tage?: number;
meldung?: string;
}
/* -------------------------------------------------- Entitäts-Verzeichnis */
/** Alle vom Backend veröffentlichten pyscript-Entitäten an einer Stelle.
@@ -144,6 +162,7 @@ export const ENTITAETEN = {
batterieverlauf: "pyscript.audi_dashboard_batterieverlauf",
belegErgebnis: "pyscript.audi_dashboard_beleg_ergebnis",
updateStatus: "pyscript.audi_dashboard_update_status",
importStatus: "pyscript.audi_dashboard_import_status",
} as const;
export type EntitaetsSchluessel = keyof typeof ENTITAETEN;
+36 -2
View File
@@ -17,6 +17,7 @@ import { Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { Bild } from "./Bild"
import { BILDPLAETZE } from "./bilder"
import { csvHerunterladen } from "./csv"
import { HistorienImport } from "./HistorienImport"
import { zugangVergessen } from "./zugang"
export function Einstellungen({
@@ -28,12 +29,21 @@ export function Einstellungen({
setzeTabBeschriftung: (an: boolean) => void
beiAbmeldung: () => void
}) {
const { einstellungen, fahrzeug, fahrten, tankvorgaenge, rohprofil, api, profilSpeichern } =
useDaten()
const {
einstellungen,
fahrzeug,
fahrten,
tankvorgaenge,
rohprofil,
api,
profilSpeichern,
jetztAktualisieren,
} = useDaten()
const [theme, themeSetzen] = useTheme()
const [entwurf, setzeEntwurf] = useState<EinstellungenWerte | null>(null)
const [laeuft, setzeLaeuft] = useState(false)
const [gespeichert, setzeGespeichert] = useState(false)
const [importOffen, setzeImportOffen] = useState(false)
const dateiwahl = useRef<HTMLInputElement | null>(null)
if (!einstellungen || !fahrzeug) return null
@@ -235,6 +245,30 @@ export function Einstellungen({
</p>
</Tile>
{/* Nachträglicher Import aus dem HA-Verlauf. Steht bewusst vor
Sicherung": beides holt Daten von außen in die App, und der Import
ist der Schritt, den man einmal nach der Einrichtung braucht. */}
<Tile>
<span className="ads-eyebrow">Vergangene Daten</span>
<p className="dm-fussnote">
Legt Fahrten, Tankvorgänge und Spannungswerte für einen vergangenen Zeitraum
nachträglich an aus dem Verlauf, den Home Assistant zu den zugeordneten Sensoren
bereits aufgezeichnet hat.
</p>
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => setzeImportOffen(true)}>
Daten importieren aus Home Assistant
</ActionButton>
</div>
</Tile>
<HistorienImport
offen={importOffen}
beiSchliessen={() => setzeImportOffen(false)}
api={api}
beiFertig={() => void jetztAktualisieren()}
/>
<Tile>
<span className="ads-eyebrow">Sicherung</span>
<Feld label="Automatische Sicherung">
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest"
import { ergebnisText } from "./HistorienImport"
/* Der Ergebnissatz ist die einzige Stelle des Imports, die aus Zahlen Sprache
macht - und die einzige mit Regeln, die man beim Umbauen versehentlich
verletzt (Einzahl/Mehrzahl, Weglassen leerer Posten). Der Rest der
Komponente ist Fenster-Verwaltung und wird vom Seiten-Rendertest
mitgenommen. */
describe("ergebnisText", () => {
it("nennt angelegte Fahrten immer, auch wenn es keine waren", () => {
expect(ergebnisText({ fahrten_angelegt: 0 })).toBe("0 Fahrten angelegt")
})
it("setzt die Einzahl bei genau einer Fahrt", () => {
expect(ergebnisText({ fahrten_angelegt: 1 })).toBe("1 Fahrt angelegt")
})
it("laesst leere Posten weg statt Nullen aufzuzaehlen", () => {
const text = ergebnisText({
fahrten_angelegt: 3,
fahrten_uebersprungen: 0,
tankvorgaenge_angelegt: 0,
batterie_tage: 0,
})
expect(text).toBe("3 Fahrten angelegt")
})
it("haengt die uebrigen Posten in fester Reihenfolge an", () => {
const text = ergebnisText({
fahrten_angelegt: 2,
fahrten_uebersprungen: 5,
fahrten_zu_kurz: 1,
tankvorgaenge_angelegt: 1,
batterie_tage: 12,
})
expect(text).toBe(
"2 Fahrten angelegt · 5 bereits vorhanden · 1 Zündung zu kurz für eine Fahrt · " +
"1 Tankvorgang · 12 Tage Batteriespannung",
)
})
it("setzt auch bei Tankvorgang und Batterietag die Einzahl", () => {
const text = ergebnisText({ fahrten_angelegt: 0, tankvorgaenge_angelegt: 1, batterie_tage: 1 })
expect(text).toBe("0 Fahrten angelegt · 1 Tankvorgang · 1 Tag Batteriespannung")
})
it("vertraegt ein leeres Ergebnis, ohne undefined auszugeben", () => {
expect(ergebnisText({})).toBe("0 Fahrten angelegt")
})
})
@@ -0,0 +1,207 @@
/**
* Daten importieren aus Home Assistant" Zeitraum wählen, importieren
* lassen, Ergebnis zeigen. Vorlage: `vImportPopup()` im Panel
* (audi-dashboard-app.js), Backend: `pyscript/historienimport.py`.
*
* Warum ein Fenster und keine Kachel mit zwei Feldern: der Import ist keine
* Einstellung, die man nebenbei ändert, sondern ein einmaliger Vorgang mit
* Start, Laufzeit und Ergebnis. Er gehört deshalb in einen eigenen,
* abgeschlossenen Schritt deckungsgleich mit dem Panel.
*
* Der eigentliche Lauf passiert im Backend. Es meldet seinen Stand über die
* Entität `pyscript.audi_dashboard_import_status`; hier wird sie im
* Sekundentakt gelesen, bis sie fertig" oder „fehler" sagt. Ein Poll statt
* eines Abwartens des Dienstaufrufs, weil pyscript-Dienste sofort
* zurückkehren, während die Arbeit im Hintergrund weiterläuft.
*/
import { useEffect, useRef, useState } from "react"
import { ActionButton, Feld, Popup } from "@audi-dash/ui"
import type { DataMetricApi, ImportErgebnis } from "../api"
import { datumZeit, de } from "../format"
/** Vorbelegung: die letzten 30 Tage, im Format von <input
type="datetime-local"> (Ortszeit, ohne Zeitzone, minutengenau) genau
das, was der Dienst als Ortszeit interpretiert. */
function zeitraumVorgabe() {
const alsFeld = (d: Date) =>
new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16)
const jetzt = new Date()
return { von: alsFeld(new Date(jetzt.getTime() - 30 * 86400000)), bis: alsFeld(jetzt) }
}
export function ergebnisText(d: ImportErgebnis): string {
const anzahl = (n: number, eins: string, viele: string) => `${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(" · ")
}
export function HistorienImport({
offen,
beiSchliessen,
api,
beiFertig,
}: {
offen: boolean
beiSchliessen: () => void
api: DataMetricApi
/** Nach einem erfolgreichen Lauf: Fahrten-/Tankvorgangslisten neu laden. */
beiFertig: () => void
}) {
const [von, setzeVon] = useState(() => zeitraumVorgabe().von)
const [bis, setzeBis] = useState(() => zeitraumVorgabe().bis)
const [laeuft, setzeLaeuft] = useState(false)
const [fehler, setzeFehler] = useState<string | null>(null)
const [ergebnis, setzeErgebnis] = useState<ImportErgebnis | null>(null)
// Nach dem Schließen darf keine noch laufende Warteschleife mehr in den
// State schreiben - React warnt sonst zu Recht über ein Update auf einer
// ausgehängten Komponente, und ein späterer Lauf würde ein längst
// weggeklicktes Ergebnis wieder einblenden.
const aktiv = useRef(true)
useEffect(() => {
aktiv.current = true
return () => {
aktiv.current = false
}
}, [])
// Beim erneuten Öffnen wieder mit den Feldern anfangen, nicht mit dem
// Ergebnis des letzten Laufs.
useEffect(() => {
if (offen) {
setzeErgebnis(null)
setzeFehler(null)
setzeLaeuft(false)
}
}, [offen])
const schliessen = () => {
if (laeuft) return
beiSchliessen()
}
const starten = async () => {
if (laeuft) return
if (!von || !bis) {
setzeFehler("Bitte Start und Ende wählen.")
return
}
if (von >= bis) {
setzeFehler("Das Ende muss nach dem Start liegen.")
return
}
setzeFehler(null)
setzeLaeuft(true)
try {
await api.historieImportieren(von, bis)
} catch (err) {
if (!aktiv.current) return
setzeLaeuft(false)
setzeFehler(err instanceof Error ? err.message : "Der Import konnte nicht gestartet werden.")
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 (!aktiv.current) return
let stand: { zustand: string; daten: ImportErgebnis }
try {
stand = await api.importStatusLesen()
} catch {
continue
}
if (!aktiv.current) return
if (stand.zustand === "fertig") {
setzeLaeuft(false)
setzeErgebnis(stand.daten)
beiFertig()
return
}
if (stand.zustand === "fehler") {
setzeLaeuft(false)
setzeFehler(stand.daten.meldung ?? "Der Import ist fehlgeschlagen.")
return
}
}
if (!aktiv.current) return
setzeLaeuft(false)
setzeFehler("Der Import dauert ungewöhnlich lange — er läuft im Hintergrund weiter.")
}
const fertig = ergebnis !== null && !laeuft
return (
<Popup open={offen} onClose={schliessen} variant="form" anchor="center">
<span className="ads-eyebrow">Daten importieren aus Home Assistant</span>
{fertig ? (
<>
<p className="dm-importergebnis">{ergebnisText(ergebnis)}</p>
{!ergebnis.fahrten_angelegt && (
<p className="dm-fussnote">
{ergebnis.ab_wann_daten
? `Aufgezeichnet ist in diesem Zeitraum ab ${datumZeit(ergebnis.ab_wann_daten)}. Weiter zurück bewahrt Home Assistant nichts mehr auf.`
: "Für diesen Zeitraum liegt in Home Assistant kein Verlauf mehr vor."}
</p>
)}
<div style={{ marginTop: 14 }}>
<ActionButton onClick={beiSchliessen}>Fertig</ActionButton>
</div>
</>
) : (
<>
<Feld label="Von">
<input
className="dm-eingabe"
type="datetime-local"
value={von}
onChange={(e) => setzeVon(e.target.value)}
/>
</Feld>
<Feld label="Bis" last>
<input
className="dm-eingabe"
type="datetime-local"
value={bis}
onChange={(e) => setzeBis(e.target.value)}
/>
</Feld>
{fehler && <p className="dm-fussnote dm-fussnote--warnung">{fehler}</p>}
<p className="dm-fussnote">
Bereits erfasste Fahrten bleiben unangetastet überschneidet sich ein Zeitraum, wird
er übersprungen statt doppelt angelegt.
</p>
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void starten()} disabled={laeuft}>
{laeuft ? "Importiere …" : "Importieren"}
</ActionButton>
</div>
<div style={{ marginTop: 10 }}>
<ActionButton onClick={schliessen} disabled={laeuft}>
Abbrechen
</ActionButton>
</div>
</>
)}
</Popup>
)
}
+12
View File
@@ -631,6 +631,18 @@
color: var(--warn);
}
/* Ergebniszeile des Historien-Imports - eine Stufe kräftiger als die
.dm-fussnote darunter, weil sie die eigentliche Antwort auf den Vorgang
ist und nicht dessen Kleingedrucktes. */
.dm-importergebnis {
margin: var(--sp-3) 0 0;
font-size: 14px;
font-weight: 300;
line-height: 1.6;
color: var(--fg);
text-wrap: pretty;
}
/* --------------------------------------------------------- Listen, Text */
.dm-liste {
+41 -2
View File
@@ -128,12 +128,38 @@ reicht `scp`/`rsync` vom gewohnten Terminal aus.
`configuration_snippet.yaml` aus diesem Ordner öffnen. Die zwei Blöcke
(`pyscript:` und `panel_custom:`) in die bestehende `configuration.yaml`
übernehmen — **nicht** die Datei komplett ersetzen. Falls dort schon ein
`pyscript:`-Block existiert, nur die Zeile `allow_all_imports: true` darin
ergänzen statt einen zweiten Block anzulegen.
`pyscript:`-Block existiert, nur die beiden Zeilen `allow_all_imports: true`
und `hass_is_global: true` darin ergänzen statt einen zweiten Block anzulegen.
`hass_is_global: true` braucht nur der nachträgliche Datenimport
(Schritt 8) — ohne die Zeile läuft alles andere unverändert, der Import
meldet dann aber, dass er den Verlauf nicht lesen kann.
Der `panel_custom:`-Block kann schon jetzt mit rein; er wird erst mit dem
Frontend-Baustein wirksam und stört bis dahin nicht.
### Datenaufbewahrung — je früher, desto mehr ist zu retten
Zusätzlich `recorder_snippet.yaml` aus demselben Ordner übernehmen. Home
Assistant löscht Sensor-Verläufe **standardmäßig nach 10 Tagen**; der Block
hebt das auf ein Jahr an.
Das ist zeitkritisch, anders als der Rest dieser Anleitung: was der recorder
einmal gelöscht hat, ist endgültig weg — auch für den Import in Schritt 8.
Wer diesen Block erst in vier Wochen einbaut, kann die dazwischen liegenden
Fahrten nicht mehr nachtragen.
Nicht betroffen sind die Bestände der App selbst (`fahrten.jsonl`,
`tankvorgaenge.jsonl`, `batteriespannung.jsonl`, `fahrzeugprofil.json` unter
`/config/audi_dashboard/`): die werden nirgends automatisch gekürzt und
bleiben dauerhaft erhalten. Die 10-Tage-Grenze betrifft nur den **rohen**
Sensor-Verlauf, aus dem die App ihre Datensätze erst ableitet.
Zum Platzbedarf (an der Testinstanz gemessen: rund 5.300 Zustandsänderungen
pro Tag, hochgerechnet grob 11,5 GB für ein Jahr) steht alles im Kopf von
`recorder_snippet.yaml`, samt einer auskommentierten `exclude:`-Liste zum
Kürzen, falls die Datenbank zu groß wird.
## Schritt 4 — die Sensoren zuordnen
Anders als früher wird dafür **nicht** mehr `pyscript/modules/
@@ -265,6 +291,19 @@ Sensor suchen, Zustand testweise auf `on`/`off` setzen). Danach in
- **Tailscale „VPN On Demand"** in der Tailscale-App selbst einrichten
(Regel „Only On", gebunden an `Audi_MMI_2804_5GHz`) — unabhängig von
Home Assistant, kann jederzeit parallel erledigt werden
- **Vergangene Daten nachtragen:** Fahrterkennung, Tankerkennung und
Batterieverlauf laufen erst ab der Installation mit. Was Home Assistant
vorher schon aufgezeichnet hat, holt **Einstellungen → Einrichten → „Daten
importieren aus Home Assistant"** nach: Zeitraum wählen, „Importieren", und
es entstehen dieselben Fahrten, Tankvorgänge und Spannungswerte, die die
Live-Erkennung erzeugt hätte. Der Vorgang ist gefahrlos wiederholbar —
überschneidet sich ein Zeitraum mit bereits erfassten Fahrten, wird er
übersprungen statt doppelt angelegt.
Wie weit er zurückreicht, hängt allein an der Aufbewahrung aus Schritt 3
(Standard 10 Tage, mit `recorder_snippet.yaml` ein Jahr). Kommt „0 Fahrten
angelegt" zurück, nennt das Fenster den frühesten Zeitpunkt, zu dem
überhaupt noch etwas aufgezeichnet ist.
## Schritt 9 — Frontend prüfen
+14
View File
@@ -3,13 +3,27 @@
# vorhandenen Top-Level-Schlüsseln einfügen (falls z. B. schon ein
# `pyscript:`-Block existiert, dort zusammenführen statt duplizieren).
# Datenaufbewahrung: Home Assistant löscht Sensor-Verläufe standardmäßig nach
# 10 Tagen - das begrenzt, wie weit "Daten aus Home Assistant importieren"
# zurückreichen kann. Der zugehörige `recorder:`-Block steht mit ausführlicher
# Begründung und gemessenem Platzbedarf in recorder_snippet.yaml daneben;
# von dort übernehmen.
# pyscript-Backend: Fahrterkennung, Fahrtabschluss, Reifenzähler,
# Belegverarbeitung (siehe pyscript/-Ordner). allow_all_imports ist nötig,
# weil die Skripte os, subprocess, urllib.request, base64 und uuid nutzen -
# pyscript erlaubt standardmäßig gar keine Importe, auch keine aus der
# Standardbibliothek.
#
# hass_is_global stellt den `hass`-Namen in pyscript bereit. Gebraucht wird er
# ausschließlich von historienimport.py ("Daten importieren aus Home
# Assistant"): der Verlauf wird über Home Assistants eigene recorder-API
# gelesen (get_significant_states), die das hass-Objekt als ersten Parameter
# erwartet. Ohne diese Zeile bleibt der Rest der App voll funktionsfähig, nur
# der Import meldet, dass er den Verlauf nicht lesen kann.
pyscript:
allow_all_imports: true
hass_is_global: true
# Frontend: bindet den umgebauten Prototyp als eigenen Sidebar-Eintrag ein
# (§10 Punkt 1). module_url zeigt auf /config/www/audi-dashboard-panel.js
@@ -3,13 +3,27 @@
# vorhandenen Top-Level-Schlüsseln einfügen (falls z. B. schon ein
# `pyscript:`-Block existiert, dort zusammenführen statt duplizieren).
# Datenaufbewahrung: Home Assistant löscht Sensor-Verläufe standardmäßig nach
# 10 Tagen - das begrenzt, wie weit "Daten aus Home Assistant importieren"
# zurückreichen kann. Der zugehörige `recorder:`-Block steht mit ausführlicher
# Begründung und gemessenem Platzbedarf in recorder_snippet.yaml daneben;
# von dort übernehmen.
# pyscript-Backend: Fahrterkennung, Fahrtabschluss, Reifenzähler,
# Belegverarbeitung (siehe pyscript/-Ordner). allow_all_imports ist nötig,
# weil die Skripte os, subprocess, urllib.request, base64 und uuid nutzen -
# pyscript erlaubt standardmäßig gar keine Importe, auch keine aus der
# Standardbibliothek.
#
# hass_is_global stellt den `hass`-Namen in pyscript bereit. Gebraucht wird er
# ausschließlich von historienimport.py ("Daten importieren aus Home
# Assistant"): der Verlauf wird über Home Assistants eigene recorder-API
# gelesen (get_significant_states), die das hass-Objekt als ersten Parameter
# erwartet. Ohne diese Zeile bleibt der Rest der App voll funktionsfähig, nur
# der Import meldet, dass er den Verlauf nicht lesen kann.
pyscript:
allow_all_imports: true
hass_is_global: true
# Frontend: bindet den umgebauten Prototyp als eigenen Sidebar-Eintrag ein
# (§10 Punkt 1). module_url zeigt auf /config/www/audi-dashboard-panel.js
+80 -5
View File
@@ -12,6 +12,35 @@
4. configuration_snippet.yaml wird in <Ziel>\configuration.yaml eingefügt
5. ha_token.txt wird geschrieben, wenn -Token übergeben wurde
SICHERHEIT GEGENÜBER DER BESTEHENDEN HA-INSTALLATION
----------------------------------------------------
Das Skript ist so gebaut, dass es eine laufende Home-Assistant-Installation
(auch HA OS) nicht gefährden kann. Konkret und nachprüfbar:
- Es LÖSCHT NIE etwas. Im gesamten Skript kommt kein Remove-Item vor, und
kopiert wird bewusst ohne robocopy /MIR - fremde Dateien in pyscript\
und www\ bleiben liegen, es wird nur ergänzt.
- Es fasst .storage\ nicht an: keine Integrationen, keine Geräte, keine
Entitäten, keine Benutzer, keine Automatisierungen. automations.yaml,
scripts.yaml, scenes.yaml und secrets.yaml werden nicht gelesen und
nicht geschrieben.
- Es startet Home Assistant nicht neu und greift nicht in den laufenden
Betrieb ein. Bis zum manuellen Neustart ändert sich am Verhalten der
Instanz nichts.
- Die einzige Datei außerhalb der eigenen Ordner, die überhaupt verändert
wird, ist configuration.yaml - und das ist der einzige Weg, auf dem
dieses Skript HA am Starten hindern könnte. Deshalb dort drei Netze:
eine zeitgestempelte Sicherung vor jeder Änderung, ein Abbruch ohne
jede Änderung falls dort schon fremde pyscript:/panel_custom:-Blöcke
stehen, und eine Rücklese-Prüfung nach dem Schreiben, die bei der
kleinsten Abweichung automatisch zurückrollt.
- -Pruefen zeigt den kompletten Ablauf, ohne irgendetwas zu schreiben.
Im Zweifel damit anfangen.
Was das Skript NICHT kann: pyscript installieren (das macht HACS),
pypdf nachinstallieren (braucht eine Shell auf der HA-Maschine) und HA
neu starten. Diese Schritte stehen am Ende als Restliste.
Grundsatz: nichts kaputtmachen, was schon da ist. Das Skript ist darauf
ausgelegt, gefahrlos mehrfach zu laufen (etwa nach einem Abbruch):
@@ -214,30 +243,70 @@ if ($fremdePyscript -or $fremdePanel) {
Write-Host ""
Write-Host " Bitte von Hand zusammenführen (Inhalt aus configuration_snippet.yaml):" -ForegroundColor Yellow
Write-Host " - unter pyscript: allow_all_imports: true"
Write-Host " - hass_is_global: true"
Write-Host " - unter panel_custom: den Listeneintrag 'audi-dashboard-panel'"
[void]$restliste.Add("configuration.yaml von Hand ergänzen (siehe Hinweis oben) - zwei gleiche Top-Level-Schlüssel wären ungültiges YAML.")
} elseif ($Pruefen) {
if ($schonDa) { Info "würde den vorhandenen Mein-Audi-Block ersetzen" }
else { Info "würde den Mein-Audi-Block anhängen" }
} else {
Copy-Item $configYaml "$configYaml.bak" -Force
Info "Sicherung: configuration.yaml.bak"
# Zeitgestempelte Sicherung statt einer festen .bak: die feste Datei wurde
# bei einem zweiten Lauf von der bereits geänderten Fassung überschrieben -
# ausgerechnet das Original, auf das man im Notfall zurückwill, war dann weg.
$sicherung = "$configYaml.bak"
$stempel = Get-Date -Format "yyyyMMdd-HHmmss"
$sicherungZeit = "$configYaml.$stempel.bak"
Copy-Item $configYaml $sicherungZeit -Force
if (-not (Test-Path $sicherung)) { Copy-Item $configYaml $sicherung -Force }
Info "Sicherung: $(Split-Path $sicherungZeit -Leaf)"
if ($schonDa) {
$neu = [regex]::Replace(
$inhalt,
"(?ms)" + [regex]::Escape($markeAuf) + ".*?" + [regex]::Escape($markeZu) + "\r?\n?",
[System.Text.RegularExpressions.MatchEvaluator]{ param($m) $block }
)
Gut "vorhandenen Block ersetzt"
} else {
$trenner = ""
if ($inhalt.Length -gt 0 -and -not $inhalt.EndsWith("`n")) { $trenner = "`r`n" }
$neu = $inhalt + $trenner + "`r`n" + $block
Gut "Block angehängt"
}
# Ohne BOM schreiben - HA liest die Datei als YAML, ein BOM hat da nichts
# zu suchen (Out-File -Encoding utf8 setzt in PowerShell 5.1 eines).
[System.IO.File]::WriteAllText($configYaml, $neu, (New-Object System.Text.UTF8Encoding($false)))
# Rücklesen und prüfen, bevor wir die Datei so stehen lassen. Eine kaputte
# configuration.yaml ist der einzige Weg, auf dem dieses Skript eine
# laufende Home-Assistant-Installation lahmlegen könnte (HA startet dann
# nicht mehr) - deshalb wird das Ergebnis hier verifiziert und im
# Zweifelsfall sofort zurückgerollt, statt es dem Nutzer zu überlassen.
$kontrolle = [System.IO.File]::ReadAllText($configYaml, $utf8OhneBom)
$bestandNoch = [regex]::Replace(
$kontrolle,
"(?ms)" + [regex]::Escape($markeAuf) + ".*?" + [regex]::Escape($markeZu) + "\r?\n?",
""
)
$treffer = ([regex]::Matches($kontrolle, [regex]::Escape($markeAuf))).Count
$heil = $true
$grund = ""
if ($kontrolle.Length -lt $inhalt.Length) {
$heil = $false; $grund = "die Datei ist kürzer geworden als vorher"
} elseif ($treffer -ne 1) {
$heil = $false; $grund = "der Mein-Audi-Block steht $treffer mal statt genau einmal darin"
} elseif ($bestandNoch.Trim() -ne $ohneUnserenBlock.Trim()) {
$heil = $false; $grund = "der bisherige Inhalt der Datei hat sich verändert"
}
if (-not $heil) {
Copy-Item $sicherungZeit $configYaml -Force
Warnung "configuration.yaml wurde zurückgerollt - $grund."
Write-Host " Die Datei ist unverändert wie vor dem Start dieses Skripts." -ForegroundColor Yellow
[void]$restliste.Add("configuration.yaml von Hand ergänzen (Inhalt aus configuration_snippet.yaml) - der automatische Weg wurde aus Sicherheitsgründen zurückgenommen.")
} elseif ($schonDa) {
Gut "vorhandenen Block ersetzt (geprüft)"
} else {
Gut "Block angehängt (geprüft)"
}
}
# --------------------------------------------------------------- 5. Token
@@ -258,9 +327,15 @@ if (Test-Path $tokenDatei) {
}
# --------------------------------------------------------------- Restliste
# Bewusst nicht automatisch eingefügt: der recorder-Block ist eine Entscheidung
# über den Plattenplatz der Instanz (ein Jahr Fahrzeugverlauf sind grob 1-1,5 GB,
# siehe Kopf von recorder_snippet.yaml), und viele Instanzen haben bereits einen
# eigenen recorder:-Block mit anderen Einstellungen. Zusammenführen ist Handarbeit.
[void]$restliste.Add("ZEITKRITISCH - Datenaufbewahrung: den Block aus recorder_snippet.yaml in die configuration.yaml übernehmen. Home Assistant löscht Sensor-Verläufe sonst nach 10 Tagen; was weg ist, lässt sich auch mit 'Daten importieren aus Home Assistant' nicht mehr nachtragen. Je früher der Block drin ist, desto mehr Vergangenheit bleibt erhalten. VORHER Einstellungen -> System -> Speicher prüfen: ein Jahr Verlauf braucht grob 1-1,5 GB. Bei wenig freiem Platz (SD-Karte, kleine eMMC) mit purge_keep_days: 90 anfangen - Details im Kopf der Datei.")
[void]$restliste.Add("Home Assistant neu starten: Einstellungen -> System -> Neu starten.")
[void]$restliste.Add("Nur für Tankbeleg-Upload: auf der HA-Maschine 'pip install pypdf' ausführen (Terminal-&-SSH-Add-on oder docker exec).")
[void]$restliste.Add("Nach dem Neustart in der App: Einstellungen -> Fahrzeug einrichten -> Setup - Sensoren zuordnen. Zwingend für die Fahrterkennung ist der Zündungs-/ACC-Sensor (binary_sensor, meist vom Teltonika FMM003).")
[void]$restliste.Add("Nach dem Neustart in der App: Einstellungen -> Fahrzeug einrichten -> Setup - Sensoren zuordnen. Im Auslieferstand ist KEIN Sensor vorbelegt; zwingend für die Fahrterkennung ist der Zündungs-/ACC-Sensor (binary_sensor, meist vom Teltonika FMM003). Danach einmal neu starten - die drei trigger-gebundenen Felder (Zündung, Kilometerstand, Tankfüllstand) werden erst dann wirksam.")
[void]$restliste.Add("Optional, sobald die Sensoren zugeordnet sind: Einstellungen -> Einrichten -> 'Daten importieren aus Home Assistant' holt Fahrten, Tankvorgänge und Spannungswerte aus dem bereits aufgezeichneten HA-Verlauf nach.")
Write-Host ""
if ($Pruefen) {
@@ -0,0 +1,511 @@
"""Nachträglicher Import vergangener Zeiträume aus dem Home-Assistant-Verlauf
(Einstellungen -> "Daten importieren aus Home Assistant").
WOZU
----
Fahrterkennung (fahrterkennung.py), Tankerkennung (tankerkennung.py) und
Batterieverlauf (batterieverlauf.py) arbeiten alle nur ab dem Moment, in dem
sie laufen: sie hängen an @state_trigger bzw. an einem 5-Minuten-Takt. Alles,
was das Fahrzeug gemeldet hat, BEVOR die App lief (oder während Home
Assistant aus war, oder bevor ein Sensor überhaupt zugeordnet war), taucht in
den Beständen der App deshalb nie auf - obwohl der recorder von Home
Assistant es längst aufgezeichnet hat.
Dieser Import schließt genau diese Lücke: er liest denselben Verlauf, den die
Live-Trigger sonst in Echtzeit sehen, und leitet daraus rückwirkend dieselben
Datensätze ab.
GRENZE, DIE MAN KENNEN MUSS
---------------------------
Weiter zurück als der recorder aufbewahrt, geht es nicht - was dort gelöscht
ist, ist endgültig weg. Home Assistant löscht standardmäßig nach 10 Tagen;
recorder_snippet.yaml hebt das auf ein Jahr an. Der Import meldet deshalb im
Ergebnis mit, ab wann im gewählten Zeitraum überhaupt Daten vorlagen
(`ab_wann_daten`), damit ein leeres Ergebnis nicht wie ein Fehler aussieht.
WIE DER VERLAUF GELESEN WIRD
----------------------------
Über Home Assistants eigene recorder-API
(`homeassistant.components.recorder.history.get_significant_states`), nicht
über direkten SQL-Zugriff auf home-assistant_v2.db. Der Umweg über die API
ist Absicht: das Datenbankschema des recorders ist HA-intern und ändert sich
zwischen Versionen, die Funktion dagegen ist die von HA selbst benutzte und
stabile Schnittstelle.
Voraussetzung dafür ist `hass_is_global: true` im pyscript-Block der
configuration.yaml (siehe configuration_snippet.yaml) - ohne das existiert
der `hass`-Name hier nicht. Der Aufruf läuft über task.executor(), weil es
eine echte externe Funktion ist und ein Datenbankzugriff nichts im
Event-Loop verloren hat (dieselbe pyscript-Einschränkung wie bei io.open in
profil.py, siehe dortiger Kopfkommentar).
`significant_changes_only=False` ist wichtig: bei numerischen Sensoren
(Kilometerstand, Tankfüllstand) liefert der Standardmodus nur "auffällige"
Änderungen und verschluckt genau die kleinen Schritte, aus denen sich
Fahrstrecke und Tankvorgänge zusammensetzen.
DOPPELTE DATENSÄTZE
-------------------
Der Import ist absichtlich mehrfach ausführbar (überlappende Zeiträume,
zweiter Versuch nach einem Abbruch): jede erzeugte Fahrt wird gegen die
bereits vorhandenen geprüft und übersprungen, wenn sich ihr Zeitraum mit
einer bestehenden Fahrt überschneidet - egal ob die live erkannt, von Hand
angelegt oder aus einem früheren Import stammt. Tankvorgänge werden über ein
Zeitfenster (TANK_DUBLETTE_MIN) entdoppelt, Batteriewerte über den Tag als
Schlüssel (dort führt profil.batterieverlauf_tageswert_aktualisieren() min/
max ohnehin zusammen, statt Zeilen zu vervielfachen).
Erzeugte Datensätze tragen `source: "import"` - dieselbe Rolle wie "ha"
(live erkannt), "manual" (von Hand) und "auto" (Tankerkennung), damit später
nachvollziehbar bleibt, woher ein Eintrag stammt.
"""
import datetime
from homeassistant.components.recorder.history import get_significant_states
import einstellungen
import frontend_veroeffentlichung
import profil
# Ein Anstieg des Tankfüllstands gilt ab denselben Schwellen als Tankvorgang
# wie in der Live-Erkennung - bewusst dieselben Zahlen wie in
# tankerkennung.py, damit ein importierter Zeitraum dieselben Vorgänge
# erzeugt, die die Live-Erkennung erzeugt hätte.
LITER_SCHWELLE = 5
PROZENT_SCHWELLE = 9
STANDARD_TANKVOLUMEN_LITER = 58
# Zwei Tankvorgänge innerhalb dieser Spanne gelten als derselbe - schützt
# gegen Dubletten, wenn derselbe Zeitraum zweimal importiert wird oder sich
# Import und Live-Erkennung am Rand überschneiden.
TANK_DUBLETTE_MIN = 90
# Fahrten, die kürzer sind, sind Zündung-an-ohne-Fahrt (Radio, Tür öffnen mit
# Zündung, Diagnose) - die Live-Erkennung legt sie zwar an, im Rückblick
# fluten sie den Bestand aber mit Nulleinträgen. Bewusst konservativ.
MINDESTDAUER_S = 60
def _status(zustand, daten=None):
"""Fortschritt/Ergebnis für die Oberfläche. Gleiches Muster wie
_status_veroeffentlichen() in updateverwaltung.py."""
state.set(
"pyscript.audi_dashboard_import_status",
zustand,
new_attributes={"daten": daten or {}},
)
def _als_zeit(wert):
"""Akzeptiert, was die Oberfläche schickt: ISO mit oder ohne Zeitzone.
Ohne Zeitzone gilt die lokale Zeit von Home Assistant - der Nutzer wählt
im Formular schließlich Ortszeit, keine UTC."""
if not wert:
return None
ts = datetime.datetime.fromisoformat(str(wert))
if ts.tzinfo is None:
ts = ts.astimezone()
return ts.astimezone(datetime.timezone.utc)
def _zahl(wert):
try:
return float(wert)
except (TypeError, ValueError):
return None
def _verlauf(entity_id, start, ende):
"""Zustandsverlauf einer Entität als Liste von (Zeitpunkt, Rohwert),
aufsteigend. Leere Liste, wenn die Entität nicht zugeordnet ist oder im
Zeitraum nichts vorliegt.
Die Zustände "unknown"/"unavailable" werden verworfen: sie bedeuten
"keine Meldung", nicht "Wert 0" - würden sie durchgereicht, ergäbe ein
Ausfall der Integration eine Fahrt mit absurder Kilometerdifferenz."""
if not entity_id:
return []
roh = task.executor(
get_significant_states, hass, start, ende, [entity_id], None, True, False
)
reihen = roh.get(entity_id) or []
ergebnis = []
for s in reihen:
if s.state in ("unknown", "unavailable", None, ""):
continue
ergebnis.append((s.last_updated, s.state))
ergebnis.sort(key=lambda p: p[0])
return ergebnis
def _wert_bei(verlauf, zeitpunkt):
"""Der zuletzt vor `zeitpunkt` gemeldete Zahlenwert, sonst der erste
danach, sonst None. "Zuletzt davor" ist die richtige Wahl für einen
Zählerstand: der Kilometerstand bei Fahrtbeginn ist der, der zuletzt
gemeldet wurde, nicht der nächste (der schon Strecke enthält)."""
davor = None
for ts, wert in verlauf:
zahl = _zahl(wert)
if zahl is None:
continue
if ts <= zeitpunkt:
davor = zahl
else:
return davor if davor is not None else zahl
return davor
def _pausenzeit_sekunden():
p = profil.profil_lesen()
if p is None:
return 15 * 60
return p.get("einstellungen", {}).get("fahrten_pausenzeit_min", 15) * 60
def _fahrtfenster(zuendung_verlauf, pausenzeit_s):
"""Aus dem Zündungsverlauf die Zeiträume, in denen gefahren wurde.
Zwei Schritte, die zusammen die Pausenregel aus §7.1 nachbilden:
erst jeden zusammenhängenden "on"-Abschnitt sammeln, dann benachbarte
Abschnitte verschmelzen, deren Lücke kürzer als die Pausenzeit ist. Genau
das tut die Live-Erkennung über task.unique() + task.sleep(), nur eben
im Nachhinein und ohne Warten."""
roh = []
offen = None
for ts, wert in zuendung_verlauf:
an = str(wert).lower() in ("on", "true", "1")
if an and offen is None:
offen = ts
elif not an and offen is not None:
roh.append((offen, ts))
offen = None
# Ein am Ende des Zeitraums noch offener Abschnitt wird verworfen: die
# Fahrt ist zu diesem Zeitpunkt noch nicht beendet, ihr Ende läge hinter
# dem gewählten Fenster. Sie beim Fensterende abzuschneiden würde eine
# Fahrt mit erfundener Endzeit erzeugen.
if not roh:
return []
verschmolzen = [roh[0]]
for start, ende in roh[1:]:
vorheriger_start, vorheriges_ende = verschmolzen[-1]
if (start - vorheriges_ende).total_seconds() < pausenzeit_s:
verschmolzen[-1] = (vorheriger_start, ende)
else:
verschmolzen.append((start, ende))
return verschmolzen
def _ueberschneidet(start, ende, bestehende):
"""True, wenn sich [start, ende] mit einer bereits erfassten Fahrt
überschneidet. Verhindert Dubletten beim wiederholten Import."""
for f in bestehende:
try:
f_start = datetime.datetime.fromisoformat(f.get("ts_start"))
f_ende = datetime.datetime.fromisoformat(f.get("ts_end"))
except (TypeError, ValueError):
continue
if f_start.tzinfo is None or f_ende.tzinfo is None:
continue
if start < f_ende and f_start < ende:
return True
return False
def _fahrten_importieren(start, ende, verlaeufe):
"""Fahrten aus dem Zündungsverlauf, mit Kilometerstand und Start-/
Zielkoordinaten aus den übrigen Verläufen ergänzt."""
fenster = _fahrtfenster(verlaeufe["zuendung"], _pausenzeit_sekunden())
if not fenster:
return {"angelegt": 0, "uebersprungen": 0, "zu_kurz": 0}
bestehende = profil.fahrten_lesen()
km_verlauf = verlaeufe["km"]
lat_verlauf = verlaeufe["lat"]
lon_verlauf = verlaeufe["lon"]
angelegt = 0
uebersprungen = 0
zu_kurz = 0
neue = []
for f_start, f_ende in fenster:
dauer_s = int((f_ende - f_start).total_seconds())
if dauer_s < MINDESTDAUER_S:
zu_kurz += 1
continue
if _ueberschneidet(f_start, f_ende, bestehende):
uebersprungen += 1
continue
odo_start = _wert_bei(km_verlauf, f_start)
odo_end = _wert_bei(km_verlauf, f_ende)
distanz = None
if odo_start is not None and odo_end is not None and odo_end >= odo_start:
distanz = round(odo_end - odo_start, 1)
durchschnitt = None
if distanz is not None and dauer_s > 0:
durchschnitt = round(distanz / (dauer_s / 3600.0), 1)
fahrt = {
"trip_id": profil.neue_id("t"),
"ts_start": f_start.isoformat(),
"ts_end": f_ende.isoformat(),
"duration_s": dauer_s,
"distance_km": distanz,
"km_quelle": "sensor" if distanz is not None else None,
"odo_start": odo_start,
"odo_end": odo_end,
"avg_speed_kmh": durchschnitt,
"start_lat": _wert_bei(lat_verlauf, f_start),
"start_lon": _wert_bei(lon_verlauf, f_start),
"end_lat": _wert_bei(lat_verlauf, f_ende),
"end_lon": _wert_bei(lon_verlauf, f_ende),
"start_address": None,
"end_address": None,
"art": "privat",
"route": None,
"pausen": [],
"source": "import",
"status": "vollständig" if distanz is not None else "offen",
"edited_fields": [],
}
neue.append(fahrt)
bestehende.append(fahrt)
angelegt += 1
# Alle neuen Fahrten in einem Rutsch anhängen statt je Fahrt einmal die
# Datei zu öffnen - bei einem Jahr Verlauf sind das sonst hunderte
# Einzelschreibvorgänge.
if neue:
alle = profil.fahrten_lesen() + neue
alle.sort(key=lambda f: f.get("ts_start") or "")
profil.fahrten_schreiben(alle)
return {"angelegt": angelegt, "uebersprungen": uebersprungen, "zu_kurz": zu_kurz}
def _schwelle_prozent():
p = profil.profil_lesen()
if p is None:
tankvolumen = STANDARD_TANKVOLUMEN_LITER
else:
tankvolumen = p.get("fahrzeug", {}).get("tankvolumen_liter") or STANDARD_TANKVOLUMEN_LITER
return min((LITER_SCHWELLE / tankvolumen) * 100, PROZENT_SCHWELLE)
def _tankvorgaenge_importieren(verlaeufe):
"""Tankvorgänge aus dem Füllstandsverlauf - dieselbe Tiefststand-Logik
wie tankerkennung.py (siehe dortiger Kopfkommentar): jeder Anstieg über
die Schwelle gegen den zuletzt gesehenen Tiefststand ist ein Tankvorgang,
nicht jeder Anstieg gegen den unmittelbar vorherigen Wert."""
verlauf = verlaeufe["tank"]
if not verlauf:
return {"angelegt": 0, "uebersprungen": 0}
schwelle = _schwelle_prozent()
km_verlauf = verlaeufe["km"]
bestehende = profil.tankvorgaenge_lesen()
fenster = datetime.timedelta(minutes=TANK_DUBLETTE_MIN)
bekannte_zeiten = []
for t in bestehende:
try:
ts = datetime.datetime.fromisoformat(t.get("ts"))
except (TypeError, ValueError):
continue
if ts.tzinfo is not None:
bekannte_zeiten.append(ts)
angelegt = 0
uebersprungen = 0
neue = []
tiefststand = None
for ts, wert in verlauf:
aktuell = _zahl(wert)
if aktuell is None:
continue
if tiefststand is None or aktuell <= tiefststand:
tiefststand = aktuell
continue
if aktuell - tiefststand < schwelle:
continue
dublette = False
for bekannt in bekannte_zeiten:
if abs((bekannt - ts).total_seconds()) < fenster.total_seconds():
dublette = True
break
if dublette:
uebersprungen += 1
tiefststand = aktuell
continue
odometer_km = _wert_bei(km_verlauf, ts)
# "Gefahren seit der letzten Tankung" heißt: seit der letzten Tankung
# VOR dieser hier - nicht seit der zeitlich jüngsten überhaupt. Beim
# Import eines vergangenen Zeitraums liegen im Bestand regelmäßig
# bereits neuere Tankvorgänge; die als Bezug zu nehmen ergäbe eine
# negative Strecke (und damit, nach der Prüfung unten, gar keine).
eigene_ts = ts.isoformat()
vorheriger = None
for t in neue + bestehende:
t_ts = t.get("ts") or ""
if t.get("odometer_km") is None or not t_ts or t_ts >= eigene_ts:
continue
if vorheriger is None or t_ts > vorheriger[0]:
vorheriger = (t_ts, t["odometer_km"])
distanz = None
if odometer_km is not None and vorheriger is not None:
distanz = round(odometer_km - vorheriger[1], 1)
if distanz < 0:
distanz = None
tankvorgang = {
"tank_id": profil.neue_id("f"),
"receipt_key": None,
"ts": ts.isoformat(),
"liters": None,
"fuel_total_eur": None,
"price_per_l": None,
"discount": None,
"station_name": None,
"odometer_km": odometer_km,
"distance_km": distanz,
"fuel_type": None,
"source": "import",
"status": "unvollständig",
"receipt_file": None,
"edited_fields": [],
}
neue.append(tankvorgang)
bekannte_zeiten.append(ts)
angelegt += 1
tiefststand = aktuell
if neue:
alle = profil.tankvorgaenge_lesen() + neue
alle.sort(key=lambda t: t.get("ts") or "")
profil.tankvorgaenge_schreiben(alle)
return {"angelegt": angelegt, "uebersprungen": uebersprungen}
def _batterie_importieren(verlaeufe):
"""Tagesminimum/-maximum der 12V-Spannung je Tag des Zeitraums.
profil.batterieverlauf_tageswert_aktualisieren() führt bestehende und
neue Werte pro Tag zusammen (min bleibt min, max bleibt max), deshalb
braucht es hier keine eigene Dubletten-Prüfung: ein zweiter Import
desselben Zeitraums verändert die Einträge nicht mehr."""
verlauf = verlaeufe["batterie"]
if not verlauf:
return {"tage": 0}
tage = {}
for ts, wert in verlauf:
spannung = _zahl(wert)
if spannung is None:
continue
tag = ts.date().isoformat()
eintrag = tage.get(tag)
if eintrag is None:
tage[tag] = {"min": spannung, "min_ts": ts, "max": spannung, "max_ts": ts}
continue
if spannung < eintrag["min"]:
eintrag["min"] = spannung
eintrag["min_ts"] = ts
if spannung > eintrag["max"]:
eintrag["max"] = spannung
eintrag["max_ts"] = ts
for tag in sorted(tage):
werte = tage[tag]
profil.batterieverlauf_tageswert_aktualisieren(
tag, werte["min_ts"].isoformat(), werte["min"]
)
profil.batterieverlauf_tageswert_aktualisieren(
tag, werte["max_ts"].isoformat(), werte["max"]
)
return {"tage": len(tage)}
@service
def audi_dashboard_historie_importieren(start=None, ende=None):
"""Liest den Home-Assistant-Verlauf im gewählten Zeitraum und leitet
daraus Fahrten, Tankvorgänge und Batteriewerte ab.
start/ende sind ISO-Zeitstempel aus der Oberfläche (Ortszeit ohne
Zeitzone ist zulässig). Aufruf als
pyscript.audi_dashboard_historie_importieren."""
profil.ordner_sicherstellen()
try:
von = _als_zeit(start)
bis = _als_zeit(ende)
except ValueError as fehler:
log.error(f"audi_dashboard: Import mit unlesbarem Zeitraum aufgerufen ({fehler})")
_status("fehler", {"meldung": "Zeitraum nicht lesbar"})
return
if von is None or bis is None or von >= bis:
log.warning("audi_dashboard: Import ohne gültigen Zeitraum aufgerufen")
_status("fehler", {"meldung": "Bitte einen Zeitraum wählen, dessen Ende nach dem Start liegt."})
return
_status("laeuft", {"von": von.isoformat(), "bis": bis.isoformat()})
log.info(f"audi_dashboard: Import gestartet für {von.isoformat()} bis {bis.isoformat()}")
try:
verlaeufe = {
"zuendung": _verlauf(einstellungen.ZUENDUNG_SENSOR, von, bis),
"km": _verlauf(einstellungen.KM_SENSOR, von, bis),
"tank": _verlauf(einstellungen.TANK_SENSOR, von, bis),
"batterie": _verlauf(einstellungen.BATTERIE_SENSOR, von, bis),
"lat": _verlauf(einstellungen.STANDORT_LAT_SENSOR, von, bis),
"lon": _verlauf(einstellungen.STANDORT_LON_SENSOR, von, bis),
}
except Exception as fehler:
log.error(f"audi_dashboard: Verlauf nicht lesbar ({type(fehler).__name__}: {fehler})")
_status("fehler", {"meldung": (
"Der Verlauf konnte nicht gelesen werden. Steht hass_is_global: true "
"im pyscript-Block der configuration.yaml?"
)})
return
# Frühester Zeitpunkt, zu dem im gewählten Fenster überhaupt etwas
# aufgezeichnet war - damit ein leeres Ergebnis erklärbar wird
# ("recorder reicht nur bis ...") statt wie ein Fehler auszusehen.
frueheste = None
for name in verlaeufe:
if verlaeufe[name]:
erster = verlaeufe[name][0][0]
if frueheste is None or erster < frueheste:
frueheste = erster
fahrten = _fahrten_importieren(von, bis, verlaeufe)
tank = _tankvorgaenge_importieren(verlaeufe)
batterie = _batterie_importieren(verlaeufe)
frontend_veroeffentlichung.fahrten_veroeffentlichen()
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
frontend_veroeffentlichung.batterieverlauf_veroeffentlichen()
ergebnis = {
"von": von.isoformat(),
"bis": bis.isoformat(),
"ab_wann_daten": frueheste.isoformat() if frueheste else None,
"fahrten_angelegt": fahrten["angelegt"],
"fahrten_uebersprungen": fahrten["uebersprungen"],
"fahrten_zu_kurz": fahrten["zu_kurz"],
"tankvorgaenge_angelegt": tank["angelegt"],
"tankvorgaenge_uebersprungen": tank["uebersprungen"],
"batterie_tage": batterie["tage"],
}
_status("fertig", ergebnis)
log.info(f"audi_dashboard: Import abgeschlossen - {ergebnis}")
@@ -13,33 +13,52 @@ entitaeten.py) - Änderungen von dort werden zur Laufzeit auf diese Variablen
angewendet (überschreiben also die hier hinterlegten Standardwerte), ohne
diese Datei anzufassen.
2026-08-12: Die HACS-Integration TommiG1/HA_VAG-EU-Data-Act (bisherige
Quelle für Kilometerstand, Tankfüllstand, Türen/Fenster/Schlösser,
Ölwechsel-/Inspektionsdaten) wird nicht mehr verwendet - die zugehörigen
Entity-IDs sind deshalb unten bewusst leer. Neue Datenquelle ist der
Teltonika FMM003 (GPS-Tracker mit CAN-Anbindung, siehe AGENTS.md Abschnitt
B); er liefert Standort, Zündungsstatus und Batteriespannung, aber keinen
Tankfüllstand, keine Tür-/Fenster-/Schlossdaten und keine Ölwechsel-/
Inspektionstermine - die entsprechenden Kacheln zeigen deshalb bis auf
Weiteres "unbekannt" statt eines falschen Werts (siehe zustand_oder_none()
in frontend_veroeffentlichung.py). Bleibt ein Wert leer ("") oder passt eine
Entity-ID nicht zur tatsächlichen Integration, liefert zustand_oder_none()
für das jeweilige Feld None statt abzustürzen.
**Im Auslieferstand sind alle Felder hier leer.** Das ist Absicht, kein
unfertiger Zustand: welche Entity-IDs richtig sind, hängt an der jeweiligen
Instanz und ihren Integrationen. Die Zuordnung passiert nach der Installation
im Setup-Menü der App (Einstellungen -> Fahrzeug einrichten -> Setup), das
sie nach data/entitaeten.json schreibt; diese Datei bleibt dabei unangetastet.
Ein leeres Feld ist der sichere Zustand: die betroffene Kachel zeigt
"unbekannt" statt eines falschen Werts (siehe zustand_oder_none() in
frontend_veroeffentlichung.py), und die trigger-gebundenen Felder
(ZUENDUNG_SENSOR, KM_SENSOR, TANK_SENSOR) registrieren gar keinen Trigger,
statt einen gegen eine nicht existierende Entität zu registrieren. Eine
gesetzte, aber falsche Entity-ID ist deshalb schlechter als eine leere.
Zwei typische Quellen auf dieser Instanz: der Teltonika FMM003 (GPS-Tracker
mit CAN-Anbindung, über flespi angebunden - Standort, Zündungsstatus,
Bordnetzspannung, CAN-Werte wie Kilometerstand und Tankfüllstand) und eine
EU-Data-Act-Integration des Herstellers (Kilometerstand, Tankfüllstand,
Türen/Fenster/Schlösser, Reifendrücke, Ölwechsel-/Inspektionstermine). Welche
davon welche Rolle bedient, entscheidet das Setup-Menü - nicht diese Datei.
"""
# Fahrterkennung (§7.1): Start/Ende einer Fahrt wird über den Zündungs-/ACC-
# Status des FMM003 erkannt (on = Fahrt läuft), nicht mehr über die WLAN-
# Verbindung des iPhones zum Fahrzeug (siehe fahrterkennung.py).
ZUENDUNG_SENSOR = "binary_sensor.testzone_fmm003_engine_ignition_or_acc_status"
#
# Leer im Auslieferstand, wie alle Felder hier. Bis 2026-08-23 stand hier die
# Entity-ID einer längst abgeräumten Testinstanz
# ("binary_sensor.testzone_fmm003_..."). Auf einer frischen Installation
# zeigte sie ins Leere - und richtete dabei mehr an, als nur nutzlos zu sein:
# fahrterkennung.py registriert seinen @state_trigger nur, WENN dieses Feld
# belegt ist ("if einstellungen.ZUENDUNG_SENSOR:", ausdrücklich als Schutz
# gegen eine leere Entity-ID gebaut). Ein gesetzter, aber nicht existierender
# Wert hebelt genau diesen Schutz aus. Dasselbe galt für BATTERIE_SENSOR
# weiter unten. Zuordnung gehört ins Setup-Menü, nicht in den Auslieferstand.
ZUENDUNG_SENSOR = ""
# Kilometerstand - bisher aus der TommiG1-Integration, aktuell keine Quelle
# vorhanden. Der FMM003 liefert unter sensor.testzone_fmm003_total_calculated_mileage
# einen selbst berechneten Wert, der aber auf einer anderen Zählbasis beruht
# als der echte Fahrzeug-Kilometerstand (GPS-Streckenberechnung statt
# Tacho) - bewusst NICHT automatisch übernommen, um Reifenzähler,
# Ölwechsel-Prognose und Fahrtabschluss-Screening nicht mit einem
# inkonsistenten Basiswert zu verfälschen. Bei Bedarf über das Setup-Menü
# gezielt zuordnen.
# Kilometerstand - für Fahrtabschluss-Screening, Reifenzähler und
# Ölwechsel-Prognose.
#
# Beim FMM003 hier NICHT den selbst berechneten Gesamtkilometerstand
# (*_total_calculated_mileage) zuordnen: der beruht auf GPS-Streckenrechnung
# statt auf dem Tacho und damit auf einer anderen Zählbasis als der echte
# Fahrzeug-Kilometerstand. Wer ihn einträgt, verfälscht alle drei genannten
# Auswertungen mit einem inkonsistenten Basiswert. Der vom CAN gelesene Wert
# (*_total_vehicle_mileage_read_from_can) bzw. der Kilometerstand der
# EU-Data-Act-Integration ist der richtige.
KM_SENSOR = ""
# Tankfüllstand (Prozent) - keine Quelle mehr vorhanden (der FMM003 ist kein
@@ -49,11 +68,12 @@ TANK_SENSOR = ""
# Reichweite (§5.1 Übersicht) - keine Quelle mehr vorhanden.
RANGE_SENSOR = ""
# 12V-Batteriespannung (Mein Audi -> Zustand). Vom FMM003 geliefert -
# external_power_voltage ist die vom Gerät gemessene Bordnetzspannung des
# Fahrzeugs, NICHT battery_voltage (das ist die interne Pufferbatterie des
# 12V-Batteriespannung (Mein Audi -> Zustand). Beim FMM003 ist das
# external_power_voltage - die vom Gerät gemessene Bordnetzspannung des
# Fahrzeugs -, NICHT battery_voltage (das ist die interne Pufferbatterie des
# Trackers selbst und hat mit der Fahrzeugbatterie nichts zu tun).
BATTERIE_SENSOR = "sensor.testzone_fmm003_external_power_voltage"
# Leer im Auslieferstand, siehe ZUENDUNG_SENSOR oben.
BATTERIE_SENSOR = ""
# Knopf für eine sofortige Neuabfrage beim Fahrzeug - kam aus der
# TommiG1-Integration, keine Entsprechung beim FMM003 vorhanden.
@@ -282,6 +282,13 @@ def batterieverlauf_tageswert_aktualisieren(datum, ts, spannung):
break
else:
verlauf.append({"datum": datum, "min": spannung, "min_ts": ts, "max": spannung, "max_ts": ts})
# Nach Datum sortiert schreiben, nicht in Einfügereihenfolge. Solange nur
# die Live-Aufzeichnung schrieb, war beides dasselbe (sie trägt immer den
# heutigen Tag ein, also stets den jüngsten). Der nachträgliche Import
# (historienimport.py) trägt dagegen vergangene Tage ein - ohne diese
# Zeile stünden sie hinter den neueren, und das Diagramm im Frontend, das
# die Datei in Dateireihenfolge zeichnet, liefe zeitlich rückwärts.
verlauf.sort(key=lambda e: e.get("datum") or "")
_zeilen_schreiben(BATTERIEVERLAUF_PFAD, verlauf)
@@ -0,0 +1,103 @@
# Datenaufbewahrung (recorder) — Ergänzung zur bestehenden configuration.yaml.
#
# WOFÜR DAS DA IST
# ----------------
# Home Assistant löscht Sensor-Verlaufsdaten standardmäßig nach 10 Tagen
# (recorder.purge_keep_days, Standardwert). Das ist die Obergrenze dafür, wie
# weit ein späterer Import "Daten aus Home Assistant importieren" überhaupt
# zurückreichen KANN — was der recorder gelöscht hat, ist unwiederbringlich
# weg, auch für die App.
#
# Die App selbst speichert dagegen bereits unbegrenzt: fahrten.jsonl,
# tankvorgaenge.jsonl, batteriespannung.jsonl und fahrzeugprofil.json unter
# /config/audi_dashboard/ werden nirgends automatisch gekürzt oder gelöscht
# (siehe profil.py — es gibt keine Purge-Logik). Betroffen ist also nur der
# ROHE Sensor-Verlauf in Home Assistants eigener Datenbank, aus dem die App
# ihre Datensätze erst ableitet.
#
# Dieser Block hebt die Aufbewahrung auf ein Jahr an. Das ist die
# Übergangslösung, bis die App die Daten selbst dauerhaft übernimmt: fährt
# der Import einmal über einen Zeitraum, liegen die daraus erzeugten Fahrten/
# Tankvorgänge/Spannungswerte dauerhaft in den .jsonl-Beständen und sind vom
# recorder unabhängig.
#
# EINBAU
# ------
# Diesen Block in die bestehende configuration.yaml übernehmen (nicht die
# Datei ersetzen). Existiert dort schon ein `recorder:`-Block, die Werte dort
# zusammenführen statt einen zweiten anzulegen. Danach Home Assistant neu
# starten (Entwicklerwerkzeuge -> YAML -> Neu starten).
#
# PLATZBEDARF — an der Testinstanz gemessen, nicht geschätzt
# ---------------------------------------------------------
# Stand 2026-08-23: 90.858 Zustandsänderungen in 17 Tagen = rund 5.300 pro
# Tag, Datenbank 64,7 MB. Hochgerechnet auf 365 Tage sind das grob 1,9 Mio.
# Zeilen und in der Größenordnung 11,5 GB.
#
# ACHTUNG bei HA OS auf kleinem Datenträger (Raspberry Pi mit SD-Karte,
# 32-GB-eMMC): das ist der einzige Punkt dieser ganzen App, der einer sonst
# gesunden Installation gefährlich werden kann. Läuft der Datenträger voll,
# startet Home Assistant nicht mehr sauber - unabhängig von dieser App.
#
# Vor dem Einbau deshalb einmal nachsehen, wie viel Platz frei ist:
# Einstellungen -> System -> Speicher. Faustregel: mindestens 5 GB frei,
# sonst besser mit einem kürzeren Zeitraum anfangen (z. B.
# purge_keep_days: 90) und die auskommentierte exclude:-Liste unten gleich
# mit aktivieren. 90 Tage reichen für den Zweck "Vergangenheit nachtragen"
# in aller Regel aus - der Import holt die Daten ja dauerhaft in die
# .jsonl-Bestände der App, die danach vom recorder unabhängig sind.
#
# Nach ein paar Wochen noch einmal unter Einstellungen -> System -> Speicher
# nachsehen und bei Bedarf nachjustieren.
#
# 96 % dieser Zeilen stammen aus der EU-Data-Act-Integration
# (`cupra_eu_data_act`, ~1.737 Zustände je Entität in 17 Tagen, also etwa
# alle 14 Minuten ein Update über rund 50 Entitäten), 1 % vom FMM003 über
# flespi, 2 % alles Übrige. Wer den Platzbedarf drücken will, kürzt also bei
# der EU-Data-Act-Integration — die auskommentierte `exclude`-Liste unten ist
# ein Vorschlag dafür, welche Entitäten historisch nichts hergeben.
recorder:
# 365 Tage statt der 10 Tage Standardaufbewahrung.
purge_keep_days: 365
# Bewusst KEIN `include:`-Block. Ein `include` würde bedeuten, dass Home
# Assistant *nur noch* die dort genannten Entitäten aufzeichnet und für
# alles andere gar keinen Verlauf mehr führt — auch nicht für die 10 Tage,
# die es vorher hatte. Da auf dieser Instanz ohnehin 97 % der Aufzeichnung
# auf Fahrzeugdaten entfällt, spart das kaum Platz, kostet aber den Verlauf
# jeder anderen Integration. Deshalb: global ein Jahr aufbewahren.
# Standardmäßig 1 Sekunde. 30 Sekunden bündelt die Schreibvorgänge, was vor
# allem SD-Karten schont; der Preis ist, dass die letzten bis zu 30
# Sekunden bei einem harten Stromausfall fehlen können. Für Fahrzeugdaten,
# die ohnehin nur alle paar Minuten aktualisiert werden, ist das
# unerheblich.
commit_interval: 30
# Nächtliches Aufräumen (Standard 04:12) mit Neuaufbau der Datenbankdatei.
# repack gibt den durch gelöschte Zeilen frei gewordenen Platz auch
# tatsächlich ans Dateisystem zurück, statt die Datei nur intern als "leer"
# zu markieren — bei einem Jahr Aufbewahrung sonst der übliche Grund, warum
# die Datei nur noch wächst.
auto_purge: true
auto_repack: true
# OPTIONAL — nur einkommentieren, wenn die Datenbank zu groß wird.
# Diese Entitäten liefern über die Zeit keinen Erkenntniswert: reine
# Diagnose-/Netzwerkwerte des Trackers und abgeleitete Momentanwerte der
# EU-Data-Act-Integration, die sich jederzeit neu berechnen lassen.
# exclude:
# entity_globs:
# - sensor.*_tire_pressure_diff_*
# - sensor.*fmm003*_mobile_network_*
# - sensor.*fmm003*_lte_*
# - sensor.*fmm003*_network_*
# - sensor.*fmm003*_id_of_*
# - sensor.*fmm003*_*dilution_of_precision
# - sensor.*fmm003*_satellites
# entities:
# - sensor.audi_rs_4_avant_ei_t1302_echo
# - sensor.audi_rs_4_avant_ei_t1302_trueness
# - sensor.audi_rs_4_avant_uncurated_fields
# - sensor.audi_rs_4_avant_minutes_since_last_snapshot
@@ -2137,6 +2137,12 @@ function vEinst() {
<div class="feld" style="border-bottom:0"><label for="oelM">Nach Zeit</label>
<select id="oelM" data-oelm>${[12, 24].map((m) => `<option value="${m}" ${m === CAR.oel.monate ? "selected" : ""}>${m === 12 ? "1 Jahr" : "2 Jahre"}</option>`).join("")}</select></div>`}
<button class="aktion" style="margin-top:20px" data-setup-oeffnen>Setup Sensoren zuordnen</button>
<button class="aktion" data-import-oeffnen>Daten importieren aus Home Assistant</button>
<span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Legt Fahrten, Tankvorgänge und Spannungswerte für einen vergangenen Zeitraum
nachträglich an aus dem Verlauf, den Home Assistant zu den oben zugeordneten
Sensoren bereits aufgezeichnet hat.</span>
` : ""}
<button class="aktion ${einrichtenOffen ? "primaer" : ""}" style="margin-top:16px" data-einrichten-auf>${einrichtenOffen ? "Fertig" : "Einrichten"}</button>
</div>
@@ -3315,6 +3321,77 @@ function zwischenablageKnopfSinnvoll() {
return window.matchMedia && window.matchMedia("(pointer: coarse)").matches;
}
/* ------------------------------------------------- Historien-Import-Popup
Zeitraum wählen, importieren lassen, Ergebnis zeigen. Der eigentliche
Import läuft im Backend (pyscript/historienimport.py) und meldet seinen
Stand über die Entität pyscript.audi_dashboard_import_status - dasselbe
Muster wie beim Update-Status (UPDATE_STATUS oben). */
let importPopup = null;
/* Vorbelegung: die letzten 30 Tage. Wert im Format, das <input
type="datetime-local"> erwartet (Ortszeit, ohne Zeitzone, minutengenau) -
genau das, was der Dienst als Ortszeit interpretiert. */
function importZeitraumVorgabe() {
const jetzt = new Date();
const vor30 = new Date(jetzt.getTime() - 30 * 86400000);
const alsFeld = (d) => new Date(d.getTime() - d.getTimezoneOffset() * 60000)
.toISOString().slice(0, 16);
return { von: alsFeld(vor30), bis: alsFeld(jetzt) };
}
function importErgebnisText(d) {
const anzahl = (n, eins, viele) => `${de(n)} ${n === 1 ? eins : viele}`;
const teile = [anzahl(d.fahrten_angelegt || 0, "Fahrt", "Fahrten") + " angelegt"];
if (d.fahrten_uebersprungen) teile.push(`${de(d.fahrten_uebersprungen)} bereits vorhanden`);
if (d.fahrten_zu_kurz) teile.push(anzahl(d.fahrten_zu_kurz, "Zündung zu kurz für eine Fahrt", "Zündungen zu kurz für eine Fahrt"));
if (d.tankvorgaenge_angelegt) teile.push(anzahl(d.tankvorgaenge_angelegt, "Tankvorgang", "Tankvorgänge"));
if (d.batterie_tage) teile.push(anzahl(d.batterie_tage, "Tag Batteriespannung", "Tage Batteriespannung"));
return teile.join(" · ");
}
function vImportPopup() {
if (!importPopup) return "";
const p = importPopup;
const fertig = p.ergebnis && !p.laeuft;
// Nach dem Lauf zeigt dasselbe Fenster das Ergebnis statt der Felder - der
// Nutzer soll sehen, was passiert ist, bevor er es wegklickt (HIG
// "Feedback"), statt das Fenster wortlos verschwinden zu lassen.
const koerper = fertig
? `<div style="margin-top:14px;font-size:14px;line-height:1.6;color:var(--fg)">
${esc(importErgebnisText(p.ergebnis))}</div>
${p.ergebnis.fahrten_angelegt === 0 && p.ergebnis.ab_wann_daten
? `<span class="label" style="margin-top:12px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Aufgezeichnet ist in diesem Zeitraum ab ${dedat(new Date(p.ergebnis.ab_wann_daten))} ·
${new Date(p.ergebnis.ab_wann_daten).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr.
Weiter zurück bewahrt Home Assistant nichts mehr auf.</span>`
: ""}
${p.ergebnis.fahrten_angelegt === 0 && !p.ergebnis.ab_wann_daten
? `<span class="label" style="margin-top:12px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Für diesen Zeitraum liegt in Home Assistant kein Verlauf mehr vor.</span>`
: ""}`
: `<div class="feld" style="margin-top:12px"><label for="impVon">Von</label>
<input id="impVon" type="datetime-local" style="width:196px" value="${esc(p.von)}" data-import-von></div>
<div class="feld" style="border-bottom:0"><label for="impBis">Bis</label>
<input id="impBis" type="datetime-local" style="width:196px" value="${esc(p.bis)}" data-import-bis></div>
${p.fehler ? `<span class="label" style="margin-top:10px;color:var(--red)">${esc(p.fehler)}</span>` : ""}
<span class="label" style="margin-top:12px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Bereits erfasste Fahrten bleiben unangetastet überschneidet sich ein
Zeitraum, wird er übersprungen statt doppelt angelegt.</span>`;
return `<div class="beleg-catcher" data-import-zu></div>
<div class="beleg-popup" role="dialog" aria-modal="true" aria-label="Daten importieren">
<span class="label">Daten importieren aus Home Assistant</span>
${koerper}
${fertig
? `<button class="aktion primaer" data-import-zu>Fertig</button>`
: `<button class="aktion primaer" data-import-start ${p.laeuft ? "disabled" : ""}>${p.laeuft ? "Importiere …" : "Importieren"}</button>
<button class="aktion" data-import-zu ${p.laeuft ? "disabled" : ""}>Abbrechen</button>`}
</div>`;
}
function vBelegPopup() {
if (!belegPopup) return "";
// Am Telefon ist Einfügen der Hauptweg (PDF aus der Mail kopiert), am
@@ -3500,7 +3577,7 @@ function render() {
// faellt sonst bei jedem render(), z. B. beim Umschalten des Filters,
// auf 0 zurueck).
const setupKoerperScroll = setupOffen ? (ROOT.querySelector(".setup-koerper") || {}).scrollTop : 0;
ROOT.getElementById("overlay").innerHTML = sheetMarkup() + vSetupPopup() + vBelegPopup();
ROOT.getElementById("overlay").innerHTML = sheetMarkup() + vSetupPopup() + vBelegPopup() + vImportPopup();
if (setupOffen) {
const koerper = ROOT.querySelector(".setup-koerper");
if (koerper) koerper.scrollTop = setupKoerperScroll || 0;
@@ -3600,6 +3677,84 @@ function randwischenVerdrahten() {
Schreibende Aktionen: lokale Anzeige sofort aktualisieren (render()),
Persistenz über hass.callService im Hintergrund - dieselbe Reihenfolge
wie im Prototyp, nur dass jetzt zusätzlich gespeichert wird. */
/* Import anstoßen und auf sein Ergebnis warten.
Bewusst NICHT über serviceRufen(): der Import ist die eine Aktion, bei der
das Fenster offen bleibt und das Ergebnis zeigt, statt optimistisch zu
schließen. Das Backend meldet seinen Stand über
pyscript.audi_dashboard_import_status; hier wird dieselbe Entität gepollt,
bis sie "fertig" oder "fehler" meldet. Ein Poll statt eines Abwartens des
callService-Versprechens, weil pyscript-Dienste sofort zurückkehren, wenn
sie im Hintergrund weiterlaufen. */
async function importStatusLesen() {
try {
const states = await HASS.callWS({ type: "get_states" });
return states.find((x) => x.entity_id === "pyscript.audi_dashboard_import_status") || null;
} catch (e) {
return null;
}
}
async function importAusloesen() {
if (!importPopup || importPopup.laeuft) return;
const von = importPopup.von;
const bis = importPopup.bis;
if (!von || !bis) { importPopup.fehler = "Bitte Start und Ende wählen."; render(); return; }
if (von >= bis) { importPopup.fehler = "Das Ende muss nach dem Start liegen."; render(); return; }
importPopup.laeuft = true;
importPopup.fehler = null;
render();
// Zeitstempel des letzten Laufs merken: nur ein danach geschriebener Stand
// ist die Antwort auf DIESEN Aufruf und nicht das Ergebnis von gestern.
const vorher = await importStatusLesen();
const vorherStand = vorher ? vorher.last_updated : null;
try {
await HASS.callService("pyscript", "audi_dashboard_historie_importieren", { start: von, ende: bis });
} catch (err) {
console.error("audi_dashboard: Import", err);
if (importPopup) {
importPopup.laeuft = false;
importPopup.fehler = (err && err.message) || "Der Import konnte nicht gestartet werden.";
render();
}
return;
}
// Bis zu zwei Minuten - ein Jahr Verlauf über sechs Sensoren braucht seine
// Zeit, und ein zu früher Abbruch sähe wie ein Fehlschlag aus, obwohl der
// Import im Hintergrund sauber zu Ende läuft.
for (let versuch = 0; versuch < 120; versuch++) {
await new Promise((r) => setTimeout(r, 1000));
if (!importPopup) return;
const s = await importStatusLesen();
if (!s || s.last_updated === vorherStand) continue;
if (s.state === "fertig") {
importPopup.laeuft = false;
importPopup.ergebnis = (s.attributes && s.attributes.daten) || {};
render();
// Fahrten-/Tankvorgangslisten im Frontend nachziehen - das Backend hat
// sie bereits neu veröffentlicht, die App muss sie nur neu lesen.
datenAktualisieren();
return;
}
if (s.state === "fehler") {
importPopup.laeuft = false;
importPopup.fehler = (s.attributes && s.attributes.daten && s.attributes.daten.meldung)
|| "Der Import ist fehlgeschlagen.";
render();
return;
}
}
if (importPopup) {
importPopup.laeuft = false;
importPopup.fehler = "Der Import dauert ungewöhnlich lange — er läuft im Hintergrund weiter.";
render();
}
}
function serviceRufen(dienst, daten) {
// HIG "Feedback": ein fehlgeschlagener Aufruf darf nicht nur in der Konsole
// landen - die aufrufende Stelle hat ihr Popup meist schon geschlossen und
@@ -3657,6 +3812,9 @@ function ereignisseVerdrahten() {
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
if (belegPopup) { belegPopup = null; render(); e.preventDefault(); return; }
// Während der Import läuft nicht wegklickbar - er läuft im Backend
// weiter, das Fenster ist die einzige Stelle, die sein Ergebnis zeigt.
if (importPopup) { if (!importPopup.laeuft) { importPopup = null; render(); } e.preventDefault(); return; }
if (sheet) { sheetSchliessen(); e.preventDefault(); return; }
if (setupSucheOffen) { setupSucheOffen = null; setupSuchtext = ""; render(); e.preventDefault(); return; }
if (setupOffen) { setupSchliessen(); e.preventDefault(); return; }
@@ -4142,6 +4300,17 @@ function ereignisseVerdrahten() {
eingabe.click();
return;
}
if (e.target.closest("[data-import-oeffnen]")) {
const vorgabe = importZeitraumVorgabe();
importPopup = { von: vorgabe.von, bis: vorgabe.bis, laeuft: false, ergebnis: null, fehler: null };
render();
return;
}
if (e.target.closest("[data-import-zu]")) {
if (importPopup && importPopup.laeuft) return;
importPopup = null; render(); return;
}
if (e.target.closest("[data-import-start]")) { importAusloesen(); return; }
const del = e.target.closest("[data-loeschen]");
if (del) { delete CAR.service.vereinbart[del.dataset.loeschen]; profilSpeichern(); render(); return; }
const vseldel = e.target.closest("[data-vselbstdel]");
@@ -4402,6 +4571,11 @@ function ereignisseVerdrahten() {
CAR.versicherung.beitrag = sum(CAR.versicherung.teile, (t) => t.betrag || 0);
profilSpeichern(); render(); return;
}
// Kein render() bei den beiden Import-Feldern: das Popup wird bei jedem
// render() neu erzeugt, ein Neuzeichnen mitten in der Eingabe risse dem
// Nutzer den gerade offenen Datumswähler weg.
if (e.target.dataset.importVon !== undefined) { if (importPopup) importPopup.von = e.target.value; return; }
if (e.target.dataset.importBis !== undefined) { if (importPopup) importPopup.bis = e.target.value; return; }
if (e.target.dataset.vgueltigab !== undefined) { CAR.versicherung.gueltigAb = e.target.value; profilSpeichern(); render(); return; }
const vnotruf = e.target.dataset.vnotruf;
if (vnotruf) { CAR.versicherung[vnotruf] = e.target.value; profilSpeichern(); render(); return; }
@@ -1 +1 @@
{"version": 1787014000}
{"version": 1787015002}
@@ -950,13 +950,30 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
.standortmenu-werte { display: flex; align-items: center; gap: 10px; margin-top: 8px; font-size: 13.5px; color: var(--fg2); }
.standortmenu-werte svg { color: var(--fg3); flex: 0 0 auto; }
.standortmenu-aktionen { display: flex; gap: 10px; margin-top: 18px; padding-bottom: 4px; }
/* Beide Pillen folgen der Knopfsprache des restlichen Panels (.aktion /
.aktion.primaer weiter oben): Umriss fuer die Nebenaktion, gefuellt fuer
die Hauptaktion.
Vorher stand hier background: var(--tile) fuer "Teilen" und
background: var(--line-strong) fuer "Route". Beides ging im Tagmodus
schief: das Blatt darunter ist var(--tile-deckend), und die iOS-Auflage
(audi-dashboard-ios.css) setzt im Tagmodus --tile UND --tile-deckend
beide auf #FFFFFF - "Teilen" war damit weiss auf weiss, Kontrast 1,0:1,
also schlicht unsichtbar. --line-strong als Flaeche war ausserdem eine
Linienfarbe in der Rolle einer Fuellung.
Der Umriss haengt jetzt an denselben Tokens wie jeder andere Knopf und
traegt deshalb in beiden Themes und auch unter der iOS-Auflage, ohne dass
hier eine eigene Farbe gepflegt werden muss. */
.standort-pille {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
height: 40px; padding: 0 20px; border-radius: var(--r-pill);
border: none; background: var(--tile); color: var(--fg);
border: 1px solid var(--line-strong); background: none; color: var(--fg);
font-family: inherit; font-size: 14px; text-decoration: none; cursor: pointer;
}
.standort-pille.primaer { background: var(--line-strong); }
.standort-pille.primaer {
background: var(--fg); border-color: var(--fg); color: var(--canvas);
}
.standort-pille:active { transform: scale(.97); }
.standort-pille[disabled] { opacity: .4; pointer-events: none; }
+511
View File
@@ -0,0 +1,511 @@
"""Nachträglicher Import vergangener Zeiträume aus dem Home-Assistant-Verlauf
(Einstellungen -> "Daten importieren aus Home Assistant").
WOZU
----
Fahrterkennung (fahrterkennung.py), Tankerkennung (tankerkennung.py) und
Batterieverlauf (batterieverlauf.py) arbeiten alle nur ab dem Moment, in dem
sie laufen: sie hängen an @state_trigger bzw. an einem 5-Minuten-Takt. Alles,
was das Fahrzeug gemeldet hat, BEVOR die App lief (oder während Home
Assistant aus war, oder bevor ein Sensor überhaupt zugeordnet war), taucht in
den Beständen der App deshalb nie auf - obwohl der recorder von Home
Assistant es längst aufgezeichnet hat.
Dieser Import schließt genau diese Lücke: er liest denselben Verlauf, den die
Live-Trigger sonst in Echtzeit sehen, und leitet daraus rückwirkend dieselben
Datensätze ab.
GRENZE, DIE MAN KENNEN MUSS
---------------------------
Weiter zurück als der recorder aufbewahrt, geht es nicht - was dort gelöscht
ist, ist endgültig weg. Home Assistant löscht standardmäßig nach 10 Tagen;
recorder_snippet.yaml hebt das auf ein Jahr an. Der Import meldet deshalb im
Ergebnis mit, ab wann im gewählten Zeitraum überhaupt Daten vorlagen
(`ab_wann_daten`), damit ein leeres Ergebnis nicht wie ein Fehler aussieht.
WIE DER VERLAUF GELESEN WIRD
----------------------------
Über Home Assistants eigene recorder-API
(`homeassistant.components.recorder.history.get_significant_states`), nicht
über direkten SQL-Zugriff auf home-assistant_v2.db. Der Umweg über die API
ist Absicht: das Datenbankschema des recorders ist HA-intern und ändert sich
zwischen Versionen, die Funktion dagegen ist die von HA selbst benutzte und
stabile Schnittstelle.
Voraussetzung dafür ist `hass_is_global: true` im pyscript-Block der
configuration.yaml (siehe configuration_snippet.yaml) - ohne das existiert
der `hass`-Name hier nicht. Der Aufruf läuft über task.executor(), weil es
eine echte externe Funktion ist und ein Datenbankzugriff nichts im
Event-Loop verloren hat (dieselbe pyscript-Einschränkung wie bei io.open in
profil.py, siehe dortiger Kopfkommentar).
`significant_changes_only=False` ist wichtig: bei numerischen Sensoren
(Kilometerstand, Tankfüllstand) liefert der Standardmodus nur "auffällige"
Änderungen und verschluckt genau die kleinen Schritte, aus denen sich
Fahrstrecke und Tankvorgänge zusammensetzen.
DOPPELTE DATENSÄTZE
-------------------
Der Import ist absichtlich mehrfach ausführbar (überlappende Zeiträume,
zweiter Versuch nach einem Abbruch): jede erzeugte Fahrt wird gegen die
bereits vorhandenen geprüft und übersprungen, wenn sich ihr Zeitraum mit
einer bestehenden Fahrt überschneidet - egal ob die live erkannt, von Hand
angelegt oder aus einem früheren Import stammt. Tankvorgänge werden über ein
Zeitfenster (TANK_DUBLETTE_MIN) entdoppelt, Batteriewerte über den Tag als
Schlüssel (dort führt profil.batterieverlauf_tageswert_aktualisieren() min/
max ohnehin zusammen, statt Zeilen zu vervielfachen).
Erzeugte Datensätze tragen `source: "import"` - dieselbe Rolle wie "ha"
(live erkannt), "manual" (von Hand) und "auto" (Tankerkennung), damit später
nachvollziehbar bleibt, woher ein Eintrag stammt.
"""
import datetime
from homeassistant.components.recorder.history import get_significant_states
import einstellungen
import frontend_veroeffentlichung
import profil
# Ein Anstieg des Tankfüllstands gilt ab denselben Schwellen als Tankvorgang
# wie in der Live-Erkennung - bewusst dieselben Zahlen wie in
# tankerkennung.py, damit ein importierter Zeitraum dieselben Vorgänge
# erzeugt, die die Live-Erkennung erzeugt hätte.
LITER_SCHWELLE = 5
PROZENT_SCHWELLE = 9
STANDARD_TANKVOLUMEN_LITER = 58
# Zwei Tankvorgänge innerhalb dieser Spanne gelten als derselbe - schützt
# gegen Dubletten, wenn derselbe Zeitraum zweimal importiert wird oder sich
# Import und Live-Erkennung am Rand überschneiden.
TANK_DUBLETTE_MIN = 90
# Fahrten, die kürzer sind, sind Zündung-an-ohne-Fahrt (Radio, Tür öffnen mit
# Zündung, Diagnose) - die Live-Erkennung legt sie zwar an, im Rückblick
# fluten sie den Bestand aber mit Nulleinträgen. Bewusst konservativ.
MINDESTDAUER_S = 60
def _status(zustand, daten=None):
"""Fortschritt/Ergebnis für die Oberfläche. Gleiches Muster wie
_status_veroeffentlichen() in updateverwaltung.py."""
state.set(
"pyscript.audi_dashboard_import_status",
zustand,
new_attributes={"daten": daten or {}},
)
def _als_zeit(wert):
"""Akzeptiert, was die Oberfläche schickt: ISO mit oder ohne Zeitzone.
Ohne Zeitzone gilt die lokale Zeit von Home Assistant - der Nutzer wählt
im Formular schließlich Ortszeit, keine UTC."""
if not wert:
return None
ts = datetime.datetime.fromisoformat(str(wert))
if ts.tzinfo is None:
ts = ts.astimezone()
return ts.astimezone(datetime.timezone.utc)
def _zahl(wert):
try:
return float(wert)
except (TypeError, ValueError):
return None
def _verlauf(entity_id, start, ende):
"""Zustandsverlauf einer Entität als Liste von (Zeitpunkt, Rohwert),
aufsteigend. Leere Liste, wenn die Entität nicht zugeordnet ist oder im
Zeitraum nichts vorliegt.
Die Zustände "unknown"/"unavailable" werden verworfen: sie bedeuten
"keine Meldung", nicht "Wert 0" - würden sie durchgereicht, ergäbe ein
Ausfall der Integration eine Fahrt mit absurder Kilometerdifferenz."""
if not entity_id:
return []
roh = task.executor(
get_significant_states, hass, start, ende, [entity_id], None, True, False
)
reihen = roh.get(entity_id) or []
ergebnis = []
for s in reihen:
if s.state in ("unknown", "unavailable", None, ""):
continue
ergebnis.append((s.last_updated, s.state))
ergebnis.sort(key=lambda p: p[0])
return ergebnis
def _wert_bei(verlauf, zeitpunkt):
"""Der zuletzt vor `zeitpunkt` gemeldete Zahlenwert, sonst der erste
danach, sonst None. "Zuletzt davor" ist die richtige Wahl für einen
Zählerstand: der Kilometerstand bei Fahrtbeginn ist der, der zuletzt
gemeldet wurde, nicht der nächste (der schon Strecke enthält)."""
davor = None
for ts, wert in verlauf:
zahl = _zahl(wert)
if zahl is None:
continue
if ts <= zeitpunkt:
davor = zahl
else:
return davor if davor is not None else zahl
return davor
def _pausenzeit_sekunden():
p = profil.profil_lesen()
if p is None:
return 15 * 60
return p.get("einstellungen", {}).get("fahrten_pausenzeit_min", 15) * 60
def _fahrtfenster(zuendung_verlauf, pausenzeit_s):
"""Aus dem Zündungsverlauf die Zeiträume, in denen gefahren wurde.
Zwei Schritte, die zusammen die Pausenregel aus §7.1 nachbilden:
erst jeden zusammenhängenden "on"-Abschnitt sammeln, dann benachbarte
Abschnitte verschmelzen, deren Lücke kürzer als die Pausenzeit ist. Genau
das tut die Live-Erkennung über task.unique() + task.sleep(), nur eben
im Nachhinein und ohne Warten."""
roh = []
offen = None
for ts, wert in zuendung_verlauf:
an = str(wert).lower() in ("on", "true", "1")
if an and offen is None:
offen = ts
elif not an and offen is not None:
roh.append((offen, ts))
offen = None
# Ein am Ende des Zeitraums noch offener Abschnitt wird verworfen: die
# Fahrt ist zu diesem Zeitpunkt noch nicht beendet, ihr Ende läge hinter
# dem gewählten Fenster. Sie beim Fensterende abzuschneiden würde eine
# Fahrt mit erfundener Endzeit erzeugen.
if not roh:
return []
verschmolzen = [roh[0]]
for start, ende in roh[1:]:
vorheriger_start, vorheriges_ende = verschmolzen[-1]
if (start - vorheriges_ende).total_seconds() < pausenzeit_s:
verschmolzen[-1] = (vorheriger_start, ende)
else:
verschmolzen.append((start, ende))
return verschmolzen
def _ueberschneidet(start, ende, bestehende):
"""True, wenn sich [start, ende] mit einer bereits erfassten Fahrt
überschneidet. Verhindert Dubletten beim wiederholten Import."""
for f in bestehende:
try:
f_start = datetime.datetime.fromisoformat(f.get("ts_start"))
f_ende = datetime.datetime.fromisoformat(f.get("ts_end"))
except (TypeError, ValueError):
continue
if f_start.tzinfo is None or f_ende.tzinfo is None:
continue
if start < f_ende and f_start < ende:
return True
return False
def _fahrten_importieren(start, ende, verlaeufe):
"""Fahrten aus dem Zündungsverlauf, mit Kilometerstand und Start-/
Zielkoordinaten aus den übrigen Verläufen ergänzt."""
fenster = _fahrtfenster(verlaeufe["zuendung"], _pausenzeit_sekunden())
if not fenster:
return {"angelegt": 0, "uebersprungen": 0, "zu_kurz": 0}
bestehende = profil.fahrten_lesen()
km_verlauf = verlaeufe["km"]
lat_verlauf = verlaeufe["lat"]
lon_verlauf = verlaeufe["lon"]
angelegt = 0
uebersprungen = 0
zu_kurz = 0
neue = []
for f_start, f_ende in fenster:
dauer_s = int((f_ende - f_start).total_seconds())
if dauer_s < MINDESTDAUER_S:
zu_kurz += 1
continue
if _ueberschneidet(f_start, f_ende, bestehende):
uebersprungen += 1
continue
odo_start = _wert_bei(km_verlauf, f_start)
odo_end = _wert_bei(km_verlauf, f_ende)
distanz = None
if odo_start is not None and odo_end is not None and odo_end >= odo_start:
distanz = round(odo_end - odo_start, 1)
durchschnitt = None
if distanz is not None and dauer_s > 0:
durchschnitt = round(distanz / (dauer_s / 3600.0), 1)
fahrt = {
"trip_id": profil.neue_id("t"),
"ts_start": f_start.isoformat(),
"ts_end": f_ende.isoformat(),
"duration_s": dauer_s,
"distance_km": distanz,
"km_quelle": "sensor" if distanz is not None else None,
"odo_start": odo_start,
"odo_end": odo_end,
"avg_speed_kmh": durchschnitt,
"start_lat": _wert_bei(lat_verlauf, f_start),
"start_lon": _wert_bei(lon_verlauf, f_start),
"end_lat": _wert_bei(lat_verlauf, f_ende),
"end_lon": _wert_bei(lon_verlauf, f_ende),
"start_address": None,
"end_address": None,
"art": "privat",
"route": None,
"pausen": [],
"source": "import",
"status": "vollständig" if distanz is not None else "offen",
"edited_fields": [],
}
neue.append(fahrt)
bestehende.append(fahrt)
angelegt += 1
# Alle neuen Fahrten in einem Rutsch anhängen statt je Fahrt einmal die
# Datei zu öffnen - bei einem Jahr Verlauf sind das sonst hunderte
# Einzelschreibvorgänge.
if neue:
alle = profil.fahrten_lesen() + neue
alle.sort(key=lambda f: f.get("ts_start") or "")
profil.fahrten_schreiben(alle)
return {"angelegt": angelegt, "uebersprungen": uebersprungen, "zu_kurz": zu_kurz}
def _schwelle_prozent():
p = profil.profil_lesen()
if p is None:
tankvolumen = STANDARD_TANKVOLUMEN_LITER
else:
tankvolumen = p.get("fahrzeug", {}).get("tankvolumen_liter") or STANDARD_TANKVOLUMEN_LITER
return min((LITER_SCHWELLE / tankvolumen) * 100, PROZENT_SCHWELLE)
def _tankvorgaenge_importieren(verlaeufe):
"""Tankvorgänge aus dem Füllstandsverlauf - dieselbe Tiefststand-Logik
wie tankerkennung.py (siehe dortiger Kopfkommentar): jeder Anstieg über
die Schwelle gegen den zuletzt gesehenen Tiefststand ist ein Tankvorgang,
nicht jeder Anstieg gegen den unmittelbar vorherigen Wert."""
verlauf = verlaeufe["tank"]
if not verlauf:
return {"angelegt": 0, "uebersprungen": 0}
schwelle = _schwelle_prozent()
km_verlauf = verlaeufe["km"]
bestehende = profil.tankvorgaenge_lesen()
fenster = datetime.timedelta(minutes=TANK_DUBLETTE_MIN)
bekannte_zeiten = []
for t in bestehende:
try:
ts = datetime.datetime.fromisoformat(t.get("ts"))
except (TypeError, ValueError):
continue
if ts.tzinfo is not None:
bekannte_zeiten.append(ts)
angelegt = 0
uebersprungen = 0
neue = []
tiefststand = None
for ts, wert in verlauf:
aktuell = _zahl(wert)
if aktuell is None:
continue
if tiefststand is None or aktuell <= tiefststand:
tiefststand = aktuell
continue
if aktuell - tiefststand < schwelle:
continue
dublette = False
for bekannt in bekannte_zeiten:
if abs((bekannt - ts).total_seconds()) < fenster.total_seconds():
dublette = True
break
if dublette:
uebersprungen += 1
tiefststand = aktuell
continue
odometer_km = _wert_bei(km_verlauf, ts)
# "Gefahren seit der letzten Tankung" heißt: seit der letzten Tankung
# VOR dieser hier - nicht seit der zeitlich jüngsten überhaupt. Beim
# Import eines vergangenen Zeitraums liegen im Bestand regelmäßig
# bereits neuere Tankvorgänge; die als Bezug zu nehmen ergäbe eine
# negative Strecke (und damit, nach der Prüfung unten, gar keine).
eigene_ts = ts.isoformat()
vorheriger = None
for t in neue + bestehende:
t_ts = t.get("ts") or ""
if t.get("odometer_km") is None or not t_ts or t_ts >= eigene_ts:
continue
if vorheriger is None or t_ts > vorheriger[0]:
vorheriger = (t_ts, t["odometer_km"])
distanz = None
if odometer_km is not None and vorheriger is not None:
distanz = round(odometer_km - vorheriger[1], 1)
if distanz < 0:
distanz = None
tankvorgang = {
"tank_id": profil.neue_id("f"),
"receipt_key": None,
"ts": ts.isoformat(),
"liters": None,
"fuel_total_eur": None,
"price_per_l": None,
"discount": None,
"station_name": None,
"odometer_km": odometer_km,
"distance_km": distanz,
"fuel_type": None,
"source": "import",
"status": "unvollständig",
"receipt_file": None,
"edited_fields": [],
}
neue.append(tankvorgang)
bekannte_zeiten.append(ts)
angelegt += 1
tiefststand = aktuell
if neue:
alle = profil.tankvorgaenge_lesen() + neue
alle.sort(key=lambda t: t.get("ts") or "")
profil.tankvorgaenge_schreiben(alle)
return {"angelegt": angelegt, "uebersprungen": uebersprungen}
def _batterie_importieren(verlaeufe):
"""Tagesminimum/-maximum der 12V-Spannung je Tag des Zeitraums.
profil.batterieverlauf_tageswert_aktualisieren() führt bestehende und
neue Werte pro Tag zusammen (min bleibt min, max bleibt max), deshalb
braucht es hier keine eigene Dubletten-Prüfung: ein zweiter Import
desselben Zeitraums verändert die Einträge nicht mehr."""
verlauf = verlaeufe["batterie"]
if not verlauf:
return {"tage": 0}
tage = {}
for ts, wert in verlauf:
spannung = _zahl(wert)
if spannung is None:
continue
tag = ts.date().isoformat()
eintrag = tage.get(tag)
if eintrag is None:
tage[tag] = {"min": spannung, "min_ts": ts, "max": spannung, "max_ts": ts}
continue
if spannung < eintrag["min"]:
eintrag["min"] = spannung
eintrag["min_ts"] = ts
if spannung > eintrag["max"]:
eintrag["max"] = spannung
eintrag["max_ts"] = ts
for tag in sorted(tage):
werte = tage[tag]
profil.batterieverlauf_tageswert_aktualisieren(
tag, werte["min_ts"].isoformat(), werte["min"]
)
profil.batterieverlauf_tageswert_aktualisieren(
tag, werte["max_ts"].isoformat(), werte["max"]
)
return {"tage": len(tage)}
@service
def audi_dashboard_historie_importieren(start=None, ende=None):
"""Liest den Home-Assistant-Verlauf im gewählten Zeitraum und leitet
daraus Fahrten, Tankvorgänge und Batteriewerte ab.
start/ende sind ISO-Zeitstempel aus der Oberfläche (Ortszeit ohne
Zeitzone ist zulässig). Aufruf als
pyscript.audi_dashboard_historie_importieren."""
profil.ordner_sicherstellen()
try:
von = _als_zeit(start)
bis = _als_zeit(ende)
except ValueError as fehler:
log.error(f"audi_dashboard: Import mit unlesbarem Zeitraum aufgerufen ({fehler})")
_status("fehler", {"meldung": "Zeitraum nicht lesbar"})
return
if von is None or bis is None or von >= bis:
log.warning("audi_dashboard: Import ohne gültigen Zeitraum aufgerufen")
_status("fehler", {"meldung": "Bitte einen Zeitraum wählen, dessen Ende nach dem Start liegt."})
return
_status("laeuft", {"von": von.isoformat(), "bis": bis.isoformat()})
log.info(f"audi_dashboard: Import gestartet für {von.isoformat()} bis {bis.isoformat()}")
try:
verlaeufe = {
"zuendung": _verlauf(einstellungen.ZUENDUNG_SENSOR, von, bis),
"km": _verlauf(einstellungen.KM_SENSOR, von, bis),
"tank": _verlauf(einstellungen.TANK_SENSOR, von, bis),
"batterie": _verlauf(einstellungen.BATTERIE_SENSOR, von, bis),
"lat": _verlauf(einstellungen.STANDORT_LAT_SENSOR, von, bis),
"lon": _verlauf(einstellungen.STANDORT_LON_SENSOR, von, bis),
}
except Exception as fehler:
log.error(f"audi_dashboard: Verlauf nicht lesbar ({type(fehler).__name__}: {fehler})")
_status("fehler", {"meldung": (
"Der Verlauf konnte nicht gelesen werden. Steht hass_is_global: true "
"im pyscript-Block der configuration.yaml?"
)})
return
# Frühester Zeitpunkt, zu dem im gewählten Fenster überhaupt etwas
# aufgezeichnet war - damit ein leeres Ergebnis erklärbar wird
# ("recorder reicht nur bis ...") statt wie ein Fehler auszusehen.
frueheste = None
for name in verlaeufe:
if verlaeufe[name]:
erster = verlaeufe[name][0][0]
if frueheste is None or erster < frueheste:
frueheste = erster
fahrten = _fahrten_importieren(von, bis, verlaeufe)
tank = _tankvorgaenge_importieren(verlaeufe)
batterie = _batterie_importieren(verlaeufe)
frontend_veroeffentlichung.fahrten_veroeffentlichen()
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
frontend_veroeffentlichung.batterieverlauf_veroeffentlichen()
ergebnis = {
"von": von.isoformat(),
"bis": bis.isoformat(),
"ab_wann_daten": frueheste.isoformat() if frueheste else None,
"fahrten_angelegt": fahrten["angelegt"],
"fahrten_uebersprungen": fahrten["uebersprungen"],
"fahrten_zu_kurz": fahrten["zu_kurz"],
"tankvorgaenge_angelegt": tank["angelegt"],
"tankvorgaenge_uebersprungen": tank["uebersprungen"],
"batterie_tage": batterie["tage"],
}
_status("fertig", ergebnis)
log.info(f"audi_dashboard: Import abgeschlossen - {ergebnis}")
+45 -25
View File
@@ -13,33 +13,52 @@ entitaeten.py) - Änderungen von dort werden zur Laufzeit auf diese Variablen
angewendet (überschreiben also die hier hinterlegten Standardwerte), ohne
diese Datei anzufassen.
2026-08-12: Die HACS-Integration TommiG1/HA_VAG-EU-Data-Act (bisherige
Quelle für Kilometerstand, Tankfüllstand, Türen/Fenster/Schlösser,
Ölwechsel-/Inspektionsdaten) wird nicht mehr verwendet - die zugehörigen
Entity-IDs sind deshalb unten bewusst leer. Neue Datenquelle ist der
Teltonika FMM003 (GPS-Tracker mit CAN-Anbindung, siehe AGENTS.md Abschnitt
B); er liefert Standort, Zündungsstatus und Batteriespannung, aber keinen
Tankfüllstand, keine Tür-/Fenster-/Schlossdaten und keine Ölwechsel-/
Inspektionstermine - die entsprechenden Kacheln zeigen deshalb bis auf
Weiteres "unbekannt" statt eines falschen Werts (siehe zustand_oder_none()
in frontend_veroeffentlichung.py). Bleibt ein Wert leer ("") oder passt eine
Entity-ID nicht zur tatsächlichen Integration, liefert zustand_oder_none()
für das jeweilige Feld None statt abzustürzen.
**Im Auslieferstand sind alle Felder hier leer.** Das ist Absicht, kein
unfertiger Zustand: welche Entity-IDs richtig sind, hängt an der jeweiligen
Instanz und ihren Integrationen. Die Zuordnung passiert nach der Installation
im Setup-Menü der App (Einstellungen -> Fahrzeug einrichten -> Setup), das
sie nach data/entitaeten.json schreibt; diese Datei bleibt dabei unangetastet.
Ein leeres Feld ist der sichere Zustand: die betroffene Kachel zeigt
"unbekannt" statt eines falschen Werts (siehe zustand_oder_none() in
frontend_veroeffentlichung.py), und die trigger-gebundenen Felder
(ZUENDUNG_SENSOR, KM_SENSOR, TANK_SENSOR) registrieren gar keinen Trigger,
statt einen gegen eine nicht existierende Entität zu registrieren. Eine
gesetzte, aber falsche Entity-ID ist deshalb schlechter als eine leere.
Zwei typische Quellen auf dieser Instanz: der Teltonika FMM003 (GPS-Tracker
mit CAN-Anbindung, über flespi angebunden - Standort, Zündungsstatus,
Bordnetzspannung, CAN-Werte wie Kilometerstand und Tankfüllstand) und eine
EU-Data-Act-Integration des Herstellers (Kilometerstand, Tankfüllstand,
Türen/Fenster/Schlösser, Reifendrücke, Ölwechsel-/Inspektionstermine). Welche
davon welche Rolle bedient, entscheidet das Setup-Menü - nicht diese Datei.
"""
# Fahrterkennung (§7.1): Start/Ende einer Fahrt wird über den Zündungs-/ACC-
# Status des FMM003 erkannt (on = Fahrt läuft), nicht mehr über die WLAN-
# Verbindung des iPhones zum Fahrzeug (siehe fahrterkennung.py).
ZUENDUNG_SENSOR = "binary_sensor.testzone_fmm003_engine_ignition_or_acc_status"
#
# Leer im Auslieferstand, wie alle Felder hier. Bis 2026-08-23 stand hier die
# Entity-ID einer längst abgeräumten Testinstanz
# ("binary_sensor.testzone_fmm003_..."). Auf einer frischen Installation
# zeigte sie ins Leere - und richtete dabei mehr an, als nur nutzlos zu sein:
# fahrterkennung.py registriert seinen @state_trigger nur, WENN dieses Feld
# belegt ist ("if einstellungen.ZUENDUNG_SENSOR:", ausdrücklich als Schutz
# gegen eine leere Entity-ID gebaut). Ein gesetzter, aber nicht existierender
# Wert hebelt genau diesen Schutz aus. Dasselbe galt für BATTERIE_SENSOR
# weiter unten. Zuordnung gehört ins Setup-Menü, nicht in den Auslieferstand.
ZUENDUNG_SENSOR = ""
# Kilometerstand - bisher aus der TommiG1-Integration, aktuell keine Quelle
# vorhanden. Der FMM003 liefert unter sensor.testzone_fmm003_total_calculated_mileage
# einen selbst berechneten Wert, der aber auf einer anderen Zählbasis beruht
# als der echte Fahrzeug-Kilometerstand (GPS-Streckenberechnung statt
# Tacho) - bewusst NICHT automatisch übernommen, um Reifenzähler,
# Ölwechsel-Prognose und Fahrtabschluss-Screening nicht mit einem
# inkonsistenten Basiswert zu verfälschen. Bei Bedarf über das Setup-Menü
# gezielt zuordnen.
# Kilometerstand - für Fahrtabschluss-Screening, Reifenzähler und
# Ölwechsel-Prognose.
#
# Beim FMM003 hier NICHT den selbst berechneten Gesamtkilometerstand
# (*_total_calculated_mileage) zuordnen: der beruht auf GPS-Streckenrechnung
# statt auf dem Tacho und damit auf einer anderen Zählbasis als der echte
# Fahrzeug-Kilometerstand. Wer ihn einträgt, verfälscht alle drei genannten
# Auswertungen mit einem inkonsistenten Basiswert. Der vom CAN gelesene Wert
# (*_total_vehicle_mileage_read_from_can) bzw. der Kilometerstand der
# EU-Data-Act-Integration ist der richtige.
KM_SENSOR = ""
# Tankfüllstand (Prozent) - keine Quelle mehr vorhanden (der FMM003 ist kein
@@ -49,11 +68,12 @@ TANK_SENSOR = ""
# Reichweite (§5.1 Übersicht) - keine Quelle mehr vorhanden.
RANGE_SENSOR = ""
# 12V-Batteriespannung (Mein Audi -> Zustand). Vom FMM003 geliefert -
# external_power_voltage ist die vom Gerät gemessene Bordnetzspannung des
# Fahrzeugs, NICHT battery_voltage (das ist die interne Pufferbatterie des
# 12V-Batteriespannung (Mein Audi -> Zustand). Beim FMM003 ist das
# external_power_voltage - die vom Gerät gemessene Bordnetzspannung des
# Fahrzeugs -, NICHT battery_voltage (das ist die interne Pufferbatterie des
# Trackers selbst und hat mit der Fahrzeugbatterie nichts zu tun).
BATTERIE_SENSOR = "sensor.testzone_fmm003_external_power_voltage"
# Leer im Auslieferstand, siehe ZUENDUNG_SENSOR oben.
BATTERIE_SENSOR = ""
# Knopf für eine sofortige Neuabfrage beim Fahrzeug - kam aus der
# TommiG1-Integration, keine Entsprechung beim FMM003 vorhanden.
+7
View File
@@ -282,6 +282,13 @@ def batterieverlauf_tageswert_aktualisieren(datum, ts, spannung):
break
else:
verlauf.append({"datum": datum, "min": spannung, "min_ts": ts, "max": spannung, "max_ts": ts})
# Nach Datum sortiert schreiben, nicht in Einfügereihenfolge. Solange nur
# die Live-Aufzeichnung schrieb, war beides dasselbe (sie trägt immer den
# heutigen Tag ein, also stets den jüngsten). Der nachträgliche Import
# (historienimport.py) trägt dagegen vergangene Tage ein - ohne diese
# Zeile stünden sie hinter den neueren, und das Diagramm im Frontend, das
# die Datei in Dateireihenfolge zeichnet, liefe zeitlich rückwärts.
verlauf.sort(key=lambda e: e.get("datum") or "")
_zeilen_schreiben(BATTERIEVERLAUF_PFAD, verlauf)
+103
View File
@@ -0,0 +1,103 @@
# Datenaufbewahrung (recorder) — Ergänzung zur bestehenden configuration.yaml.
#
# WOFÜR DAS DA IST
# ----------------
# Home Assistant löscht Sensor-Verlaufsdaten standardmäßig nach 10 Tagen
# (recorder.purge_keep_days, Standardwert). Das ist die Obergrenze dafür, wie
# weit ein späterer Import "Daten aus Home Assistant importieren" überhaupt
# zurückreichen KANN — was der recorder gelöscht hat, ist unwiederbringlich
# weg, auch für die App.
#
# Die App selbst speichert dagegen bereits unbegrenzt: fahrten.jsonl,
# tankvorgaenge.jsonl, batteriespannung.jsonl und fahrzeugprofil.json unter
# /config/audi_dashboard/ werden nirgends automatisch gekürzt oder gelöscht
# (siehe profil.py — es gibt keine Purge-Logik). Betroffen ist also nur der
# ROHE Sensor-Verlauf in Home Assistants eigener Datenbank, aus dem die App
# ihre Datensätze erst ableitet.
#
# Dieser Block hebt die Aufbewahrung auf ein Jahr an. Das ist die
# Übergangslösung, bis die App die Daten selbst dauerhaft übernimmt: fährt
# der Import einmal über einen Zeitraum, liegen die daraus erzeugten Fahrten/
# Tankvorgänge/Spannungswerte dauerhaft in den .jsonl-Beständen und sind vom
# recorder unabhängig.
#
# EINBAU
# ------
# Diesen Block in die bestehende configuration.yaml übernehmen (nicht die
# Datei ersetzen). Existiert dort schon ein `recorder:`-Block, die Werte dort
# zusammenführen statt einen zweiten anzulegen. Danach Home Assistant neu
# starten (Entwicklerwerkzeuge -> YAML -> Neu starten).
#
# PLATZBEDARF — an der Testinstanz gemessen, nicht geschätzt
# ---------------------------------------------------------
# Stand 2026-08-23: 90.858 Zustandsänderungen in 17 Tagen = rund 5.300 pro
# Tag, Datenbank 64,7 MB. Hochgerechnet auf 365 Tage sind das grob 1,9 Mio.
# Zeilen und in der Größenordnung 11,5 GB.
#
# ACHTUNG bei HA OS auf kleinem Datenträger (Raspberry Pi mit SD-Karte,
# 32-GB-eMMC): das ist der einzige Punkt dieser ganzen App, der einer sonst
# gesunden Installation gefährlich werden kann. Läuft der Datenträger voll,
# startet Home Assistant nicht mehr sauber - unabhängig von dieser App.
#
# Vor dem Einbau deshalb einmal nachsehen, wie viel Platz frei ist:
# Einstellungen -> System -> Speicher. Faustregel: mindestens 5 GB frei,
# sonst besser mit einem kürzeren Zeitraum anfangen (z. B.
# purge_keep_days: 90) und die auskommentierte exclude:-Liste unten gleich
# mit aktivieren. 90 Tage reichen für den Zweck "Vergangenheit nachtragen"
# in aller Regel aus - der Import holt die Daten ja dauerhaft in die
# .jsonl-Bestände der App, die danach vom recorder unabhängig sind.
#
# Nach ein paar Wochen noch einmal unter Einstellungen -> System -> Speicher
# nachsehen und bei Bedarf nachjustieren.
#
# 96 % dieser Zeilen stammen aus der EU-Data-Act-Integration
# (`cupra_eu_data_act`, ~1.737 Zustände je Entität in 17 Tagen, also etwa
# alle 14 Minuten ein Update über rund 50 Entitäten), 1 % vom FMM003 über
# flespi, 2 % alles Übrige. Wer den Platzbedarf drücken will, kürzt also bei
# der EU-Data-Act-Integration — die auskommentierte `exclude`-Liste unten ist
# ein Vorschlag dafür, welche Entitäten historisch nichts hergeben.
recorder:
# 365 Tage statt der 10 Tage Standardaufbewahrung.
purge_keep_days: 365
# Bewusst KEIN `include:`-Block. Ein `include` würde bedeuten, dass Home
# Assistant *nur noch* die dort genannten Entitäten aufzeichnet und für
# alles andere gar keinen Verlauf mehr führt — auch nicht für die 10 Tage,
# die es vorher hatte. Da auf dieser Instanz ohnehin 97 % der Aufzeichnung
# auf Fahrzeugdaten entfällt, spart das kaum Platz, kostet aber den Verlauf
# jeder anderen Integration. Deshalb: global ein Jahr aufbewahren.
# Standardmäßig 1 Sekunde. 30 Sekunden bündelt die Schreibvorgänge, was vor
# allem SD-Karten schont; der Preis ist, dass die letzten bis zu 30
# Sekunden bei einem harten Stromausfall fehlen können. Für Fahrzeugdaten,
# die ohnehin nur alle paar Minuten aktualisiert werden, ist das
# unerheblich.
commit_interval: 30
# Nächtliches Aufräumen (Standard 04:12) mit Neuaufbau der Datenbankdatei.
# repack gibt den durch gelöschte Zeilen frei gewordenen Platz auch
# tatsächlich ans Dateisystem zurück, statt die Datei nur intern als "leer"
# zu markieren — bei einem Jahr Aufbewahrung sonst der übliche Grund, warum
# die Datei nur noch wächst.
auto_purge: true
auto_repack: true
# OPTIONAL — nur einkommentieren, wenn die Datenbank zu groß wird.
# Diese Entitäten liefern über die Zeit keinen Erkenntniswert: reine
# Diagnose-/Netzwerkwerte des Trackers und abgeleitete Momentanwerte der
# EU-Data-Act-Integration, die sich jederzeit neu berechnen lassen.
# exclude:
# entity_globs:
# - sensor.*_tire_pressure_diff_*
# - sensor.*fmm003*_mobile_network_*
# - sensor.*fmm003*_lte_*
# - sensor.*fmm003*_network_*
# - sensor.*fmm003*_id_of_*
# - sensor.*fmm003*_*dilution_of_precision
# - sensor.*fmm003*_satellites
# entities:
# - sensor.audi_rs_4_avant_ei_t1302_echo
# - sensor.audi_rs_4_avant_ei_t1302_trueness
# - sensor.audi_rs_4_avant_uncurated_fields
# - sensor.audi_rs_4_avant_minutes_since_last_snapshot
+175 -1
View File
@@ -2137,6 +2137,12 @@ function vEinst() {
<div class="feld" style="border-bottom:0"><label for="oelM">Nach Zeit</label>
<select id="oelM" data-oelm>${[12, 24].map((m) => `<option value="${m}" ${m === CAR.oel.monate ? "selected" : ""}>${m === 12 ? "1 Jahr" : "2 Jahre"}</option>`).join("")}</select></div>`}
<button class="aktion" style="margin-top:20px" data-setup-oeffnen>Setup Sensoren zuordnen</button>
<button class="aktion" data-import-oeffnen>Daten importieren aus Home Assistant</button>
<span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Legt Fahrten, Tankvorgänge und Spannungswerte für einen vergangenen Zeitraum
nachträglich an aus dem Verlauf, den Home Assistant zu den oben zugeordneten
Sensoren bereits aufgezeichnet hat.</span>
` : ""}
<button class="aktion ${einrichtenOffen ? "primaer" : ""}" style="margin-top:16px" data-einrichten-auf>${einrichtenOffen ? "Fertig" : "Einrichten"}</button>
</div>
@@ -3315,6 +3321,77 @@ function zwischenablageKnopfSinnvoll() {
return window.matchMedia && window.matchMedia("(pointer: coarse)").matches;
}
/* ------------------------------------------------- Historien-Import-Popup
Zeitraum wählen, importieren lassen, Ergebnis zeigen. Der eigentliche
Import läuft im Backend (pyscript/historienimport.py) und meldet seinen
Stand über die Entität pyscript.audi_dashboard_import_status - dasselbe
Muster wie beim Update-Status (UPDATE_STATUS oben). */
let importPopup = null;
/* Vorbelegung: die letzten 30 Tage. Wert im Format, das <input
type="datetime-local"> erwartet (Ortszeit, ohne Zeitzone, minutengenau) -
genau das, was der Dienst als Ortszeit interpretiert. */
function importZeitraumVorgabe() {
const jetzt = new Date();
const vor30 = new Date(jetzt.getTime() - 30 * 86400000);
const alsFeld = (d) => new Date(d.getTime() - d.getTimezoneOffset() * 60000)
.toISOString().slice(0, 16);
return { von: alsFeld(vor30), bis: alsFeld(jetzt) };
}
function importErgebnisText(d) {
const anzahl = (n, eins, viele) => `${de(n)} ${n === 1 ? eins : viele}`;
const teile = [anzahl(d.fahrten_angelegt || 0, "Fahrt", "Fahrten") + " angelegt"];
if (d.fahrten_uebersprungen) teile.push(`${de(d.fahrten_uebersprungen)} bereits vorhanden`);
if (d.fahrten_zu_kurz) teile.push(anzahl(d.fahrten_zu_kurz, "Zündung zu kurz für eine Fahrt", "Zündungen zu kurz für eine Fahrt"));
if (d.tankvorgaenge_angelegt) teile.push(anzahl(d.tankvorgaenge_angelegt, "Tankvorgang", "Tankvorgänge"));
if (d.batterie_tage) teile.push(anzahl(d.batterie_tage, "Tag Batteriespannung", "Tage Batteriespannung"));
return teile.join(" · ");
}
function vImportPopup() {
if (!importPopup) return "";
const p = importPopup;
const fertig = p.ergebnis && !p.laeuft;
// Nach dem Lauf zeigt dasselbe Fenster das Ergebnis statt der Felder - der
// Nutzer soll sehen, was passiert ist, bevor er es wegklickt (HIG
// "Feedback"), statt das Fenster wortlos verschwinden zu lassen.
const koerper = fertig
? `<div style="margin-top:14px;font-size:14px;line-height:1.6;color:var(--fg)">
${esc(importErgebnisText(p.ergebnis))}</div>
${p.ergebnis.fahrten_angelegt === 0 && p.ergebnis.ab_wann_daten
? `<span class="label" style="margin-top:12px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Aufgezeichnet ist in diesem Zeitraum ab ${dedat(new Date(p.ergebnis.ab_wann_daten))} ·
${new Date(p.ergebnis.ab_wann_daten).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr.
Weiter zurück bewahrt Home Assistant nichts mehr auf.</span>`
: ""}
${p.ergebnis.fahrten_angelegt === 0 && !p.ergebnis.ab_wann_daten
? `<span class="label" style="margin-top:12px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Für diesen Zeitraum liegt in Home Assistant kein Verlauf mehr vor.</span>`
: ""}`
: `<div class="feld" style="margin-top:12px"><label for="impVon">Von</label>
<input id="impVon" type="datetime-local" style="width:196px" value="${esc(p.von)}" data-import-von></div>
<div class="feld" style="border-bottom:0"><label for="impBis">Bis</label>
<input id="impBis" type="datetime-local" style="width:196px" value="${esc(p.bis)}" data-import-bis></div>
${p.fehler ? `<span class="label" style="margin-top:10px;color:var(--red)">${esc(p.fehler)}</span>` : ""}
<span class="label" style="margin-top:12px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Bereits erfasste Fahrten bleiben unangetastet überschneidet sich ein
Zeitraum, wird er übersprungen statt doppelt angelegt.</span>`;
return `<div class="beleg-catcher" data-import-zu></div>
<div class="beleg-popup" role="dialog" aria-modal="true" aria-label="Daten importieren">
<span class="label">Daten importieren aus Home Assistant</span>
${koerper}
${fertig
? `<button class="aktion primaer" data-import-zu>Fertig</button>`
: `<button class="aktion primaer" data-import-start ${p.laeuft ? "disabled" : ""}>${p.laeuft ? "Importiere …" : "Importieren"}</button>
<button class="aktion" data-import-zu ${p.laeuft ? "disabled" : ""}>Abbrechen</button>`}
</div>`;
}
function vBelegPopup() {
if (!belegPopup) return "";
// Am Telefon ist Einfügen der Hauptweg (PDF aus der Mail kopiert), am
@@ -3500,7 +3577,7 @@ function render() {
// faellt sonst bei jedem render(), z. B. beim Umschalten des Filters,
// auf 0 zurueck).
const setupKoerperScroll = setupOffen ? (ROOT.querySelector(".setup-koerper") || {}).scrollTop : 0;
ROOT.getElementById("overlay").innerHTML = sheetMarkup() + vSetupPopup() + vBelegPopup();
ROOT.getElementById("overlay").innerHTML = sheetMarkup() + vSetupPopup() + vBelegPopup() + vImportPopup();
if (setupOffen) {
const koerper = ROOT.querySelector(".setup-koerper");
if (koerper) koerper.scrollTop = setupKoerperScroll || 0;
@@ -3600,6 +3677,84 @@ function randwischenVerdrahten() {
Schreibende Aktionen: lokale Anzeige sofort aktualisieren (render()),
Persistenz über hass.callService im Hintergrund - dieselbe Reihenfolge
wie im Prototyp, nur dass jetzt zusätzlich gespeichert wird. */
/* Import anstoßen und auf sein Ergebnis warten.
Bewusst NICHT über serviceRufen(): der Import ist die eine Aktion, bei der
das Fenster offen bleibt und das Ergebnis zeigt, statt optimistisch zu
schließen. Das Backend meldet seinen Stand über
pyscript.audi_dashboard_import_status; hier wird dieselbe Entität gepollt,
bis sie "fertig" oder "fehler" meldet. Ein Poll statt eines Abwartens des
callService-Versprechens, weil pyscript-Dienste sofort zurückkehren, wenn
sie im Hintergrund weiterlaufen. */
async function importStatusLesen() {
try {
const states = await HASS.callWS({ type: "get_states" });
return states.find((x) => x.entity_id === "pyscript.audi_dashboard_import_status") || null;
} catch (e) {
return null;
}
}
async function importAusloesen() {
if (!importPopup || importPopup.laeuft) return;
const von = importPopup.von;
const bis = importPopup.bis;
if (!von || !bis) { importPopup.fehler = "Bitte Start und Ende wählen."; render(); return; }
if (von >= bis) { importPopup.fehler = "Das Ende muss nach dem Start liegen."; render(); return; }
importPopup.laeuft = true;
importPopup.fehler = null;
render();
// Zeitstempel des letzten Laufs merken: nur ein danach geschriebener Stand
// ist die Antwort auf DIESEN Aufruf und nicht das Ergebnis von gestern.
const vorher = await importStatusLesen();
const vorherStand = vorher ? vorher.last_updated : null;
try {
await HASS.callService("pyscript", "audi_dashboard_historie_importieren", { start: von, ende: bis });
} catch (err) {
console.error("audi_dashboard: Import", err);
if (importPopup) {
importPopup.laeuft = false;
importPopup.fehler = (err && err.message) || "Der Import konnte nicht gestartet werden.";
render();
}
return;
}
// Bis zu zwei Minuten - ein Jahr Verlauf über sechs Sensoren braucht seine
// Zeit, und ein zu früher Abbruch sähe wie ein Fehlschlag aus, obwohl der
// Import im Hintergrund sauber zu Ende läuft.
for (let versuch = 0; versuch < 120; versuch++) {
await new Promise((r) => setTimeout(r, 1000));
if (!importPopup) return;
const s = await importStatusLesen();
if (!s || s.last_updated === vorherStand) continue;
if (s.state === "fertig") {
importPopup.laeuft = false;
importPopup.ergebnis = (s.attributes && s.attributes.daten) || {};
render();
// Fahrten-/Tankvorgangslisten im Frontend nachziehen - das Backend hat
// sie bereits neu veröffentlicht, die App muss sie nur neu lesen.
datenAktualisieren();
return;
}
if (s.state === "fehler") {
importPopup.laeuft = false;
importPopup.fehler = (s.attributes && s.attributes.daten && s.attributes.daten.meldung)
|| "Der Import ist fehlgeschlagen.";
render();
return;
}
}
if (importPopup) {
importPopup.laeuft = false;
importPopup.fehler = "Der Import dauert ungewöhnlich lange — er läuft im Hintergrund weiter.";
render();
}
}
function serviceRufen(dienst, daten) {
// HIG "Feedback": ein fehlgeschlagener Aufruf darf nicht nur in der Konsole
// landen - die aufrufende Stelle hat ihr Popup meist schon geschlossen und
@@ -3657,6 +3812,9 @@ function ereignisseVerdrahten() {
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
if (belegPopup) { belegPopup = null; render(); e.preventDefault(); return; }
// Während der Import läuft nicht wegklickbar - er läuft im Backend
// weiter, das Fenster ist die einzige Stelle, die sein Ergebnis zeigt.
if (importPopup) { if (!importPopup.laeuft) { importPopup = null; render(); } e.preventDefault(); return; }
if (sheet) { sheetSchliessen(); e.preventDefault(); return; }
if (setupSucheOffen) { setupSucheOffen = null; setupSuchtext = ""; render(); e.preventDefault(); return; }
if (setupOffen) { setupSchliessen(); e.preventDefault(); return; }
@@ -4142,6 +4300,17 @@ function ereignisseVerdrahten() {
eingabe.click();
return;
}
if (e.target.closest("[data-import-oeffnen]")) {
const vorgabe = importZeitraumVorgabe();
importPopup = { von: vorgabe.von, bis: vorgabe.bis, laeuft: false, ergebnis: null, fehler: null };
render();
return;
}
if (e.target.closest("[data-import-zu]")) {
if (importPopup && importPopup.laeuft) return;
importPopup = null; render(); return;
}
if (e.target.closest("[data-import-start]")) { importAusloesen(); return; }
const del = e.target.closest("[data-loeschen]");
if (del) { delete CAR.service.vereinbart[del.dataset.loeschen]; profilSpeichern(); render(); return; }
const vseldel = e.target.closest("[data-vselbstdel]");
@@ -4402,6 +4571,11 @@ function ereignisseVerdrahten() {
CAR.versicherung.beitrag = sum(CAR.versicherung.teile, (t) => t.betrag || 0);
profilSpeichern(); render(); return;
}
// Kein render() bei den beiden Import-Feldern: das Popup wird bei jedem
// render() neu erzeugt, ein Neuzeichnen mitten in der Eingabe risse dem
// Nutzer den gerade offenen Datumswähler weg.
if (e.target.dataset.importVon !== undefined) { if (importPopup) importPopup.von = e.target.value; return; }
if (e.target.dataset.importBis !== undefined) { if (importPopup) importPopup.bis = e.target.value; return; }
if (e.target.dataset.vgueltigab !== undefined) { CAR.versicherung.gueltigAb = e.target.value; profilSpeichern(); render(); return; }
const vnotruf = e.target.dataset.vnotruf;
if (vnotruf) { CAR.versicherung[vnotruf] = e.target.value; profilSpeichern(); render(); return; }
@@ -1 +1 @@
{"version": 1787014000}
{"version": 1787015002}
+19 -2
View File
@@ -950,13 +950,30 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
.standortmenu-werte { display: flex; align-items: center; gap: 10px; margin-top: 8px; font-size: 13.5px; color: var(--fg2); }
.standortmenu-werte svg { color: var(--fg3); flex: 0 0 auto; }
.standortmenu-aktionen { display: flex; gap: 10px; margin-top: 18px; padding-bottom: 4px; }
/* Beide Pillen folgen der Knopfsprache des restlichen Panels (.aktion /
.aktion.primaer weiter oben): Umriss fuer die Nebenaktion, gefuellt fuer
die Hauptaktion.
Vorher stand hier background: var(--tile) fuer "Teilen" und
background: var(--line-strong) fuer "Route". Beides ging im Tagmodus
schief: das Blatt darunter ist var(--tile-deckend), und die iOS-Auflage
(audi-dashboard-ios.css) setzt im Tagmodus --tile UND --tile-deckend
beide auf #FFFFFF - "Teilen" war damit weiss auf weiss, Kontrast 1,0:1,
also schlicht unsichtbar. --line-strong als Flaeche war ausserdem eine
Linienfarbe in der Rolle einer Fuellung.
Der Umriss haengt jetzt an denselben Tokens wie jeder andere Knopf und
traegt deshalb in beiden Themes und auch unter der iOS-Auflage, ohne dass
hier eine eigene Farbe gepflegt werden muss. */
.standort-pille {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
height: 40px; padding: 0 20px; border-radius: var(--r-pill);
border: none; background: var(--tile); color: var(--fg);
border: 1px solid var(--line-strong); background: none; color: var(--fg);
font-family: inherit; font-size: 14px; text-decoration: none; cursor: pointer;
}
.standort-pille.primaer { background: var(--line-strong); }
.standort-pille.primaer {
background: var(--fg); border-color: var(--fg); color: var(--canvas);
}
.standort-pille:active { transform: scale(.97); }
.standort-pille[disabled] { opacity: .4; pointer-events: none; }