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