diff --git a/AGENTS.md b/AGENTS.md
index 1a8d9be..f5c4fc0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
1–10 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 1–1,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 ~1–1,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
+17–21: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)
diff --git a/companion-app/src/api/index.ts b/companion-app/src/api/index.ts
index dc4386a..f26e9a7 100644
--- a/companion-app/src/api/index.ts
+++ b/companion-app/src/api/index.ts
@@ -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 {
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 {
+ 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 ?? {} };
+ }
}
diff --git a/companion-app/src/api/types.ts b/companion-app/src/api/types.ts
index 2b5136e..fd20929 100644
--- a/companion-app/src/api/types.ts
+++ b/companion-app/src/api/types.ts
@@ -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;
diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx
index 37c3c48..bd70f6f 100644
--- a/companion-app/src/screens/Einstellungen.tsx
+++ b/companion-app/src/screens/Einstellungen.tsx
@@ -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(null)
const [laeuft, setzeLaeuft] = useState(false)
const [gespeichert, setzeGespeichert] = useState(false)
+ const [importOffen, setzeImportOffen] = useState(false)
const dateiwahl = useRef(null)
if (!einstellungen || !fahrzeug) return null
@@ -235,6 +245,30 @@ export function Einstellungen({
+ {/* 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. */}
+
+ Vergangene Daten
+
+ 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.
+
+
+ setzeImportOffen(true)}>
+ Daten importieren aus Home Assistant
+
+
+
+
+ setzeImportOffen(false)}
+ api={api}
+ beiFertig={() => void jetztAktualisieren()}
+ />
+
Sicherung
diff --git a/companion-app/src/screens/HistorienImport.test.ts b/companion-app/src/screens/HistorienImport.test.ts
new file mode 100644
index 0000000..75d17e5
--- /dev/null
+++ b/companion-app/src/screens/HistorienImport.test.ts
@@ -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")
+ })
+})
diff --git a/companion-app/src/screens/HistorienImport.tsx b/companion-app/src/screens/HistorienImport.tsx
new file mode 100644
index 0000000..4fcdc35
--- /dev/null
+++ b/companion-app/src/screens/HistorienImport.tsx
@@ -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 (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(null)
+ const [ergebnis, setzeErgebnis] = useState(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 (
+
+ Daten importieren aus Home Assistant
+
+ {fertig ? (
+ <>
+
{ergebnisText(ergebnis)}
+ {!ergebnis.fahrten_angelegt && (
+
+ {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."}
+
+ >
+ )}
+
+ )
+}
diff --git a/companion-app/src/stile/screens.css b/companion-app/src/stile/screens.css
index 1713fcb..6cc10b3 100644
--- a/companion-app/src/stile/screens.css
+++ b/companion-app/src/stile/screens.css
@@ -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 {
diff --git a/homeassistant/INSTALL.md b/homeassistant/INSTALL.md
index ee67b68..103d136 100644
--- a/homeassistant/INSTALL.md
+++ b/homeassistant/INSTALL.md
@@ -128,12 +128,38 @@ reicht `scp`/`rsync` vom gewohnten Terminal aus.
`configuration_snippet.yaml` aus diesem Ordner öffnen. Die zwei Blöcke
(`pyscript:` und `panel_custom:`) in die bestehende `configuration.yaml`
übernehmen — **nicht** die Datei komplett ersetzen. Falls dort schon ein
-`pyscript:`-Block existiert, nur die Zeile `allow_all_imports: true` darin
-ergänzen statt einen zweiten Block anzulegen.
+`pyscript:`-Block existiert, nur die beiden Zeilen `allow_all_imports: true`
+und `hass_is_global: true` darin ergänzen statt einen zweiten Block anzulegen.
+
+`hass_is_global: true` braucht nur der nachträgliche Datenimport
+(Schritt 8) — ohne die Zeile läuft alles andere unverändert, der Import
+meldet dann aber, dass er den Verlauf nicht lesen kann.
Der `panel_custom:`-Block kann schon jetzt mit rein; er wird erst mit dem
Frontend-Baustein wirksam und stört bis dahin nicht.
+### Datenaufbewahrung — je früher, desto mehr ist zu retten
+
+Zusätzlich `recorder_snippet.yaml` aus demselben Ordner übernehmen. Home
+Assistant löscht Sensor-Verläufe **standardmäßig nach 10 Tagen**; der Block
+hebt das auf ein Jahr an.
+
+Das ist zeitkritisch, anders als der Rest dieser Anleitung: was der recorder
+einmal gelöscht hat, ist endgültig weg — auch für den Import in Schritt 8.
+Wer diesen Block erst in vier Wochen einbaut, kann die dazwischen liegenden
+Fahrten nicht mehr nachtragen.
+
+Nicht betroffen sind die Bestände der App selbst (`fahrten.jsonl`,
+`tankvorgaenge.jsonl`, `batteriespannung.jsonl`, `fahrzeugprofil.json` unter
+`/config/audi_dashboard/`): die werden nirgends automatisch gekürzt und
+bleiben dauerhaft erhalten. Die 10-Tage-Grenze betrifft nur den **rohen**
+Sensor-Verlauf, aus dem die App ihre Datensätze erst ableitet.
+
+Zum Platzbedarf (an der Testinstanz gemessen: rund 5.300 Zustandsänderungen
+pro Tag, hochgerechnet grob 1–1,5 GB für ein Jahr) steht alles im Kopf von
+`recorder_snippet.yaml`, samt einer auskommentierten `exclude:`-Liste zum
+Kürzen, falls die Datenbank zu groß wird.
+
## Schritt 4 — die Sensoren zuordnen
Anders als früher wird dafür **nicht** mehr `pyscript/modules/
@@ -265,6 +291,19 @@ Sensor suchen, Zustand testweise auf `on`/`off` setzen). Danach in
- **Tailscale „VPN On Demand"** in der Tailscale-App selbst einrichten
(Regel „Only On", gebunden an `Audi_MMI_2804_5GHz`) — unabhängig von
Home Assistant, kann jederzeit parallel erledigt werden
+- **Vergangene Daten nachtragen:** Fahrterkennung, Tankerkennung und
+ Batterieverlauf laufen erst ab der Installation mit. Was Home Assistant
+ vorher schon aufgezeichnet hat, holt **Einstellungen → Einrichten → „Daten
+ importieren aus Home Assistant"** nach: Zeitraum wählen, „Importieren", und
+ es entstehen dieselben Fahrten, Tankvorgänge und Spannungswerte, die die
+ Live-Erkennung erzeugt hätte. Der Vorgang ist gefahrlos wiederholbar —
+ überschneidet sich ein Zeitraum mit bereits erfassten Fahrten, wird er
+ übersprungen statt doppelt angelegt.
+
+ Wie weit er zurückreicht, hängt allein an der Aufbewahrung aus Schritt 3
+ (Standard 10 Tage, mit `recorder_snippet.yaml` ein Jahr). Kommt „0 Fahrten
+ angelegt" zurück, nennt das Fenster den frühesten Zeitpunkt, zu dem
+ überhaupt noch etwas aufgezeichnet ist.
## Schritt 9 — Frontend prüfen
diff --git a/homeassistant/configuration_snippet.yaml b/homeassistant/configuration_snippet.yaml
index 4763226..938a077 100644
--- a/homeassistant/configuration_snippet.yaml
+++ b/homeassistant/configuration_snippet.yaml
@@ -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
diff --git a/homeassistant/installationspaket/configuration_snippet.yaml b/homeassistant/installationspaket/configuration_snippet.yaml
index 4763226..938a077 100644
--- a/homeassistant/installationspaket/configuration_snippet.yaml
+++ b/homeassistant/installationspaket/configuration_snippet.yaml
@@ -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
diff --git a/homeassistant/installationspaket/install.ps1 b/homeassistant/installationspaket/install.ps1
index 690a187..c78b689 100644
--- a/homeassistant/installationspaket/install.ps1
+++ b/homeassistant/installationspaket/install.ps1
@@ -12,6 +12,35 @@
4. configuration_snippet.yaml wird in \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) {
diff --git a/homeassistant/installationspaket/pyscript/historienimport.py b/homeassistant/installationspaket/pyscript/historienimport.py
new file mode 100644
index 0000000..8ec6881
--- /dev/null
+++ b/homeassistant/installationspaket/pyscript/historienimport.py
@@ -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}")
diff --git a/homeassistant/installationspaket/pyscript/modules/einstellungen.py b/homeassistant/installationspaket/pyscript/modules/einstellungen.py
index 4fe37d4..04f08b1 100644
--- a/homeassistant/installationspaket/pyscript/modules/einstellungen.py
+++ b/homeassistant/installationspaket/pyscript/modules/einstellungen.py
@@ -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.
diff --git a/homeassistant/installationspaket/pyscript/modules/profil.py b/homeassistant/installationspaket/pyscript/modules/profil.py
index 23bb3f2..9cc7c18 100644
--- a/homeassistant/installationspaket/pyscript/modules/profil.py
+++ b/homeassistant/installationspaket/pyscript/modules/profil.py
@@ -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)
diff --git a/homeassistant/installationspaket/recorder_snippet.yaml b/homeassistant/installationspaket/recorder_snippet.yaml
new file mode 100644
index 0000000..83265d3
--- /dev/null
+++ b/homeassistant/installationspaket/recorder_snippet.yaml
@@ -0,0 +1,103 @@
+# Datenaufbewahrung (recorder) — Ergänzung zur bestehenden configuration.yaml.
+#
+# WOFÜR DAS DA IST
+# ----------------
+# Home Assistant löscht Sensor-Verlaufsdaten standardmäßig nach 10 Tagen
+# (recorder.purge_keep_days, Standardwert). Das ist die Obergrenze dafür, wie
+# weit ein späterer Import "Daten aus Home Assistant importieren" überhaupt
+# zurückreichen KANN — was der recorder gelöscht hat, ist unwiederbringlich
+# weg, auch für die App.
+#
+# Die App selbst speichert dagegen bereits unbegrenzt: fahrten.jsonl,
+# tankvorgaenge.jsonl, batteriespannung.jsonl und fahrzeugprofil.json unter
+# /config/audi_dashboard/ werden nirgends automatisch gekürzt oder gelöscht
+# (siehe profil.py — es gibt keine Purge-Logik). Betroffen ist also nur der
+# ROHE Sensor-Verlauf in Home Assistants eigener Datenbank, aus dem die App
+# ihre Datensätze erst ableitet.
+#
+# Dieser Block hebt die Aufbewahrung auf ein Jahr an. Das ist die
+# Übergangslösung, bis die App die Daten selbst dauerhaft übernimmt: fährt
+# der Import einmal über einen Zeitraum, liegen die daraus erzeugten Fahrten/
+# Tankvorgänge/Spannungswerte dauerhaft in den .jsonl-Beständen und sind vom
+# recorder unabhängig.
+#
+# EINBAU
+# ------
+# Diesen Block in die bestehende configuration.yaml übernehmen (nicht die
+# Datei ersetzen). Existiert dort schon ein `recorder:`-Block, die Werte dort
+# zusammenführen statt einen zweiten anzulegen. Danach Home Assistant neu
+# starten (Entwicklerwerkzeuge -> YAML -> Neu starten).
+#
+# PLATZBEDARF — an der Testinstanz gemessen, nicht geschätzt
+# ---------------------------------------------------------
+# Stand 2026-08-23: 90.858 Zustandsänderungen in 17 Tagen = rund 5.300 pro
+# Tag, Datenbank 64,7 MB. Hochgerechnet auf 365 Tage sind das grob 1,9 Mio.
+# Zeilen und in der Größenordnung 1–1,5 GB.
+#
+# ACHTUNG bei HA OS auf kleinem Datenträger (Raspberry Pi mit SD-Karte,
+# 32-GB-eMMC): das ist der einzige Punkt dieser ganzen App, der einer sonst
+# gesunden Installation gefährlich werden kann. Läuft der Datenträger voll,
+# startet Home Assistant nicht mehr sauber - unabhängig von dieser App.
+#
+# Vor dem Einbau deshalb einmal nachsehen, wie viel Platz frei ist:
+# Einstellungen -> System -> Speicher. Faustregel: mindestens 5 GB frei,
+# sonst besser mit einem kürzeren Zeitraum anfangen (z. B.
+# purge_keep_days: 90) und die auskommentierte exclude:-Liste unten gleich
+# mit aktivieren. 90 Tage reichen für den Zweck "Vergangenheit nachtragen"
+# in aller Regel aus - der Import holt die Daten ja dauerhaft in die
+# .jsonl-Bestände der App, die danach vom recorder unabhängig sind.
+#
+# Nach ein paar Wochen noch einmal unter Einstellungen -> System -> Speicher
+# nachsehen und bei Bedarf nachjustieren.
+#
+# 96 % dieser Zeilen stammen aus der EU-Data-Act-Integration
+# (`cupra_eu_data_act`, ~1.737 Zustände je Entität in 17 Tagen, also etwa
+# alle 14 Minuten ein Update über rund 50 Entitäten), 1 % vom FMM003 über
+# flespi, 2 % alles Übrige. Wer den Platzbedarf drücken will, kürzt also bei
+# der EU-Data-Act-Integration — die auskommentierte `exclude`-Liste unten ist
+# ein Vorschlag dafür, welche Entitäten historisch nichts hergeben.
+
+recorder:
+ # 365 Tage statt der 10 Tage Standardaufbewahrung.
+ purge_keep_days: 365
+
+ # Bewusst KEIN `include:`-Block. Ein `include` würde bedeuten, dass Home
+ # Assistant *nur noch* die dort genannten Entitäten aufzeichnet und für
+ # alles andere gar keinen Verlauf mehr führt — auch nicht für die 10 Tage,
+ # die es vorher hatte. Da auf dieser Instanz ohnehin 97 % der Aufzeichnung
+ # auf Fahrzeugdaten entfällt, spart das kaum Platz, kostet aber den Verlauf
+ # jeder anderen Integration. Deshalb: global ein Jahr aufbewahren.
+
+ # Standardmäßig 1 Sekunde. 30 Sekunden bündelt die Schreibvorgänge, was vor
+ # allem SD-Karten schont; der Preis ist, dass die letzten bis zu 30
+ # Sekunden bei einem harten Stromausfall fehlen können. Für Fahrzeugdaten,
+ # die ohnehin nur alle paar Minuten aktualisiert werden, ist das
+ # unerheblich.
+ commit_interval: 30
+
+ # Nächtliches Aufräumen (Standard 04:12) mit Neuaufbau der Datenbankdatei.
+ # repack gibt den durch gelöschte Zeilen frei gewordenen Platz auch
+ # tatsächlich ans Dateisystem zurück, statt die Datei nur intern als "leer"
+ # zu markieren — bei einem Jahr Aufbewahrung sonst der übliche Grund, warum
+ # die Datei nur noch wächst.
+ auto_purge: true
+ auto_repack: true
+
+ # OPTIONAL — nur einkommentieren, wenn die Datenbank zu groß wird.
+ # Diese Entitäten liefern über die Zeit keinen Erkenntniswert: reine
+ # Diagnose-/Netzwerkwerte des Trackers und abgeleitete Momentanwerte der
+ # EU-Data-Act-Integration, die sich jederzeit neu berechnen lassen.
+ # exclude:
+ # entity_globs:
+ # - sensor.*_tire_pressure_diff_*
+ # - sensor.*fmm003*_mobile_network_*
+ # - sensor.*fmm003*_lte_*
+ # - sensor.*fmm003*_network_*
+ # - sensor.*fmm003*_id_of_*
+ # - sensor.*fmm003*_*dilution_of_precision
+ # - sensor.*fmm003*_satellites
+ # entities:
+ # - sensor.audi_rs_4_avant_ei_t1302_echo
+ # - sensor.audi_rs_4_avant_ei_t1302_trueness
+ # - sensor.audi_rs_4_avant_uncurated_fields
+ # - sensor.audi_rs_4_avant_minutes_since_last_snapshot
diff --git a/homeassistant/installationspaket/www/audi-dashboard-app.js b/homeassistant/installationspaket/www/audi-dashboard-app.js
index 8e6fd68..f37475c 100644
--- a/homeassistant/installationspaket/www/audi-dashboard-app.js
+++ b/homeassistant/installationspaket/www/audi-dashboard-app.js
@@ -2137,6 +2137,12 @@ function vEinst() {
`}
+
+
+ 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.
` : ""}
@@ -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 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
+ ? `
+ ${esc(importErgebnisText(p.ergebnis))}
+ ${p.ergebnis.fahrten_angelegt === 0 && p.ergebnis.ab_wann_daten
+ ? `
+ 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.`
+ : ""}
+ ${p.ergebnis.fahrten_angelegt === 0 && !p.ergebnis.ab_wann_daten
+ ? `
+ Für diesen Zeitraum liegt in Home Assistant kein Verlauf mehr vor.`
+ : ""}`
+ : `
+
+
+
+ ${p.fehler ? `${esc(p.fehler)}` : ""}
+
+ Bereits erfasste Fahrten bleiben unangetastet — überschneidet sich ein
+ Zeitraum, wird er übersprungen statt doppelt angelegt.`;
+
+ return `
+
+ Daten importieren aus Home Assistant
+ ${koerper}
+ ${fertig
+ ? ``
+ : `
+ `}
+
`;
+}
+
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; }
diff --git a/homeassistant/installationspaket/www/audi-dashboard-version.json b/homeassistant/installationspaket/www/audi-dashboard-version.json
index 4c4ab29..5bdaffe 100644
--- a/homeassistant/installationspaket/www/audi-dashboard-version.json
+++ b/homeassistant/installationspaket/www/audi-dashboard-version.json
@@ -1 +1 @@
-{"version": 1787014000}
+{"version": 1787015002}
diff --git a/homeassistant/installationspaket/www/audi-dashboard.css b/homeassistant/installationspaket/www/audi-dashboard.css
index 350e3f8..3f76b6b 100644
--- a/homeassistant/installationspaket/www/audi-dashboard.css
+++ b/homeassistant/installationspaket/www/audi-dashboard.css
@@ -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; }
diff --git a/homeassistant/pyscript/historienimport.py b/homeassistant/pyscript/historienimport.py
new file mode 100644
index 0000000..8ec6881
--- /dev/null
+++ b/homeassistant/pyscript/historienimport.py
@@ -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}")
diff --git a/homeassistant/pyscript/modules/einstellungen.py b/homeassistant/pyscript/modules/einstellungen.py
index 4fe37d4..04f08b1 100644
--- a/homeassistant/pyscript/modules/einstellungen.py
+++ b/homeassistant/pyscript/modules/einstellungen.py
@@ -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.
diff --git a/homeassistant/pyscript/modules/profil.py b/homeassistant/pyscript/modules/profil.py
index 23bb3f2..9cc7c18 100644
--- a/homeassistant/pyscript/modules/profil.py
+++ b/homeassistant/pyscript/modules/profil.py
@@ -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)
diff --git a/homeassistant/recorder_snippet.yaml b/homeassistant/recorder_snippet.yaml
new file mode 100644
index 0000000..83265d3
--- /dev/null
+++ b/homeassistant/recorder_snippet.yaml
@@ -0,0 +1,103 @@
+# Datenaufbewahrung (recorder) — Ergänzung zur bestehenden configuration.yaml.
+#
+# WOFÜR DAS DA IST
+# ----------------
+# Home Assistant löscht Sensor-Verlaufsdaten standardmäßig nach 10 Tagen
+# (recorder.purge_keep_days, Standardwert). Das ist die Obergrenze dafür, wie
+# weit ein späterer Import "Daten aus Home Assistant importieren" überhaupt
+# zurückreichen KANN — was der recorder gelöscht hat, ist unwiederbringlich
+# weg, auch für die App.
+#
+# Die App selbst speichert dagegen bereits unbegrenzt: fahrten.jsonl,
+# tankvorgaenge.jsonl, batteriespannung.jsonl und fahrzeugprofil.json unter
+# /config/audi_dashboard/ werden nirgends automatisch gekürzt oder gelöscht
+# (siehe profil.py — es gibt keine Purge-Logik). Betroffen ist also nur der
+# ROHE Sensor-Verlauf in Home Assistants eigener Datenbank, aus dem die App
+# ihre Datensätze erst ableitet.
+#
+# Dieser Block hebt die Aufbewahrung auf ein Jahr an. Das ist die
+# Übergangslösung, bis die App die Daten selbst dauerhaft übernimmt: fährt
+# der Import einmal über einen Zeitraum, liegen die daraus erzeugten Fahrten/
+# Tankvorgänge/Spannungswerte dauerhaft in den .jsonl-Beständen und sind vom
+# recorder unabhängig.
+#
+# EINBAU
+# ------
+# Diesen Block in die bestehende configuration.yaml übernehmen (nicht die
+# Datei ersetzen). Existiert dort schon ein `recorder:`-Block, die Werte dort
+# zusammenführen statt einen zweiten anzulegen. Danach Home Assistant neu
+# starten (Entwicklerwerkzeuge -> YAML -> Neu starten).
+#
+# PLATZBEDARF — an der Testinstanz gemessen, nicht geschätzt
+# ---------------------------------------------------------
+# Stand 2026-08-23: 90.858 Zustandsänderungen in 17 Tagen = rund 5.300 pro
+# Tag, Datenbank 64,7 MB. Hochgerechnet auf 365 Tage sind das grob 1,9 Mio.
+# Zeilen und in der Größenordnung 1–1,5 GB.
+#
+# ACHTUNG bei HA OS auf kleinem Datenträger (Raspberry Pi mit SD-Karte,
+# 32-GB-eMMC): das ist der einzige Punkt dieser ganzen App, der einer sonst
+# gesunden Installation gefährlich werden kann. Läuft der Datenträger voll,
+# startet Home Assistant nicht mehr sauber - unabhängig von dieser App.
+#
+# Vor dem Einbau deshalb einmal nachsehen, wie viel Platz frei ist:
+# Einstellungen -> System -> Speicher. Faustregel: mindestens 5 GB frei,
+# sonst besser mit einem kürzeren Zeitraum anfangen (z. B.
+# purge_keep_days: 90) und die auskommentierte exclude:-Liste unten gleich
+# mit aktivieren. 90 Tage reichen für den Zweck "Vergangenheit nachtragen"
+# in aller Regel aus - der Import holt die Daten ja dauerhaft in die
+# .jsonl-Bestände der App, die danach vom recorder unabhängig sind.
+#
+# Nach ein paar Wochen noch einmal unter Einstellungen -> System -> Speicher
+# nachsehen und bei Bedarf nachjustieren.
+#
+# 96 % dieser Zeilen stammen aus der EU-Data-Act-Integration
+# (`cupra_eu_data_act`, ~1.737 Zustände je Entität in 17 Tagen, also etwa
+# alle 14 Minuten ein Update über rund 50 Entitäten), 1 % vom FMM003 über
+# flespi, 2 % alles Übrige. Wer den Platzbedarf drücken will, kürzt also bei
+# der EU-Data-Act-Integration — die auskommentierte `exclude`-Liste unten ist
+# ein Vorschlag dafür, welche Entitäten historisch nichts hergeben.
+
+recorder:
+ # 365 Tage statt der 10 Tage Standardaufbewahrung.
+ purge_keep_days: 365
+
+ # Bewusst KEIN `include:`-Block. Ein `include` würde bedeuten, dass Home
+ # Assistant *nur noch* die dort genannten Entitäten aufzeichnet und für
+ # alles andere gar keinen Verlauf mehr führt — auch nicht für die 10 Tage,
+ # die es vorher hatte. Da auf dieser Instanz ohnehin 97 % der Aufzeichnung
+ # auf Fahrzeugdaten entfällt, spart das kaum Platz, kostet aber den Verlauf
+ # jeder anderen Integration. Deshalb: global ein Jahr aufbewahren.
+
+ # Standardmäßig 1 Sekunde. 30 Sekunden bündelt die Schreibvorgänge, was vor
+ # allem SD-Karten schont; der Preis ist, dass die letzten bis zu 30
+ # Sekunden bei einem harten Stromausfall fehlen können. Für Fahrzeugdaten,
+ # die ohnehin nur alle paar Minuten aktualisiert werden, ist das
+ # unerheblich.
+ commit_interval: 30
+
+ # Nächtliches Aufräumen (Standard 04:12) mit Neuaufbau der Datenbankdatei.
+ # repack gibt den durch gelöschte Zeilen frei gewordenen Platz auch
+ # tatsächlich ans Dateisystem zurück, statt die Datei nur intern als "leer"
+ # zu markieren — bei einem Jahr Aufbewahrung sonst der übliche Grund, warum
+ # die Datei nur noch wächst.
+ auto_purge: true
+ auto_repack: true
+
+ # OPTIONAL — nur einkommentieren, wenn die Datenbank zu groß wird.
+ # Diese Entitäten liefern über die Zeit keinen Erkenntniswert: reine
+ # Diagnose-/Netzwerkwerte des Trackers und abgeleitete Momentanwerte der
+ # EU-Data-Act-Integration, die sich jederzeit neu berechnen lassen.
+ # exclude:
+ # entity_globs:
+ # - sensor.*_tire_pressure_diff_*
+ # - sensor.*fmm003*_mobile_network_*
+ # - sensor.*fmm003*_lte_*
+ # - sensor.*fmm003*_network_*
+ # - sensor.*fmm003*_id_of_*
+ # - sensor.*fmm003*_*dilution_of_precision
+ # - sensor.*fmm003*_satellites
+ # entities:
+ # - sensor.audi_rs_4_avant_ei_t1302_echo
+ # - sensor.audi_rs_4_avant_ei_t1302_trueness
+ # - sensor.audi_rs_4_avant_uncurated_fields
+ # - sensor.audi_rs_4_avant_minutes_since_last_snapshot
diff --git a/homeassistant/www/audi-dashboard-app.js b/homeassistant/www/audi-dashboard-app.js
index 8e6fd68..f37475c 100644
--- a/homeassistant/www/audi-dashboard-app.js
+++ b/homeassistant/www/audi-dashboard-app.js
@@ -2137,6 +2137,12 @@ function vEinst() {
`}
+
+
+ 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.
` : ""}
@@ -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 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
+ ? `
+ ${esc(importErgebnisText(p.ergebnis))}
+ ${p.ergebnis.fahrten_angelegt === 0 && p.ergebnis.ab_wann_daten
+ ? `
+ 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.`
+ : ""}
+ ${p.ergebnis.fahrten_angelegt === 0 && !p.ergebnis.ab_wann_daten
+ ? `
+ Für diesen Zeitraum liegt in Home Assistant kein Verlauf mehr vor.`
+ : ""}`
+ : `
+
+
+
+ ${p.fehler ? `${esc(p.fehler)}` : ""}
+
+ Bereits erfasste Fahrten bleiben unangetastet — überschneidet sich ein
+ Zeitraum, wird er übersprungen statt doppelt angelegt.`;
+
+ return `
+
+ Daten importieren aus Home Assistant
+ ${koerper}
+ ${fertig
+ ? ``
+ : `
+ `}
+
`;
+}
+
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; }
diff --git a/homeassistant/www/audi-dashboard-version.json b/homeassistant/www/audi-dashboard-version.json
index 4c4ab29..5bdaffe 100644
--- a/homeassistant/www/audi-dashboard-version.json
+++ b/homeassistant/www/audi-dashboard-version.json
@@ -1 +1 @@
-{"version": 1787014000}
+{"version": 1787015002}
diff --git a/homeassistant/www/audi-dashboard.css b/homeassistant/www/audi-dashboard.css
index 350e3f8..3f76b6b 100644
--- a/homeassistant/www/audi-dashboard.css
+++ b/homeassistant/www/audi-dashboard.css
@@ -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; }