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."} +

+ )} +
+ Fertig +
+ + ) : ( + <> + + setzeVon(e.target.value)} + /> + + + setzeBis(e.target.value)} + /> + + {fehler &&

{fehler}

} +

+ Bereits erfasste Fahrten bleiben unangetastet — überschneidet sich ein Zeitraum, wird + er übersprungen statt doppelt angelegt. +

+
+ void starten()} disabled={laeuft}> + {laeuft ? "Importiere …" : "Importieren"} + +
+
+ + Abbrechen + +
+ + )} +
+ ) +} 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 `
+ `; +} + 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 `
+ `; +} + 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; }