pyscript-Backend zur echten HA-Integration umgebaut (HACS-fähig)

Das Backend liegt jetzt als custom_components/audi_dashboard/ vor - eine
normale Home-Assistant-Integration mit Config-Flow, einer sensor-Plattform
und 18 Diensten. Damit ist die App über HACS installierbar; bis das Repo auf
GitHub gespiegelt ist (HACS spricht ausschließlich mit GitHub), installiert
homeassistant/installationspaket/install.ps1 denselben Ordner ohne HACS.

Fünf Installationsschritte entfallen ersatzlos: der pyscript:-Block, der
panel_custom:-Block, das Kopieren der Oberfläche nach www/, das langlebige
Zugriffstoken (der Verlauf wird direkt über die recorder-API gelesen) und
"pip install pypdf" (steht in manifest.json). Das Fahrzeugprofil legt die
Integration beim ersten Start aus ihrer Vorlage an.

Drei alte Schwächen sind dabei mit erledigt:
- Die Nutzlast landet nicht mehr in der Recorder-Datenbank
  (_unrecorded_attributes - das kann nur eine echte Entität).
- Eine laufende Fahrt überlebt einen Neustart (Store statt Arbeitsspeicher);
  fiel sie während eines Ausfalls ins Ende, schließt
  nach_neustart_fortsetzen() sie beim letzten aufgezeichneten Zeitpunkt.
- Sensor-Zuordnungen wirken sofort - die Zustandsbeobachter werden neu
  gebunden, der Neustart-Hinweis und der Neustart-Dienst sind weg.

Namensvertrag geändert, beide Oberflächen mitgezogen:
pyscript.audi_dashboard_x -> sensor.audi_dashboard_x,
pyscript.audi_dashboard_y -> audi_dashboard.y. Eine Companion-App vom alten
Stand findet nach dem Umstieg nichts mehr und muss neu gebaut werden; das
Panel liegt in der Integration und kann nicht driften.

Der selbstgebaute Updater entfällt - HACS ist die Update-Mechanik, die Home
Assistant kennt. Die Versionierung schrumpft auf eine Quelle: manifest.json.

Geprüft am laufenden Testcontainer (Container byteweise identisch mit dem
Repo): alle 18 Dienste, Panel, Config-Entry neu laden, Historienimport,
echter Shell-Beleg in-process, Neuinstallation im Wegwerf-Container blank mit
automatisch nachinstalliertem pypdf. Companion-App: tsc sauber, 112/112
Tests, beide Rauchtests gegen das laufende Backend grün. Belegparser 8/8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 23:53:56 +02:00
parent 99cef7c393
commit d8b12da36d
136 changed files with 5289 additions and 17641 deletions
+9 -16
View File
@@ -1,16 +1,9 @@
# Echte Fahrzeug-/Personendaten - nie versionieren (VIN, Kennzeichen,
# Versicherung, Werkstattkontakt, Servicehistorie, Fahrten, Tankvorgänge,
# echte Tankbelege). Vorlage ohne echte Daten bleibt getrackt, siehe
# data/fahrzeugprofil.example.json und README.md.
data/fahrzeugprofil.json
data/fahrten.jsonl
data/tankvorgaenge.jsonl
data/entitaeten.json
data/tests/belege/*.pdf
# installationspaket/ wird seit 2026-08-16 mitversioniert (vorher ignoriert):
# es ist zwar nur eine Kopie der bereits getrackten pyscript/www/Vorlage-
# Dateien, soll aber direkt aus dem Repo auf eine neue HA-Instanz kopierbar
# sein, ohne es vorher von Hand zusammenzustellen. Es enthält bewusst keine
# echten Fahrzeugdaten - nur data/fahrzeugprofil.example.json (Platzhalter,
# siehe oben). Beim Ändern von pyscript/ oder www/ immer mitsynchronisieren.
# Die Fahrzeugdaten leben in Home Assistant selbst (/config/audi_dashboard/),
# nicht in diesem Repo - die Integration legt den Ordner beim ersten Start an
# und füllt ihn aus der Vorlage
# (../custom_components/audi_dashboard/vorlage/fahrzeugprofil.json).
#
# Bis 2026-08-23 lag hier ein data/-Ordner, aus dem die Erstbefüllung von Hand
# kopiert werden musste; die Regeln darunter hielten die echten Fahrzeug- und
# Personendaten aus dem Repo heraus. Beides ist mit dem Umbau zur Integration
# entfallen - es gibt hier nichts mehr zu ignorieren.
+171 -353
View File
@@ -1,404 +1,222 @@
# Installation — Schritt für Schritt
Betrifft den kompletten aktuellen Baustand: das pyscript-Backend
(Fahrterkennung, Fahrtabschluss, Reifenzähler, Belegverarbeitung) **und**
das Frontend (`panel_custom`, Sidebar-Eintrag „Mein Audi").
Zwei Wege führen zum selben Ergebnis: HACS lädt den Ordner
`custom_components/audi_dashboard/` aus dem Repository, das Skript kopiert
denselben Ordner von der Festplatte. Was danach passiert, ist identisch.
**Getestet, nicht nur geprüft.** Beides lief in einer Wegwerf-Testinstanz
(Home Assistant in Docker) wirklich: das Backend über echte Service-Aufrufe
(Reifen wechseln hat die Profildatei tatsächlich umgeschrieben, eine
manuell angelegte Fahrt wurde tatsächlich an `fahrten.jsonl` angehängt),
das Frontend, indem das Custom Element mit genau diesen echten Daten
gefüttert wurde und daraus korrekt die Übersicht und die Fahrtenliste
gerendert hat — inklusive der zuvor testweise umgeschalteten Winterbereifung,
sichtbar an der Fahrbahn-Szene. Wofür die Testinstanz **nicht** reichte: der
Weg über Home Assistants echte WebSocket-Verbindung, mit der der
`panel_custom`-Rahmen (`ha-panel-custom`) das Element normalerweise selbst
erzeugt und mit `hass` versorgt — diese Testumgebung ließ keine
WebSocket-Verbindung zu, unabhängig vom eigenen Code. Dieser letzte Schritt
— dass die Oberfläche beim ganz normalen Draufklicken in der Sidebar
erscheint — ist deshalb der einzige Teil, der sich erst bei euch wirklich
zeigt. `panel_custom` selbst ist Home Assistants eigener, seit Jahren
stabiler Mechanismus, nicht eigener Code, entsprechend gering ist das
Risiko dort.
**Voraussetzung:** HACS ist bereits installiert (wird für pyscript selbst
gebraucht, siehe Schritt 1). Für die Fahrterkennung und die Fahrzeugdaten
wird zusätzlich ein Teltonika FMM003 (bzw. dessen Integration in Home
Assistant, z. B. über flespi) vorausgesetzt — Details siehe
`COMPANION_APP_ARCHITECTURE.md` im Projektstamm. Ohne FMM003 läuft die App
trotzdem: alle davon abhängigen Werte zeigen einfach „unbekannt" statt
eines Werts.
Zeitaufwand: ca. 3040 Minuten, größtenteils Warten auf Neustarts.
Wer von der früheren pyscript-Fassung kommt: [Umstieg](#umstieg-von-der-pyscript-fassung)
weiter unten. Die Fahrzeugdaten bleiben dabei, wo sie sind.
---
## Voraussetzungen
## Der schnelle Weg: install.sh
- Home Assistant 2025.1 oder neuer
- Zugriff auf das `config`-Verzeichnis (Samba-Add-on, SSH oder ein
gemounteter Pfad) — nur für den Weg ohne HACS
- Mindestens eine Datenquelle, die den Zündungs-/ACC-Status des Fahrzeugs
als `binary_sensor` meldet. Ohne sie läuft die App, erkennt aber keine
Fahrten.
Für eine neue Instanz gibt es ein Skript, das die Schritte 1 bis 3 und 5
zusammen erledigt. Am einfachsten direkt auf der HA-Instanz im Add-on
**Terminal & SSH**:
Nicht mehr nötig, anders als bei der pyscript-Fassung: pyscript selbst, ein
langlebiges Zugriffstoken, ein `pip install` im Container und Einträge in der
`configuration.yaml`. Details dazu im Kopf von
[`__init__.py`](../custom_components/audi_dashboard/__init__.py).
```bash
bash <(curl -fsSL https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/homeassistant/install.sh)
---
## Weg A — über HACS
1. HACS → **Integrationen** → Menü oben rechts → **Benutzerdefinierte
Repositories**
2. Repository-URL eintragen, Kategorie **Integration**, hinzufügen
3. **Audi Dashboard** suchen und herunterladen
4. Home Assistant neu starten
**Hürde, die man kennen muss:** HACS spricht ausschließlich mit GitHub —
`github.com` und `api.github.com` stehen fest im Code, es gibt keinen
Schalter für Gitea, GitLab oder eine selbst gehostete Instanz. Liegt das
Repository woanders, findet HACS es auch als benutzerdefiniertes Repository
nicht, und es bleibt Weg B.
## Weg B — ohne HACS, per Skript
Vom Windows-Rechner aus, der das `config`-Verzeichnis erreicht:
```
homeassistant\installationspaket\Installieren.cmd
```
Liegt das Repository privat (Standard), braucht der Aufruf Zugangsdaten — mit
Token oder mit Nutzer und Passwort, beides funktioniert:
Das Skript sucht die HA-Instanz selbst (`\\homeassistant\config` und die
üblichen Varianten) und fragt nach, wenn es sie nicht findet. Es kopiert
`custom_components\audi_dashboard\` dorthin — und sonst nichts.
```bash
curl -fsSL -H "Authorization: token <TOKEN>" \
https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/homeassistant/install.sh -o install.sh
bash install.sh --repo https://<nutzer>:<token>@gitea.nothaft.cloud/paul/audi-app.git
Zum Vorabschauen, ohne dass etwas geschrieben wird:
```
powershell -ExecutionPolicy Bypass -File homeassistant\installationspaket\install.ps1 -Pruefen
```
**Eine Überlegung lohnt sich dabei:** Was in `--repo` steht, wird als Updatequelle
hinterlegt und landet im Klartext in `pyscript/modules/einstellungen.py`. Ein
Token ist dort besser aufgehoben als das Kontopasswort, weil er einzeln
widerrufbar und auf Lesezugriff beschränkbar ist. Wer gar nichts ablegen will,
hängt `--update-repo aus` an und aktualisiert weiter über dieses Skript.
Die Abwägung im Einzelnen steht in `ha_install.md`, Abschnitt 1b.
Was das Skript garantiert (und warum), steht in seinem eigenen Kopfkommentar.
Die kurze Fassung: es schreibt ausschließlich in seinen eigenen Ordner, fasst
`configuration.yaml` und `.storage\` nicht an, löscht nur den eigenen Ordner
und auch den nur, wenn dessen `manifest.json` ihn als solchen ausweist.
Alternativ von einem Rechner aus gegen ein eingebundenes config-Verzeichnis:
Danach Home Assistant neu starten.
```bash
./install.sh --ziel /Volumes/config --von ~/Development/audi-app
```
---
Das Skript installiert pyscript, spielt Backend und Oberfläche ein, legt das
Fahrzeugprofil aus der Vorlage an (**vorhandene Daten bleiben unangetastet**),
trägt den Abschnitt in die `configuration.yaml` ein — mit Sicherungskopie und
so, dass ein zweiter Lauf ihn ersetzt statt anhängt — und verdrahtet die
Selbstaktualisierung, damit „Update suchen" in der App funktioniert.
`--hilfe` zeigt alle Optionen.
## Nach dem Neustart
Danach bleiben nur noch drei Dinge, alle in der Weboberfläche: neu starten,
Sensoren zuordnen (Schritt 4 unten), Fahrzeugdaten eintragen.
### 1. Integration hinzufügen
Wer lieber jeden Schritt selbst nachvollzieht, folgt der ausführlichen
Anleitung ab hier.
**Einstellungen → Geräte & Dienste → Integration hinzufügen → Audi
Dashboard.** Es gibt nichts einzugeben — ein Bestätigungsschritt, mehr nicht.
Beim ersten Laden installiert Home Assistant die Abhängigkeit `pypdf` nach
(für die Tankbelege); das kann eine Minute dauern.
## Schritt 1 — pyscript installieren
Danach steht **Mein Audi** in der Seitenleiste, und es entstehen zwölf
Entitäten `sensor.audi_dashboard_*` sowie 18 Dienste `audi_dashboard.*`.
1. In Home Assistant: **HACS → Integrationen → Explore & Download Repositories**
2. Nach „pyscript" suchen, auswählen, **Download**
3. Home Assistant neu starten (**Einstellungen → System → Neu starten**)
### 2. Sensoren zuordnen
## Schritt 2 — Dateien auf den Home-Assistant-Rechner kopieren
In der App: **Einstellungen → Fahrzeug einrichten → Setup.**
Drei Ordner müssen in das `config`-Verzeichnis von Home Assistant kopiert
werden. Wählt den Weg, der zu eurer Installation passt:
Im Auslieferstand ist **kein** Sensor vorbelegt. Das ist Absicht: welche
Entity-IDs richtig sind, hängt an der Instanz und ihren Integrationen, und
eine gesetzte, aber falsche ID ist schlechter als eine leere — die App zeigt
dann „unbekannt" statt eines falschen Werts.
**Vorher:** `data/fahrzeugprofil.json` enthält bei einer laufenden Installation
echte Fahrzeug- und Personendaten (VIN, Kennzeichen, Versicherung, Werkstatt,
Servicehistorie) und ist deshalb nicht Teil dieses Repos (siehe `.gitignore`).
Für eine neue Installation zuerst `data/fahrzeugprofil.example.json` nach
`data/fahrzeugprofil.json` kopieren — die Platzhalter darin lassen sich nach
dem ersten Start bequem direkt in der App eintragen: **Einstellungen →
Fahrzeug einrichten** deckt FIN, Kennzeichen, Erstzulassung und Ausführung ab,
Versicherung/Werkstatt/Reifen haben eigene Bearbeiten-Ansichten.
| Rolle | Braucht es für |
|---|---|
| **Zündung/ACC** (Pflicht) | Fahrterkennung, Anzeige „fährt/steht" |
| Kilometerstand | Fahrtabschluss, Reifenzähler, Ölwechsel-Prognose |
| Tankfüllstand | automatische Tankerkennung |
| 12V-Spannung | Batterieverlauf |
| GPS Breiten-/Längengrad | Standort-Kachel |
| Türen, Fenster, Heckklappe, Haube | „Sicher abgestellt" |
| Reichweite, Service-Fälligkeiten | Anzeige in der Übersicht |
**Am einfachsten: Samba-Share-Add-on**
1. Falls noch nicht installiert: **Einstellungen → Add-ons → Add-on Store →
„Samba share"** installieren und starten
2. Am Windows-Rechner im Explorer verbinden: `\\<HA-IP-Adresse>\config`
3. Von diesem Projektordner aus kopieren:
- `pyscript\` (der ganze Ordner) → `\\<HA-IP>\config\pyscript\`
- `data\` (der ganze Ordner) → `\\<HA-IP>\config\audi_dashboard\`
(Ordner beim Kopieren von `data` in `audi_dashboard` umbenennen)
- `www\` (der ganze Ordner, Frontend) → `\\<HA-IP>\config\www\`
(Inhalt zusammenführen, falls dort schon eine `www`-Ablage existiert)
Beim FMM003 **nicht** den selbst berechneten Gesamtkilometerstand
(`*_total_calculated_mileage`) zuordnen: der beruht auf GPS-Streckenrechnung
statt auf dem Tacho und verfälscht damit alle drei Auswertungen, die daran
hängen. Der vom CAN gelesene Wert ist der richtige.
**Alternative: Studio Code Server Add-on**
1. **Einstellungen → Add-ons → Add-on Store → „Studio Code Server"**
installieren, starten, öffnen
2. Im Dateibaum links Ordner `pyscript`, `audi_dashboard` und `www` unter
`/config` anlegen, falls sie fehlen
3. Dateien einzeln per Drag & Drop aus dem Explorer in den Browser ziehen,
oder Rechtsklick → „Upload"
Änderungen wirken sofort. Der frühere Hinweis „wirkt erst nach einem
Neustart" bei Zündung, Kilometerstand und Tankfüllstand ist entfallen — die
Integration meldet ihre Beobachter bei jedem Speichern neu an.
**Alternative: SSH & Terminal Add-on**, falls bereits eingerichtet — dann
reicht `scp`/`rsync` vom gewohnten Terminal aus.
### 3. Datenaufbewahrung verlängern — zeitkritisch
## Schritt 3 — configuration.yaml ergänzen
Home Assistant löscht Sensor-Verläufe nach **10 Tagen**. Das ist die Grenze,
bis zu der „Daten importieren aus Home Assistant" zurückreichen kann; was
gelöscht ist, kommt nicht wieder. Je früher der Block aus
[`recorder_snippet.yaml`](recorder_snippet.yaml) in der `configuration.yaml`
steht, desto mehr Vergangenheit bleibt erhalten.
`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 beiden Zeilen `allow_all_imports: true`
und `hass_is_global: true` darin ergänzen statt einen zweiten Block anzulegen.
Vorher **Einstellungen → System → Speicher** prüfen: ein Jahr Verlauf braucht
grob 11,5 GB. Bei knappem Platz mit `purge_keep_days: 90` anfangen.
`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.
Bewusst nicht automatisch eingetragen: das ist eine Entscheidung über den
Plattenplatz der Instanz, und viele Instanzen haben bereits einen eigenen
`recorder:`-Block, den man zusammenführen muss statt zu überschreiben.
Der `panel_custom:`-Block kann schon jetzt mit rein; er wird erst mit dem
Frontend-Baustein wirksam und stört bis dahin nicht.
### 4. Fahrzeugdaten eintragen
### Datenaufbewahrung — je früher, desto mehr ist zu retten
**Einstellungen → Fahrzeug einrichten.** Das Profil ist bereits angelegt —
die Integration hat es beim ersten Start aus ihrer Vorlage erzeugt. Zu
ersetzen sind die Platzhalter: VIN, Kennzeichen, Erstzulassung, HU-Termin,
Versicherung, Werkstatt, Servicebuch.
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.
### 5. Vergangenes nachholen (optional)
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.
**Einstellungen → Einrichten → Daten importieren aus Home Assistant.** Der
Import liest denselben Verlauf, den die Live-Erkennung sonst in Echtzeit
sieht, und leitet daraus rückwirkend Fahrten, Tankvorgänge und
Spannungswerte ab. Mehrfach ausführbar — überschneidende Zeiträume erzeugen
keine Dubletten.
Nicht betroffen sind die Bestände der App selbst (`fahrten.jsonl`,
`tankvorgaenge.jsonl`, `batteriespannung.jsonl`, `fahrzeugprofil.json` unter
`/config/audi_dashboard/`): die werden nirgends automatisch gekürzt und
bleiben dauerhaft erhalten. Die 10-Tage-Grenze betrifft nur den **rohen**
Sensor-Verlauf, aus dem die App ihre Datensätze erst ableitet.
---
Zum Platzbedarf (an der Testinstanz gemessen: rund 5.300 Zustandsänderungen
pro Tag, hochgerechnet grob 11,5 GB für ein Jahr) steht alles im Kopf von
`recorder_snippet.yaml`, samt einer auskommentierten `exclude:`-Liste zum
Kürzen, falls die Datenbank zu groß wird.
## Prüfen, ob alles geladen hat
## Schritt 4 — die Sensoren zuordnen
**Entwicklerwerkzeuge → Zustände**, Filter `audi_dashboard`. Erwartet werden
zwölf Entitäten. Zwei sagen auf einen Blick, ob es läuft:
Anders als früher wird dafür **nicht** mehr `pyscript/modules/
einstellungen.py` von Hand bearbeitet — das übernimmt ein grafisches
Setup-Menü direkt in der App: **Mein Audi → Einstellungen Fahrzeug
einrichten → Einrichten → „Setup — Sensoren zuordnen"** (letzter Punkt,
erscheint erst nach Klick auf „Einrichten").
- `sensor.audi_dashboard_app_version` — steht auf der installierten Version
- `sensor.audi_dashboard_fahrzeugstatus` — Attribut `daten` enthält die live
gelesenen Fahrzeugwerte
> **Reihenfolge beachten:** Dieser Schritt braucht eine bereits laufende App.
> Arbeite deshalb erst die Schritte 5 bis 9 ab (Token, Neustart, Prüfung,
> Restarbeiten, Frontend) und komm dann hierher zurück. Schritt 7 verweist
> seinerseits auf die hier vorgenommene Zuordnung — das ist kein Widerspruch,
> sondern schlicht die Reihenfolge: erst starten, dann zuordnen, dann prüfen.
Im Protokoll (**Einstellungen → System → Protokolle**) steht beim Start eine
Zeile `Audi Dashboard <Version> eingerichtet`.
Das Setup-Menü listet jede Sensor-Rolle, die die App kennt (Zündung/
Fahrterkennung, Kilometerstand, Tankfüllstand, Standort, Batteriespannung,
Türen/Fenster/Schlösser, Ölwechsel/Inspektion, …), schlägt je Rolle
passende vorhandene HA-Entitäten vor (Schalter „Nur passende Sensoren
anzeigen" grenzt auf die erwartete Domäne/Einheit ein) und schreibt die
Auswahl direkt in eine Override-Datei — `einstellungen.py` selbst bleibt
unverändert.
---
Die Override-Datei ist `/config/audi_dashboard/entitaeten.json`. Sie wird von
der automatischen Sicherung und vom Backup-Export mit erfasst; ein
Wiederherstellen spielt die Zuordnung also mit zurück.
## Umstieg von der pyscript-Fassung
**Hinweis zu den Vorgabewerten:** Drei Rollen sind in `einstellungen.py` mit
den Test-Entitäten der Entwicklungsinstanz vorbelegt (Zündung, Batterie-
spannung, Standort — jeweils `*_testzone_fmm003`). Auf einer frischen
Installation zeigen sie ins Leere; das Setup-Menü meldet dann „Entität nicht
gefunden". Einfach die eigenen Entitäten zuordnen, damit ist es erledigt.
Die Daten wandern nicht: Ordnername (`/config/audi_dashboard/`) und
Dateiformate sind unverändert. Die Integration liest den bestehenden Bestand
einfach weiter — es gibt keine Migration und damit auch keinen Weg, dabei
etwas zu verlieren.
**Zwingend, damit die Fahrterkennung läuft:**
- **Zündung/ACC-Status** (`ZUENDUNG_SENSOR`) — ein `binary_sensor`, `on`
= Fahrt läuft. Kommt vom Teltonika FMM003 (z. B.
`binary_sensor.<gerätename>_engine_ignition_or_acc_status`).
1. Integration installieren (Weg A oder B oben)
2. Aus der `configuration.yaml` entfernen: den `pyscript:`-Block und den
`panel_custom:`-Eintrag `audi-dashboard-panel`. Bleiben sie stehen, gibt
es den Sidebar-Eintrag zweimal, und beide Backends schreiben in dieselben
Dateien.
3. Aus `/config/pyscript/` entfernen: `backup.py`, `batterieverlauf.py`,
`belegverarbeitung.py`, `bilderverwaltung.py`, `fahrtabschluss.py`,
`fahrterkennung.py`, `frontend_api.py`, `historienimport.py`,
`reifenzaehler.py`, `tankerkennung.py`, `updateverwaltung.py` und den
Ordner `modules/`. Andere pyscript-Skripte bleiben unberührt; wird
pyscript sonst nicht gebraucht, kann es über HACS ganz entfernt werden.
4. Aus `/config/www/` entfernen: `audi-dashboard-app.js`,
`audi-dashboard-panel.js`, `audi-dashboard.css`, `audi-dashboard-ios.css`,
`audi-dashboard-version.json` und den Ordner `badges/`. Die Integration
liefert diese Dateien selbst aus. **`www/bilder/` bleibt** — das sind die
eigenen Fahrzeugfotos.
5. `/config/audi_dashboard/ha_token.txt` kann weg: der Verlauf wird nicht mehr
über die REST-API gelesen.
6. Home Assistant neu starten, Integration hinzufügen.
**Alles Weitere ist optional** — ohne zugeordneten Sensor zeigt die
Oberfläche „unbekannt"/em-dash statt eines Werts, kein Absturz:
- Kilometerstand (`KM_SENSOR`), Tankfüllstand (`TANK_SENSOR`), Reichweite
(`RANGE_SENSOR`) — bis zu einer neuen Datenquelle unbelegt, siehe
`AGENTS.md` (die frühere `TommiG1/HA_VAG-EU-Data-Act`-Integration liefert
diese nicht mehr)
- Standort (`STANDORT_LAT_SENSOR`/`STANDORT_LON_SENSOR`, zwei eigene
`sensor`-Entities für Breiten-/Längengrad — flespi liefert Koordinaten so,
nicht als `latitude`/`longitude`-Attribute eines `device_tracker`) und
Batteriespannung (`BATTERIE_SENSOR`) — vom FMM003, z. B.
`sensor.<gerätename>_latitude_coordinate_value`/`..._longitude_coordinate_value`
bzw. `sensor.<gerätename>_external_power_voltage` (**nicht**
`..._battery_voltage` — das ist die interne Pufferzelle des Trackers,
nicht die Fahrzeugbatterie)
- Türen/Fenster/Schlösser, Ölwechsel/Inspektion — je nach Fahrzeug/
Integration vorhanden oder nicht
Die Sensor-Zuordnung aus `entitaeten.json` wird unverändert übernommen.
Wer lieber direkt in Entwicklerwerkzeuge → Zustände nach Entity-IDs sucht,
kann das weiterhin tun — die Suche im Setup-Menü filtert exakt auf
denselben Datenbestand (`HASS.states`), nur mit Vorschlägen und Filter.
**Einschränkung bei drei Feldern** (Zündung, Kilometerstand,
Tankfüllstand): sie sind intern fest mit einem Auslöser verdrahtet
(`@state_trigger`), der einmalig beim Laden des Backends gesetzt wird.
Eine Änderung im Setup-Menü wird gespeichert, wirkt für die Fahrterkennung
selbst aber erst nach einem Neustart von Home Assistant — das Setup-Menü
zeigt dafür einen Hinweis an.
## Schritt 5 — Long-Lived Access Token erzeugen
Wird für das Kilometerstand-Screening gebraucht (§7.2) — pyscript hat keinen
eingebauten Weg, auf die Recorder-Historie zuzugreifen, deshalb läuft das
über die normale Home-Assistant-Web-API.
1. Unten links auf den eigenen Profil-Avatar klicken
2. Ganz nach unten scrollen zu **„Long-lived access tokens"**
3. **„Token erstellen"**, einen Namen vergeben (z. B. `audi_dashboard`)
4. Den angezeigten Token-Wert **sofort kopieren** — er wird danach nicht
noch einmal angezeigt
5. Eine neue Datei `audi_dashboard/ha_token.txt` anlegen (im selben Ordner,
in den `data/` kopiert wurde) und **nur den Token-Wert** hineinschreiben,
keine Anführungszeichen, keine zweite Zeile
⚠️ Diese Datei enthält ein Geheimnis. Nicht weitergeben, nicht in ein
Backup hochladen, das öffentlich einsehbar ist.
## Schritt 6 — neu starten
**Einstellungen → System → Neu starten.**
## Schritt 7 — prüfen, ob alles geladen hat
1. **Einstellungen → System → Protokolle**, nach „pyscript" oder
„audi_dashboard" filtern — es sollten keine roten Fehlermeldungen
auftauchen, insbesondere keine `ImportError` oder `ModuleNotFoundError`
2. **Entwicklerwerkzeuge → Zustände**, nach `pyscript.reifen` suchen — dort
sollten `pyscript.reifen_sommer_km`, `pyscript.reifen_winter_km` (Wert `0`,
solange noch kein Kilometerstand-Update seit der Installation einging) und
`pyscript.reifen_aktiver_satz` auftauchen
3. **Entwicklerwerkzeuge → Aktionen**, nach „audi_dashboard" suchen — die
Services `pyscript.audi_dashboard_screening_jetzt`,
`pyscript.audi_dashboard_reifen_wechseln`,
`pyscript.audi_dashboard_fahrt_manuell_anlegen`,
`pyscript.audi_dashboard_beleg_hochladen` und
`pyscript.audi_dashboard_tankvorgang_manuell` sollten dort erscheinen
Wenn das alles stimmt, läuft das Backend. Die Fahrterkennung selbst lässt
sich am einfachsten testen, indem der Zündungs-Sensor (`ZUENDUNG_SENSOR`,
nach dem Zuordnen in Schritt 4) kurz auf `on` und wieder auf `off` gesetzt
wird (Pausenregel greift, kurzer Ausflug wird als eine Fahrt gewertet) bzw.
länger als die eingestellte Pausenzeit auf `off` bleibt (Fahrt wird
angelegt) — am Fahrzeug reicht dafür kurz die Zündung, ohne Fahrzeug
funktioniert es auch manuell über **Entwicklerwerkzeuge → Zustände** (den
Sensor suchen, Zustand testweise auf `on`/`off` setzen). Danach in
`audi_dashboard/fahrten.jsonl` nachsehen, ob eine Zeile entstanden ist.
## Schritt 8 — was jetzt noch fehlt, bevor es vollständig nutzbar ist
- **Reifen-Kilometerstände:** laufen automatisch mit (`reifen.saetze.
sommer/winter.km`, siehe reifenzaehler.py) — beide starten bei der
Installation bei `0`. Hat einer der Sätze schon Laufleistung von vor der
Installation, den Wert einmalig direkt in `audi_dashboard/
fahrzeugprofil.json` nachtragen
- **Kfz-Steuer-Fälligkeit:** `steuer.faellig` im selben Profil eintragen
- **shell_beleg_parser.py** liegt bereits unter `data/shell_beleg_parser.py`
und wird mit dem `data`-Ordner aus Schritt 2 automatisch nach
`audi_dashboard/shell_beleg_parser.py` mitkopiert. Er ruft `python3` als
eigenen Prozess auf (nicht die pyscript-Sandbox) und braucht dafür
einmalig `pypdf`: im **Terminal & SSH**-Add-on (oder per `docker exec`)
`pip install pypdf` ausführen. Ohne das schlägt jeder Beleg-Upload mit
`ModuleNotFoundError: No module named 'pypdf'` im Log fehl.
- **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
Der `panel_custom`-Eintrag aus Schritt 3 zeigt auf
`/config/www/audi-dashboard-panel.js`, die in Schritt 2 mitkopiert wurde.
1. In der Sidebar sollte nach dem Neustart aus Schritt 6 ein neuer Eintrag
„Mein Audi" erscheinen (Auto-Symbol). Anklicken.
2. Die Übersicht sollte erscheinen: Fahrzeugbild (als Platzhalter, siehe
unten), Typenschild, Kilometerstand, Reichweite. Falls Kilometerstand
oder Reichweite „unbekannt"/leer bleiben, obwohl Schritt 7 erfolgreich
war: die optionalen Sensoren aus Schritt 4 im Setup-Menü zuordnen.
3. Falls die Seite leer bleibt oder gar nicht in der Sidebar erscheint: im
Browser die Entwicklerkonsole öffnen (F12) und nach Fehlern mit
„audi-dashboard" oder „pyscript" suchen — siehe Troubleshooting.
**Was direkt sichtbar fehlt, ganz bewusst (siehe README):**
- **Bilder** — der Ordner `bilder/` mit den elf Fotos aus §7a ist nicht
Teil dieses Baustands. Die Bildflächen bleiben leer bzw. zeigen ein
gebrochenes Bild-Symbol, das Layout selbst springt nicht (die Maße sind
reserviert). Bilder können jederzeit einzeln unter `www/bilder/` nachgereicht
werden, feste Dateinamen siehe §7a im Lastenheft.
- **Karten** (Leaflet) laden weiterhin von einem CDN, genau wie im
Prototyp. Das Gerät braucht dafür zusätzlich zur Tailscale-Verbindung
normalen Internetzugang — sonst bleibt die Karte auf der Fahrt- und
Tankvorgang-Detailseite leer.
- **Statistik-Seite** wertet die eigenen Fahrten und Tankvorgänge echt aus
(Zeiträume, Verbrauch, Tag/Nacht, privat/Arbeitsweg). Sie bleibt nur so
lange leer, wie noch keine Fahrten und Tankungen erfasst sind.
## Schritt 10 — künftige Updates einspielen, ohne die App neu zu bauen
Ab hier ändert sich der Ablauf: kein Neustart mehr nötig, weder für
Backend- noch für Frontend-Änderungen.
**Backend (pyscript):** pyscript überwacht `pyscript/` selbst auf
Änderungen und lädt geänderte Dateien automatisch neu — an der
Testinstanz gemessen: eine bearbeitete Datei war innerhalb von
Millisekunden aktiv, neue Services erschienen sofort in
Entwicklerwerkzeuge → Aktionen, ganz ohne Neustart. Es reicht, die neue
Datei per Samba/Studio-Code-Server/`scp` in `pyscript/` zu überschreiben.
**Frontend:** hier gibt es keinen eingebauten Auto-Reload, dafür aber ein
echtes Cache-Problem — Home Assistant liefert Dateien aus `/local/` mit
`Cache-Control: max-age=2678400` aus, 31 Tage (an der Testinstanz
gemessen). Ohne Gegenmaßnahme würde eine Aktualisierung im Browser
tagelang nicht ankommen. Deshalb ist das Frontend zweigeteilt:
- `audi-dashboard-panel.js` ist ein kleiner, stabil bleibender Lade-Stub,
auf den `panel_custom` in der `configuration.yaml` zeigt
- er lädt bei jedem Seitenaufruf zuerst `audi-dashboard-version.json`
ungecacht, hängt deren Versionsnummer als Parameter an und lädt darüber
erst den eigentlichen Code aus `audi-dashboard-app.js` nach — jede neue
Versionsnummer ist für den Browser eine neue URL und wird nie aus einem
alten Cache bedient
**Am einfachsten mit dem beiliegenden Skript:**
```powershell
.\update.ps1 -Ziel "\\<HA-IP-Adresse>\config"
```
Kopiert `pyscript/` und die Frontend-Dateien, schreibt
`audi-dashboard-version.json` automatisch mit einem neuen Zeitstempel —
**lässt `data/` unangetastet**, damit ein bereits laufendes Fahrzeugprofil
oder Fahrten-Archiv nicht überschrieben wird. Voraussetzung: der
Samba-Share aus Schritt 2 ist als Netzlaufwerk verbunden.
Danach: pyscript-Änderungen sind sofort aktiv, für das Frontend reicht ein
ganz normales Neuladen der Seite (F5) — kein Hard-Refresh, kein
HA-Neustart.
⚠️ Server-seitig geprüft: eine neue `audi-dashboard-version.json` und ein
geänderter `audi-dashboard-app.js`-Inhalt standen an der Testinstanz sofort
zur Verfügung (per `curl` nachgemessen). Ob ein echter Browser das beim
nächsten Öffnen tatsächlich nachlädt, ließ sich in der Sandbox-Testumgebung
nicht abschließend zeigen — deren eigene Netzwerkschicht verhielt sich
bereits beim WebSocket-Test nicht wie ein normaler Browser (siehe README).
Das Verfahren selbst (`cache:"no-store"` plus versionierter
Import-Parameter) ist eine Standardtechnik gegen genau dieses Problem,
keine Vermutung — der erste echte Test dafür ist trotzdem der erste echte
Update-Durchlauf bei euch.
**Die Companion-App muss neu gebaut und aufgespielt werden.** Sie spricht
Entitäten und Dienste unter neuen Namen an (`sensor.audi_dashboard_*` statt
`pyscript.audi_dashboard_*`, `audi_dashboard.<dienst>` statt
`pyscript.audi_dashboard_<dienst>`) — eine App vom alten Stand findet nach
dem Umstieg nichts mehr. Das Panel ist davon nicht betroffen: es wird von der
Integration mitgeliefert und ist damit automatisch auf demselben Stand.
---
## Troubleshooting
| Symptom | Wahrscheinliche Ursache |
|---|---|
| `ModuleNotFoundError: No module named 'einstellungen'` (oder `profil`, `fahrtabschluss_logik`) | Ordner falsch kopiert — `modules/`-Unterordner muss unter `pyscript/modules/` liegen, nicht direkt unter `pyscript/` |
| pyscript lädt gar nicht, keine Fehler, keine Services | `allow_all_imports: true` fehlt oder Einrückung in der `configuration.yaml` ist falsch (YAML ist einrückungsempfindlich) |
| Fahrt wird nie angelegt | `ZUENDUNG_SENSOR` ist im Setup-Menü nicht oder falsch zugeordnet, oder er ist nach einer Änderung im Setup-Menü noch nicht durch einen HA-Neustart aktiv geworden (siehe Schritt 4) |
| Screening findet nie einen Kilometerstand | `ha_token.txt` fehlt, ist leer, oder der Token wurde widerrufen — in den Protokollen nach `HTTPError` oder `401` suchen |
| Reifenzähler zeigt dauerhaft „unbekannt" | `KM_SENSOR` ist im Setup-Menü nicht zugeordnet, oder es kam seit der Installation noch keine Änderung des Kilometerstands an (reifenzaehler.py reagiert nur auf Sensor-Änderungen) |
| „Mein Audi" fehlt in der Sidebar | `panel_custom:`-Block fehlt oder ist falsch eingerückt in `configuration.yaml`; nach Änderungen daran hilft nur ein vollständiger Neustart, kein „YAML neu laden" |
| Sidebar-Eintrag da, Seite bleibt leer | Browser-Konsole (F12) prüfen: 404 bei `/local/audi-dashboard-panel.js` → Datei liegt nicht unter `/config/www/`; JS-Fehler beim Laden → Datei unvollständig kopiert, Dateigröße mit dem Original vergleichen |
| Übersicht erscheint, aber alle Werte „unbekannt" | Normal, solange `KM_SENSOR`/`TANK_SENSOR`/... im Setup-Menü noch nicht zugeordnet sind (Schritt 4) — kein Frontend-Fehler |
| Karten bleiben leer auf Fahrt-/Tankdetailseite | Gerät hat keinen Internetzugang zusätzlich zu Tailscale — Leaflet lädt von einem CDN (siehe Schritt 9) |
**„Mein Audi" fehlt in der Seitenleiste.** Ist die Integration unter
Einstellungen → Geräte & Dienste wirklich hinzugefügt? Das Panel entsteht
erst dabei, nicht schon beim Kopieren der Dateien.
**Das Panel bleibt auf „Lädt …".** Die App fragt so lange nach, bis das
Backend Daten liefert — ein Abbruch ist nicht vorgesehen. Bleibt es dauerhaft
stehen, im Protokoll nach `audi_dashboard` suchen.
**Alle Kacheln zeigen „unbekannt".** Kein Sensor zugeordnet (siehe Schritt 2)
oder die zugeordneten Entitäten existieren nicht mehr. Das Setup-Fenster
zeigt neben jedem Feld, ob der gewählte Sensor gerade einen brauchbaren Wert
liefert.
**Fahrten werden nicht erkannt.** Ohne Zündungs-/ACC-Sensor gibt es keine
Fahrterkennung — das ist das eine Pflichtfeld. Prüfen lässt sich das direkt:
den Zustand der zugeordneten Entität in Entwicklerwerkzeuge → Zustände
beobachten, während das Fahrzeug an- und ausgeht.
**Fahrten bleiben „offen".** „Offen" heißt: die Strecke fehlt noch, nicht
„unterwegs". Der Kilometerstand kommt laut Datenquelle teils erst mit der
nächsten Fahrt; das Screening trägt ihn dann nach. Bleibt es dauerhaft offen,
fehlt der Kilometerstand-Sensor oder der recorder reicht nicht weit genug
zurück.
**Belege lassen sich nicht lesen.** `pypdf` fehlt — normalerweise
installiert Home Assistant es beim ersten Laden der Integration selbst. Im
Protokoll nach `pypdf` suchen.
+28 -155
View File
@@ -1,168 +1,41 @@
# Audi-Dashboard — Backend und Frontend, Stand nach dritter Bauphase
# homeassistant/ — Installation und Konfiguration
Dieser Ordner enthält Backend **und** Frontend aus dem Lastenheft
(`bauauftrag.md`): das pyscript-Backend für Fahrterkennung, Fahrtabschluss,
Reifenzähler und Belegverarbeitung, plus der zum `panel_custom`-Custom-
Element umgebaute Prototyp.
Der Code liegt nicht mehr hier. Seit dem Umbau zur Home-Assistant-Integration
(2026-08-23) steht er unter [`../custom_components/audi_dashboard/`](../custom_components/audi_dashboard/):
Backend, Panel-Dateien, Vorlage und Belegleser in einem Ordner, der genau so
installiert wird, wie HACS ihn installieren würde.
**Installation:** siehe [`INSTALL.md`](INSTALL.md) — Schritt für Schritt,
inklusive der einzigen Stelle, die inhaltlich angepasst werden muss
(`pyscript/modules/einstellungen.py`, Entity-IDs).
Was hier bleibt, ist alles, was **um** die Installation herum gebraucht wird.
## Getestet, nicht nur geprüft
| Datei | Wofür |
|---|---|
| [`INSTALL.md`](INSTALL.md) | Installation Schritt für Schritt, inklusive Umstieg von der pyscript-Fassung |
| [`installationspaket/`](installationspaket/) | Ein-Klick-Installation ohne HACS (`Installieren.cmd`) samt Anleitung |
| [`recorder_snippet.yaml`](recorder_snippet.yaml) | Datenaufbewahrung von 10 Tagen auf ein Jahr anheben — der einzige YAML-Block, der noch von Hand eingetragen wird |
| [`REVERSE_PROXY.md`](REVERSE_PROXY.md) | Zugriff von außen |
| `www/dm360-qr.html` | QR-Code-Seite zum Einrichten der Companion-App |
Beides lief in einer Wegwerf-Testinstanz (Home Assistant in Docker), nicht
nur gegen Dokumentation abgeglichen:
## Wo die Fahrzeugdaten liegen
- **Backend:** alle Module laden fehlerfrei, alle Trigger und Services
registrieren sich korrekt. Zwei echte Service-Aufrufe über die HA-API
haben tatsächlich Daten geschrieben — `audi_dashboard_reifen_wechseln`
hat `fahrzeugprofil.json` von „sommer" auf „winter" umgestellt,
`audi_dashboard_fahrt_manuell_anlegen` hat einen vollständigen Datensatz
an `fahrten.jsonl` angehängt.
- **Frontend:** das Custom Element wurde mit genau diesen echten
Backend-Daten gefüttert (per REST abgerufen) und hat daraus korrekt die
Übersicht und die Fahrtenliste gerendert — inklusive der zuvor
umgeschalteten Winterbereifung, sichtbar an der Fahrbahn-Szene, und der
manuell angelegten Testfahrt in der Fahrten-Ansicht.
In Home Assistant selbst, unter `/config/audi_dashboard/`:
Fahrzeugprofil, Fahrten, Tankvorgänge, Batterieverlauf, Sensor-Zuordnung,
hochgeladene Belege und die automatischen Sicherungen.
Dabei wurden drei echte pyscript-Eigenheiten gefunden und behoben, die in
keiner Dokumentation standen:
Dieser Ordner gehört dem Nutzer, nicht der Installation: kein Update — weder
über HACS noch über das Installationsskript — fasst ihn an. Dasselbe gilt für
die selbst hochgeladenen Fahrzeugfotos unter `/config/www/bilder/`.
1. `task.executor()` akzeptiert nur echte externe Python-Funktionen (z. B.
`io.open`), keine im pyscript-Ordner selbst definierten.
2. Variablen aus einem `with ... as f:`-Block waren danach außerhalb nicht
mehr auffindbar (`NameError`).
3. Das eingebaute `open()` existiert in pyscript nicht.
**Eine Lücke bleibt:** die Testinstanz ließ keine WebSocket-Verbindung zu
(Einschränkung der Testumgebung, nicht des Codes). Der letzte Schritt — dass
`panel_custom` das Element beim normalen Draufklicken in der Sidebar selbst
erzeugt und mit `hass` versorgt — läuft über genau diese Verbindung und
ließ sich deshalb nicht End-to-End nachstellen. `panel_custom` ist aber
Home Assistants eigener, langjährig stabiler Mechanismus, nicht eigener
Code — das Risiko dort ist gering, verglichen mit dem, was schon getestet
werden konnte.
Bis 2026-08-23 lag hier ein `data/`-Ordner, aus dem die Erstbefüllung von Hand
kopiert werden musste. Den gibt es nicht mehr: die Integration legt das
Fahrzeugprofil beim ersten Start selbst aus ihrer Vorlage an
([`vorlage/fahrzeugprofil.json`](../custom_components/audi_dashboard/vorlage/fahrzeugprofil.json)).
## Persönliche Daten
Alles Fahrzeug- und Personenspezifische (VIN, Kennzeichen, Versicherung,
Werkstattkontakt, Servicehistorie, Fahrten, Tankvorgänge) liegt ausschließlich
in `data/` und wird nie im App-Code (`pyscript/`, `www/`) fest hinterlegt —
`data/fahrzeugprofil.json`, `data/fahrten.jsonl`, `data/tankvorgaenge.jsonl`
sowie die echten Tankbeleg-PDFs unter `data/tests/belege/` sind deshalb per
`.gitignore` von der Versionierung ausgeschlossen. `data/fahrzeugprofil.
example.json` ist die getrackte Vorlage ohne echte Werte für eine
Neuinstallation (siehe INSTALL.md Schritt 2); die Kernangaben lassen sich
danach direkt in der App unter **Einstellungen → Fahrzeug einrichten**
eintragen, ohne die JSON-Datei von Hand zu bearbeiten. Das Backup
(**Einstellungen → Backup**) exportiert ausschließlich diese drei
Datendateien — nie App-Code oder -Konfiguration.
in diesem Datenordner in Home Assistant und steht nirgends im App-Code. Im
Repository liegt nur die Vorlage mit Platzhaltern.
## Danach noch offen
- **Reifen-Kilometerstände** (`reifen.saetze.sommer/winter.km`) laufen seit
der Frontend-Anpassung automatisch mit: jeder gefahrene Kilometer zählt auf
den Satz, der laut `reifen.aktiv` gerade montiert ist (reifenzaehler.py,
reagiert auf `KM_SENSOR`). Beide starten bei `0` — Laufleistung von vor der
Installation muss einmalig direkt im Profil nachgetragen werden.
- **Kfz-Steuer-Fälligkeit:** `steuer.faellig` steht auf `null`, aus §13 noch
offen.
- **shell_beleg_parser.py** fehlte im Projektordner beim Bau und wurde
nachträglich neu geschrieben — liegt jetzt unter `data/shell_beleg_parser.py`
und landet mit dem `data`-Ordner automatisch unter
`/config/audi_dashboard/shell_beleg_parser.py` (siehe INSTALL.md Schritt 2
und Schritt 8). Braucht dort einmalig `pip install pypdf` im System-`python3`
des Containers (eigener Prozess, nicht die pyscript-Sandbox). Geprüft gegen
zehn echte Mobile-Payment-Belege aus vier verschiedenen Stationen (Ingolstadt/
Zrenner, Rain am Lech/Bauch, Ansbach/Sengül, Königsbronn/Mogler) — die
Erkennung ist bewusst nicht an Markenzeilen-Wortlaut ("SHELL STATION" vs.
"Shell-Station") oder Rabatt-Label ("V-Power Smart Deal" vs.
"ClubSmartRabatt") gebunden, siehe Regressionstest unter
`data/tests/test_shell_beleg_parser.py` (Aufruf: `python3
data/tests/test_shell_beleg_parser.py`). Andere Zahlungswege als Mobile
Payment oder Belege mit mehreren Positionen (z. B. zusätzlicher
Autowäsche-Posten mit abweichendem MwSt.-Satz) sind nicht durch reale
Belege abgedeckt und müssten bei Bedarf nachgezogen werden.
- **Tailscale:** „VPN On Demand" mit Regel „Only On", gebunden an
`Audi_MMI_2804_5GHz`, muss in der Tailscale-App selbst eingerichtet werden
(§9, §10 Punkt 8) — das lässt sich nicht von hier aus mitinstallieren.
- **GPS-Fallback (§7.2) ist nicht umgesetzt.** Fahrten ohne passenden
Kilometerstand bleiben aktuell dauerhaft `offen`, statt auf die
GPS-Strecke auszuweichen. Erfordert eine `device_tracker`-Anbindung und
Adressauflösung.
- **Fahrterkennung übersteht keinen HA-Neustart mitten in einer Fahrt oder
Wartezeit** — der Zwischenstand lebt nur im Arbeitsspeicher von
`fahrterkennung.py`.
- **Bilder fehlen** (§7a) — Ordner `www/bilder/` ist noch leer. Layout
springt nicht (Maße sind reserviert), aber die Flächen bleiben leer.
- **Leaflet lädt per CDN**, wie im Prototyp — braucht zusätzlich zu
Tailscale echten Internetzugang auf dem Gerät.
## Was schon funktioniert (getestet)
- Fahrterkennung über den Zündungssensor mit Pausenregel (`task.unique` + `task.sleep`);
bis 2026-08-12 lief sie über den WLAN-Sensor des iPhones
- Zweistufiger Fahrtabschluss per Recorder-Historie-Screening, inklusive
Verkettung direkt anschließender Fahrten
- Reifenzähler per direkter Subtraktion, kein `utility_meter`
- Belegverarbeitung mit Duplikatserkennung über `receipt_key` (die zunächst
geplante 97-%-Volltankungsregel wurde per Änderungswunsch entfernt, siehe
Kopfkommentar in `pyscript/belegverarbeitung.py`)
- Manuelle Fallback-Wege für Fahrten und Tankvorgänge ohne Beleg
- Frontend: Übersicht, Fahrtenliste, Navigation zwischen allen Ansichten,
Datenadapter Profil→Oberfläche, Schreibaktionen über `hass.callService`
- Statistik-Seite mit echter Auswertung aus Fahrten und Tankvorgängen
(Zeiträume, Verbrauch, Tag/Nacht, privat/Arbeitsweg)
- Setup-Menü für die Sensor-Zuordnung, inklusive Sicherung in
`data/entitaeten.json`
## Dateiübersicht
```
homeassistant/
├── INSTALL.md Schritt-für-Schritt-Installation
├── README.md diese Datei
├── configuration_snippet.yaml Ergänzung zur configuration.yaml
├── data/
│ ├── fahrzeugprofil.json Fahrzeugprofil (§6.1), mit echten Daten befüllt — gitignored
│ ├── fahrzeugprofil.example.json Vorlage ohne echte Daten, bleibt getrackt (siehe INSTALL.md Schritt 2)
│ ├── fahrten.jsonl Fahrten-Archiv (§6.1) — gitignored
│ └── tankvorgaenge.jsonl Tankvorgänge-Archiv (§6.1) — gitignored
├── pyscript/
│ ├── modules/
│ │ ├── einstellungen.py zentrale Entity-ID-Konfiguration (Schritt 4)
│ │ ├── profil.py Datenzugriff (§6)
│ │ ├── entitaeten.py Sensor-Zuordnung aus dem Setup-Menü
│ │ ├── fahrtabschluss_logik.py Screening-Logik (§7.2)
│ │ └── frontend_veroeffentlichung.py Zustände fürs Frontend (§10 Punkt 7 Ersatz)
│ ├── fahrterkennung.py §7.1 (Zündungssensor, früher WLAN)
│ ├── fahrtabschluss.py §7.2 (Trigger-Registrierung)
│ ├── tankerkennung.py §7.4 (automatische Erkennung am Füllstandsanstieg)
│ ├── reifenzaehler.py §7.5
│ ├── belegverarbeitung.py §7.4, §7.7
│ ├── batterieverlauf.py 12-V-Spannung, Tagesminimum/-maximum
│ ├── bilderverwaltung.py Upload/Löschen der Fahrzeugfotos
│ ├── backup.py tägliche Sicherung und Wiederherstellung
│ ├── updateverwaltung.py Update suchen und installieren
│ └── frontend_api.py Lese-/Schreib-Anbindung fürs Frontend
├── www/
│ ├── audi-dashboard-panel.js Lade-Stub (zeigt configuration.yaml hierher), ändert sich kaum
│ ├── audi-dashboard-app.js das eigentliche Custom Element, hier passiert die Arbeit
│ ├── audi-dashboard-version.json Cache-Buster, wird von update.ps1 automatisch neu geschrieben
│ ├── audi-dashboard.css aus dem Prototyp extrahiert, für Shadow DOM angepasst
│ └── bilder/ noch leer, siehe „Danach noch offen"
└── update.ps1 Updates einspielen ohne Neustart (Schritt 10 in INSTALL.md)
```
## Updates einspielen
`pyscript/` lädt sich bei Änderungen selbst neu, ganz ohne HA-Neustart — an
der Testinstanz gemessen (Datei überschrieben, Service erschien innerhalb
von Millisekunden). Fürs Frontend gibt es dafür keinen eingebauten
Mechanismus, dazu kommt ein echtes Cache-Problem: `/local/` wird 31 Tage
lang gecacht (`Cache-Control: max-age=2678400`, ebenfalls gemessen). Gelöst
über einen kleinen Lade-Stub (`audi-dashboard-panel.js`), der bei jedem
Seitenaufruf ungecacht eine Versionsnummer nachlädt und darüber den
eigentlichen Code cache-sicher nachzieht — siehe Schritt 10 in
[`INSTALL.md`](INSTALL.md) und [`update.ps1`](update.ps1).
Das Backup in der App (**Einstellungen → Backup**) exportiert ausschließlich
diese Datendateien — nie App-Code oder -Konfiguration.
-52
View File
@@ -1,52 +0,0 @@
# Ergänzung zur bestehenden configuration.yaml.
# Nicht die ganze Datei ersetzen - diese Zeilen unter den jeweils schon
# 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
# (lokal erreichbar unter /local/audi-dashboard-panel.js). Die Datei selbst
# entsteht erst in Phase 2 (Frontend-Umbau) - bis dahin ist dieser Eintrag
# noch nicht funktionsfähig.
#
# Das ?v=1 am Ende ist absichtlich: audi-dashboard-panel.js selbst wird von
# Home Assistant mit einer sehr langen Standard-Cache-Zeit ausgeliefert
# (31 Tage), weil sie als stabiler Lade-Stub gedacht ist, der praktisch nie
# wieder angefasst wird (siehe deren eigener Kopfkommentar). Ändert sie sich
# doch einmal (ist an der Testinstanz bereits vorgekommen), bleibt jeder
# Browser, der die Datei vorher schon geladen hatte, sonst für bis zu 31
# Tage auf dem alten Stand hängen - unabhängig davon, wie oft die Datei auf
# dem Server ersetzt wird. Die Versionsnummer hier zu erhöhen ändert die
# angefragte URL und erzwingt damit einen echten Neuabruf bei jedem Browser.
# Das erfordert einen HA-Neustart (Änderungen an configuration.yaml), ist
# aber dafür zuverlässig - anders als bei audi-dashboard-app.js/.css, die
# über audi-dashboard-version.json ganz ohne Neustart aktualisiert werden.
panel_custom:
- name: audi-dashboard-panel
sidebar_title: Mein Audi
sidebar_icon: mdi:car-sports
module_url: /local/audi-dashboard-panel.js?v=1
embed_iframe: false
trust_external_script: false
@@ -1,184 +0,0 @@
{
"_hinweis": "Vorlage ohne echte Fahrzeug-/Personendaten. Vor der Installation nach fahrzeugprofil.json kopieren und die Platzhalter unten ersetzen (VIN, Kennzeichen, Erstzulassung, HU-Termin, Versicherung, Werkstatt, Servicebuch) - am einfachsten über den Button 'Einrichten' in den Einstellungen der App, sobald sie einmal läuft. Diese Datei selbst bleibt bewusst im Repo (siehe .gitignore), die echte fahrzeugprofil.json nicht.",
"fahrzeug": {
"fin": "",
"kennzeichen": "",
"titel": "RS 4 Avant competition",
"zusatz": "Avant competition",
"untertitel": "quattro · 331 kW",
"details": "2.9 TFSI quattro competition · 331 kW · 450 PS",
"erstzulassung": "",
"hauptuntersuchung_faellig": "",
"tankvolumen_liter": 58,
"ausfuehrung": "competition"
},
"einstellungen": {
"nacht_von": "19:00",
"nacht_bis": "06:00",
"fahrten_pausenzeit_min": 15,
"uebersichtsbild": "Frontansicht",
"kraftstoffanbieter": "Shell",
"smartdeal": { "aktiv": false, "laeuft_ab": "" },
"oelwechsel_intervall": {
"modus": "hersteller",
"km": 15000,
"monate": 12,
"hersteller_km": 30000,
"hersteller_monate": 24
},
"backup_intervall": "aus",
"letztes_backup": null
},
"versicherung": {
"gesellschaft": "",
"vertrag": "",
"nummer": "",
"service": "",
"notfall": "",
"beitrag": 0,
"steueranteil": 0,
"steuersatz": "19 %",
"gueltigAb": "",
"zahlweise": "jährlich",
"teile": [
{ "bez": "Haftpflicht", "betrag": 0, "sf": "", "sfAlt": "", "satz": "", "satzAlt": "" },
{ "bez": "Vollkasko inklusive Teilkasko", "betrag": 0, "sf": "", "sfAlt": "", "satz": "", "satzAlt": "" },
{ "bez": "Schutzbrief Mobilität", "betrag": 0, "sf": null, "sfAlt": null, "satz": null, "satzAlt": null }
],
"selbst": [["Vollkasko", "0 €"], ["Teilkasko", "0 €"]],
"schutzbrief": {
"stand": "",
"gilt": "",
"versichert": "",
"ausloeser": [],
"entweder": [],
"gruppen": [],
"nicht": [],
"pflichten": [],
"rang": ""
},
"leistungen": []
},
"steuer": {
"betrag": 0,
"zeitraum": "jährlich",
"faellig": null,
"grundlage": "Hubraum und CO₂-Ausstoß",
"lastschrift": true,
"finanzamt": ""
},
"reifen": {
"aktiv": "sommer",
"anzugsmoment_nm": 120,
"wechsel": { "datum": "", "vereinbart": false },
"referenz_odo_km": null,
"saetze": {
"sommer": {
"km": 0,
"bild": "/local/bilder/rad-sommer.webp",
"marke": "",
"modell": "",
"dot": "",
"mass": "275/30 R20 97Y XL",
"druck_vorne": "2,5",
"druck_hinten": "2,4",
"kommentar": ""
},
"winter": {
"km": 0,
"bild": "/local/bilder/rad-winter.webp",
"marke": "",
"modell": "",
"dot": "",
"mass": "265/35 R19",
"druck_vorne": "2,6",
"druck_hinten": "2,5",
"kommentar": ""
}
}
},
"service": {
"vereinbart": {},
"autohaus": {
"name": "",
"adresse": "",
"tel": "",
"mail": "",
"pos": null
},
"buch": []
},
"technik": [
{ "gruppe": "Motor", "posten": [
["Bauart", "V6 Ottomotor, Direkteinspritzung, Biturbo"],
["Hubraum", "2 894 cm³"],
["Leistung", "331 kW / 450 PS bei 5 7006 700 min⁻¹"],
["Maximales Drehmoment", "600 Nm bei 2 0005 000 min⁻¹"],
["Abgasreinigung", "Katalysator, Lambdasonde, Ottopartikelfilter"],
["Abgasnorm", "EU6 AP"],
["Motormanagement", "vollelektronisch"]
]},
{ "gruppe": "Kraftübertragung", "posten": [
["Getriebe", "8-Gang tiptronic"],
["Antrieb", "quattro, permanent, selbstsperrendes Mittendifferenzial"],
["Kupplung", "hydraulischer Drehmomentwandler mit Überbrückung"]
]},
{ "gruppe": "Fahrleistungen", "posten": [
["0 auf 100 km/h", "3,9 s"],
["Höchstgeschwindigkeit", "290 km/h"],
["Effizienzklasse", "E"],
["Fahrgeräusch", "70 dB(A)"]
]},
{ "gruppe": "Fahrwerk, Lenkung, Bremsen", "posten": [
["Fahrwerk", "RS-Sportfahrwerk"],
["Vorderachse", "5-Lenker, Rohrstabilisator"],
["Hinterachse", "5-Lenker, Rohrstabilisator"],
["Lenkung", "elektromechanisch, geschwindigkeitsabhängig"],
["Wendekreis", "ca. 11,7 m"],
["Bremsanlage", "RS-Stahlbremse, Sättel rot glänzend"],
["Vorn", "Faustsattel, Scheibe innenbelüftet"],
["Hinten", "Faustsattel mit elektrischer Parkbremse, innenbelüftet"]
]},
{ "gruppe": "Räder und Reifen", "posten": [
["Räder", "Leichtmetall 9J × 20, Audi Sport"],
["Reifen", "275/30 R20 97Y XL"],
["Pannenhilfe", "Reifenreparaturset, kein Ersatzrad"],
["Radschrauben", "Standard"],
["Anzugsmoment", "120 Nm"]
]},
{ "gruppe": "Maße, Gewichte, Volumen", "posten": [
["Leergewicht", "1 820 kg"],
["Zulässiges Gesamtgewicht", "2 350 kg"],
["Dachlast / Stützlast", "90 / 80 kg"],
["Anhängelast gebremst", "2 100 kg bei 8 %, 1 900 kg bei 12 %"],
["Anhängelast ungebremst", "750 kg"],
["Gepäckraum", "495 / 1 495 l"],
["Tankinhalt", "58 l, erhöhtes Füllvolumen für quattro"]
]},
{ "gruppe": "Bordnetz", "posten": [
["Batterie", "420 A, 75 Ah"],
["Generator", "100150 A"],
["Kältemittel", "R1234yf"]
]},
{ "gruppe": "Wartung", "posten": [
["Serviceanzeige", "30 000 km oder 2 Jahre, variabel"],
["Garantie", "Anschlussgarantie 3 Jahre, max. 100 000 km"]
]}
],
"ausstattung": [
{ "gruppe": "RS-Paket", "sonder": ["RS competition", "RS-Designpaket rot", "RS-Komfortpaket", "RS-Sportabgasanlage plus", "RS-Sportfahrwerk", "RS-Stahlbremsanlage, Sättel rot glänzend", "Bremssättel rot lackiert", "Anhebung der Höchstgeschwindigkeit auf 290 km/h", "Sicherheitsgurte schwarz mit rotem Rand", "RS-Einstiegsleisten"] },
{ "gruppe": "Außen", "sonder": ["Optikpaket schwarz plus", "Optikpaket schwarz glänzend", "Panorama-Glasdach", "Dachreling schwarz", "Anhängevorrichtung", "Gepäckraumklappe elektrisch öffnend und schließend", "Matrix-LED-Scheinwerfer mit dynamischer Lichtinszenierung", "LED-Heckleuchten mit dynamischer Lichtinszenierung", "Außenspiegel elektrisch, beheiz- und anklappbar, abblendend, mit Memory", "Außenspiegelgehäuse in Wagenfarbe", "Sonnenschutzverglasung abgedunkelt"] },
{ "gruppe": "Innen", "sonder": ["Mikrofaser Dinamica/Leder mit Wabensteppung und RS-Stickung", "Sportsitze plus vorn", "Vordersitze elektrisch mit Memory für den Fahrersitz", "Sitzheizung vorn und hinten", "Lendenwirbelstütze mit Massagefunktion", "Dekoreinlagen Carbon Köper matt", "Ambiente-Lichtpaket plus", "3-Zonen-Komfortklimaautomatik", "Lederlenkrad 3-Speichen mit Lenkradheizung", "Innenspiegel automatisch abblendend, rahmenlos", "USB-Ladeschnittstellen im Fond", "Sonnenschutzrollo hinten", "Fußmatten RS-Designpaket"] },
{ "gruppe": "Infotainment", "sonder": ["MMI Navigation plus mit MMI touch", "Audi virtual cockpit plus", "Head-up-Display", "Bang & Olufsen Premium Sound System mit 3D-Klang", "Audi phone box", "LTE-Unterstützung für Audi phone box", "Audi connect Navigation & Infotainment"] },
{ "gruppe": "Assistenz", "sonder": ["Assistenzpaket Tour", "Assistenzpaket Stadt mit Parken", "Audi adaptive cruise control", "Audi active lane assist", "Halteassistent", "Parkassistent mit Einparkhilfe plus", "Umgebungskameras", "Kamerabasierte Verkehrszeichenerkennung", "Audi pre sense rear und basic", "Garagentoröffner", "Komfortschlüssel mit sensorgesteuerter Heckklappe"] },
{ "gruppe": "Serie, ausgewählt", "sonder": ["Audi drive select", "Audi pre sense city", "Spurwechselwarnung", "Reifendruck-Kontrollanzeige", "Start-Stop-System", "Audi connect Notruf & Service mit Remote & Control", "Smartphone-Interface", "Kraftstoffbehälter mit erhöhtem Füllvolumen", "Pedalerie und Fußstütze aus Edelstahl"] }
]
}
-452
View File
@@ -1,452 +0,0 @@
#!/usr/bin/env python3
"""Parser für Shell-Tankbelege (PDF), aufgerufen von belegverarbeitung.py.
Diese Datei fehlte beim Bau im Projektordner (siehe Kopfkommentar in
pyscript/belegverarbeitung.py und README.md, Abschnitt "Bekannte Lücken").
Sie ist hier gegen echte Shell-eReceipts neu geschrieben und gegen deren
tatsächlich von pypdf extrahierten Text geprüft - nicht gegen eine
angenommene Struktur. Regressionstest mit zehn realen Belegen aus vier
Stationen liegt unter tests/test_shell_beleg_parser.py (Aufruf: python3
tests/test_shell_beleg_parser.py aus diesem Ordner). Die Belege
unterscheiden sich u. a. in der Markenzeile ("SHELL STATION" vs.
"Shell Station" vs. "Shell-Station") und der Rabattbezeichnung
("V-Power Smart Deal" vs. "ClubSmartRabatt") - deshalb ist die
Stations- und Rabatterkennung unten bewusst nicht an deren genauen
Wortlaut gebunden, sondern an feste Beleg-Struktur-Marker (Obj.-Nr.,
Betragsmuster vor "EUR #A").
Abhängigkeit: pypdf (reine Textextraktion, der Beleg ist ein normales
Text-PDF, kein Scan/Bild - kein OCR nötig). Läuft als eigener Prozess über
`python3 shell_beleg_parser.py <pfad>` (system-python3 im Container, nicht
die pyscript-Sandbox - siehe _parser_aufrufen() in belegverarbeitung.py),
deshalb reicht `pip install pypdf` einmalig im selben Python, das
`python3` in configuration.yaml/Docker auch sonst benutzt.
Kommandozeilen-Schnittstelle wie von belegverarbeitung.py angenommen:
Ein Pfad zu einer PDF-Datei als einziges Argument, JSON-Objekt auf stdout
bei Erfolg, Exit-Code ungleich 0 und Fehlertext auf stderr bei Misserfolg
(z. B. wenn der Beleg keiner der erkannten Vorlagen entspricht).
Aufbau eines Shell-eReceipts (aus dem echten Beispiel, Reihenfolge fix):
SHELL STATION
<Betreibername, z.B. "A. Zrenner GmbH">
<Straße>
<PLZ Ort>
Obj.-Nr.: <Stations-ID> Tel. ...
Beleg-Nr. <receipt_no> <TT.MM.JJ> <HH:MM>
*<article_no> <Produktname> <Listenbetrag> EUR #A*
*Zp <Zapfsäule> <Liter> l <Listenpreis/l> EUR/l # *
[<Rabattname> -<Rabattbetrag> EUR #A -- optional, nur mit SmartDeal
<Rabatt/l> EUR/Liter -- optional]
Gesamtbetrag <Endbetrag> EUR
Typ Netto Mwst Brutto
A:<Mwst-Satz>% <Netto> <Mwst> <Brutto>
...
Mobile Payment
<TT.MM.JJJJ> <HH:MM:SS> -- Zahlungszeitstempel
...
(Seite 2) POS:...-...-... TSE-Beleg-Nr: <tse_beleg_nr>
Start/Ende: <TT.MM.JJJJ HH:MM:SS> / ...
"""
import datetime
import hashlib
import json
import re
import sys
try:
import pypdf
except ImportError:
print(
"pypdf fehlt. Einmalig installieren mit: pip install pypdf "
"(im selben Python, das diesen Parser ausführt, siehe Kopfkommentar).",
file=sys.stderr,
)
sys.exit(1)
def _text_aus_pdf(pdf_pfad):
leser = pypdf.PdfReader(pdf_pfad)
return "\n".join(seite.extract_text() or "" for seite in leser.pages)
def _de_zahl(text):
""""103,06" -> 103.06 / "2,399" -> 2.399. Kein Tausenderpunkt bei
Tankbeträgen dieser Größenordnung zu erwarten, trotzdem robust: Punkte
vor dem Umwandeln entfernen, nicht nur das Komma ersetzen."""
if text is None:
return None
return float(text.strip().replace(".", "").replace(",", "."))
def _iso(tag, monat, jahr, stunde, minute, sekunde="0"):
jahr = int(jahr)
if jahr < 100:
jahr += 2000
return datetime.datetime(
jahr, int(monat), int(tag), int(stunde), int(minute), int(sekunde)
).isoformat()
_ZAHL = r"[\d.,]+"
# Echte Geldangabe: Nachkommastellen sind Pflicht, damit im stationsunabhängigen
# Parser (_parsen_generisch) keine Artikel-/Belegnummer als Betrag durchgeht.
_GELD = r"\d{1,3}(?:[ .]\d{3})*,\d{2}"
_GESAMT_WOERTER = ("GESAMT", "ABSOLUT", "ENDBETRAG", "ZUZAHLEN", "TOTAL", "SUMME")
# Steuer- und Nettozeilen tragen dieselben Schlüsselwörter ("MWST GESAMT"),
# nennen aber nie den bezahlten Rechnungsbetrag.
_STEUER_WOERTER = ("MWST", "UST", "VAT", "STEUER", "NETTO")
# Kraftstoffmarken, wie sie im Belegkopf stehen. Bewusst nur dort gesucht (die
# ersten Zeilen), nicht im ganzen Text: "Total" wäre sonst z. B. auch als
# Summenzeile ein Treffer. Längere Namen zuerst, damit "TotalEnergies" nicht
# als "Total" endet. Die Schreibweise hier ist die, die angezeigt wird - auf
# Bons steht die Marke meist in Großbuchstaben ("SHELL STATION").
_MARKEN = (
"TotalEnergies", "Total", "Aral", "Shell", "Eni", "Agip", "Esso", "OMV",
"Avia", "Turmöl", "Turmoel", "Orlen", "Tamoil", "Westfalen", "Allguth",
"Classic", "Sprint", "Raiffeisen", "BayWa", "Elan", "HEM", "Star", "JET",
"bft", "BP", "Q1",
)
_STRASSE_WOERTER = r"(?:stra(?:ss|ß)e|str\.|gasse|weg|platz|allee|ring|damm|chaussee)"
def _marke(zeilen):
"""Markenname aus dem Belegkopf ("SHELL STATION" -> "Shell"). None, wenn
keine bekannte Marke draufsteht - dann bleibt es beim Betreibernamen."""
kopf = "\n".join(zeilen[:8])
for marke in _MARKEN:
if re.search(r"\b" + re.escape(marke) + r"\b", kopf, re.IGNORECASE):
return marke
return None
def _ist_strasse(zeile):
"""Straßenzeilen tragen eine Hausnummer oder ein Straßenwort - der
Betreibername ("AUTO B. FRISCHMANN GMBH") tut beides nicht."""
if not zeile:
return False
return bool(re.search(r"\d", zeile) or re.search(_STRASSE_WOERTER, zeile, re.IGNORECASE))
def _tankstelle(marke, strasse, ort, ersatzname=None):
"""Anzeigename der Tankstelle im Format "Marke, Straße, Ort" (§ Anzeige im
Frontend, Feld station_name). Fehlende Teile fallen weg statt als leere
Kommastelle stehen zu bleiben - nicht jeder Beleg nennt eine Straße. Ohne
erkennbare Marke tritt der Betreibername an ihre Stelle."""
teile = [t for t in (marke or ersatzname, strasse, ort) if t]
return ", ".join(teile) if teile else None
def _parsen(text):
def suchen(muster, *, pflicht=True, flags=re.MULTILINE):
treffer = re.search(muster, text, flags)
if treffer is None and pflicht:
raise ValueError(f"Muster nicht gefunden: {muster!r}")
return treffer
# ---- Station ---------------------------------------------------------
# Nicht auf den Wortlaut der Markenzeile selbst verankert ("SHELL
# STATION" vs. "Shell-STATION" - beide an echten Belegen gesehen,
# offenbar je nach Pächter/Kassensystem unterschiedlich geschrieben).
# Stattdessen einfach die drei Zeilen direkt vor "Obj.-Nr." nehmen - die
# Reihenfolge Betreiber/Straße/Ort direkt davor ist auf beiden echten
# Belegen gleich.
station = suchen(
r"(?P<name>.+?)\s*\n\s*(?P<strasse>.+?)\s*\n\s*(?P<ort>.+?)\s*\n\s*Obj\.-Nr\."
)
station_id = suchen(r"Obj\.-Nr\.:\s*(\d+)").group(1)
# ---- Beleg-Kopf (Beleg-Nr., Datum/Zeit auf dem Kassenbon) ----------
kopf = suchen(
r"Beleg-Nr\.\s*(?P<receipt_no>[\d/]+)\s+(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{2})\s+(?P<stunde>\d{2}):(?P<minute>\d{2})"
)
receipt_no = kopf.group("receipt_no")
ts = _iso(kopf.group("tag"), kopf.group("monat"), kopf.group("jahr"),
kopf.group("stunde"), kopf.group("minute"))
# ---- Kraftstoff-Position --------------------------------------------
position = suchen(
r"\*(?P<article_no>\d+)\s+(?P<produkt>.+?)\s+" + _ZAHL + r"\s*EUR\s*#A\*"
)
article_no = position.group("article_no")
product_name = position.group("produkt").strip()
zapfsaeule = suchen(
r"\*Zp\s*\d+\s+(?P<liter>" + _ZAHL + r")\s*l\s+(?P<preis>" + _ZAHL + r")\s*EUR/l"
)
liters = _de_zahl(zapfsaeule.group("liter"))
list_price_per_l = _de_zahl(zapfsaeule.group("preis"))
# ---- SmartDeal-Rabatt (optional - nicht jeder Tankvorgang hat einen) --
rabatt_treffer = suchen(r"-(" + _ZAHL + r")\s*EUR\s*#A\s*$", pflicht=False)
discount = _de_zahl(rabatt_treffer.group(1)) if rabatt_treffer else None
rabatt_l_treffer = suchen(r"(" + _ZAHL + r")\s*EUR/Liter", pflicht=False)
discount_per_l = _de_zahl(rabatt_l_treffer.group(1)) if rabatt_l_treffer else None
# ---- Beträge ----------------------------------------------------------
fuel_total_eur = _de_zahl(suchen(r"Gesamtbetrag\s+(" + _ZAHL + r")\s*EUR").group(1))
steuerzeile = suchen(
r"A:" + _ZAHL + r"%\s+(?P<netto>" + _ZAHL + r")\s+(?P<mwst>" + _ZAHL + r")\s+(?P<brutto>" + _ZAHL + r")"
)
net_eur = _de_zahl(steuerzeile.group("netto"))
vat_eur = _de_zahl(steuerzeile.group("mwst"))
receipt_total_eur = _de_zahl(steuerzeile.group("brutto"))
# ---- Zahlungszeitstempel (Mobile Payment) - optional, falls anderes
# Zahlungsverfahren auf dem Beleg steht ---------------------------------
zahlung = suchen(
r"Mobile Payment\s*\n+\s*(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{4})\s+(?P<stunde>\d{2}):(?P<minute>\d{2}):(?P<sekunde>\d{2})",
pflicht=False,
)
ts_payment = (
_iso(zahlung.group("tag"), zahlung.group("monat"), zahlung.group("jahr"),
zahlung.group("stunde"), zahlung.group("minute"), zahlung.group("sekunde"))
if zahlung else None
)
# ---- TSE-Signatur (Seite 2) - optional -------------------------------
tse_nr = suchen(r"TSE-Beleg-Nr:\s*(\d+)", pflicht=False)
tse_beleg_nr = tse_nr.group(1) if tse_nr else None
tse_zeit = suchen(
r"Start/Ende:\s*(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{4})\s+(?P<stunde>\d{2}):(?P<minute>\d{2}):(?P<sekunde>\d{2})",
pflicht=False,
)
ts_tse = (
_iso(tse_zeit.group("tag"), tse_zeit.group("monat"), tse_zeit.group("jahr"),
tse_zeit.group("stunde"), tse_zeit.group("minute"), tse_zeit.group("sekunde"))
if tse_zeit else None
)
# receipt_key: minutengenauer Deduplizierungs-Schlüssel (§6.4/§7.7 Regel 3)
receipt_key = f"{station_id}_{receipt_no.replace('/', '-')}_{ts[:16]}"
return {
"receipt_key": receipt_key,
"receipt_no": receipt_no,
"tse_beleg_nr": tse_beleg_nr,
"ts": ts,
"ts_payment": ts_payment,
"ts_tse": ts_tse,
"station_id": station_id,
# Anzeigename "Marke, Straße, Ort" (siehe _tankstelle). Der Ort steht
# auf dem Bon als "85057 Ingolstadt" - die PLZ gehört nur in die
# ausführliche station_address darunter, nicht in den Anzeigenamen.
"station_name": _tankstelle(
_marke(text.splitlines()),
station.group("strasse").strip(),
re.sub(r"^\d{4,5}\s+", "", station.group("ort").strip()),
ersatzname=station.group("name").strip(),
),
"station_address": f"{station.group('strasse').strip()}, {station.group('ort').strip()}",
"article_no": article_no,
"product_name": product_name,
"fuel_type": product_name,
"liters": liters,
"list_price_per_l": list_price_per_l,
"discount": discount,
"discount_per_l": discount_per_l,
"fuel_total_eur": fuel_total_eur,
"receipt_total_eur": receipt_total_eur,
"net_eur": net_eur,
"vat_eur": vat_eur,
}
def _parsen_generisch(text):
"""Stationsunabhängiger Fallback für Belege, die keinem der oben fest an
Shells Beleg-Struktur verankerten Muster entsprechen (_parsen() wirft in
dem Fall ValueError - siehe main()). Arbeitet nach demselben Verfahren,
das an einem echten Beleg einer bislang unbekannten Tankstelle von Hand
nachvollzogen wurde: Adresse suchen, Gesamtbetrag suchen ("Gesamt.../
Absolut..."), Literangabe suchen ("Menge"/"Amount" oder eine an "l"/
"Liter" hängende Zahl), daraus den tatsächlich bezahlten Preis/Liter
errechnen (Gesamtbetrag / Liter). Steht daneben auch ein aufgedruckter
Preis/Liter auf dem Beleg und weicht er vom errechneten ab, wurde ein
Rabatt gewährt - auf dem Beleg meist als eigener Betrag mit einem Minus
davor erkennbar, danach wird zuerst gesucht, sonst aus der Preisdifferenz
hergeleitet. Ob ein so gefundener Rabatt als "SmartDeal" behandelt wird,
entscheidet einzig station_name (Shell-Logo/-Label im Frontend, siehe
frontend_veroeffentlichung.py) - kein Sonderfall hier nötig, die Frontend-
Anzeige hängt nur an einem gesetzten discount, nicht an einem eigenen
Kennzeichen.
Liefert deutlich weniger Felder als _parsen() (kein Bon-Kleingedrucktes
wie TSE-Nummer/Zahlungszeitstempel) - unproblematisch, denn
belegverarbeitung.py liest nur receipt_key als echtes Pflichtfeld,
alles andere per .get()."""
def suchen(muster, flags=re.IGNORECASE | re.MULTILINE):
return re.search(muster, text, flags)
zeilen = [z.rstrip() for z in text.splitlines()]
# ---- Adresse -----------------------------------------------------------
# Die PLZ-Ort-Angabe steht nicht zuverlässig auf einer eigenen Zeile (ein
# österreichischer Beleg führt sie als "SHELL TANKSTELLE, 6450 SÖLDEN"
# zusammen mit der Filialbezeichnung) und PLZ sind vier- *oder*
# fünfstellig (AT/DE). Deshalb nicht mehr drei feste Zeilen hintereinander
# verlangen, sondern im Belegkopf die erste Zeile mit PLZ+Ort suchen; der
# Name ist die erste Textzeile darüber.
plz_treffer = None
plz_zeile = None
for i, zeile in enumerate(zeilen[:12]):
treffer = re.search(r"\b\d{4,5}\s+([A-Za-zÄÖÜäöüß][^\n]*)$", zeile)
if treffer:
plz_treffer, plz_zeile = treffer, i
break
ort = None
strasse = None
betreiber = None
station_address = None
if plz_treffer is not None:
ort = plz_treffer.group(1).strip(" ,.-")
station_address = zeilen[plz_zeile].strip()
# Die Straße steht entweder vor der PLZ auf derselben Zeile
# ("Musterstr. 5, 80331 München") oder auf der Zeile darüber.
vor_plz = zeilen[plz_zeile][: plz_treffer.start()].strip(" ,.-")
if _ist_strasse(vor_plz):
strasse = vor_plz
for zeile in reversed(zeilen[:plz_zeile]):
if not zeile.strip():
continue
if strasse is None and _ist_strasse(zeile.strip()):
strasse = zeile.strip()
continue
betreiber = zeile.strip()
break
if betreiber is None:
for zeile in zeilen[:5]:
if zeile.strip():
betreiber = zeile.strip()
break
if station_address and strasse and strasse not in station_address:
station_address = f"{strasse}, {station_address}"
station_name = _tankstelle(_marke(zeilen), strasse, ort, ersatzname=betreiber)
# ---- Gesamtbetrag ------------------------------------------------------
# Zeilenweise statt über den ganzen Text, aus zwei Gründen, die beide an
# einem echten Beleg aufgefallen sind: Bondrucker sperren Überschriften
# gern buchstabenweise ("G E S A M T BETRAG EUR: 92,60"), und ein frei
# laufendes Muster fand vorher die Spaltenüberschrift "SUMME-EUR" und
# las die Artikelnummer der Folgezeile als Betrag. Deshalb: Schlüsselwort
# auf der leerzeichenfreien Zeile prüfen, Betrag nur als echte Geldangabe
# (mit Nachkommastellen) akzeptieren und Steuerzeilen ausschließen.
kandidaten = []
for zeile in zeilen:
kompakt = re.sub(r"\s+", "", zeile).upper()
if not any(wort in kompakt for wort in _GESAMT_WOERTER):
continue
if any(wort in kompakt for wort in _STEUER_WOERTER):
continue
betraege = re.findall(_GELD, zeile)
if betraege:
kandidaten.append(_de_zahl(betraege[-1]))
if not kandidaten:
raise ValueError("Kein Gesamtbetrag gefunden (weder 'Gesamt' noch 'Absolut')")
# Mehrere Treffer (z. B. Summenzeile und Kartenzahlungszeile): der
# Rechnungsbetrag ist der größte - Teilbeträge sind nie größer.
fuel_total_eur = max(kandidaten)
# ---- Menge in Litern ----------------------------------------------------
menge = suchen(r"(?:Menge|Amount)\D{0,15}?(" + _ZAHL + r")\s*(?:l\b|L\b|Liter|Ltr\.?)")
if not menge:
menge = suchen(r"(" + _ZAHL + r")\s*(?:Liter|Ltr\.?|l)\b")
if not menge:
raise ValueError("Keine Literangabe gefunden (weder 'Menge'/'Amount' noch 'X l')")
liters = _de_zahl(menge.group(1))
if not liters:
raise ValueError("Literangabe ist 0 oder unlesbar")
berechneter_preis_l = round(fuel_total_eur / liters, 3)
# ---- Aufgedruckter Preis/Liter - Abweichung verrät einen Rabatt -------
# "Preis/L" (ohne ausgeschriebenes "Liter") kommt auf echten Belegen vor,
# deshalb ist alles nach dem L optional.
aufgedruckt = suchen(
r"(?:Preis\s*/?\s*(?:je\s*)?L(?:iter|tr\.?)?|Price\s*/?\s*L(?:iter)?|€\s*/\s*l|EUR\s*/\s*l)\D{0,10}?(" + _ZAHL + r")"
)
list_price_per_l = _de_zahl(aufgedruckt.group(1)) if aufgedruckt else berechneter_preis_l
discount = None
discount_per_l = None
if aufgedruckt and abs(list_price_per_l - berechneter_preis_l) > 0.005:
minus = suchen(r"-\s*(" + _ZAHL + r")\s*(?:€|EUR)")
if minus:
discount = _de_zahl(minus.group(1))
discount_per_l = round(discount / liters, 3)
else:
discount_per_l = round(list_price_per_l - berechneter_preis_l, 3)
discount = round(discount_per_l * liters, 2)
# ---- Zeitstempel: irgendein Datum+Uhrzeit auf dem Beleg ----------------
zeit = suchen(r"(\d{2})\.(\d{2})\.(\d{2,4})\D{0,10}?(\d{2}):(\d{2})")
ts = _iso(zeit.group(1), zeit.group(2), zeit.group(3), zeit.group(4), zeit.group(5)) if zeit else None
# ---- Belegnummer, sonst deterministischer Ersatzschlüssel --------------
# Wichtig: kein eingebautes hash() (pro Prozess zufällig gesalzen, siehe
# PYTHONHASHSEED) - der Parser läuft für jeden Beleg als eigener Prozess
# (Kopfkommentar oben), ein instabiler Schlüssel würde den §7.7-Regel-3-
# Dedup-Vergleich in belegverarbeitung.py unbrauchbar machen.
beleg_nr = suchen(
r"(?:Beleg-?Nr\.?|Bon-?Nr\.?|Rechnungs-?Nr\.?|Receipt\s*No\.?|Trans(?:aktions)?-?Nr\.?)\D{0,5}?([\dA-Za-z/\-]+)"
)
receipt_no = beleg_nr.group(1) if beleg_nr else None
schluessel_basis = "|".join(
str(teil) for teil in (receipt_no, ts, station_address, fuel_total_eur, liters) if teil
) or text[:80]
receipt_key = "generisch_" + hashlib.sha1(schluessel_basis.encode("utf-8")).hexdigest()[:16]
return {
"receipt_key": receipt_key,
"receipt_no": receipt_no,
"tse_beleg_nr": None,
"ts": ts,
"ts_payment": None,
"ts_tse": None,
"station_id": None,
"station_name": station_name,
"station_address": station_address,
"article_no": None,
"product_name": None,
"fuel_type": None,
"liters": liters,
"list_price_per_l": list_price_per_l,
"discount": discount,
"discount_per_l": discount_per_l,
"fuel_total_eur": fuel_total_eur,
"receipt_total_eur": fuel_total_eur,
"net_eur": None,
"vat_eur": None,
}
def main():
if len(sys.argv) != 2:
print("Aufruf: python3 shell_beleg_parser.py <pfad-zur-pdf>", file=sys.stderr)
sys.exit(1)
pdf_pfad = sys.argv[1]
try:
text = _text_aus_pdf(pdf_pfad)
except Exception as exc:
print(f"PDF konnte nicht gelesen werden ({type(exc).__name__}): {exc}", file=sys.stderr)
sys.exit(1)
try:
ergebnis = _parsen(text)
except Exception:
# Kein Shell-Beleg im bekannten Format - Versuch mit dem
# stationsunabhängigen Fallback (_parsen_generisch() oben), bevor
# endgültig aufgegeben wird.
try:
ergebnis = _parsen_generisch(text)
except Exception as exc:
print(f"Beleg konnte nicht geparst werden ({type(exc).__name__}): {exc}", file=sys.stderr)
sys.exit(1)
print(json.dumps(ergebnis, ensure_ascii=False))
if __name__ == "__main__":
main()
@@ -1,138 +0,0 @@
#!/usr/bin/env python3
"""Regressionstest für shell_beleg_parser.py gegen zehn echte Shell-eReceipts.
Die PDFs in belege/ sind reale, vom Nutzer bereitgestellte Tankbelege aus
vier verschiedenen Stationen (Ingolstadt/Zrenner, Rain am Lech/Bauch,
Ansbach/Sengül, Königsbronn/Mogler) und decken damit die tatsächlich
beobachtete Formatvielfalt ab, nicht nur eine angenommene Struktur:
- Markenzeile: "SHELL STATION" / "Shell Station" / "Shell-Station"
- Rabattbezeichnung: "V-Power Smart Deal" / "ClubSmartRabatt"
- POS-Zeile: mit und ohne "68002-"-Präfix vor der Stations-ID
- unterschiedliche Fußzeilen-Länge/-Inhalte
Aufruf: python3 data/tests/test_shell_beleg_parser.py
(braucht pypdf im selben Python wie shell_beleg_parser.py selbst)
"""
import os
import sys
import unittest
_HIER = os.path.dirname(os.path.abspath(__file__))
_BELEGE = os.path.join(_HIER, "belege")
sys.path.insert(0, os.path.dirname(_HIER)) # .. /data, wo shell_beleg_parser.py liegt
import shell_beleg_parser as p # noqa: E402
def _parsen_datei(dateiname):
pfad = os.path.join(_BELEGE, dateiname)
return p._parsen(p._text_aus_pdf(pfad))
class AlleBelegeParsenOhneFehler(unittest.TestCase):
"""Jeder der zehn echten Belege muss ohne Exception durchlaufen und
strukturell plausible Werte liefern - das ist der eigentliche Zweck
dieser Suite: künftige Regex-Änderungen dürfen keinen der bekannten
realen Belegtypen mehr brechen."""
@classmethod
def setUpClass(cls):
cls.dateien = sorted(f for f in os.listdir(_BELEGE) if f.endswith(".pdf"))
cls.ergebnisse = {f: _parsen_datei(f) for f in cls.dateien}
def test_zehn_belege_gefunden(self):
self.assertEqual(len(self.dateien), 10, self.dateien)
def test_pflichtfelder_gesetzt(self):
pflichtfelder = [
"receipt_key", "receipt_no", "ts", "station_id", "station_name",
"station_address", "article_no", "product_name", "fuel_type",
"liters", "list_price_per_l", "fuel_total_eur",
"receipt_total_eur", "net_eur", "vat_eur",
]
for datei, d in self.ergebnisse.items():
for feld in pflichtfelder:
with self.subTest(datei=datei, feld=feld):
self.assertIsNotNone(d[feld], f"{feld} fehlt in {datei}")
def test_betraege_plausibel(self):
for datei, d in self.ergebnisse.items():
with self.subTest(datei=datei):
self.assertGreater(d["liters"], 0)
self.assertGreater(d["list_price_per_l"], 0)
self.assertGreater(d["fuel_total_eur"], 0)
# Netto + MwSt muss dem Bruttobetrag entsprechen (Rundung: 1 Cent)
self.assertAlmostEqual(d["net_eur"] + d["vat_eur"], d["receipt_total_eur"], delta=0.01)
# Gesamtbetrag der Position == Bruttobetrag der Steuerzeile
self.assertAlmostEqual(d["fuel_total_eur"], d["receipt_total_eur"], delta=0.01)
if d["discount"] is not None:
self.assertGreater(d["discount"], 0)
# Rabatt darf den Bruttobetrag nicht übersteigen
self.assertLess(d["discount"], d["fuel_total_eur"])
def test_receipt_key_eindeutig(self):
schluessel = [d["receipt_key"] for d in self.ergebnisse.values()]
self.assertEqual(len(schluessel), len(set(schluessel)), schluessel)
def test_bekannte_stationen(self):
# Vier verschiedene Standorte in den zehn Belegen - stellt sicher, dass
# die Stationserkennung nicht zufällig nur für einen Beleg passt.
# Anzeigeformat seit 2026-08-16: "Marke, Straße, Ort" statt des reinen
# Betreibernamens ("A. Zrenner GmbH" o. ä.) - der Betreiber sagt beim
# Durchblättern der Tankvorgänge weniger als Marke und Ort.
namen = {d["station_name"] for d in self.ergebnisse.values()}
erwartet = {
"Shell, Pascalstr. 8, Ingolstadt",
"Shell, Münchener Str.15a, Ingolstadt",
"Shell, Nürnberger Str.74, Ansbach",
"Shell, Heidenheimerstr.6, Königsbronn",
}
self.assertEqual(namen, erwartet)
def test_rabatt_label_unabhaengig(self):
# e-receipt7.pdf nutzt "ClubSmartRabatt" statt "V-Power Smart Deal" -
# die Rabatterkennung darf nicht am Label hängen, nur am Betragsmuster.
d = self.ergebnisse["e-receipt7.pdf"]
self.assertAlmostEqual(d["discount"], 6.95, delta=0.001)
self.assertAlmostEqual(d["discount_per_l"], 0.18, delta=0.001)
def test_stichproben_werte(self):
# Punktuelle Kontrolle einzelner, von Hand aus dem Beleg abgelesener
# Werte - fängt stille Verschiebungen (z.B. falsche Regex-Gruppe) ab,
# die die reinen Plausibilitätschecks oben nicht erkennen würden.
stichproben = {
"e-receipt.pdf": dict(liters=42.15, fuel_total_eur=78.78, discount=11.80, station_id="0000007770"),
"e-receipt3.pdf": dict(liters=21.73, fuel_total_eur=44.53, discount=6.08, station_id="0000000456"),
"e-receipt5.pdf": dict(liters=47.76, fuel_total_eur=79.23, discount=12.90, station_id="0000002227"),
"e-receipt9.pdf": dict(liters=51.59, fuel_total_eur=85.59, discount=13.41, station_id="0000002103"),
}
for datei, erwartet in stichproben.items():
d = self.ergebnisse[datei]
with self.subTest(datei=datei):
self.assertAlmostEqual(d["liters"], erwartet["liters"], delta=0.001)
self.assertAlmostEqual(d["fuel_total_eur"], erwartet["fuel_total_eur"], delta=0.001)
self.assertAlmostEqual(d["discount"], erwartet["discount"], delta=0.001)
self.assertEqual(d["station_id"], erwartet["station_id"])
class BelegOhneRabatt(unittest.TestCase):
"""SmartDeal/ClubSmart ist optional - nicht jeder Tankvorgang hat einen
Rabatt. Simuliert durch Entfernen der Rabattzeilen aus einem echten
Beleg, da keiner der zehn realen Belege rabattfrei war."""
def test_ohne_rabatt_bleibt_alles_andere_korrekt(self):
text = p._text_aus_pdf(os.path.join(_BELEGE, "e-receipt.pdf"))
ohne_rabatt = "\n".join(
z for z in text.split("\n")
if "Smart Deal" not in z and "EUR/Liter" not in z
)
d = p._parsen(ohne_rabatt)
self.assertIsNone(d["discount"])
self.assertIsNone(d["discount_per_l"])
self.assertAlmostEqual(d["fuel_total_eur"], 78.78, delta=0.001)
self.assertAlmostEqual(d["liters"], 42.15, delta=0.001)
if __name__ == "__main__":
unittest.main(verbosity=2)
-318
View File
@@ -1,318 +0,0 @@
#!/usr/bin/env bash
#
# DataMetric360 — Installation in Home Assistant
#
# Richtet das Panel samt Backend auf einer Home-Assistant-Instanz ein und
# verdrahtet die Selbstaktualisierung, damit danach alles Weitere über die
# Weboberfläche läuft: Sensoren zuordnen, nach Updates suchen, einspielen.
#
# Wo ausführen:
# Am einfachsten direkt auf der HA-Instanz, im Add-on "Terminal & SSH":
# bash <(curl -fsSL <ROH-URL dieser Datei>)
# Alternativ von einem Rechner aus gegen ein eingebundenes config-Verzeichnis:
# ./install.sh --ziel /Volumes/config
#
# Mehrfach ausführbar: bereits vorhandene Fahrzeugdaten werden nie
# überschrieben, und der Abschnitt in der configuration.yaml wird beim
# erneuten Lauf ersetzt statt ein zweites Mal angehängt.
#
set -euo pipefail
# ------------------------------------------------------------ Voreinstellungen
REPO_STANDARD="https://gitea.nothaft.cloud/paul/audi-app.git"
BRANCH="main"
ZIEL="/config"
QUELLE=""
TOKEN=""
REPO="$REPO_STANDARD"
UPDATE_REPO="" # leer = wie --repo
PYSCRIPT_INSTALLIEREN="ja"
INTERAKTIV="ja"
MARKE_START="# >>> DataMetric360 (von install.sh verwaltet) >>>"
MARKE_ENDE="# <<< DataMetric360 <<<"
# ------------------------------------------------------------------- Ausgabe
rot() { printf '\033[31m%s\033[0m\n' "$*"; }
gruen(){ printf '\033[32m%s\033[0m\n' "$*"; }
grau() { printf '\033[90m%s\033[0m\n' "$*"; }
schritt(){ printf '\n\033[1m==> %s\033[0m\n' "$*"; }
fehler(){ rot "FEHLER: $*"; exit 1; }
hilfe() {
cat <<'ENDE'
DataMetric360 — Installation in Home Assistant
--ziel <pfad> config-Verzeichnis von Home Assistant (Standard: /config)
--von <pfad> lokale Arbeitskopie benutzen statt zu klonen
--repo <url> Repository zum Klonen (Standard: das Projekt-Repository)
--update-repo <url|aus>
Was als Updatequelle hinterlegt wird. Standard: wie --repo.
"aus" hinterlegt nichts - dann gibt es keine
Selbstaktualisierung, aber auch keine Zugangsdaten auf der
Platte. Siehe ha_install.md, Abschnitt 1b.
--branch <name> Branch (Standard: main)
--token <wert> langlebiges Zugriffstoken für die Kilometerstand-Historie
--ohne-pyscript pyscript nicht mitinstallieren (wenn schon vorhanden)
--still keine Rückfragen stellen (für unbeaufsichtigte Läufe)
--hilfe diese Übersicht
Beispiele
bash install.sh # auf der HA-Instanz, klont selbst
bash install.sh --von ~/audi-app # aus einer lokalen Arbeitskopie
bash install.sh --ziel /Volumes/config # gegen ein eingebundenes Laufwerk
bash install.sh --repo https://nutzer:token@gitea.example/paul/audi-app.git
bash install.sh --von . --update-repo aus # lokal einspielen, ohne Zugangsdaten abzulegen
ENDE
}
while [ $# -gt 0 ]; do
case "$1" in
--ziel) ZIEL="${2:-}"; shift 2 ;;
--von) QUELLE="${2:-}"; shift 2 ;;
--repo) REPO="${2:-}"; shift 2 ;;
--update-repo) UPDATE_REPO="${2:-}"; shift 2 ;;
--branch) BRANCH="${2:-}"; shift 2 ;;
--token) TOKEN="${2:-}"; shift 2 ;;
--ohne-pyscript) PYSCRIPT_INSTALLIEREN="nein"; shift ;;
--still) INTERAKTIV="nein"; shift ;;
--hilfe|-h) hilfe; exit 0 ;;
*) fehler "Unbekannte Option: $1 (--hilfe zeigt alle)" ;;
esac
done
# --------------------------------------------------------------- Vorprüfungen
schritt "Umgebung prüfen"
for werkzeug in python3 curl unzip; do
command -v "$werkzeug" >/dev/null 2>&1 || fehler "$werkzeug wird gebraucht, ist aber nicht vorhanden."
done
[ -d "$ZIEL" ] || fehler "Das Zielverzeichnis $ZIEL gibt es nicht. Mit --ziel den richtigen Pfad angeben."
[ -w "$ZIEL" ] || fehler "Keine Schreibrechte auf $ZIEL."
if [ ! -f "$ZIEL/configuration.yaml" ]; then
rot "In $ZIEL liegt keine configuration.yaml."
fehler "Das sieht nicht nach einem Home-Assistant-Verzeichnis aus. Pfad mit --ziel prüfen."
fi
grau " Ziel: $ZIEL"
# ------------------------------------------------------------ Quelle besorgen
AUFRAEUMEN=""
saeubern() { [ -n "$AUFRAEUMEN" ] && rm -rf "$AUFRAEUMEN" || true; }
trap saeubern EXIT
if [ -n "$QUELLE" ]; then
schritt "Lokale Arbeitskopie benutzen"
[ -d "$QUELLE/homeassistant" ] || fehler "In $QUELLE gibt es keinen Ordner homeassistant/."
HA_QUELLE="$QUELLE/homeassistant"
grau " $HA_QUELLE"
else
schritt "Projekt herunterladen"
command -v git >/dev/null 2>&1 || fehler "git wird zum Klonen gebraucht (oder --von <pfad> benutzen)."
AUFRAEUMEN="$(mktemp -d)"
if ! git clone --depth 1 --branch "$BRANCH" "$REPO" "$AUFRAEUMEN/quelle" >/dev/null 2>&1; then
rot "Klonen von $REPO fehlgeschlagen."
grau " Bei einem privaten Repository ein Zugangstoken in die URL aufnehmen, etwa:"
grau " --repo https://<nutzer>:<token>@gitea.example/paul/audi-app.git"
exit 1
fi
HA_QUELLE="$AUFRAEUMEN/quelle/homeassistant"
grau " $REPO ($BRANCH)"
fi
[ -d "$HA_QUELLE/pyscript" ] || fehler "Im Projekt fehlt homeassistant/pyscript/."
# ------------------------------------------------------------------- pyscript
if [ "$PYSCRIPT_INSTALLIEREN" = "ja" ]; then
if [ -d "$ZIEL/custom_components/pyscript" ]; then
schritt "pyscript ist bereits vorhanden"
grau " $ZIEL/custom_components/pyscript"
else
schritt "pyscript installieren"
VERSION="$(curl -fsSL https://api.github.com/repos/custom-components/pyscript/releases/latest \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])' 2>/dev/null || true)"
[ -n "$VERSION" ] || fehler "Konnte die neueste pyscript-Version nicht ermitteln (kein Internet?)."
TMPPY="$(mktemp -d)"
curl -fsSL -o "$TMPPY/pyscript.zip" \
"https://github.com/custom-components/pyscript/archive/refs/tags/${VERSION}.zip"
unzip -oq "$TMPPY/pyscript.zip" -d "$TMPPY"
mkdir -p "$ZIEL/custom_components"
cp -r "$TMPPY/pyscript-${VERSION}/custom_components/pyscript" "$ZIEL/custom_components/"
rm -rf "$TMPPY"
gruen " pyscript $VERSION installiert"
fi
fi
# --------------------------------------------------------- Dateien einspielen
schritt "Backend und Oberfläche einspielen"
mkdir -p "$ZIEL/pyscript" "$ZIEL/www" "$ZIEL/audi_dashboard"
# pyscript und www sind Code - vollständig ersetzen.
cp -r "$HA_QUELLE/pyscript/." "$ZIEL/pyscript/"
cp -r "$HA_QUELLE/www/." "$ZIEL/www/"
grau " pyscript/ und www/ aktualisiert"
# Der Belegleser liegt in data/, ist aber Code (belegverarbeitung.py ruft ihn
# als Unterprozess auf) - deshalb hier mit, anders als die Datenbestände.
cp "$HA_QUELLE/data/shell_beleg_parser.py" "$ZIEL/audi_dashboard/shell_beleg_parser.py"
# Datenbestände: nur anlegen, was fehlt. Vorhandenes nie überschreiben.
if [ ! -f "$ZIEL/audi_dashboard/fahrzeugprofil.json" ]; then
cp "$HA_QUELLE/data/fahrzeugprofil.example.json" "$ZIEL/audi_dashboard/fahrzeugprofil.json"
gruen " Fahrzeugprofil aus der Vorlage angelegt"
else
grau " Fahrzeugprofil vorhanden - unangetastet"
fi
for datei in fahrten.jsonl tankvorgaenge.jsonl; do
[ -f "$ZIEL/audi_dashboard/$datei" ] || : > "$ZIEL/audi_dashboard/$datei"
done
mkdir -p "$ZIEL/audi_dashboard/belege"
# --------------------------------------------------- Selbstaktualisierung
schritt "Selbstaktualisierung verdrahten"
# Was hinterlegt wird, ist bewusst von der Installationsquelle getrennt: die
# Selbstaktualisierung laeuft spaeter im Home-Assistant-Prozess und klont
# allein anhand dieser URL - Zugangsdaten muessen also darin stehen und landen
# damit im Klartext in einstellungen.py. Wer das nicht will, gibt
# "--update-repo aus" an und aktualisiert weiterhin ueber dieses Skript.
QUELLE_FUER_UPDATE="${UPDATE_REPO:-$REPO}"
if [ "$QUELLE_FUER_UPDATE" = "aus" ]; then
QUELLE_FUER_UPDATE=""
fi
python3 - "$ZIEL/pyscript/modules/einstellungen.py" "$QUELLE_FUER_UPDATE" "$BRANCH" <<'PYENDE'
import re, sys
pfad, repo, branch = sys.argv[1], sys.argv[2], sys.argv[3]
with open(pfad, encoding="utf-8") as f:
inhalt = f.read()
inhalt = re.sub(r'^UPDATE_REPO_URL\s*=.*$', f'UPDATE_REPO_URL = "{repo}"', inhalt, count=1, flags=re.M)
inhalt = re.sub(r'^UPDATE_BRANCH\s*=.*$', f'UPDATE_BRANCH = "{branch}"', inhalt, count=1, flags=re.M)
with open(pfad, "w", encoding="utf-8") as f:
f.write(inhalt)
PYENDE
if [ -n "$QUELLE_FUER_UPDATE" ]; then
grau " Updatequelle: $(printf '%s' "$QUELLE_FUER_UPDATE" | sed -E 's#://[^@/]+@#://***@#') ($BRANCH)"
grau " Danach in der App unter Einstellungen → Version → \"Update suchen\""
case "$QUELLE_FUER_UPDATE" in
*://*:*@*) rot " Hinweis: Die URL enthaelt Zugangsdaten. Sie stehen im Klartext in"
rot " pyscript/modules/einstellungen.py und damit auch in jeder Sicherung." ;;
esac
else
grau " Keine Updatequelle hinterlegt - Aktualisieren laeuft weiter ueber dieses Skript."
fi
# ---------------------------------------------------------- configuration.yaml
schritt "configuration.yaml ergänzen"
KONFIG="$ZIEL/configuration.yaml"
SICHERUNG="$KONFIG.vor-datametric360-$(date +%Y%m%d_%H%M%S)"
cp "$KONFIG" "$SICHERUNG"
grau " Sicherung: $(basename "$SICHERUNG")"
python3 - "$KONFIG" "$MARKE_START" "$MARKE_ENDE" <<'PYENDE'
import re, sys
pfad, start, ende = sys.argv[1], sys.argv[2], sys.argv[3]
with open(pfad, encoding="utf-8") as f:
inhalt = f.read()
block = f"""{start}
# Von install.sh eingefügt. Aenderungen innerhalb dieses Abschnitts werden bei
# der naechsten Installation ersetzt - Eigenes bitte ausserhalb notieren.
pyscript:
allow_all_imports: true
panel_custom:
- name: audi-dashboard-panel
sidebar_title: Mein Audi
sidebar_icon: mdi:car-sports
module_url: /local/audi-dashboard-panel.js?v=1
embed_iframe: false
trust_external_script: false
{ende}"""
muster = re.compile(re.escape(start) + r".*?" + re.escape(ende), re.S)
if muster.search(inhalt):
inhalt = muster.sub(block, inhalt)
print(" Abschnitt ersetzt")
else:
for schluessel in ("pyscript:", "panel_custom:"):
if re.search(rf"^{schluessel}", inhalt, re.M):
print(f" ACHTUNG: '{schluessel}' steht bereits ausserhalb des verwalteten Abschnitts.")
print(" Der Abschnitt wird trotzdem angehaengt - Home Assistant meldet dann")
print(" einen doppelten Schluessel. Bitte den alten Eintrag von Hand entfernen.")
if not inhalt.endswith("\n"):
inhalt += "\n"
inhalt += "\n" + block + "\n"
print(" Abschnitt angehaengt")
with open(pfad, "w", encoding="utf-8") as f:
f.write(inhalt)
PYENDE
# ------------------------------------------------------------------- Token
schritt "Zugriffstoken für die Kilometerstand-Historie"
TOKENDATEI="$ZIEL/audi_dashboard/ha_token.txt"
if [ -n "$TOKEN" ]; then
printf '%s' "$TOKEN" > "$TOKENDATEI"
chmod 600 "$TOKENDATEI" 2>/dev/null || true
gruen " Token gespeichert"
elif [ -s "$TOKENDATEI" ]; then
grau " Vorhandenes Token bleibt unangetastet"
elif [ "$INTERAKTIV" = "ja" ] && [ -t 0 ]; then
echo " Der Fahrtabschluss liest den Kilometerstand aus dem Verlauf und braucht dafür"
echo " ein langlebiges Zugriffstoken (Profil → Sicherheit → Langlebige Zugriffstoken)."
echo " Leer lassen und später nachtragen ist möglich - Fahrten bleiben dann ohne Strecke."
printf " Token (Eingabe bleibt verborgen): "
read -rs EINGABE || EINGABE=""
echo
if [ -n "$EINGABE" ]; then
printf '%s' "$EINGABE" > "$TOKENDATEI"
chmod 600 "$TOKENDATEI" 2>/dev/null || true
gruen " Token gespeichert"
else
rot " Übersprungen - später in $TOKENDATEI ablegen."
fi
else
rot " Kein Token angegeben. Später in $TOKENDATEI ablegen,"
rot " sonst bleiben Fahrten ohne Streckenangabe."
fi
# ------------------------------------------------------------------ Abschluss
schritt "Fertig"
cat <<ENDE
Noch zu tun, alles in der Weboberfläche:
1. Home Assistant neu starten
Einstellungen → System → oben rechts → Neu starten
2. Sensoren zuordnen
Mein Audi → Einstellungen → Fahrzeug einrichten → Einrichten
→ "Setup — Sensoren zuordnen"
Pflicht ist nur die Zündung; alles Weitere ist optional.
3. Fahrzeugdaten eintragen
Im selben Menü unter "Einrichten": Kennzeichen, FIN, Versicherung, …
Ab dann läuft alles über die Oberfläche - auch Updates:
Mein Audi → Einstellungen → Version → "Update suchen"
Sicherung der configuration.yaml:
$SICHERUNG
ENDE
gruen "Installation abgeschlossen."
+119 -157
View File
@@ -1,189 +1,151 @@
# Kurzanleitung — Installation auf einer neuen Home-Assistant-Instanz
# Kurzanleitung — Installation auf einer Home-Assistant-Instanz
Alle Dateien, die dafür nötig sind, liegen schon in diesem Ordner. Ausführliche
Erklärungen, Troubleshooting und Hintergründe stehen in `../INSTALL.md` — hier
nur die Checkliste.
Ausführliche Erklärungen, Umstieg von der alten Fassung und Troubleshooting
stehen in [`../INSTALL.md`](../INSTALL.md) — hier nur die Checkliste.
## Voraussetzungen
- HACS installiert, darüber **pyscript** installiert (HACS → Integrationen →
„pyscript" suchen → Download → HA neu starten)
- Home Assistant 2025.1 oder neuer
- Das **Samba-Share-Add-on** in HA läuft, damit `\\<HA-IP>\config` vom PC aus
erreichbar ist
Das war's. Anders als früher braucht es weder HACS noch pyscript, kein
Zugriffstoken und keinen `pip install` auf der HA-Maschine.
---
## Der schnelle Weg: `Installieren.cmd`
**Doppelklick auf `Installieren.cmd`** — das erledigt die Schritte 13 unten von
selbst: es sucht die HA-Instanz im Netz (`\\homeassistant\config` und die
üblichen Alternativen, sonst fragt es nach dem Pfad), kopiert `pyscript\`,
`data\``audi_dashboard\` und `www\`, trägt die beiden Blöcke in die
`configuration.yaml` ein und listet am Ende auf, was noch von Hand zu tun ist
(Neustart, Token, Sensoren zuordnen).
**Doppelklick auf `Installieren.cmd`.** Das Skript sucht die HA-Instanz im
Netz (`\\homeassistant\config` und die üblichen Alternativen, sonst fragt es
nach dem Pfad) und kopiert einen einzigen Ordner:
Was es dabei **nicht** tut:
```
custom_components\audi_dashboard\ -> <config>\custom_components\
```
- Bestehende Daten überschreiben. `fahrzeugprofil.json`, `fahrten.jsonl`,
`tankvorgaenge.jsonl`, `entitaeten.json` und `ha_token.txt` bleiben
unangetastet — ein zweiter Lauf auf einer laufenden Installation ist deshalb
gefahrlos.
- Die `configuration.yaml` blind verändern. Sie wird vorher zeitgestempelt
gesichert (`configuration.yaml.<Datum-Uhrzeit>.bak`), und stehen dort schon
eigene `pyscript:`- oder `panel_custom:`-Einträge, fasst das Skript sie gar
nicht an und sagt stattdessen, was zu ergänzen ist (zwei gleiche
Top-Level-Schlüssel wären ungültiges YAML — HA würde nicht mehr starten).
Nach dem Schreiben liest es die Datei zurück und prüft sie; weicht auch nur
eine Kleinigkeit ab, rollt es selbsttätig auf die Sicherung zurück.
Am Ende listet es auf, was noch von Hand zu tun ist.
### Ist das für meine bestehende HA-Installation gefährlich?
Nein — und das lässt sich nachprüfen statt glauben:
- Es **löscht nie etwas**. Im ganzen Skript kommt kein `Remove-Item` vor, und
kopiert wird ohne `robocopy /MIR`; fremde Dateien in `pyscript\` und `www\`
bleiben liegen.
- Es fasst **`.storage\` nicht an** — keine Integrationen, Geräte, Entitäten,
Benutzer, Automatisierungen. `automations.yaml`, `scripts.yaml`, `scenes.yaml`
und `secrets.yaml` werden weder gelesen noch geschrieben.
- Es **startet HA nicht neu**. Bis zum manuellen Neustart ändert sich am
laufenden Betrieb nichts.
- Die einzige Datei außerhalb der eigenen Ordner, die es überhaupt anfasst, ist
`configuration.yaml` — mit den drei Netzen oben.
Der einzige Punkt, der einer sonst gesunden Installation gefährlich werden
kann, ist **nicht** der Installer, sondern die Datenaufbewahrung aus Schritt 3:
ein Jahr Fahrzeugverlauf braucht grob 11,5 GB. Auf HA OS mit SD-Karte oder
kleiner eMMC vorher unter **Einstellungen → System → Speicher** nachsehen; ist
der Datenträger voll, startet HA nicht mehr — unabhängig von dieser App.
Erst schauen, was passieren würde, ohne etwas zu schreiben:
Erst schauen, was passieren würde, ohne dass etwas geschrieben wird:
```powershell
.\install.ps1 -Pruefen
```
Mit festem Ziel und Token in einem Rutsch:
Mit festem Ziel:
```powershell
.\install.ps1 -Ziel "\\192.168.1.50\config" -Token "eyJhb..."
.\install.ps1 -Ziel "\\192.168.1.50\config"
```
Danach weiter bei **Schritt 3** (Datenaufbewahrung — macht der Installer
bewusst nicht selbst, siehe dort), **4** (Token, falls nicht übergeben),
**5** und **6**.
### Ist das für meine bestehende HA-Installation gefährlich?
Nein — und das lässt sich nachprüfen statt glauben.
Die riskanteste Stelle der früheren Fassung ist weg: sie musste Blöcke in die
`configuration.yaml` eintragen, und eine kaputte `configuration.yaml` ist der
eine Weg, auf dem ein Installer Home Assistant am Starten hindern kann.
**Diese Fassung fasst die Datei überhaupt nicht mehr an** — die Integration
meldet ihr Panel selbst an, dafür braucht es kein YAML.
Was bleibt:
- Geschrieben wird **ausschließlich** in
`<config>\custom_components\audi_dashboard`. Kein anderer Pfad — nicht
`.storage\`, nicht `configuration.yaml`, nicht `automations.yaml`, nicht
`www\`, nicht `audi_dashboard\` (die Fahrzeugdaten).
- Gelöscht wird nur der eigene Ordner, und auch der nur, wenn darin eine
`manifest.json` mit `"domain": "audi_dashboard"` liegt. Ein fremder Ordner
unter demselben Namen führt zum Abbruch, nicht zum Löschen.
- Nach dem Kopieren liest das Skript die `manifest.json` im Ziel zurück und
prüft die Version. Stimmt sie nicht, bricht es mit einer Meldung ab, statt
eine halbe Installation stehen zu lassen.
- Es **startet HA nicht neu**. Bis zum manuellen Neustart ändert sich am
laufenden Betrieb nichts.
Ein zweiter Lauf auf einer laufenden Installation ist damit gefahrlos — er ist
sogar der vorgesehene Weg für Updates.
Der einzige Punkt, der einer sonst gesunden Installation gefährlich werden
kann, ist **nicht** der Installer, sondern die Datenaufbewahrung aus Schritt 3
unten: ein Jahr Fahrzeugverlauf braucht grob 11,5 GB. Auf HA OS mit SD-Karte
oder kleiner eMMC vorher unter **Einstellungen → System → Speicher**
nachsehen; ist der Datenträger voll, startet HA nicht mehr — unabhängig von
dieser App.
---
## Der Weg von Hand
Falls `Installieren.cmd` nicht durchläuft oder lieber nachvollziehbar
Schritt für Schritt gearbeitet werden soll:
Falls `Installieren.cmd` nicht durchläuft oder lieber nachvollziehbar:
## 1. Dateien kopieren
Ziel ist das `config`-Verzeichnis der neuen HA-Instanz, z. B. über das
Samba-Share-Add-on (`\\<HA-IP>\config`):
| Aus diesem Ordner | Nach |
|---|---|
| `pyscript\` | `\\<HA-IP>\config\pyscript\` |
| `data\` | `\\<HA-IP>\config\audi_dashboard\` (**umbenennen** von `data` zu `audi_dashboard`) |
| `www\` | `\\<HA-IP>\config\www\` |
In `audi_dashboard\` danach `fahrzeugprofil.example.json` zu
`fahrzeugprofil.json` umbenennen — die Platzhalter darin lassen sich nach dem
ersten Start direkt in der App unter **Einstellungen → Fahrzeug einrichten**
eintragen.
## 2. configuration.yaml ergänzen
Die beiden Blöcke aus `configuration_snippet.yaml` (`pyscript:` und
`panel_custom:`) in die bestehende `configuration.yaml` übernehmen — nicht
ersetzen. Falls dort schon `pyscript:` existiert, nur die beiden Zeilen
`allow_all_imports: true` und `hass_is_global: true` ergänzen.
## 3. Datenaufbewahrung — je früher, desto besser
Auch `recorder_snippet.yaml` übernehmen. Home Assistant löscht Sensor-Verläufe
**standardmäßig nach 10 Tagen**; der Block hebt das auf ein Jahr an.
Das ist der einzige zeitkritische Schritt der ganzen Anleitung: was der
recorder einmal gelöscht hat, ist endgültig weg und lässt sich auch mit „Daten
importieren aus Home Assistant" (Schritt 7) nicht mehr nachtragen. Jeder Tag
ohne diesen Block ist ein Tag Vergangenheit, den es später nicht mehr gibt.
Vorher **Einstellungen → System → Speicher** ansehen: ein Jahr braucht grob
11,5 GB. Bei wenig Platz mit `purge_keep_days: 90` anfangen — Begründung,
gemessene Zahlen und eine `exclude:`-Liste zum Kürzen stehen im Kopf der Datei.
Nicht betroffen sind die Bestände der App selbst (`fahrten.jsonl`,
`tankvorgaenge.jsonl`, `batteriespannung.jsonl`, `fahrzeugprofil.json`) — die
werden nirgends automatisch gekürzt.
## 4. Long-Lived Access Token
Profil-Avatar (unten links) → „Long-lived access tokens" → Token erstellen →
Wert in eine neue Datei `audi_dashboard\ha_token.txt` einfügen (nur der Token,
keine Anführungszeichen). Wird fürs Kilometerstand-Screening gebraucht.
## 5. Neu starten
**Einstellungen → System → Neu starten.**
## 6. Sensoren zuordnen
Nach dem Neustart sollte „Mein Audi" in der Sidebar erscheinen. Dort:
**Einstellungen → Fahrzeug einrichten → Einrichten → „Setup — Sensoren
zuordnen"**. Im Auslieferstand ist **kein** Sensor vorbelegt.
Zwingend für die Fahrterkennung: der Zündungs-/ACC-Sensor (`binary_sensor`,
meist vom Teltonika FMM003). Alles Weitere ist optional — ohne zugeordneten
Sensor zeigt die App „unbekannt" statt eines Werts.
Danach **noch einmal neu starten**: die drei trigger-gebundenen Felder
(Zündung, Kilometerstand, Tankfüllstand) werden erst mit einem Neustart
wirksam. Das Setup-Menü weist bei diesen Feldern selbst darauf hin.
Beim Kilometerstand nicht den vom FMM003 selbst berechneten Wert
(`*_total_calculated_mileage`) nehmen — der beruht auf GPS-Streckenrechnung
statt auf dem Tacho und verfälscht Reifenzähler, Ölwechsel-Prognose und
Fahrtabschluss. Richtig ist der CAN-Wert
(`*_total_vehicle_mileage_read_from_can`) oder der Kilometerstand der
EU-Data-Act-Integration.
## 7. Optional: Vergangenheit nachtragen
Sobald die Sensoren zugeordnet sind, holt **Einstellungen → Einrichten →
„Daten importieren aus Home Assistant"** nach, was Home Assistant schon vor
der Installation aufgezeichnet hat: Zeitraum wählen, „Importieren", fertig.
Es entstehen dieselben Fahrten, Tankvorgänge und Spannungswerte, die die
Live-Erkennung erzeugt hätte.
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 Schritt 3.
## 8. Optional: Beleg-Parser
Nur nötig, wenn Shell-Tankbelege hochgeladen werden sollen: im Terminal &
SSH-Add-on (oder `docker exec`) `pip install pypdf` ausführen.
## Künftige Updates
Nicht diesen Ordner erneut kopieren — stattdessen `..\update.ps1` aus dem
Hauptprojekt verwenden:
```powershell
..\update.ps1 -Ziel "\\<HA-IP-Adresse>\config"
```
Kopiert `pyscript\` und alle `www\`-Dateien (inkl. `audi-dashboard-ios.css`
und `badges\`, die früher hier im Skript fehlten), lässt `data\`/
`audi_dashboard\` unangetastet. pyscript lädt automatisch neu, fürs Frontend
reicht ein normales Neuladen der Seite (F5).
Den Ordner `custom_components\audi_dashboard` aus dem Projekt nach
`<config>\custom_components\` kopieren. Ist dort schon eine ältere Fassung,
diese vorher komplett löschen — bleibt eine Datei liegen, die es im neuen
Stand nicht mehr gibt, lädt Home Assistant sie trotzdem mit.
---
Details, Screenshots-Ersatz-Erklärungen und die vollständige
Troubleshooting-Tabelle: siehe `../INSTALL.md`.
## Danach
### 1. Home Assistant neu starten
**Einstellungen → System → Neu starten.** Beim ersten Start lädt HA die
Abhängigkeit `pypdf` nach (für die Tankbelege) — das kann eine Minute dauern.
### 2. Integration hinzufügen
**Einstellungen → Geräte & Dienste → Integration hinzufügen → Audi
Dashboard.** Es gibt nichts einzugeben.
Danach steht **Mein Audi** in der Seitenleiste.
### 3. Datenaufbewahrung — zeitkritisch
Den Block aus [`recorder_snippet.yaml`](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 den Speicher prüfen (siehe oben). Bei knappem Platz mit
`purge_keep_days: 90` anfangen — Details im Kopf der Datei.
Bewusst nicht vom Installer erledigt: das ist eine Entscheidung über den
Plattenplatz der Instanz, und viele Instanzen haben schon einen eigenen
`recorder:`-Block, den man zusammenführen muss statt zu überschreiben.
### 4. Sensoren zuordnen
In der App: **Einstellungen → Fahrzeug einrichten → Setup.**
Im Auslieferstand ist **kein** Sensor vorbelegt — das ist Absicht, siehe
`../INSTALL.md`. Zwingend ist allein der Zündungs-/ACC-Sensor
(`binary_sensor`, meist vom Teltonika FMM003): er trägt die Fahrterkennung.
Änderungen wirken sofort, ein Neustart ist dafür nicht mehr nötig.
### 5. Fahrzeugdaten eintragen
**Einstellungen → Fahrzeug einrichten.** Das Profil ist bereits da — die
Integration hat es beim ersten Start aus ihrer Vorlage angelegt. Zu ersetzen
sind die Platzhalter: VIN, Kennzeichen, Erstzulassung, HU-Termin,
Versicherung, Werkstatt, Servicebuch.
### 6. Vergangenes nachholen (optional)
**Einstellungen → Einrichten → Daten importieren aus Home Assistant** holt
Fahrten, Tankvorgänge und Spannungswerte aus dem bereits aufgezeichneten
HA-Verlauf nach.
---
## Updates
Denselben Aufruf noch einmal — `Installieren.cmd` oder `install.ps1`. Der
Ordner wird ersetzt, die Fahrzeugdaten unter `audi_dashboard\` und die eigenen
Fotos unter `www\bilder\` bleiben unangetastet. Danach HA neu starten (oder
kürzer: **Einstellungen → Geräte & Dienste → Audi Dashboard → Neu laden**).
Liegt das Projekt auf GitHub, übernimmt HACS das: es meldet neue Fassungen
von selbst, installiert sie und kann zurückrollen.
Binary file not shown.
@@ -1,52 +0,0 @@
# Ergänzung zur bestehenden configuration.yaml.
# Nicht die ganze Datei ersetzen - diese Zeilen unter den jeweils schon
# 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
# (lokal erreichbar unter /local/audi-dashboard-panel.js). Die Datei selbst
# entsteht erst in Phase 2 (Frontend-Umbau) - bis dahin ist dieser Eintrag
# noch nicht funktionsfähig.
#
# Das ?v=1 am Ende ist absichtlich: audi-dashboard-panel.js selbst wird von
# Home Assistant mit einer sehr langen Standard-Cache-Zeit ausgeliefert
# (31 Tage), weil sie als stabiler Lade-Stub gedacht ist, der praktisch nie
# wieder angefasst wird (siehe deren eigener Kopfkommentar). Ändert sie sich
# doch einmal (ist an der Testinstanz bereits vorgekommen), bleibt jeder
# Browser, der die Datei vorher schon geladen hatte, sonst für bis zu 31
# Tage auf dem alten Stand hängen - unabhängig davon, wie oft die Datei auf
# dem Server ersetzt wird. Die Versionsnummer hier zu erhöhen ändert die
# angefragte URL und erzwingt damit einen echten Neuabruf bei jedem Browser.
# Das erfordert einen HA-Neustart (Änderungen an configuration.yaml), ist
# aber dafür zuverlässig - anders als bei audi-dashboard-app.js/.css, die
# über audi-dashboard-version.json ganz ohne Neustart aktualisiert werden.
panel_custom:
- name: audi-dashboard-panel
sidebar_title: Mein Audi
sidebar_icon: mdi:car-sports
module_url: /local/audi-dashboard-panel.js?v=1
embed_iframe: false
trust_external_script: false
@@ -1,184 +0,0 @@
{
"_hinweis": "Vorlage ohne echte Fahrzeug-/Personendaten. Vor der Installation nach fahrzeugprofil.json kopieren und die Platzhalter unten ersetzen (VIN, Kennzeichen, Erstzulassung, HU-Termin, WLAN-Name, Versicherung, Werkstatt, Servicebuch) - am einfachsten über den Button 'Einrichten' in den Einstellungen der App, sobald sie einmal läuft. Diese Datei selbst bleibt bewusst im Repo (siehe .gitignore), die echte fahrzeugprofil.json nicht.",
"fahrzeug": {
"fin": "",
"kennzeichen": "",
"titel": "RS 4 Avant competition",
"zusatz": "Avant competition",
"untertitel": "quattro · 331 kW",
"details": "2.9 TFSI quattro competition · 331 kW · 450 PS",
"erstzulassung": "",
"hauptuntersuchung_faellig": "",
"tankvolumen_liter": 58,
"ausfuehrung": "competition"
},
"einstellungen": {
"nacht_von": "19:00",
"nacht_bis": "06:00",
"fahrten_pausenzeit_min": 15,
"uebersichtsbild": "Frontansicht",
"kraftstoffanbieter": "Shell",
"smartdeal": { "aktiv": false, "laeuft_ab": "" },
"oelwechsel_intervall": {
"modus": "hersteller",
"km": 15000,
"monate": 12,
"hersteller_km": 30000,
"hersteller_monate": 24
},
"backup_intervall": "aus",
"letztes_backup": null
},
"versicherung": {
"gesellschaft": "",
"vertrag": "",
"nummer": "",
"service": "",
"notfall": "",
"beitrag": 0,
"steueranteil": 0,
"steuersatz": "19 %",
"gueltigAb": "",
"zahlweise": "jährlich",
"teile": [
{ "bez": "Haftpflicht", "betrag": 0, "sf": "", "sfAlt": "", "satz": "", "satzAlt": "" },
{ "bez": "Vollkasko inklusive Teilkasko", "betrag": 0, "sf": "", "sfAlt": "", "satz": "", "satzAlt": "" },
{ "bez": "Schutzbrief Mobilität", "betrag": 0, "sf": null, "sfAlt": null, "satz": null, "satzAlt": null }
],
"selbst": [["Vollkasko", "0 €"], ["Teilkasko", "0 €"]],
"schutzbrief": {
"stand": "",
"gilt": "",
"versichert": "",
"ausloeser": [],
"entweder": [],
"gruppen": [],
"nicht": [],
"pflichten": [],
"rang": ""
},
"leistungen": []
},
"steuer": {
"betrag": 0,
"zeitraum": "jährlich",
"faellig": null,
"grundlage": "Hubraum und CO₂-Ausstoß",
"lastschrift": true,
"finanzamt": ""
},
"reifen": {
"aktiv": "sommer",
"anzugsmoment_nm": 120,
"wechsel": { "datum": "", "vereinbart": false },
"referenz_odo_km": null,
"saetze": {
"sommer": {
"km": 0,
"bild": "/local/bilder/rad-sommer.webp",
"marke": "",
"modell": "",
"dot": "",
"mass": "275/30 R20 97Y XL",
"druck_vorne": "2,5",
"druck_hinten": "2,4",
"kommentar": ""
},
"winter": {
"km": 0,
"bild": "/local/bilder/rad-winter.webp",
"marke": "",
"modell": "",
"dot": "",
"mass": "265/35 R19",
"druck_vorne": "2,6",
"druck_hinten": "2,5",
"kommentar": ""
}
}
},
"service": {
"vereinbart": {},
"autohaus": {
"name": "",
"adresse": "",
"tel": "",
"mail": "",
"pos": null
},
"buch": []
},
"technik": [
{ "gruppe": "Motor", "posten": [
["Bauart", "V6 Ottomotor, Direkteinspritzung, Biturbo"],
["Hubraum", "2 894 cm³"],
["Leistung", "331 kW / 450 PS bei 5 7006 700 min⁻¹"],
["Maximales Drehmoment", "600 Nm bei 2 0005 000 min⁻¹"],
["Abgasreinigung", "Katalysator, Lambdasonde, Ottopartikelfilter"],
["Abgasnorm", "EU6 AP"],
["Motormanagement", "vollelektronisch"]
]},
{ "gruppe": "Kraftübertragung", "posten": [
["Getriebe", "8-Gang tiptronic"],
["Antrieb", "quattro, permanent, selbstsperrendes Mittendifferenzial"],
["Kupplung", "hydraulischer Drehmomentwandler mit Überbrückung"]
]},
{ "gruppe": "Fahrleistungen", "posten": [
["0 auf 100 km/h", "3,9 s"],
["Höchstgeschwindigkeit", "290 km/h"],
["Effizienzklasse", "E"],
["Fahrgeräusch", "70 dB(A)"]
]},
{ "gruppe": "Fahrwerk, Lenkung, Bremsen", "posten": [
["Fahrwerk", "RS-Sportfahrwerk"],
["Vorderachse", "5-Lenker, Rohrstabilisator"],
["Hinterachse", "5-Lenker, Rohrstabilisator"],
["Lenkung", "elektromechanisch, geschwindigkeitsabhängig"],
["Wendekreis", "ca. 11,7 m"],
["Bremsanlage", "RS-Stahlbremse, Sättel rot glänzend"],
["Vorn", "Faustsattel, Scheibe innenbelüftet"],
["Hinten", "Faustsattel mit elektrischer Parkbremse, innenbelüftet"]
]},
{ "gruppe": "Räder und Reifen", "posten": [
["Räder", "Leichtmetall 9J × 20, Audi Sport"],
["Reifen", "275/30 R20 97Y XL"],
["Pannenhilfe", "Reifenreparaturset, kein Ersatzrad"],
["Radschrauben", "Standard"],
["Anzugsmoment", "120 Nm"]
]},
{ "gruppe": "Maße, Gewichte, Volumen", "posten": [
["Leergewicht", "1 820 kg"],
["Zulässiges Gesamtgewicht", "2 350 kg"],
["Dachlast / Stützlast", "90 / 80 kg"],
["Anhängelast gebremst", "2 100 kg bei 8 %, 1 900 kg bei 12 %"],
["Anhängelast ungebremst", "750 kg"],
["Gepäckraum", "495 / 1 495 l"],
["Tankinhalt", "58 l, erhöhtes Füllvolumen für quattro"]
]},
{ "gruppe": "Bordnetz", "posten": [
["Batterie", "420 A, 75 Ah"],
["Generator", "100150 A"],
["Kältemittel", "R1234yf"]
]},
{ "gruppe": "Wartung", "posten": [
["Serviceanzeige", "30 000 km oder 2 Jahre, variabel"],
["Garantie", "Anschlussgarantie 3 Jahre, max. 100 000 km"]
]}
],
"ausstattung": [
{ "gruppe": "RS-Paket", "sonder": ["RS competition", "RS-Designpaket rot", "RS-Komfortpaket", "RS-Sportabgasanlage plus", "RS-Sportfahrwerk", "RS-Stahlbremsanlage, Sättel rot glänzend", "Bremssättel rot lackiert", "Anhebung der Höchstgeschwindigkeit auf 290 km/h", "Sicherheitsgurte schwarz mit rotem Rand", "RS-Einstiegsleisten"] },
{ "gruppe": "Außen", "sonder": ["Optikpaket schwarz plus", "Optikpaket schwarz glänzend", "Panorama-Glasdach", "Dachreling schwarz", "Anhängevorrichtung", "Gepäckraumklappe elektrisch öffnend und schließend", "Matrix-LED-Scheinwerfer mit dynamischer Lichtinszenierung", "LED-Heckleuchten mit dynamischer Lichtinszenierung", "Außenspiegel elektrisch, beheiz- und anklappbar, abblendend, mit Memory", "Außenspiegelgehäuse in Wagenfarbe", "Sonnenschutzverglasung abgedunkelt"] },
{ "gruppe": "Innen", "sonder": ["Mikrofaser Dinamica/Leder mit Wabensteppung und RS-Stickung", "Sportsitze plus vorn", "Vordersitze elektrisch mit Memory für den Fahrersitz", "Sitzheizung vorn und hinten", "Lendenwirbelstütze mit Massagefunktion", "Dekoreinlagen Carbon Köper matt", "Ambiente-Lichtpaket plus", "3-Zonen-Komfortklimaautomatik", "Lederlenkrad 3-Speichen mit Lenkradheizung", "Innenspiegel automatisch abblendend, rahmenlos", "USB-Ladeschnittstellen im Fond", "Sonnenschutzrollo hinten", "Fußmatten RS-Designpaket"] },
{ "gruppe": "Infotainment", "sonder": ["MMI Navigation plus mit MMI touch", "Audi virtual cockpit plus", "Head-up-Display", "Bang & Olufsen Premium Sound System mit 3D-Klang", "Audi phone box", "LTE-Unterstützung für Audi phone box", "Audi connect Navigation & Infotainment"] },
{ "gruppe": "Assistenz", "sonder": ["Assistenzpaket Tour", "Assistenzpaket Stadt mit Parken", "Audi adaptive cruise control", "Audi active lane assist", "Halteassistent", "Parkassistent mit Einparkhilfe plus", "Umgebungskameras", "Kamerabasierte Verkehrszeichenerkennung", "Audi pre sense rear und basic", "Garagentoröffner", "Komfortschlüssel mit sensorgesteuerter Heckklappe"] },
{ "gruppe": "Serie, ausgewählt", "sonder": ["Audi drive select", "Audi pre sense city", "Spurwechselwarnung", "Reifendruck-Kontrollanzeige", "Start-Stop-System", "Audi connect Notruf & Service mit Remote & Control", "Smartphone-Interface", "Kraftstoffbehälter mit erhöhtem Füllvolumen", "Pedalerie und Fußstütze aus Edelstahl"] }
]
}
@@ -1,452 +0,0 @@
#!/usr/bin/env python3
"""Parser für Shell-Tankbelege (PDF), aufgerufen von belegverarbeitung.py.
Diese Datei fehlte beim Bau im Projektordner (siehe Kopfkommentar in
pyscript/belegverarbeitung.py und README.md, Abschnitt "Bekannte Lücken").
Sie ist hier gegen echte Shell-eReceipts neu geschrieben und gegen deren
tatsächlich von pypdf extrahierten Text geprüft - nicht gegen eine
angenommene Struktur. Regressionstest mit zehn realen Belegen aus vier
Stationen liegt unter tests/test_shell_beleg_parser.py (Aufruf: python3
tests/test_shell_beleg_parser.py aus diesem Ordner). Die Belege
unterscheiden sich u. a. in der Markenzeile ("SHELL STATION" vs.
"Shell Station" vs. "Shell-Station") und der Rabattbezeichnung
("V-Power Smart Deal" vs. "ClubSmartRabatt") - deshalb ist die
Stations- und Rabatterkennung unten bewusst nicht an deren genauen
Wortlaut gebunden, sondern an feste Beleg-Struktur-Marker (Obj.-Nr.,
Betragsmuster vor "EUR #A").
Abhängigkeit: pypdf (reine Textextraktion, der Beleg ist ein normales
Text-PDF, kein Scan/Bild - kein OCR nötig). Läuft als eigener Prozess über
`python3 shell_beleg_parser.py <pfad>` (system-python3 im Container, nicht
die pyscript-Sandbox - siehe _parser_aufrufen() in belegverarbeitung.py),
deshalb reicht `pip install pypdf` einmalig im selben Python, das
`python3` in configuration.yaml/Docker auch sonst benutzt.
Kommandozeilen-Schnittstelle wie von belegverarbeitung.py angenommen:
Ein Pfad zu einer PDF-Datei als einziges Argument, JSON-Objekt auf stdout
bei Erfolg, Exit-Code ungleich 0 und Fehlertext auf stderr bei Misserfolg
(z. B. wenn der Beleg keiner der erkannten Vorlagen entspricht).
Aufbau eines Shell-eReceipts (aus dem echten Beispiel, Reihenfolge fix):
SHELL STATION
<Betreibername, z.B. "A. Zrenner GmbH">
<Straße>
<PLZ Ort>
Obj.-Nr.: <Stations-ID> Tel. ...
Beleg-Nr. <receipt_no> <TT.MM.JJ> <HH:MM>
*<article_no> <Produktname> <Listenbetrag> EUR #A*
*Zp <Zapfsäule> <Liter> l <Listenpreis/l> EUR/l # *
[<Rabattname> -<Rabattbetrag> EUR #A -- optional, nur mit SmartDeal
<Rabatt/l> EUR/Liter -- optional]
Gesamtbetrag <Endbetrag> EUR
Typ Netto Mwst Brutto
A:<Mwst-Satz>% <Netto> <Mwst> <Brutto>
...
Mobile Payment
<TT.MM.JJJJ> <HH:MM:SS> -- Zahlungszeitstempel
...
(Seite 2) POS:...-...-... TSE-Beleg-Nr: <tse_beleg_nr>
Start/Ende: <TT.MM.JJJJ HH:MM:SS> / ...
"""
import datetime
import hashlib
import json
import re
import sys
try:
import pypdf
except ImportError:
print(
"pypdf fehlt. Einmalig installieren mit: pip install pypdf "
"(im selben Python, das diesen Parser ausführt, siehe Kopfkommentar).",
file=sys.stderr,
)
sys.exit(1)
def _text_aus_pdf(pdf_pfad):
leser = pypdf.PdfReader(pdf_pfad)
return "\n".join(seite.extract_text() or "" for seite in leser.pages)
def _de_zahl(text):
""""103,06" -> 103.06 / "2,399" -> 2.399. Kein Tausenderpunkt bei
Tankbeträgen dieser Größenordnung zu erwarten, trotzdem robust: Punkte
vor dem Umwandeln entfernen, nicht nur das Komma ersetzen."""
if text is None:
return None
return float(text.strip().replace(".", "").replace(",", "."))
def _iso(tag, monat, jahr, stunde, minute, sekunde="0"):
jahr = int(jahr)
if jahr < 100:
jahr += 2000
return datetime.datetime(
jahr, int(monat), int(tag), int(stunde), int(minute), int(sekunde)
).isoformat()
_ZAHL = r"[\d.,]+"
# Echte Geldangabe: Nachkommastellen sind Pflicht, damit im stationsunabhängigen
# Parser (_parsen_generisch) keine Artikel-/Belegnummer als Betrag durchgeht.
_GELD = r"\d{1,3}(?:[ .]\d{3})*,\d{2}"
_GESAMT_WOERTER = ("GESAMT", "ABSOLUT", "ENDBETRAG", "ZUZAHLEN", "TOTAL", "SUMME")
# Steuer- und Nettozeilen tragen dieselben Schlüsselwörter ("MWST GESAMT"),
# nennen aber nie den bezahlten Rechnungsbetrag.
_STEUER_WOERTER = ("MWST", "UST", "VAT", "STEUER", "NETTO")
# Kraftstoffmarken, wie sie im Belegkopf stehen. Bewusst nur dort gesucht (die
# ersten Zeilen), nicht im ganzen Text: "Total" wäre sonst z. B. auch als
# Summenzeile ein Treffer. Längere Namen zuerst, damit "TotalEnergies" nicht
# als "Total" endet. Die Schreibweise hier ist die, die angezeigt wird - auf
# Bons steht die Marke meist in Großbuchstaben ("SHELL STATION").
_MARKEN = (
"TotalEnergies", "Total", "Aral", "Shell", "Eni", "Agip", "Esso", "OMV",
"Avia", "Turmöl", "Turmoel", "Orlen", "Tamoil", "Westfalen", "Allguth",
"Classic", "Sprint", "Raiffeisen", "BayWa", "Elan", "HEM", "Star", "JET",
"bft", "BP", "Q1",
)
_STRASSE_WOERTER = r"(?:stra(?:ss|ß)e|str\.|gasse|weg|platz|allee|ring|damm|chaussee)"
def _marke(zeilen):
"""Markenname aus dem Belegkopf ("SHELL STATION" -> "Shell"). None, wenn
keine bekannte Marke draufsteht - dann bleibt es beim Betreibernamen."""
kopf = "\n".join(zeilen[:8])
for marke in _MARKEN:
if re.search(r"\b" + re.escape(marke) + r"\b", kopf, re.IGNORECASE):
return marke
return None
def _ist_strasse(zeile):
"""Straßenzeilen tragen eine Hausnummer oder ein Straßenwort - der
Betreibername ("AUTO B. FRISCHMANN GMBH") tut beides nicht."""
if not zeile:
return False
return bool(re.search(r"\d", zeile) or re.search(_STRASSE_WOERTER, zeile, re.IGNORECASE))
def _tankstelle(marke, strasse, ort, ersatzname=None):
"""Anzeigename der Tankstelle im Format "Marke, Straße, Ort" (§ Anzeige im
Frontend, Feld station_name). Fehlende Teile fallen weg statt als leere
Kommastelle stehen zu bleiben - nicht jeder Beleg nennt eine Straße. Ohne
erkennbare Marke tritt der Betreibername an ihre Stelle."""
teile = [t for t in (marke or ersatzname, strasse, ort) if t]
return ", ".join(teile) if teile else None
def _parsen(text):
def suchen(muster, *, pflicht=True, flags=re.MULTILINE):
treffer = re.search(muster, text, flags)
if treffer is None and pflicht:
raise ValueError(f"Muster nicht gefunden: {muster!r}")
return treffer
# ---- Station ---------------------------------------------------------
# Nicht auf den Wortlaut der Markenzeile selbst verankert ("SHELL
# STATION" vs. "Shell-STATION" - beide an echten Belegen gesehen,
# offenbar je nach Pächter/Kassensystem unterschiedlich geschrieben).
# Stattdessen einfach die drei Zeilen direkt vor "Obj.-Nr." nehmen - die
# Reihenfolge Betreiber/Straße/Ort direkt davor ist auf beiden echten
# Belegen gleich.
station = suchen(
r"(?P<name>.+?)\s*\n\s*(?P<strasse>.+?)\s*\n\s*(?P<ort>.+?)\s*\n\s*Obj\.-Nr\."
)
station_id = suchen(r"Obj\.-Nr\.:\s*(\d+)").group(1)
# ---- Beleg-Kopf (Beleg-Nr., Datum/Zeit auf dem Kassenbon) ----------
kopf = suchen(
r"Beleg-Nr\.\s*(?P<receipt_no>[\d/]+)\s+(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{2})\s+(?P<stunde>\d{2}):(?P<minute>\d{2})"
)
receipt_no = kopf.group("receipt_no")
ts = _iso(kopf.group("tag"), kopf.group("monat"), kopf.group("jahr"),
kopf.group("stunde"), kopf.group("minute"))
# ---- Kraftstoff-Position --------------------------------------------
position = suchen(
r"\*(?P<article_no>\d+)\s+(?P<produkt>.+?)\s+" + _ZAHL + r"\s*EUR\s*#A\*"
)
article_no = position.group("article_no")
product_name = position.group("produkt").strip()
zapfsaeule = suchen(
r"\*Zp\s*\d+\s+(?P<liter>" + _ZAHL + r")\s*l\s+(?P<preis>" + _ZAHL + r")\s*EUR/l"
)
liters = _de_zahl(zapfsaeule.group("liter"))
list_price_per_l = _de_zahl(zapfsaeule.group("preis"))
# ---- SmartDeal-Rabatt (optional - nicht jeder Tankvorgang hat einen) --
rabatt_treffer = suchen(r"-(" + _ZAHL + r")\s*EUR\s*#A\s*$", pflicht=False)
discount = _de_zahl(rabatt_treffer.group(1)) if rabatt_treffer else None
rabatt_l_treffer = suchen(r"(" + _ZAHL + r")\s*EUR/Liter", pflicht=False)
discount_per_l = _de_zahl(rabatt_l_treffer.group(1)) if rabatt_l_treffer else None
# ---- Beträge ----------------------------------------------------------
fuel_total_eur = _de_zahl(suchen(r"Gesamtbetrag\s+(" + _ZAHL + r")\s*EUR").group(1))
steuerzeile = suchen(
r"A:" + _ZAHL + r"%\s+(?P<netto>" + _ZAHL + r")\s+(?P<mwst>" + _ZAHL + r")\s+(?P<brutto>" + _ZAHL + r")"
)
net_eur = _de_zahl(steuerzeile.group("netto"))
vat_eur = _de_zahl(steuerzeile.group("mwst"))
receipt_total_eur = _de_zahl(steuerzeile.group("brutto"))
# ---- Zahlungszeitstempel (Mobile Payment) - optional, falls anderes
# Zahlungsverfahren auf dem Beleg steht ---------------------------------
zahlung = suchen(
r"Mobile Payment\s*\n+\s*(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{4})\s+(?P<stunde>\d{2}):(?P<minute>\d{2}):(?P<sekunde>\d{2})",
pflicht=False,
)
ts_payment = (
_iso(zahlung.group("tag"), zahlung.group("monat"), zahlung.group("jahr"),
zahlung.group("stunde"), zahlung.group("minute"), zahlung.group("sekunde"))
if zahlung else None
)
# ---- TSE-Signatur (Seite 2) - optional -------------------------------
tse_nr = suchen(r"TSE-Beleg-Nr:\s*(\d+)", pflicht=False)
tse_beleg_nr = tse_nr.group(1) if tse_nr else None
tse_zeit = suchen(
r"Start/Ende:\s*(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{4})\s+(?P<stunde>\d{2}):(?P<minute>\d{2}):(?P<sekunde>\d{2})",
pflicht=False,
)
ts_tse = (
_iso(tse_zeit.group("tag"), tse_zeit.group("monat"), tse_zeit.group("jahr"),
tse_zeit.group("stunde"), tse_zeit.group("minute"), tse_zeit.group("sekunde"))
if tse_zeit else None
)
# receipt_key: minutengenauer Deduplizierungs-Schlüssel (§6.4/§7.7 Regel 3)
receipt_key = f"{station_id}_{receipt_no.replace('/', '-')}_{ts[:16]}"
return {
"receipt_key": receipt_key,
"receipt_no": receipt_no,
"tse_beleg_nr": tse_beleg_nr,
"ts": ts,
"ts_payment": ts_payment,
"ts_tse": ts_tse,
"station_id": station_id,
# Anzeigename "Marke, Straße, Ort" (siehe _tankstelle). Der Ort steht
# auf dem Bon als "85057 Ingolstadt" - die PLZ gehört nur in die
# ausführliche station_address darunter, nicht in den Anzeigenamen.
"station_name": _tankstelle(
_marke(text.splitlines()),
station.group("strasse").strip(),
re.sub(r"^\d{4,5}\s+", "", station.group("ort").strip()),
ersatzname=station.group("name").strip(),
),
"station_address": f"{station.group('strasse').strip()}, {station.group('ort').strip()}",
"article_no": article_no,
"product_name": product_name,
"fuel_type": product_name,
"liters": liters,
"list_price_per_l": list_price_per_l,
"discount": discount,
"discount_per_l": discount_per_l,
"fuel_total_eur": fuel_total_eur,
"receipt_total_eur": receipt_total_eur,
"net_eur": net_eur,
"vat_eur": vat_eur,
}
def _parsen_generisch(text):
"""Stationsunabhängiger Fallback für Belege, die keinem der oben fest an
Shells Beleg-Struktur verankerten Muster entsprechen (_parsen() wirft in
dem Fall ValueError - siehe main()). Arbeitet nach demselben Verfahren,
das an einem echten Beleg einer bislang unbekannten Tankstelle von Hand
nachvollzogen wurde: Adresse suchen, Gesamtbetrag suchen ("Gesamt.../
Absolut..."), Literangabe suchen ("Menge"/"Amount" oder eine an "l"/
"Liter" hängende Zahl), daraus den tatsächlich bezahlten Preis/Liter
errechnen (Gesamtbetrag / Liter). Steht daneben auch ein aufgedruckter
Preis/Liter auf dem Beleg und weicht er vom errechneten ab, wurde ein
Rabatt gewährt - auf dem Beleg meist als eigener Betrag mit einem Minus
davor erkennbar, danach wird zuerst gesucht, sonst aus der Preisdifferenz
hergeleitet. Ob ein so gefundener Rabatt als "SmartDeal" behandelt wird,
entscheidet einzig station_name (Shell-Logo/-Label im Frontend, siehe
frontend_veroeffentlichung.py) - kein Sonderfall hier nötig, die Frontend-
Anzeige hängt nur an einem gesetzten discount, nicht an einem eigenen
Kennzeichen.
Liefert deutlich weniger Felder als _parsen() (kein Bon-Kleingedrucktes
wie TSE-Nummer/Zahlungszeitstempel) - unproblematisch, denn
belegverarbeitung.py liest nur receipt_key als echtes Pflichtfeld,
alles andere per .get()."""
def suchen(muster, flags=re.IGNORECASE | re.MULTILINE):
return re.search(muster, text, flags)
zeilen = [z.rstrip() for z in text.splitlines()]
# ---- Adresse -----------------------------------------------------------
# Die PLZ-Ort-Angabe steht nicht zuverlässig auf einer eigenen Zeile (ein
# österreichischer Beleg führt sie als "SHELL TANKSTELLE, 6450 SÖLDEN"
# zusammen mit der Filialbezeichnung) und PLZ sind vier- *oder*
# fünfstellig (AT/DE). Deshalb nicht mehr drei feste Zeilen hintereinander
# verlangen, sondern im Belegkopf die erste Zeile mit PLZ+Ort suchen; der
# Name ist die erste Textzeile darüber.
plz_treffer = None
plz_zeile = None
for i, zeile in enumerate(zeilen[:12]):
treffer = re.search(r"\b\d{4,5}\s+([A-Za-zÄÖÜäöüß][^\n]*)$", zeile)
if treffer:
plz_treffer, plz_zeile = treffer, i
break
ort = None
strasse = None
betreiber = None
station_address = None
if plz_treffer is not None:
ort = plz_treffer.group(1).strip(" ,.-")
station_address = zeilen[plz_zeile].strip()
# Die Straße steht entweder vor der PLZ auf derselben Zeile
# ("Musterstr. 5, 80331 München") oder auf der Zeile darüber.
vor_plz = zeilen[plz_zeile][: plz_treffer.start()].strip(" ,.-")
if _ist_strasse(vor_plz):
strasse = vor_plz
for zeile in reversed(zeilen[:plz_zeile]):
if not zeile.strip():
continue
if strasse is None and _ist_strasse(zeile.strip()):
strasse = zeile.strip()
continue
betreiber = zeile.strip()
break
if betreiber is None:
for zeile in zeilen[:5]:
if zeile.strip():
betreiber = zeile.strip()
break
if station_address and strasse and strasse not in station_address:
station_address = f"{strasse}, {station_address}"
station_name = _tankstelle(_marke(zeilen), strasse, ort, ersatzname=betreiber)
# ---- Gesamtbetrag ------------------------------------------------------
# Zeilenweise statt über den ganzen Text, aus zwei Gründen, die beide an
# einem echten Beleg aufgefallen sind: Bondrucker sperren Überschriften
# gern buchstabenweise ("G E S A M T BETRAG EUR: 92,60"), und ein frei
# laufendes Muster fand vorher die Spaltenüberschrift "SUMME-EUR" und
# las die Artikelnummer der Folgezeile als Betrag. Deshalb: Schlüsselwort
# auf der leerzeichenfreien Zeile prüfen, Betrag nur als echte Geldangabe
# (mit Nachkommastellen) akzeptieren und Steuerzeilen ausschließen.
kandidaten = []
for zeile in zeilen:
kompakt = re.sub(r"\s+", "", zeile).upper()
if not any(wort in kompakt for wort in _GESAMT_WOERTER):
continue
if any(wort in kompakt for wort in _STEUER_WOERTER):
continue
betraege = re.findall(_GELD, zeile)
if betraege:
kandidaten.append(_de_zahl(betraege[-1]))
if not kandidaten:
raise ValueError("Kein Gesamtbetrag gefunden (weder 'Gesamt' noch 'Absolut')")
# Mehrere Treffer (z. B. Summenzeile und Kartenzahlungszeile): der
# Rechnungsbetrag ist der größte - Teilbeträge sind nie größer.
fuel_total_eur = max(kandidaten)
# ---- Menge in Litern ----------------------------------------------------
menge = suchen(r"(?:Menge|Amount)\D{0,15}?(" + _ZAHL + r")\s*(?:l\b|L\b|Liter|Ltr\.?)")
if not menge:
menge = suchen(r"(" + _ZAHL + r")\s*(?:Liter|Ltr\.?|l)\b")
if not menge:
raise ValueError("Keine Literangabe gefunden (weder 'Menge'/'Amount' noch 'X l')")
liters = _de_zahl(menge.group(1))
if not liters:
raise ValueError("Literangabe ist 0 oder unlesbar")
berechneter_preis_l = round(fuel_total_eur / liters, 3)
# ---- Aufgedruckter Preis/Liter - Abweichung verrät einen Rabatt -------
# "Preis/L" (ohne ausgeschriebenes "Liter") kommt auf echten Belegen vor,
# deshalb ist alles nach dem L optional.
aufgedruckt = suchen(
r"(?:Preis\s*/?\s*(?:je\s*)?L(?:iter|tr\.?)?|Price\s*/?\s*L(?:iter)?|€\s*/\s*l|EUR\s*/\s*l)\D{0,10}?(" + _ZAHL + r")"
)
list_price_per_l = _de_zahl(aufgedruckt.group(1)) if aufgedruckt else berechneter_preis_l
discount = None
discount_per_l = None
if aufgedruckt and abs(list_price_per_l - berechneter_preis_l) > 0.005:
minus = suchen(r"-\s*(" + _ZAHL + r")\s*(?:€|EUR)")
if minus:
discount = _de_zahl(minus.group(1))
discount_per_l = round(discount / liters, 3)
else:
discount_per_l = round(list_price_per_l - berechneter_preis_l, 3)
discount = round(discount_per_l * liters, 2)
# ---- Zeitstempel: irgendein Datum+Uhrzeit auf dem Beleg ----------------
zeit = suchen(r"(\d{2})\.(\d{2})\.(\d{2,4})\D{0,10}?(\d{2}):(\d{2})")
ts = _iso(zeit.group(1), zeit.group(2), zeit.group(3), zeit.group(4), zeit.group(5)) if zeit else None
# ---- Belegnummer, sonst deterministischer Ersatzschlüssel --------------
# Wichtig: kein eingebautes hash() (pro Prozess zufällig gesalzen, siehe
# PYTHONHASHSEED) - der Parser läuft für jeden Beleg als eigener Prozess
# (Kopfkommentar oben), ein instabiler Schlüssel würde den §7.7-Regel-3-
# Dedup-Vergleich in belegverarbeitung.py unbrauchbar machen.
beleg_nr = suchen(
r"(?:Beleg-?Nr\.?|Bon-?Nr\.?|Rechnungs-?Nr\.?|Receipt\s*No\.?|Trans(?:aktions)?-?Nr\.?)\D{0,5}?([\dA-Za-z/\-]+)"
)
receipt_no = beleg_nr.group(1) if beleg_nr else None
schluessel_basis = "|".join(
str(teil) for teil in (receipt_no, ts, station_address, fuel_total_eur, liters) if teil
) or text[:80]
receipt_key = "generisch_" + hashlib.sha1(schluessel_basis.encode("utf-8")).hexdigest()[:16]
return {
"receipt_key": receipt_key,
"receipt_no": receipt_no,
"tse_beleg_nr": None,
"ts": ts,
"ts_payment": None,
"ts_tse": None,
"station_id": None,
"station_name": station_name,
"station_address": station_address,
"article_no": None,
"product_name": None,
"fuel_type": None,
"liters": liters,
"list_price_per_l": list_price_per_l,
"discount": discount,
"discount_per_l": discount_per_l,
"fuel_total_eur": fuel_total_eur,
"receipt_total_eur": fuel_total_eur,
"net_eur": None,
"vat_eur": None,
}
def main():
if len(sys.argv) != 2:
print("Aufruf: python3 shell_beleg_parser.py <pfad-zur-pdf>", file=sys.stderr)
sys.exit(1)
pdf_pfad = sys.argv[1]
try:
text = _text_aus_pdf(pdf_pfad)
except Exception as exc:
print(f"PDF konnte nicht gelesen werden ({type(exc).__name__}): {exc}", file=sys.stderr)
sys.exit(1)
try:
ergebnis = _parsen(text)
except Exception:
# Kein Shell-Beleg im bekannten Format - Versuch mit dem
# stationsunabhängigen Fallback (_parsen_generisch() oben), bevor
# endgültig aufgegeben wird.
try:
ergebnis = _parsen_generisch(text)
except Exception as exc:
print(f"Beleg konnte nicht geparst werden ({type(exc).__name__}): {exc}", file=sys.stderr)
sys.exit(1)
print(json.dumps(ergebnis, ensure_ascii=False))
if __name__ == "__main__":
main()
+109 -259
View File
@@ -1,90 +1,72 @@
<#
.SYNOPSIS
Installiert "Mein Audi" (DataMetric360) in einem Rutsch auf einer neuen
Home-Assistant-Instanz - die Schritte 1-3 aus ANLEITUNG.md.
Installiert "Mein Audi" (DataMetric360) als Home-Assistant-Integration -
der Weg ohne HACS.
.BESCHREIBUNG
Nimmt dem Erstaufsetzen die Handarbeit ab:
1. pyscript\ -> <Ziel>\pyscript\
2. data\ -> <Ziel>\audi_dashboard\ (Ordner wird umbenannt)
fahrzeugprofil.example.json -> fahrzeugprofil.json
3. www\ -> <Ziel>\www\
4. configuration_snippet.yaml wird in <Ziel>\configuration.yaml eingefügt
5. ha_token.txt wird geschrieben, wenn -Token übergeben wurde
Kopiert genau einen Ordner:
custom_components\audi_dashboard\ -> <Ziel>\custom_components\
Mehr ist nicht nötig. Alles, was früher an dieser Stelle von Hand gemacht
werden musste, macht die Integration inzwischen selbst: sie meldet ihr Panel
an, liefert ihre Oberflächen-Dateien selbst aus, legt beim ersten Start ein
Fahrzeugprofil aus der Vorlage an und bringt pypdf über ihre manifest.json
mit, das Home Assistant beim ersten Laden nachinstalliert.
WOFÜR ES DIESES SKRIPT ÜBERHAUPT NOCH GIBT
------------------------------------------
Der eigentliche Weg ist HACS. HACS spricht aber ausschließlich mit GitHub
(github.com/api.github.com stecken fest im Code) - solange dieses Projekt
nur auf der eigenen Gitea-Instanz liegt, findet HACS es nicht. Dieses Skript
ist der Weg dorthin, der ohne GitHub auskommt. Es installiert exakt
denselben Ordner, den auch HACS installieren würde; ein späterer Wechsel auf
HACS überschreibt ihn einfach.
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:
Die riskanteste Stelle der früheren Fassung gibt es nicht mehr: sie musste
die configuration.yaml ergänzen (pyscript:, panel_custom:), und eine kaputte
configuration.yaml ist der eine Weg, auf dem ein Installer Home Assistant am
Starten hindern kann. Diese Fassung fasst die Datei überhaupt nicht mehr an.
- 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.
Was bleibt, ist nachprüfbar:
- Geschrieben wird ausschließlich in <Ziel>\custom_components\audi_dashboard.
Kein anderer Pfad wird angefasst - nicht .storage\, nicht
configuration.yaml, nicht automations.yaml, nicht www\, nicht
audi_dashboard\ (die Fahrzeugdaten).
- Entfernt wird nur der eigene Ordner, und nur, wenn darin eine
manifest.json mit "domain": "audi_dashboard" liegt. Ein fremder Ordner
unter diesem Namen führt zum Abbruch, nicht zum Löschen.
- 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.
Betrieb ein.
- -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.
Warum der Zielordner vor dem Kopieren geleert wird (und nicht nur
überschrieben): bleibt eine Datei aus einer älteren Fassung liegen, die es
im neuen Stand nicht mehr gibt, lädt Home Assistant sie trotzdem mit. Das
ist genau der Fehler, den man hinterher am schwersten findet. HACS macht es
aus demselben Grund so.
Grundsatz: nichts kaputtmachen, was schon da ist. Das Skript ist darauf
ausgelegt, gefahrlos mehrfach zu laufen (etwa nach einem Abbruch):
- Bestandsdaten (fahrzeugprofil.json, fahrten.jsonl, tankvorgaenge.jsonl,
entitaeten.json, ha_token.txt) werden NIE überschrieben. Wer eine
laufende Installation neu bespielt, behält sein Fahrzeugprofil, seine
Fahrten, Tankvorgänge und Sensorzuordnung.
- configuration.yaml wird vor jeder Änderung nach configuration.yaml.bak
kopiert. Der eingefügte Teil steht zwischen zwei Markierungszeilen und
wird bei einem erneuten Lauf ersetzt statt ein zweites Mal angehängt.
- Stehen `pyscript:` oder `panel_custom:` dort schon aus anderer Quelle,
fasst das Skript die Datei NICHT an, sondern zeigt an, was von Hand zu
ergänzen ist. Zwei gleiche Top-Level-Schlüssel wären ungültiges YAML und
HA würde nicht mehr starten - das ist keine Entscheidung, die ein
Installer still für jemanden treffen sollte.
Nicht automatisierbar und deshalb am Ende als Restliste ausgegeben:
HA-Neustart, `pip install pypdf` (braucht eine Shell auf der HA-Maschine)
und das Zuordnen der Sensoren im Setup-Menü der App.
Die Fahrzeugdaten liegen woanders (<Ziel>\audi_dashboard\) und werden von
diesem Skript nie berührt - ein Update kann sie deshalb gar nicht treffen.
.PARAMETER Ziel
config-Verzeichnis der HA-Instanz, z. B. "\\homeassistant.local\config"
(Samba-Add-on) oder ein lokal gemounteter Pfad. Ohne Angabe sucht das
Skript die üblichen Samba-Namen selbst ab.
.PARAMETER Token
Long-Lived Access Token aus HA (Profil-Avatar -> "Long-lived access
tokens"). Wird nur nach audi_dashboard\ha_token.txt geschrieben und für das
Kilometerstand-Screening gebraucht. Ohne Angabe wird die Datei nicht
angelegt und der Schritt landet in der Restliste. Eine schon vorhandene
Datei bleibt in jedem Fall unangetastet.
.PARAMETER Pruefen
Nur anzeigen, was passieren würde - es wird nichts geschrieben.
.BEISPIEL
.\install.ps1
.\install.ps1 -Ziel "\\192.168.1.50\config"
.\install.ps1 -Ziel "\\homeassistant.local\config" -Token "eyJhb..."
.\install.ps1 -Pruefen
#>
param(
[string]$Ziel,
[string]$Token,
[switch]$Pruefen
)
@@ -96,7 +78,6 @@ function Gut($text) { Write-Host " [ok] $text" -ForegroundColor Green }
function Info($text) { Write-Host " [info] $text" -ForegroundColor DarkGray }
function Warnung($text) { Write-Host " [!] $text" -ForegroundColor Yellow }
# Sammelt alles ein, was der Mensch am Ende noch selbst tun muss.
$restliste = New-Object System.Collections.ArrayList
Write-Host ""
@@ -104,6 +85,23 @@ Write-Host " Mein Audi - Installation" -ForegroundColor White
Write-Host " ========================" -ForegroundColor White
if ($Pruefen) { Warnung "Pruefmodus - es wird nichts geschrieben." }
# ------------------------------------------------------------ Quelle finden
# Zwei Lagen, damit sowohl der Aufruf aus dem Projektordner heraus als auch
# ein separat weitergegebenes Paket funktioniert.
$quellKandidaten = @(
(Join-Path $hier "custom_components\audi_dashboard"),
(Join-Path $hier "..\..\custom_components\audi_dashboard")
)
$quelle = $null
foreach ($k in $quellKandidaten) {
if (Test-Path (Join-Path $k "manifest.json")) { $quelle = (Resolve-Path $k).Path; break }
}
if (-not $quelle) {
throw "custom_components\audi_dashboard nicht gefunden - dieses Skript gehört in den Projektordner (oder das Paket daneben)."
}
$version = (Get-Content (Join-Path $quelle "manifest.json") -Raw | ConvertFrom-Json).version
Gut "Quelle: $quelle (Version $version)"
# ---------------------------------------------------------------- Ziel finden
if (-not $Ziel) {
Schritt "Suche Home Assistant ..."
@@ -132,229 +130,81 @@ if (-not $Ziel) {
if (-not (Test-Path $Ziel)) {
throw "Ziel nicht erreichbar: $Ziel - Samba-Share verbunden? Pfad korrekt?"
}
Gut "Ziel: $Ziel"
# Ein config-Verzeichnis ohne configuration.yaml ist mit hoher Wahrscheinlichkeit
# der falsche Ordner - lieber hier abbrechen als Dateien irgendwohin streuen.
$configYaml = Join-Path $Ziel "configuration.yaml"
if (-not (Test-Path $configYaml)) {
if (-not (Test-Path (Join-Path $Ziel "configuration.yaml"))) {
throw "In $Ziel liegt keine configuration.yaml - ist das wirklich das config-Verzeichnis von HA?"
}
Gut "Ziel: $Ziel"
# pyscript wird über HACS installiert, nicht von hier - ohne die Integration
# läuft das komplette Backend nicht, deshalb früh darauf hinweisen.
if (-not (Test-Path (Join-Path $Ziel "custom_components\pyscript"))) {
Warnung "custom_components\pyscript fehlt - pyscript ist offenbar noch nicht über HACS installiert."
[void]$restliste.Add("pyscript über HACS installieren (HACS -> Integrationen -> 'pyscript' -> Download), danach dieses Skript erneut laufen lassen.")
# ------------------------------------------------------- Zielordner prüfen
$zielOrdner = Join-Path $Ziel "custom_components\audi_dashboard"
$vorhandeneVersion = $null
if (Test-Path $zielOrdner) {
$zielManifest = Join-Path $zielOrdner "manifest.json"
if (-not (Test-Path $zielManifest)) {
throw "In $zielOrdner liegt keine manifest.json. Das ist nicht unser Ordner - Abbruch, es wird nichts angefasst."
}
$vorhandenesManifest = Get-Content $zielManifest -Raw | ConvertFrom-Json
if ($vorhandenesManifest.domain -ne "audi_dashboard") {
throw "$zielOrdner gehört zur Integration '$($vorhandenesManifest.domain)', nicht zu 'audi_dashboard' - Abbruch."
}
$vorhandeneVersion = $vorhandenesManifest.version
Info "bereits installiert: Version $vorhandeneVersion"
}
# --------------------------------------------------------------- 1. pyscript
Schritt "1/5 pyscript-Backend kopieren"
# ----------------------------------------------------------------- Kopieren
Schritt "Integration kopieren"
if ($Pruefen) {
Info "würde kopieren: $hier\pyscript\* -> $Ziel\pyscript\"
if ($vorhandeneVersion) { Info "würde ersetzen: $zielOrdner (Version $vorhandeneVersion -> $version)" }
else { Info "würde anlegen: $zielOrdner (Version $version)" }
Info "würde sonst nichts anfassen - keine configuration.yaml, keine Fahrzeugdaten"
} else {
New-Item -ItemType Directory -Force -Path (Join-Path $Ziel "pyscript") | Out-Null
# Bewusst kein robocopy /MIR: das würde fremde Skripte im selben Ordner
# löschen. Hier wird nur ergänzt und überschrieben.
Copy-Item (Join-Path $hier "pyscript\*") (Join-Path $Ziel "pyscript") -Recurse -Force
Gut "pyscript\ kopiert"
}
# ------------------------------------------------------- 2. data -> audi_dashboard
Schritt "2/5 Datenverzeichnis anlegen (data -> audi_dashboard)"
$datenZiel = Join-Path $Ziel "audi_dashboard"
# Dateien, die einer laufenden Installation gehören - nie überschreiben.
$bestandsdaten = @(
"fahrzeugprofil.json",
"fahrten.jsonl",
"tankvorgaenge.jsonl",
"entitaeten.json",
"ha_token.txt"
)
if (-not $Pruefen) { New-Item -ItemType Directory -Force -Path $datenZiel | Out-Null }
foreach ($datei in Get-ChildItem (Join-Path $hier "data") -File) {
# fahrzeugprofil.example.json ist die Vorlage und heißt am Ziel anders.
$zielName = $datei.Name
if ($zielName -eq "fahrzeugprofil.example.json") { $zielName = "fahrzeugprofil.json" }
$zielPfad = Join-Path $datenZiel $zielName
if (($bestandsdaten -contains $zielName) -and (Test-Path $zielPfad)) {
Info "$zielName existiert bereits - bleibt unverändert"
continue
New-Item -ItemType Directory -Force -Path (Join-Path $Ziel "custom_components") | Out-Null
if (Test-Path $zielOrdner) {
# Die Prüfung oben hat bestätigt, dass das unser Ordner ist.
Remove-Item $zielOrdner -Recurse -Force
}
if ($Pruefen) {
Info "würde schreiben: $zielPfad"
} else {
Copy-Item $datei.FullName $zielPfad -Force
Gut "$zielName"
Copy-Item $quelle $zielOrdner -Recurse -Force
# Übersetzte Reste einer früheren Fassung gehören nicht ins Ziel.
Get-ChildItem $zielOrdner -Recurse -Directory -Filter "__pycache__" |
ForEach-Object { Remove-Item $_.FullName -Recurse -Force }
# Rücklesen: liegt im Ziel wirklich das, was gemeint war?
$kontrolle = Join-Path $zielOrdner "manifest.json"
if (-not (Test-Path $kontrolle)) {
throw "Nach dem Kopieren fehlt $kontrolle - die Installation ist unvollständig."
}
}
# --------------------------------------------------------------- 3. Frontend
Schritt "3/5 Frontend kopieren"
if ($Pruefen) {
Info "würde kopieren: $hier\www\* -> $Ziel\www\"
} else {
New-Item -ItemType Directory -Force -Path (Join-Path $Ziel "www") | Out-Null
Copy-Item (Join-Path $hier "www\*") (Join-Path $Ziel "www") -Recurse -Force
# Wie in update.ps1: die Versionsdatei bestimmt, ob Browser den neuen Code
# ziehen. Immer frisch schreiben, sonst zeigt ein Browser, der die App
# schon einmal geladen hatte, weiter den alten Stand.
# Zwei Zahlen mit verschiedenen Aufgaben, siehe VERSIONIERUNG.md:
# version = Unix-Sekunden, bricht den Browser-Cache, bei jedem Deploy neu
# app = der Stand aus der Datei VERSION, identifiziert die Fassung
# Die App-Version kommt aus der mitgelieferten Datei und wird ÜBERNOMMEN,
# nicht neu erfunden - sonst verlöre die iOS-App ihren Vergleichswert und
# könnte ihre eigene Veraltung nicht mehr erkennen.
$version = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$appVersion = ""
$quellVersion = Join-Path $hier "www\audi-dashboard-version.json"
if (Test-Path $quellVersion) {
try { $appVersion = (Get-Content $quellVersion -Raw | ConvertFrom-Json).app } catch { $appVersion = "" }
$geschrieben = (Get-Content $kontrolle -Raw | ConvertFrom-Json).version
if ($geschrieben -ne $version) {
throw "Nach dem Kopieren steht Version '$geschrieben' statt '$version' im Ziel - die Installation ist unvollständig."
}
$versionsDatei = Join-Path $Ziel "www\audi-dashboard-version.json"
$inhaltVersion = if ($appVersion) { "{ ""version"": $version, ""app"": ""$appVersion"" }" }
else { "{ ""version"": $version }" }
[System.IO.File]::WriteAllText($versionsDatei, $inhaltVersion, (New-Object System.Text.UTF8Encoding($false)))
Gut "www\ kopiert (Version $version)"
}
# ---------------------------------------------------- 4. configuration.yaml
Schritt "4/5 configuration.yaml ergänzen"
$markeAuf = "# >>> Mein Audi (DataMetric360) - automatisch eingefügt, nicht von Hand bearbeiten"
$markeZu = "# <<< Mein Audi (DataMetric360)"
# Beide Dateien ausdrücklich als UTF-8 lesen: Get-Content nimmt in
# PowerShell 5.1 sonst die ANSI-Codepage an und macht aus "Ergänzung" ein
# "Ergänzung", das dann so in der configuration.yaml landet.
$utf8OhneBom = New-Object System.Text.UTF8Encoding($false)
$snippet = [System.IO.File]::ReadAllText((Join-Path $hier "configuration_snippet.yaml"), $utf8OhneBom)
$inhalt = [System.IO.File]::ReadAllText($configYaml, $utf8OhneBom)
if ($null -eq $inhalt) { $inhalt = "" }
$block = "$markeAuf`r`n$snippet`r`n$markeZu`r`n"
$schonDa = $inhalt.Contains($markeAuf)
# Top-Level-Schlüssel aus fremder Quelle? Dann Finger weg (siehe Kopfkommentar).
$ohneUnserenBlock = $inhalt
if ($schonDa) {
$ohneUnserenBlock = [regex]::Replace(
$inhalt,
"(?ms)" + [regex]::Escape($markeAuf) + ".*?" + [regex]::Escape($markeZu) + "\r?\n?",
""
)
}
$fremdePyscript = [regex]::IsMatch($ohneUnserenBlock, "(?m)^pyscript:")
$fremdePanel = [regex]::IsMatch($ohneUnserenBlock, "(?m)^panel_custom:")
if ($fremdePyscript -or $fremdePanel) {
Warnung "configuration.yaml enthält bereits eigene Einträge - sie wird NICHT verändert."
if ($fremdePyscript) { Info "gefunden: pyscript:" }
if ($fremdePanel) { Info "gefunden: panel_custom:" }
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 {
# 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 }
)
} else {
$trenner = ""
if ($inhalt.Length -gt 0 -and -not $inhalt.EndsWith("`n")) { $trenner = "`r`n" }
$neu = $inhalt + $trenner + "`r`n" + $block
}
# 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
Schritt "5/5 Zugriffstoken"
$tokenDatei = Join-Path $datenZiel "ha_token.txt"
if (Test-Path $tokenDatei) {
Info "ha_token.txt existiert bereits - bleibt unverändert"
} elseif ($Token) {
if ($Pruefen) {
Info "würde schreiben: $tokenDatei"
} else {
[System.IO.File]::WriteAllText($tokenDatei, $Token.Trim(), (New-Object System.Text.UTF8Encoding($false)))
Gut "ha_token.txt geschrieben"
}
} else {
Info "kein -Token übergeben"
[void]$restliste.Add("Long-Lived Access Token anlegen (HA: Profil-Avatar unten links -> 'Long-lived access tokens' -> Token erstellen) und den Wert in $tokenDatei speichern - nur den Token, keine Anführungszeichen. Wird fürs Kilometerstand-Screening gebraucht.")
$anzahl = (Get-ChildItem $zielOrdner -Recurse -File).Count
Gut "custom_components\audi_dashboard (Version $version, $anzahl Dateien)"
}
# --------------------------------------------------------------- Restliste
if (-not $vorhandeneVersion) {
[void]$restliste.Add("Home Assistant neu starten: Einstellungen -> System -> Neu starten. (Beim ersten Start ladt HA die Abhaengigkeit pypdf nach - das kann eine Minute dauern.)")
[void]$restliste.Add("Integration hinzufuegen: Einstellungen -> Geraete & Dienste -> Integration hinzufuegen -> 'Audi Dashboard'. Danach steht 'Mein Audi' in der Seitenleiste.")
[void]$restliste.Add("In der App: Einstellungen -> Fahrzeug einrichten -> Setup - Sensoren zuordnen. Im Auslieferstand ist KEIN Sensor vorbelegt; zwingend fuer die Fahrterkennung ist der Zuendungs-/ACC-Sensor (binary_sensor, meist vom Teltonika FMM003). Ein Neustart ist dafuer nicht mehr noetig - Aenderungen wirken sofort.")
} else {
[void]$restliste.Add("Home Assistant neu starten: Einstellungen -> System -> Neu starten. (Oder kuerzer: Einstellungen -> Geraete & Dienste -> Audi Dashboard -> Neu laden.)")
}
# 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. 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.")
[void]$restliste.Add("ZEITKRITISCH - Datenaufbewahrung: den Block aus recorder_snippet.yaml in die configuration.yaml uebernehmen. Home Assistant loescht Sensor-Verlaeufe sonst nach 10 Tagen; was weg ist, laesst sich auch mit 'Daten importieren aus Home Assistant' nicht mehr nachtragen. Je frueher der Block drin ist, desto mehr Vergangenheit bleibt erhalten. VORHER Einstellungen -> System -> Speicher pruefen: 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("Optional, sobald die Sensoren zugeordnet sind: Einstellungen -> Einrichten -> 'Daten importieren aus Home Assistant' holt Fahrten, Tankvorgaenge und Spannungswerte aus dem bereits aufgezeichneten HA-Verlauf nach.")
Write-Host ""
if ($Pruefen) {
Write-Host " Pruefmodus beendet - es wurde nichts geschrieben." -ForegroundColor Yellow
} else {
Write-Host " Dateien sind kopiert." -ForegroundColor Green
Write-Host " Die Integration ist installiert." -ForegroundColor Green
}
Write-Host ""
Write-Host " Das fehlt noch:" -ForegroundColor White
@@ -364,6 +214,6 @@ foreach ($punkt in $restliste) {
$nr++
}
Write-Host ""
Write-Host " Danach erscheint 'Mein Audi' in der Seitenleiste." -ForegroundColor White
Write-Host " Spätere Updates: update.ps1 statt install.ps1 - das lässt audi_dashboard\ in Ruhe."
Write-Host " Fuer spaetere Updates reicht derselbe Aufruf noch einmal." -ForegroundColor White
Write-Host " Die Fahrzeugdaten unter audi_dashboard\ bleiben dabei unangetastet."
Write-Host ""
@@ -1,113 +0,0 @@
"""Backup-Funktion für Fahrzeugprofil, Fahrten und Tankvorgänge.
Manuell über "Backup jetzt erstellen" in den Einstellungen oder automatisch
je nach einstellungen.backup_intervall im Fahrzeugprofil (aus/täglich/
wöchentlich/monatlich) - immer um 04:00 Uhr, wenn eingestellt. Ein einzelner
täglicher Trigger um 04:00 genügt für alle drei Intervalle: wöchentlich läuft
nur montags, monatlich nur am Monatsersten, täglich jeden Tag - so reicht eine
einzige Trigger-Registrierung statt drei.
Jedes Backup ist ein eigener Zeitstempel-Unterordner mit Kopien der drei
Bestände (siehe profil.py), keine ZIP-Datei - das würde den vorhandenen
task.executor(io.open, ...)-Weg (siehe dessen Kopfkommentar in profil.py)
unnötig verkomplizieren. shutil.copy() ist wie io.open eine echte externe
Funktion und läuft deshalb über denselben task.executor-Weg.
"Backup exportieren"/"importieren" in der Oberfläche laufen dagegen rein
über den Browser (aktuell geladenes Profil + Fahrten + Tankvorgänge als eine
JSON-Datei, siehe audi-dashboard-app.js) - audi_dashboard_backup_wiederherstellen()
hier ist nur das Gegenstück zum Import, das die drei Bestände wieder
komplett überschreibt.
"""
import datetime
import json
import os
import shutil
import entitaeten
import frontend_veroeffentlichung
import profil
BACKUP_ORDNER = f"{profil.BASIS}/backups"
# entitaeten.json gehört dazu: Ohne sie wäre nach einer Wiederherstellung die
# komplette Sensor-Zuordnung aus dem Setup-Menü verloren, und weil sie sonst
# nirgends abgelegt ist, gäbe es nichts zurückzuholen.
_DATEIEN = [
"fahrzeugprofil.json",
"fahrten.jsonl",
"tankvorgaenge.jsonl",
"entitaeten.json",
]
def _backup_jetzt():
ziel = f"{BACKUP_ORDNER}/{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
os.makedirs(ziel, exist_ok=True)
for name in _DATEIEN:
quelle = f"{profil.BASIS}/{name}"
if os.path.exists(quelle):
task.executor(shutil.copy, quelle, f"{ziel}/{name}")
return ziel
def _letztes_backup_vermerken():
p = profil.profil_lesen()
if p is None:
return
p.setdefault("einstellungen", {})["letztes_backup"] = datetime.datetime.now().isoformat()
profil.profil_schreiben(p)
frontend_veroeffentlichung.profil_veroeffentlichen()
@service
def audi_dashboard_backup_jetzt():
"""Erstellt sofort ein Backup. Aufruf als pyscript.audi_dashboard_backup_jetzt."""
ziel = _backup_jetzt()
_letztes_backup_vermerken()
log.info(f"audi_dashboard: Backup erstellt unter {ziel}")
@time_trigger("cron(0 4 * * *)")
def _automatisches_backup():
p = profil.profil_lesen()
if p is None:
return
intervall = p.get("einstellungen", {}).get("backup_intervall", "aus")
heute = datetime.date.today()
faellig = (
intervall == "taeglich"
or (intervall == "woechentlich" and heute.weekday() == 0)
or (intervall == "monatlich" and heute.day == 1)
)
if not faellig:
return
ziel = _backup_jetzt()
_letztes_backup_vermerken()
log.info(f"audi_dashboard: automatisches Backup ({intervall}) erstellt unter {ziel}")
@service
def audi_dashboard_backup_wiederherstellen(profil_json=None, fahrten_json=None, tankvorgaenge_json=None, entitaeten_json=None):
"""Stellt ein über "Backup exportieren" heruntergeladenes Backup wieder
her - überschreibt Profil, Fahrten, Tankvorgänge und die Sensor-Zuordnung
komplett. Aufruf als pyscript.audi_dashboard_backup_wiederherstellen.
entitaeten_json ist optional: ältere Sicherungen, die vor dem Setup-Menü
entstanden sind, enthalten den Abschnitt nicht - dann bleibt die aktuelle
Zuordnung unangetastet, statt sie zu leeren."""
if profil_json:
p = json.loads(profil_json) if isinstance(profil_json, str) else profil_json
profil.profil_schreiben(p)
if fahrten_json is not None:
fahrten = json.loads(fahrten_json) if isinstance(fahrten_json, str) else fahrten_json
profil.fahrten_schreiben(fahrten)
if tankvorgaenge_json is not None:
tankvorgaenge = json.loads(tankvorgaenge_json) if isinstance(tankvorgaenge_json, str) else tankvorgaenge_json
profil.tankvorgaenge_schreiben(tankvorgaenge)
if entitaeten_json:
zuordnung = json.loads(entitaeten_json) if isinstance(entitaeten_json, str) else entitaeten_json
entitaeten.overrides_schreiben(zuordnung)
entitaeten.overrides_anwenden()
frontend_veroeffentlichung.alles_veroeffentlichen()
log.info("audi_dashboard: Backup wiederhergestellt")
@@ -1,62 +0,0 @@
"""Langfristige Aufzeichnung der 12V-Batteriespannung (Mein Audi -> Zustand
-> Batteriespannung antippen). Eine über die Jahre absinkende Spannung
deutet auf eine alternde Batterie hin - dafür braucht es einen Verlauf,
nicht nur den aktuellen Wert.
Ein Eintrag pro Tag ({datum, min, min_ts, max, max_ts}) reicht für diesen
Zweck aus und hält batteriespannung.jsonl auch über viele Jahre klein - auch
wenn der Sensor selbst künftig mehrfach pro Stunde meldet (aktive Fahrt:
Motor an/aus, Ladezustand des Alternators wechselt laufend).
batterie_verlauf_pruefen() läuft deshalb häufig (alle 5 Minuten) und trägt
jede Messung in den Tageseintrag ein, statt wie zuvor nur einmal täglich
einen Einzelwert zu speichern - profil.batterieverlauf_tageswert_aktualisieren()
erweitert dabei nur min/max samt dem genauen Zeitstempel der jeweiligen
Extremmessung, robust gegen Neustarts und schwankende Erreichbarkeit.
Der Tagesminimalwert ist die für die Entladung aussagekräftige Größe: er
fällt typischerweise auf den Moment kurz nach dem Start, bevor der
Alternator die Ladespannung hochfährt - also am nächsten an die "echte"
Ruhespannung heran, ohne 2+ Stunden Standzeit abwarten zu müssen. Deshalb
verwendet auch die Ladezustands-/Gesundheitsauswertung im Frontend
(audi-dashboard-app.js, AGM_RUHE_MAX_V) den Tagesminimalwert als
repräsentativen Wert des Tages, nicht den Maximalwert.
Anders als in der ersten Fassung wird hier NICHT mehr gefiltert, welche
Messwerte gespeichert werden - min UND max jedes Tages werden roh
aufgezeichnet (der Maximalwert ist z. B. informativ beim Antippen eines
Punkts im Diagramm sichtbar). Die AGM-Ruhespannungs-Grenze wird stattdessen
erst beim Anzeigen angewendet (siehe AGM_RUHE_MAX_V im Frontend): Tage, an
denen selbst der Minimalwert über der Grenze liegt (Fahrzeug nie im
Ruhezustand beobachtet, z. B. bei einer sehr langen Fahrt), tauchen dort
nicht in der Ladezustands-Statistik auf.
Ohne eingetragenen einstellungen.BATTERIE_SENSOR (aktuell der Fall - die
genutzte Integration liefert dafür keinen Sensor, siehe dortiger Kommentar)
passiert hier bewusst nichts, statt Platzhalter-Nullwerte aufzuzeichnen.
"""
import datetime
import einstellungen
import frontend_veroeffentlichung
import profil
@time_trigger("startup")
@time_trigger("period(now, 5 minutes)")
def batterie_verlauf_pruefen():
if not einstellungen.BATTERIE_SENSOR:
return
wert = frontend_veroeffentlichung.zustand_oder_none(einstellungen.BATTERIE_SENSOR)
if wert is None:
return
try:
spannung = float(wert)
except (TypeError, ValueError):
return
heute = datetime.date.today().isoformat()
ts = datetime.datetime.now(datetime.timezone.utc).isoformat()
profil.batterieverlauf_tageswert_aktualisieren(heute, ts, spannung)
frontend_veroeffentlichung.batterieverlauf_veroeffentlichen()
log.info(f"audi_dashboard: Batteriespannung erfasst ({heute}: {spannung} V)")
@@ -1,275 +0,0 @@
"""Belegverarbeitung für Shell-Tankbelege (§7.7, §10 Punkt 7).
Beleg-Upload direkt in der App statt Postfachanbindung - kein IMAP-Zugriff.
shell_beleg_parser.py, im Lastenheft (§2) als mitgeliefert genannt, lag
beim Bau nicht im Projektordner vor (siehe Ersteinschätzung) und wurde
nachträglich unter audi_dashboard/shell_beleg_parser.py gegen einen echten
Shell-eReceipt neu geschrieben (siehe dessen Kopfkommentar). Aufruf über
eine Kommandozeilen-Schnittstelle: `python3 shell_beleg_parser.py
<pfad-zur-pdf>`, Ausgabe als JSON auf stdout mit den Feldern aus §6.4
(receipt_key, ts, station_name, liters, fuel_total_eur, discount, ...).
Muss unter /config/audi_dashboard/shell_beleg_parser.py liegen (siehe
INSTALL.md) - eigener Prozess mit dem System-python3 des Containers, nicht
die pyscript-Sandbox, deshalb dort einmalig `pip install pypdf` nötig.
ABWEICHUNG VOM URSPRÜNGLICHEN LASTENHEFT (Frontend-Anpassung, nach dem
ursprünglichen Bau nachträglich verlangt):
- Volltankung (§7.4) ist entfernt - weder Berechnung noch Anzeige. Damit
entfallen auch level_before_pct/level_after_pct, die nur dafür gebraucht
wurden.
- fuel_total_eur ist immer der reale, bereits rabattierte Betrag.
Die SmartDeal-Ersparnis steht separat in discount. price_per_l wird
grundsätzlich aus fuel_total_eur ÷ liters berechnet, nie aus einem vom
Beleg übernommenen Wert übernommen - siehe Kopfkommentar in
audi-dashboard-app.js (tankFelder()) für dieselbe Regel im Frontend.
Entity-IDs stehen zentral in pyscript/modules/einstellungen.py.
"""
import base64
import datetime
import io
import json
import os
import subprocess
import einstellungen
import frontend_veroeffentlichung
import profil
PARSER_SKRIPT = "/config/audi_dashboard/shell_beleg_parser.py"
# --------------------------------------------------------------- Hilfsfunktionen
def _pdf_speichern(inhalt_bytes, dateiname):
# io.open über task.executor, kein with, kein bloßes open() - siehe
# Kopfkommentar in profil.py für alle drei Gründe.
os.makedirs(profil.BELEGE_ORDNER, exist_ok=True)
pfad = f"{profil.BELEGE_ORDNER}/{dateiname}"
f = task.executor(io.open, pfad, "wb")
f.write(inhalt_bytes)
f.close()
return pfad
def _parser_aufrufen(pdf_pfad):
ergebnis = subprocess.run(
["python3", PARSER_SKRIPT, pdf_pfad],
capture_output=True, text=True, timeout=30,
)
if ergebnis.returncode != 0:
raise RuntimeError(f"shell_beleg_parser.py fehlgeschlagen: {ergebnis.stderr}")
return json.loads(ergebnis.stdout)
def _preis_je_liter(fuel_total_eur, liters):
if not liters:
return None
return round(fuel_total_eur / liters, 3)
# -------------------------------------------------------------------- Service
@service
def audi_dashboard_beleg_hochladen(pdf_base64=None, dateiname=None, tank_id=None):
"""Nimmt einen per Base64 codierten PDF-Beleg entgegen (Upload aus der
Oberfläche über hass.callService).
Mit tank_id: trägt die Belegdaten direkt in einen bereits bestehenden
Tankvorgang nach - egal ob der ursprünglich manuell oder automatisch
angelegt wurde. Der Kilometerstand/die gefahrene Distanz dieses
Tankvorgangs bleiben dabei unangetastet (die kommen weiterhin vom
Fahrzeug, nie vom Beleg, §7.7 Regel 2), nur die Beleg-Felder (Liter,
Kosten, Ersparnis, Station, Zeitstempel, ...) werden ergänzt
beziehungsweise überschrieben. Passt das Belegdatum nicht zum bereits
erfassten Tankvorgang (unterschiedlicher Kalendertag), wird nichts
übernommen - stattdessen ein Fehler veröffentlicht, damit in der
Oberfläche nicht versehentlich ein falscher Beleg einem bestehenden
Eintrag zugeordnet wird.
Ohne tank_id: legt KEINEN eigenständigen Tankvorgang mehr an (frühere
Abweichung vom Lastenheft, auf Wunsch entfernt), sondern parst den Beleg
nur und veröffentlicht das Ergebnis - die Oberfläche übernimmt die Werte
in den gerade offenen Entwurf (neuer oder bearbeiteter Tankvorgang) und
speichert erst auf einen expliziten Klick auf "Speichern".
Aufruf als pyscript.audi_dashboard_beleg_hochladen."""
if not pdf_base64:
log.warning("audi_dashboard: kein pdf_base64 übergeben")
return
# task.executor entfällt hier bewusst: _pdf_speichern/_parser_aufrufen sind
# in dieser pyscript-Datei definiert, task.executor akzeptiert aber nur
# echte externe Python-Funktionen (siehe Kopfkommentar in profil.py).
#
# Alles bis zum geparsten Ergebnis in einem try: eine unbehandelte
# Exception hier (kaputtes Base64, volle Festplatte, ...) wäre für die
# Oberfläche unsichtbar geblieben - serviceRufen() im Frontend loggt einen
# rejizierten Service-Aufruf nur in die Browser-Konsole, zeigt aber nichts
# an (siehe audi-dashboard-app.js). Bisher war nur der Parser-Aufruf
# selbst so abgesichert; b64decode/_pdf_speichern fehlten.
try:
inhalt_bytes = base64.b64decode(pdf_base64)
dateiname = dateiname or f"beleg_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
pdf_pfad = _pdf_speichern(inhalt_bytes, dateiname)
geparst = _parser_aufrufen(pdf_pfad)
except Exception as exc:
log.error(f"audi_dashboard: Beleg konnte nicht verarbeitet werden: {exc}")
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg konnte nicht gelesen werden", "tank_id": tank_id})
return
receipt_key = geparst.get("receipt_key")
if not receipt_key:
log.error("audi_dashboard: Parser lieferte keinen receipt_key")
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg konnte nicht gelesen werden", "tank_id": tank_id})
return
# §7.7 Regel 3: derselbe Beleg (receipt_key, minutengenau) erzeugt
# keinen zweiten Datensatz
vorhandener_beleg = profil.tankvorgang_nach_receipt_key(receipt_key)
if vorhandener_beleg is not None and vorhandener_beleg.get("tank_id") != tank_id:
log.info(f"audi_dashboard: Beleg {receipt_key} bereits vorhanden, übersprungen")
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg bereits erfasst", "tank_id": tank_id})
return
liters = geparst.get("liters")
fuel_total_eur = geparst.get("fuel_total_eur") # real, bereits rabattiert
beleg_felder = {
"receipt_key": receipt_key,
"receipt_no": geparst.get("receipt_no"),
"tse_beleg_nr": geparst.get("tse_beleg_nr"),
"ts": geparst.get("ts"),
"ts_payment": geparst.get("ts_payment"),
"ts_tse": geparst.get("ts_tse"),
"station_id": geparst.get("station_id"),
"station_name": geparst.get("station_name"),
"station_address": geparst.get("station_address"),
"article_no": geparst.get("article_no"),
"product_name": geparst.get("product_name"),
"fuel_type": geparst.get("fuel_type"),
# Liter, Kosten und Ersparnis kommen vom Beleg (§7.7 Regel 2).
# price_per_l wird nie vom Beleg übernommen, sondern immer berechnet.
"liters": liters,
"fuel_total_eur": fuel_total_eur,
"price_per_l": _preis_je_liter(fuel_total_eur, liters),
"discount": geparst.get("discount"), # SmartDeal-Ersparnis
"discount_per_l": geparst.get("discount_per_l"),
"list_price_per_l": geparst.get("list_price_per_l"),
"receipt_total_eur": geparst.get("receipt_total_eur"),
"net_eur": geparst.get("net_eur"),
"vat_eur": geparst.get("vat_eur"),
"receipt_file": pdf_pfad,
}
if tank_id:
vorhanden = profil.tankvorgang_nach_id(tank_id)
if not vorhanden:
log.warning(f"audi_dashboard: Tankvorgang {tank_id} für Beleg-Nachtrag nicht gefunden")
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Tankvorgang nicht gefunden", "tank_id": tank_id})
return
vorhandenes_datum = (vorhanden.get("ts") or "")[:10]
belegdatum = (geparst.get("ts") or "")[:10]
if vorhandenes_datum and belegdatum and vorhandenes_datum != belegdatum:
log.warning(f"audi_dashboard: Belegdatum {belegdatum} passt nicht zu Tankvorgang {tank_id} (Datum {vorhandenes_datum})")
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Falsches Belegdatum", "tank_id": tank_id})
return
aenderungen = dict(beleg_felder)
aenderungen["status"] = "vollständig"
if profil.tankvorgang_aktualisieren(tank_id, aenderungen):
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"erfolg": True, "tank_id": tank_id})
log.info(f"audi_dashboard: Beleg {receipt_key} in Tankvorgang {tank_id} nachgetragen")
else:
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Tankvorgang nicht gefunden", "tank_id": tank_id})
return
# Kein tank_id: nur parsen und dem gerade offenen Entwurf in der
# Oberfläche zum Ausfüllen bereitstellen - legt selbst KEINEN
# Tankvorgang an (siehe Kopfkommentar oben).
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({**beleg_felder, "tank_id": None})
log.info(f"audi_dashboard: Beleg {receipt_key} geparst, wartet auf Speichern in der Oberfläche")
@service
def audi_dashboard_tankvorgang_manuell(ts=None, liter=None, kosten=None, km=None, ersparnis=None, station=None, distanz=None, kraftstoff=None, receipt_key=None, receipt_file=None):
"""Erfassung eines Tankvorgangs aus dem Formular "Neuer Tankvorgang" -
Fallback aus §7.7 / §10 Punkt 7, unter „Tanken" vorgesehen (§5.5).
Dieselben Felder wie beim Bearbeiten eines bestehenden Vorgangs (siehe
audi_dashboard_tankvorgang_aktualisieren). receipt_key/receipt_file sind
optional: kommen sie mit (weil zuvor ein Beleg über
audi_dashboard_beleg_hochladen ohne tank_id in den Entwurf übernommen
wurde), zählt der Tankvorgang als per Beleg erfasst, sonst als manuell.
Aufruf als pyscript.audi_dashboard_tankvorgang_manuell."""
if liter is None or kosten is None:
log.warning("audi_dashboard: liter und kosten sind Pflichtfelder")
return
odometer_km = km if km is not None else frontend_veroeffentlichung.zustand_oder_none(einstellungen.KM_SENSOR)
tankvorgang = {
"tank_id": profil.neue_id("f"),
"receipt_key": receipt_key,
"ts": ts or datetime.datetime.now(datetime.timezone.utc).isoformat(),
"liters": liter,
"fuel_total_eur": kosten,
"price_per_l": _preis_je_liter(kosten, liter),
"discount": ersparnis,
"station_name": station,
"odometer_km": odometer_km,
# Vorschlag aus der Differenz zum letzten Tankvorgang, aber wie
# odometer_km selbst frei überschreibbar - deshalb nimmt ein manuell
# übergebener Wert immer Vorrang vor der Berechnung.
"distance_km": distanz if distanz is not None else profil.distanz_seit_letzter_tankung(odometer_km),
"fuel_type": kraftstoff,
"source": "beleg" if receipt_key else "manual",
"status": "vollständig",
"receipt_file": receipt_file,
"edited_fields": [],
}
profil.tankvorgang_anhaengen(tankvorgang)
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
log.info(f"audi_dashboard: Tankvorgang {tankvorgang['tank_id']} angelegt")
@service
def audi_dashboard_tankvorgang_aktualisieren(tank_id=None, ts=None, liter=None, kosten=None, km=None, ersparnis=None, station=None, distanz=None, kraftstoff=None, receipt_key=None, receipt_file=None):
"""Bearbeitet einen bestehenden Tankvorgang (Einzelbeleg-Seite, egal ob
ursprünglich per Beleg oder manuell angelegt). receipt_key/receipt_file
sind optional: kommen sie mit (ein im Bearbeiten-Formular hochgeladener
Beleg wurde in den Entwurf übernommen), wird der Tankvorgang zusätzlich
als per Beleg erfasst markiert. Aufruf als
pyscript.audi_dashboard_tankvorgang_aktualisieren."""
if not tank_id:
log.warning("audi_dashboard: tankvorgang_aktualisieren ohne tank_id aufgerufen")
return
aenderungen = {
"ts": ts, "liters": liter, "fuel_total_eur": kosten,
"price_per_l": _preis_je_liter(kosten, liter),
"odometer_km": km, "discount": ersparnis, "station_name": station,
"distance_km": distanz, "fuel_type": kraftstoff,
}
if receipt_key:
aenderungen["receipt_key"] = receipt_key
aenderungen["receipt_file"] = receipt_file
aenderungen["source"] = "beleg"
if profil.tankvorgang_aktualisieren(tank_id, aenderungen):
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
log.info(f"audi_dashboard: Tankvorgang {tank_id} aktualisiert")
else:
log.warning(f"audi_dashboard: Tankvorgang {tank_id} nicht gefunden")
@service
def audi_dashboard_tankvorgang_loeschen(tank_id=None):
"""Löscht einen Tankvorgang unwiderruflich (Löschfunktion in der
Oberfläche). Aufruf als pyscript.audi_dashboard_tankvorgang_loeschen."""
if not tank_id:
log.warning("audi_dashboard: tankvorgang_loeschen ohne tank_id aufgerufen")
return
if profil.tankvorgang_loeschen(tank_id):
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
log.info(f"audi_dashboard: Tankvorgang {tank_id} gelöscht")
else:
log.warning(f"audi_dashboard: Tankvorgang {tank_id} nicht gefunden")
@@ -1,71 +0,0 @@
"""Fahrzeugbilder direkt aus der Oberfläche hochladen/löschen (Einstellungen
-> Fahrzeugbilder, siehe BILDER_UPLOAD_SLOTS in audi-dashboard-app.js).
Nur die festen, im Frontend fest verdrahteten Dateinamen sind erlaubt - eine
Positivliste, damit ein manipulierter Dateiname aus dem Frontend niemals
außerhalb von www/bilder/ schreiben kann (kein Pfad-Traversal über "../" oder
absolute Pfade). Die Bytes werden unverändert unter dem erwarteten Namen
abgelegt, unabhängig vom tatsächlichen Bildformat des Originalfotos (z. B.
JPEG von einem iPhone unter einem ".webp"-Namen) - Browser stellen <img> nach
den tatsächlichen Bilddaten dar, nicht nach der Dateiendung, das funktioniert
also trotzdem. Eine Größenbeschränkung gibt es bewusst nicht (Vorgabe: jede
Fotogröße muss hochladbar sein).
"""
import base64
import io
import os
BILDER_ORDNER = "/config/www/bilder"
ERLAUBTE_DATEINAMEN = {
"seitenansicht.webp",
"seitenansicht-winter.webp",
"front-schraeg.webp",
"frontansicht.webp",
"heckansicht.webp",
"cockpit.webp",
"scheinwerfer.webp",
"sitze.webp",
"rad-sommer.webp",
"rad-winter.webp",
}
def _pfad(dateiname):
if dateiname not in ERLAUBTE_DATEINAMEN:
return None
return f"{BILDER_ORDNER}/{dateiname}"
@service
def audi_dashboard_bild_hochladen(dateiname=None, daten_base64=None):
"""Speichert ein aus der Oberfläche hochgeladenes Fahrzeugfoto unter
einem der festen erwarteten Namen. Aufruf als
pyscript.audi_dashboard_bild_hochladen."""
pfad = _pfad(dateiname)
if not pfad or not daten_base64:
log.warning(f"audi_dashboard: Bild-Upload abgelehnt (dateiname={dateiname})")
return
os.makedirs(BILDER_ORDNER, exist_ok=True)
rohdaten = base64.b64decode(daten_base64)
tmp = pfad + ".tmp"
f = task.executor(io.open, tmp, "wb")
f.write(rohdaten)
f.close()
os.replace(tmp, pfad)
log.info(f"audi_dashboard: Fahrzeugbild gespeichert ({dateiname}, {len(rohdaten)} Bytes)")
@service
def audi_dashboard_bild_loeschen(dateiname=None):
"""Entfernt ein zuvor hochgeladenes Fahrzeugfoto wieder - die Ansicht
fällt danach auf den Platzhalter zurück. Aufruf als
pyscript.audi_dashboard_bild_loeschen."""
pfad = _pfad(dateiname)
if not pfad:
log.warning(f"audi_dashboard: Bild-Löschen abgelehnt (dateiname={dateiname})")
return
if os.path.exists(pfad):
os.remove(pfad)
log.info(f"audi_dashboard: Fahrzeugbild gelöscht ({dateiname})")
@@ -1,31 +0,0 @@
"""Trigger-Registrierung für den zweistufigen Fahrtabschluss (§7.2).
Die eigentliche Logik liegt in pyscript/modules/fahrtabschluss_logik.py
(importierbares Modul, auch von fahrterkennung.py genutzt). Diese Datei
sorgt nur dafür, dass ein Screening auch dann läuft, wenn der Kilometerstand-
Sensor sich ändert - unabhängig vom Fahrtende-Ereignis selbst, weil der Wert
laut §10 Punkt 4 auch erst mit der nächsten Fahrt eintreffen kann.
Entity-IDs stehen zentral in pyscript/modules/einstellungen.py.
"""
import einstellungen
import fahrtabschluss_logik
# Ohne konfigurierten Kilometerstand-Sensor keinen Trigger registrieren -
# eine leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes
# Verhalten, siehe gleiches Muster in fahrterkennung.py.
if einstellungen.KM_SENSOR:
@state_trigger(f"{einstellungen.KM_SENSOR}")
def kilometerstand_geaendert(value=None, old_value=None):
if value is None or old_value is None:
return
fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR)
@service
def audi_dashboard_screening_jetzt():
"""Manueller Anstoß des Screenings, z. B. aus der Oberfläche heraus
(pyscript.audi_dashboard_screening_jetzt)."""
fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR)
@@ -1,244 +0,0 @@
"""Fahrterkennung über den Zündungs-/ACC-Status des FMM003 (§7.1).
Start: die Zündungs-Entität (einstellungen.ZUENDUNG_SENSOR) wechselt auf
"on". Ende: sie wechselt auf "off" und kommt binnen der Pausenzeit nicht
zurück.
2026-08-12: ersetzt die frühere WLAN-basierte Erkennung (Vergleich des vom
iPhone gemeldeten verbundenen WLANs gegen den im Fahrzeugprofil hinterlegten
Namen) - die Zündung ist ein direkteres, von jeder Companion-App-Eigenheit
unabhängiges Signal.
Die Pausenregel wird über task.unique() umgesetzt: jede neue Änderung der
Zündungs-Entität killt eine noch laufende, wartende Bestätigung des
vorherigen Ereignisses. Kommt die Zündung während der Wartezeit zurück, wird
dadurch die wartende Ende-Bestätigung automatisch abgebrochen, ohne dass die
Fahrt beendet wird - das entspricht der Pausenregel aus §7.1.
Die Entity-IDs stehen zentral in pyscript/modules/einstellungen.py - dort vor
der Installation einmalig anpassen (siehe INSTALL.md), oder über das
Setup-Menü in der Oberfläche zuordnen (siehe entitaeten.py; Änderungen dort
wirken für DIESEN Trigger allerdings erst nach einem Neustart, da
@state_trigger die Entity-ID einmalig beim Modul-Laden einliest).
EINSCHRÄNKUNG: der Zustand einer laufenden Fahrt (_fahrt_start_ts) lebt nur
im Arbeitsspeicher dieses Skripts. Ein HA-Neustart mitten in einer Fahrt oder
während der Wartezeit verliert diesen Zwischenstand - die Fahrt würde dann
nicht aufgezeichnet. Das ist ein bekannter Punkt für eine spätere Härtung
(z. B. Zwischenstand in einer input_datetime-Helper-Entität sichern), hier
bewusst nicht mitgebaut, um die Kernlogik nicht zu verkomplizieren, bevor sie
sich im echten Betrieb bewährt hat.
"""
import datetime
import einstellungen
import fahrtabschluss_logik
import frontend_veroeffentlichung
import profil
_fahrt_start_ts = None
def _pausenzeit_sekunden():
p = profil.profil_lesen()
if p is None:
return 15 * 60
return p.get("einstellungen", {}).get("fahrten_pausenzeit_min", 15) * 60
@time_trigger("startup")
def beim_start():
profil.ordner_sicherstellen()
# Ohne konfigurierte Zündungs-Entität keinen Trigger registrieren - eine
# leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes Verhalten
# (siehe zustand_oder_none()-Kommentar in frontend_veroeffentlichung.py zur
# Grundhaltung "kein geratener Wert"), hier stattdessen ganz weggelassen.
if einstellungen.ZUENDUNG_SENSOR:
@state_trigger(f"{einstellungen.ZUENDUNG_SENSOR}")
def zuendung_geaendert(value=None, old_value=None):
# Killt eine noch laufende Ende-Bestätigung aus einer vorherigen
# Änderung - das ist der Mechanismus hinter der Pausenregel.
task.unique("audi_dashboard_fahrt_ende_pruefung")
global _fahrt_start_ts
an_jetzt = (value == "on")
an_vorher = (old_value == "on")
if an_jetzt and not an_vorher:
if _fahrt_start_ts is None:
_fahrt_start_ts = datetime.datetime.now(datetime.timezone.utc)
log.info(f"audi_dashboard: Fahrt gestartet um {_fahrt_start_ts.isoformat()}")
return
if an_vorher and not an_jetzt and _fahrt_start_ts is not None:
start_ts = _fahrt_start_ts
abbruch_ts = datetime.datetime.now(datetime.timezone.utc)
wartezeit_s = _pausenzeit_sekunden()
# task.unique() oben markiert diese Ausführung als die aktuell
# gültige. Kommt die Zündung vor Ablauf der Wartezeit zurück,
# ruft der nächste Trigger erneut task.unique() auf und killt
# genau diesen task.sleep() - der Code danach läuft dann nie.
task.sleep(wartezeit_s)
_fahrt_beenden(start_ts, abbruch_ts)
def _fahrt_beenden(start_ts, ende_ts):
global _fahrt_start_ts
dauer_s = int((ende_ts - start_ts).total_seconds())
fahrt = {
"trip_id": profil.neue_id("t"),
"ts_start": start_ts.isoformat(),
"ts_end": ende_ts.isoformat(),
"duration_s": dauer_s,
"distance_km": None,
"km_quelle": None,
"odo_start": None,
"odo_end": None,
"avg_speed_kmh": None,
"start_lat": None,
"start_lon": None,
"end_lat": None,
"end_lon": None,
"start_address": None,
"end_address": None,
"art": "privat",
"route": None,
"pausen": [],
"source": "ha",
"status": "offen",
"edited_fields": [],
}
profil.fahrt_anhaengen(fahrt)
frontend_veroeffentlichung.fahrten_veroeffentlichen()
log.info(f"audi_dashboard: Fahrt {fahrt['trip_id']} beendet, Status 'offen', Screening angestoßen")
_fahrt_start_ts = None
fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR)
def _handfelder(start_ort, ziel_ort, odo_start, odo_end, distanz):
"""Die von Hand befüllbaren Felder einer Fahrt auf ihre Datensatz-Namen
abgebildet, ohne die leer gelassenen. Der Aufrufer schreibt die Schlüssel
zusätzlich nach edited_fields - das schützt sie vor dem automatischen
Kilometerstand-Screening (§7.2), das sonst später darüberschreiben würde."""
werte = {
"start_address": start_ort or None,
"end_address": ziel_ort or None,
"odo_start": odo_start,
"odo_end": odo_end,
"distance_km": distanz,
}
return {k: v for k, v in werte.items() if v is not None}
def _strecke_ergaenzen(felder):
"""Fehlt die Distanz, ergibt sie sich aus den beiden Kilometerständen -
dieselbe Rechnung wie im Screening (fahrtabschluss_logik)."""
if "distance_km" not in felder and "odo_start" in felder and "odo_end" in felder:
felder["distance_km"] = round(felder["odo_end"] - felder["odo_start"], 1)
return felder
@service
def audi_dashboard_fahrt_manuell_anlegen(ts_start=None, ts_end=None, art="privat",
start_ort=None, ziel_ort=None,
odo_start=None, odo_end=None, distanz=None):
"""Manuelle Fahrterfassung aus der Oberfläche (§5.3), unabhängig von der
automatischen Zündungs-Erkennung. Dieselben Felder wie beim Bearbeiten
einer bestehenden Fahrt (siehe audi_dashboard_fahrt_aktualisieren); alles
außer Start- und Endzeitpunkt ist optional und bleibt leer, wenn es das
Screening später selbst ergänzen soll. Aufruf als
pyscript.audi_dashboard_fahrt_manuell_anlegen."""
start_ts = datetime.datetime.fromisoformat(ts_start)
ende_ts = datetime.datetime.fromisoformat(ts_end)
dauer_s = int((ende_ts - start_ts).total_seconds())
hand = _strecke_ergaenzen(_handfelder(start_ort, ziel_ort, odo_start, odo_end, distanz))
fahrt = {
"trip_id": profil.neue_id("t"),
"ts_start": start_ts.isoformat(),
"ts_end": ende_ts.isoformat(),
"duration_s": dauer_s,
"distance_km": None,
"km_quelle": None,
"odo_start": None,
"odo_end": None,
"avg_speed_kmh": None,
"start_lat": None,
"start_lon": None,
"end_lat": None,
"end_lon": None,
"start_address": None,
"end_address": None,
"art": art,
"route": None,
"pausen": [],
"source": "manual",
"status": "offen",
"edited_fields": [],
}
fahrt.update(hand)
fahrt["edited_fields"] = sorted(hand)
if fahrt["distance_km"] is not None:
fahrt["km_quelle"] = "manuell"
fahrt["status"] = "vollständig"
profil.fahrt_anhaengen(fahrt)
frontend_veroeffentlichung.fahrten_veroeffentlichen()
fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR)
@service
def audi_dashboard_fahrt_aktualisieren(trip_id=None, ts_start=None, ts_end=None, art=None,
start_ort=None, ziel_ort=None,
odo_start=None, odo_end=None, distanz=None):
"""Bearbeitet eine bestehende Fahrt (Einzelfahrt-Seite), egal ob sie
automatisch erkannt oder von Hand angelegt wurde. Aufruf als
pyscript.audi_dashboard_fahrt_aktualisieren."""
if not trip_id:
log.warning("audi_dashboard: fahrt_aktualisieren ohne trip_id aufgerufen")
return
aenderungen = _strecke_ergaenzen(_handfelder(start_ort, ziel_ort, odo_start, odo_end, distanz))
if art:
aenderungen["art"] = art
if ts_start and ts_end:
start_ts = datetime.datetime.fromisoformat(ts_start)
ende_ts = datetime.datetime.fromisoformat(ts_end)
aenderungen["ts_start"] = start_ts.isoformat()
aenderungen["ts_end"] = ende_ts.isoformat()
aenderungen["duration_s"] = int((ende_ts - start_ts).total_seconds())
if aenderungen.get("distance_km") is not None:
aenderungen["km_quelle"] = "manuell"
aenderungen["status"] = "vollständig"
# Von Hand gesetzte Felder bleiben von Hand gesetzt: die Liste wächst mit,
# damit ein späteres Screening sie nicht wieder überschreibt.
# Bewusst eine Listen-Abstraktion statt next(<Generator>): pyscript kann
# keine Generator-Ausdrücke auswerten ("not implemented ast
# ast_generatorexp"), Listen-Abstraktionen dagegen schon.
passend = [f for f in profil.fahrten_lesen() if f.get("trip_id") == trip_id]
handfelder = {"start_address", "end_address", "odo_start", "odo_end", "distance_km"}
bisher = set(passend[0].get("edited_fields", [])) if passend else set()
aenderungen["edited_fields"] = sorted(bisher | (handfelder & set(aenderungen)))
if profil.fahrt_bearbeiten(trip_id, aenderungen):
frontend_veroeffentlichung.fahrten_veroeffentlichen()
log.info(f"audi_dashboard: Fahrt {trip_id} aktualisiert")
else:
log.warning(f"audi_dashboard: Fahrt {trip_id} nicht gefunden")
@service
def audi_dashboard_fahrt_loeschen(trip_id=None):
"""Löscht eine Fahrt unwiderruflich (Löschfunktion in der Oberfläche,
§Löschfunktion). Aufruf als pyscript.audi_dashboard_fahrt_loeschen."""
if not trip_id:
log.warning("audi_dashboard: fahrt_loeschen ohne trip_id aufgerufen")
return
if profil.fahrt_loeschen(trip_id):
frontend_veroeffentlichung.fahrten_veroeffentlichen()
log.info(f"audi_dashboard: Fahrt {trip_id} gelöscht")
else:
log.warning(f"audi_dashboard: Fahrt {trip_id} nicht gefunden")
@@ -1,112 +0,0 @@
"""Verbindung zwischen Frontend und Backend.
Lesen: das Frontend liest hass.states["pyscript.audi_dashboard_profil"] usw.
direkt - kein Service-Aufruf mit Rückgabewert. Die Frontend-Dokumentation von
Home Assistant zu response-fähigen Services beschreibt das Verhalten aus
Sicht des Browsers nicht abschließend (Community-Diskussionen deuten auf
Einschränkungen hin); Zustände lesen ist dagegen ein seit Jahren stabiler,
einfacher Weg. Veröffentlicht wird periodisch UND sofort nach jeder
Schreibaktion (siehe frontend_veroeffentlichung.py, dort auch der Hinweis zur
Größengrenze von Zustands-Attributen).
Schreiben: einzig audi_dashboard_profil_schreiben() als generischer Weg für
das gesamte Profil - das Frontend hält Profil, Fahrzeugdaten und
Einstellungen ohnehin als einen zusammenhängenden Stand im Speicher (wie
schon im Prototyp) und schickt ihn nach jeder Änderung komplett zurück,
statt für jedes einzelne Feld einen eigenen Service zu brauchen.
"""
import json
import einstellungen
import entitaeten
import frontend_veroeffentlichung
import profil
@time_trigger("startup")
def beim_start():
profil.ordner_sicherstellen()
# Vor der ersten Veröffentlichung anwenden, damit sie die im Setup-Menü
# gespeicherten Zuordnungen (data/entitaeten.json) schon zeigt, siehe
# entitaeten.py-Kopfkommentar.
entitaeten.overrides_anwenden()
frontend_veroeffentlichung.alles_veroeffentlichen()
@time_trigger("period(now, 20 seconds)")
def periodisch_aktualisieren():
frontend_veroeffentlichung.fahrzeugstatus_veroeffentlichen()
@time_trigger("period(now, 60 seconds)")
def periodisch_daten_aktualisieren():
# seltener als der Fahrzeugstatus - Profil/Fahrten/Tankvorgänge ändern
# sich normalerweise nur durch Aktionen, die ohnehin sofort selbst
# veröffentlichen (siehe unten); das hier ist nur das Sicherheitsnetz
# für Änderungen direkt im Dateisystem (§6.2, dritter Bearbeitungsweg).
frontend_veroeffentlichung.profil_veroeffentlichen()
frontend_veroeffentlichung.fahrten_veroeffentlichen()
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
@service
def audi_dashboard_jetzt_aktualisieren():
"""Gegenstück zum "Ziehen zum Aktualisieren" auf der Übersicht: stößt den
von der Integration bereitgestellten Refresh-Knopf an (siehe
einstellungen.REFRESH_BUTTON) statt auf den nächsten periodischen Takt zu
warten, und veröffentlicht den Status danach sofort neu. Ohne
eingetragenen Knopf (leerer Platzhalter) nur die direkte Neu-
veröffentlichung - besser als gar nichts, auch wenn dann kein echter neuer
Abruf beim Fahrzeug passiert. Aufruf als
pyscript.audi_dashboard_jetzt_aktualisieren."""
if einstellungen.REFRESH_BUTTON:
button.press(entity_id=einstellungen.REFRESH_BUTTON)
task.sleep(3)
frontend_veroeffentlichung.fahrzeugstatus_veroeffentlichen()
log.info("audi_dashboard: manuelle Aktualisierung (Ziehen zum Aktualisieren) ausgelöst")
@service
def audi_dashboard_profil_schreiben(profil_json=None):
"""Überschreibt das komplette Fahrzeugprofil. Aufruf als
pyscript.audi_dashboard_profil_schreiben."""
if not profil_json:
log.warning("audi_dashboard: profil_schreiben ohne profil_json aufgerufen")
return
p = json.loads(profil_json) if isinstance(profil_json, str) else profil_json
profil.profil_schreiben(p)
frontend_veroeffentlichung.profil_veroeffentlichen()
log.info("audi_dashboard: Profil aktualisiert")
@service
def audi_dashboard_entitaeten_schreiben(zuordnung_json=None):
"""Speichert die im Setup-Menü (Einstellungen -> Fahrzeug einrichten ->
Setup) gewählte Sensor-zu-Entität-Zuordnung, wendet sie sofort an und
veröffentlicht Katalog+Status neu, damit Übersicht/Mein Audi ohne
Neustart die neuen Werte zeigen (Ausnahme: die drei trigger-gebundenen
Felder, siehe entitaeten.py). Aufruf als
pyscript.audi_dashboard_entitaeten_schreiben."""
if not zuordnung_json:
log.warning("audi_dashboard: entitaeten_schreiben ohne zuordnung_json aufgerufen")
return
mapping = json.loads(zuordnung_json) if isinstance(zuordnung_json, str) else zuordnung_json
entitaeten.overrides_schreiben(mapping)
entitaeten.overrides_anwenden()
frontend_veroeffentlichung.entitaeten_veroeffentlichen()
frontend_veroeffentlichung.fahrzeugstatus_veroeffentlichen()
log.info("audi_dashboard: Entitäten-Zuordnung aktualisiert")
@service
def audi_dashboard_neustart():
"""Startet Home Assistant neu. Einziger Zweck: eine Änderung an einem der
drei trigger-gebundenen Setup-Felder (ZUENDUNG_SENSOR/KM_SENSOR/
TANK_SENSOR, siehe entitaeten.py) wird erst nach einem Neustart wirksam,
weil @state_trigger die Entity-ID beim Modul-Laden einmalig einliest.
Wird vom Setup-Popup als expliziter, vom Nutzer bestätigter Knopf
angeboten - kein automatischer Selbst-Reload direkt nach dem Speichern.
Aufruf als pyscript.audi_dashboard_neustart."""
log.info("audi_dashboard: Neustart über das Setup-Menü ausgelöst")
homeassistant.restart()
@@ -1,511 +0,0 @@
"""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}")
@@ -1,112 +0,0 @@
"""Technische Konfiguration — die einzige Stelle, die vor der Installation
angepasst werden muss (siehe INSTALL.md Schritt 4).
Alles andere (WLAN-Name des Fahrzeugs, Pausenzeit, Reifendaten, ...) ist Teil
des Fahrzeugprofils (data/fahrzeugprofil.json) und über die Oberfläche
änderbar. Diese Entity-IDs sind es bewusst nicht: @state_trigger und die
Fahrzeugstatus-Abfrage brauchen sie als festen Wert, bevor überhaupt ein
Profil gelesen werden kann.
In Home Assistant unter Entwicklerwerkzeuge -> Zustände nachschlagen. Werte
lassen sich außerdem über das Setup-Menü in der Oberfläche zuordnen (siehe
entitaeten.py) - Änderungen von dort werden zur Laufzeit auf diese Variablen
angewendet (überschreiben also die hier hinterlegten Standardwerte), ohne
diese Datei anzufassen.
**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).
#
# 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 - 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
# Tankgeber).
TANK_SENSOR = ""
# Reichweite (§5.1 Übersicht) - keine Quelle mehr vorhanden.
RANGE_SENSOR = ""
# 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).
# 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.
REFRESH_BUTTON = ""
# Türen (§4.1) - keine Quelle mehr vorhanden.
TUER_SENSOREN = []
# Fenster (§4.1) - keine Quelle mehr vorhanden.
FENSTER_SENSOREN = []
# Heckklappe und Motorhaube - keine Quelle mehr vorhanden.
HECKKLAPPE_SENSOR = ""
HAUBE_SENSOR = ""
# Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die App-eigene,
# aus dem Servicebuch berechnete Prognose) - keine Quelle mehr vorhanden.
NAECHSTER_OELWECHSEL_SENSOR = ""
OELWECHSEL_STRECKE_SENSOR = ""
NAECHSTE_INSPEKTION_SENSOR = ""
INSPEKTION_STRECKE_SENSOR = ""
# Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel). Breiten-/
# Längengrad als zwei eigene sensor-Entities (flespi liefert Koordinaten so,
# nicht als Attribute einer device_tracker-Entity - siehe _standort() in
# frontend_veroeffentlichung.py).
STANDORT_LAT_SENSOR = ""
STANDORT_LON_SENSOR = ""
# Update-Funktion (Einstellungen -> "Update suchen", siehe
# updateverwaltung.py): Git-Repository, in das dieses Projekt gepflegt wird -
# z. B. ein privates GitHub-Repo, genau wie für die HACS-Integration bereits
# verwendet. Leer lassen, solange es keins gibt - "Update suchen" meldet dann
# nur "keine Update-Quelle eingerichtet", ohne etwas zu tun.
UPDATE_REPO_URL = ""
UPDATE_BRANCH = "main"
@@ -1,241 +0,0 @@
"""Sensor-zu-Entität-Zuordnung fürs Setup-Menü (Einstellungen -> Fahrzeug
einrichten -> Setup).
Ersetzt NICHT das manuelle Eintragen in einstellungen.py (dessen Kommentare
sind die eigentliche Dokumentation der Integration und bleiben unangetastet),
sondern legt eine zusätzliche, zur Laufzeit angewendete Override-Ebene
darüber: data/entitaeten.json enthält nur die vom Nutzer über das Setup-Menü
geänderten Zuordnungen, keyed nach den einstellungen.py-Variablennamen.
Warum das funktioniert, ohne jede Verbraucher-Datei anzufassen: Alle
bestehenden Leser (frontend_veroeffentlichung.py, fahrterkennung.py, ...)
machen `import einstellungen` und lesen dann `einstellungen.KM_SENSOR` -
ein lebendiger Attributzugriff auf das Modul-Objekt, kein `from einstellungen
import KM_SENSOR` (das würde den Wert beim Import einfrieren). overrides_
anwenden() unten nutzt genau das: setattr(einstellungen, key, wert) ändert
das Attribut auf dem bereits importierten Modul-Objekt, jeder spätere
Zugriff `einstellungen.KM_SENSOR` liefert sofort den neuen Wert.
Eine Ausnahme (siehe TRIGGER_GEBUNDEN unten): mehrere @state_trigger(f"...")-
Dekoratoren backen ZUENDUNG_SENSOR/KM_SENSOR/TANK_SENSOR einmalig beim
Modul-Laden in die Trigger-Registrierung ein. Eine Änderung dieser drei
Felder wirkt für die Fahrterkennung selbst erst nach einem Neustart von
Home Assistant (bzw. pyscript.reload) - das Setup-Menü weist deshalb bei
diesen drei Feldern ausdrücklich darauf hin, statt automatisch und riskant
einen Selbst-Reload aus einer laufenden pyscript-Funktion heraus zu
versuchen.
"""
import io
import json
import os
import einstellungen
BASIS = "/config/audi_dashboard"
OVERRIDES_PFAD = f"{BASIS}/entitaeten.json"
# Vier gleich aufgebaute Positionen, wie schon in frontend_veroeffentlichung.py
# (dort POSITIONEN genannt) - hier dupliziert statt importiert, damit
# entitaeten.py nicht von frontend_veroeffentlichung.py abhängt (wäre die
# falsche Richtung: die Veröffentlichung importiert diese Datei, nicht
# umgekehrt).
POSITIONEN = ["vorne links", "vorne rechts", "hinten links", "hinten rechts"]
# Katalog aller von der App genutzten Entity-Rollen, fürs Setup-Menü im
# Frontend. Reihenfolge = Anzeige-Reihenfolge innerhalb jeder Gruppe.
# domains/device_classes/units sind Erwartungen fürs Frontend-seitige
# Filtern+Vorschlagen (Schalter "Nur passende Sensoren anzeigen") - eine
# fehlende oder leere Liste bedeutet "keine Einschränkung" bzw. "diese Rolle
# hat üblicherweise keine Einheit" (binary_sensor/button/device_tracker).
FELDER = [
{"key": "ZUENDUNG_SENSOR", "label": "Zündung/ACC-Status", "gruppe": "fahrterkennung",
"hinweis": "on = Fahrt läuft, erkennt Fahrtbeginn/-ende (ersetzt die frühere WLAN-Erkennung).",
"domains": ["binary_sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": True,
"stichworte": ["zündung", "ignition", "acc", "motor", "engine"]},
{"key": "KM_SENSOR", "label": "Kilometerstand", "gruppe": "fahrterkennung",
"hinweis": "Gesamt-Kilometerstand des Fahrzeugs - für Fahrtabschluss-Screening, Reifenzähler und Ölwechsel-Prognose.",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
"stichworte": ["kilometer", "km", "mileage", "odometer", "tacho", "laufleistung"]},
{"key": "TANK_SENSOR", "label": "Tankfüllstand", "gruppe": "fahrterkennung",
"hinweis": "Füllstand in Prozent - für die automatische Tankerkennung.",
"domains": ["sensor"], "device_classes": [], "units": ["%"], "liste": False, "pflicht": False,
"stichworte": ["tank", "fuel", "kraftstoff", "füllstand", "level"]},
{"key": "RANGE_SENSOR", "label": "Reichweite", "gruppe": "uebersicht",
"hinweis": "Für die Übersicht - bleibt leer, zeigt die Oberfläche \"unbekannt\".",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
"stichworte": ["reichweite", "range"]},
{"key": "BATTERIE_SENSOR", "label": "12V-Batteriespannung", "gruppe": "uebersicht",
"hinweis": "Nicht jede Integration liefert diesen Wert - bleibt er leer, zeigt \"Mein Audi\" \"unbekannt\".",
"domains": ["sensor"], "device_classes": ["voltage"], "units": ["V"], "liste": False, "pflicht": False,
"stichworte": ["batterie", "battery", "spannung", "volt", "12v"]},
{"key": "REFRESH_BUTTON", "label": "Sofort-Aktualisierung (Knopf)", "gruppe": "uebersicht",
"hinweis": "Der von der Integration bereitgestellte Knopf für \"Ziehen zum Aktualisieren\".",
"domains": ["button"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
"stichworte": ["refresh", "aktualisieren", "update", "abrufen", "poll"]},
{"key": "TUER_SENSOREN", "label": "Türen", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu. Für \"Sicher abgestellt\" wichtig.",
"domains": ["binary_sensor"], "device_classes": ["door"], "units": [], "liste": True, "positionen": POSITIONEN,
"pflicht": False, "stichworte": ["tür", "door"]},
{"key": "FENSTER_SENSOREN", "label": "Fenster", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["window"], "units": [], "liste": True, "positionen": POSITIONEN,
"pflicht": False, "stichworte": ["fenster", "window"]},
{"key": "HECKKLAPPE_SENSOR", "label": "Heckklappe", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
"stichworte": ["heckklappe", "tailgate", "kofferraum", "trunk"]},
{"key": "HAUBE_SENSOR", "label": "Motorhaube", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
"stichworte": ["haube", "hood", "bonnet", "motorhaube"]},
{"key": "NAECHSTER_OELWECHSEL_SENSOR", "label": "Nächster Ölwechsel (Datum)", "gruppe": "uebersicht",
"hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit, ergänzt die App-eigene Servicebuch-Prognose.",
"domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False,
"stichworte": ["ölwechsel", "oil", "service", "change"]},
{"key": "OELWECHSEL_STRECKE_SENSOR", "label": "Nächster Ölwechsel (Restkilometer)", "gruppe": "uebersicht",
"hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
"stichworte": ["ölwechsel", "oil", "distance", "strecke"]},
{"key": "NAECHSTE_INSPEKTION_SENSOR", "label": "Nächste Inspektion (Datum)", "gruppe": "uebersicht",
"hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit.",
"domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False,
"stichworte": ["inspektion", "inspection", "service"]},
{"key": "INSPEKTION_STRECKE_SENSOR", "label": "Nächste Inspektion (Restkilometer)", "gruppe": "uebersicht",
"hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
"stichworte": ["inspektion", "inspection", "distance", "strecke"]},
{"key": "STANDORT_LAT_SENSOR", "label": "GPS-Standort: Breitengrad", "gruppe": "standort",
"hinweis": "Breitengrad als eigener Sensor (z. B. flespi).",
"domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
"stichworte": ["breitengrad", "latitude", "lat", "standort", "gps", "position", "ortung"]},
{"key": "STANDORT_LON_SENSOR", "label": "GPS-Standort: Längengrad", "gruppe": "standort",
"hinweis": "Längengrad als eigener Sensor (z. B. flespi).",
"domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
"stichworte": ["längengrad", "longitude", "lon", "lng", "standort", "gps", "position", "ortung"]},
]
# Mehrere @state_trigger(f"...")-Dekoratoren (fahrterkennung.py,
# fahrtabschluss.py, tankerkennung.py, reifenzaehler.py) backen diese drei
# Felder beim Modul-Laden ein (siehe Kopfkommentar) - im Setup-Menü als
# "wirkt erst nach Neustart"-Hinweis markiert.
_TRIGGER_GEBUNDEN = {"ZUENDUNG_SENSOR", "KM_SENSOR", "TANK_SENSOR"}
for _feld in FELDER:
_feld["trigger_gebunden"] = _feld["key"] in _TRIGGER_GEBUNDEN
_SCHLUESSEL = {feld["key"] for feld in FELDER}
# Schnappschuss der eingebauten Standardwerte aus einstellungen.py, genommen
# BEVOR overrides_anwenden() jemals lief (Modul-Ebene, läuft beim `import
# entitaeten` in frontend_api.py - vor dem @time_trigger("startup"), der
# overrides_anwenden() erstmals aufruft). Notwendig fürs Setup-Menü
# "Zurücksetzen": setattr() in overrides_anwenden() überschreibt das Attribut
# auf dem laufenden einstellungen-Modul dauerhaft, ein einfaches Weglassen
# des Overrides beim nächsten Speichern stellt den eingebauten Wert NICHT
# wieder her (das Attribut bleibt ja gesetzt) - "Zurücksetzen" muss den
# Standardwert stattdessen aktiv zurückschreiben.
_STANDARDWERTE = {feld["key"]: getattr(einstellungen, feld["key"], None) for feld in FELDER}
# --------------------------------------------------------------- JSON-I/O ---
# Gleiches Muster wie profil.profil_lesen()/profil_schreiben(): io.open nur
# über task.executor (pyscript-Einschränkung, siehe Kopfkommentar dort),
# atomares Schreiben über eine .tmp-Datei + os.replace.
def overrides_lesen():
"""Liest die gespeicherten Zuordnungen, oder {} wenn die Datei fehlt bzw.
beschädigt ist.
Der try/except ist nicht Vorsicht um ihrer selbst willen: overrides_
anwenden() läuft in beim_start() VOR alles_veroeffentlichen(). Ohne die
Absicherung reißt eine einzige unlesbare Zeile in dieser Datei den
gesamten Startvorgang mit - das Panel bliebe komplett leer, ohne dass
irgendetwas auf die Ursache hindeutet."""
if not os.path.exists(OVERRIDES_PFAD):
return {}
f = task.executor(io.open, OVERRIDES_PFAD, "r")
inhalt = f.read()
f.close()
if not inhalt.strip():
return {}
try:
gelesen = json.loads(inhalt)
except ValueError as fehler:
log.error(
f"audi_dashboard: {OVERRIDES_PFAD} ist kein gültiges JSON ({fehler}). "
"Die eingebauten Standardwerte gelten weiter; die Zuordnung lässt sich "
"im Setup-Menü neu speichern."
)
return {}
if not isinstance(gelesen, dict):
log.error(f"audi_dashboard: {OVERRIDES_PFAD} enthält kein Objekt - wird ignoriert.")
return {}
return gelesen
def overrides_schreiben(mapping):
"""Schreibt nur bekannte Schlüssel (siehe FELDER); unbekannte werden
stillschweigend verworfen, damit ein späterer Katalog-Umbau keine
Datenleichen in entitaeten.json hinterlässt."""
bereinigt = {k: v for k, v in mapping.items() if k in _SCHLUESSEL}
os.makedirs(BASIS, exist_ok=True)
tmp = OVERRIDES_PFAD + ".tmp"
text = json.dumps(bereinigt, ensure_ascii=False, indent=2)
f = task.executor(io.open, tmp, "w")
f.write(text)
f.close()
os.replace(tmp, OVERRIDES_PFAD)
# ------------------------------------------------------- Anwenden/Lesen ---
def _ist_leer(wert):
"""Leer heißt: keine Zuordnung. Auch eine Liste, die nur leere Einträge
enthält - das Setup-Menü schickt für unbelegte Positionen ["","",...],
und das ist inhaltlich dasselbe wie gar keine Liste.
Die Schleife steht hier bewusst statt eines any(...)-Generatorausdrucks:
pyscript kennt Generatorausdrücke nicht und wirft zur Laufzeit
"not implemented ast ast_generatorexp". Mengen-, Listen- und
Dict-Comprehensions funktionieren dagegen (siehe _SCHLUESSEL oben)."""
if wert in (None, ""):
return True
if isinstance(wert, list):
for eintrag in wert:
if eintrag:
return False
return True
return False
def overrides_anwenden():
"""Setzt für JEDES bekannte Feld den wirksamen Wert auf dem laufenden
einstellungen-Modul - den Override, wenn einer hinterlegt ist, sonst den
eingebauten Standardwert.
Wichtig ist das "jedes": Eine frühere Fassung übersprang leere Werte und
setzte nur die belegten. Weil setattr() das Modul-Attribut aber dauerhaft
verändert, blieb ein einmal gesetzter Wert danach für immer stehen -
"Zurücksetzen" im Setup-Menü hatte bei allen Feldern mit leerem
Standardwert (15 von 17) schlicht keine Wirkung, und die Oberfläche zeigte
beim nächsten Zeichnen wieder den alten Wert, als sei das Speichern
fehlgeschlagen. Umgekehrt wurde eine Liste aus leeren Einträgen gesetzt
statt übersprungen, was den Sicherheitscheck mit zwölf "unbekannt"-Zeilen
füllte und "Sicher abgestellt" dauerhaft grau werden ließ.
Jedes Feld bei jedem Durchlauf zu schreiben ist damit nicht nur korrekt,
sondern auch idempotent: der Zustand hängt allein an entitaeten.json,
nicht an der Reihenfolge vorheriger Aufrufe."""
overrides = overrides_lesen()
for key in _SCHLUESSEL:
wert = overrides.get(key)
setattr(einstellungen, key, _STANDARDWERTE[key] if _ist_leer(wert) else wert)
def aktueller_stand():
"""Katalog + aktuell wirksamer Wert je Feld (aus dem einstellungen-Modul
selbst gelesen, nicht direkt aus der JSON-Datei) - zeigt damit sowohl
bereits angewendete Overrides als auch die eingebauten Standardwerte
einheitlich. Grundlage für pyscript.audi_dashboard_entitaeten."""
werte = {feld["key"]: getattr(einstellungen, feld["key"], None) for feld in FELDER}
return {"katalog": FELDER, "werte": werte, "standardwerte": _STANDARDWERTE}
@@ -1,196 +0,0 @@
"""Screening-Logik für den zweistufigen Fahrtabschluss (Bauauftrag §7.2).
Importierbar aus anderen pyscript-Dateien mit `import fahrtabschluss_logik`.
Der Kilometerstand kommt laut Datenquelle nicht sicher mit Fahrtende, sondern
teils erst mit Beginn oder während der nächsten Fahrt (§10 Punkt 4). Statt auf
einen festen Zeitpunkt zu warten, wird deshalb die Recorder-Historie des
Kilometerstand-Sensors nach dem Wert durchsucht, dessen Zeitstempel am
nächsten am Verbindungsabbruch liegt.
Zugriff auf die Historie läuft über die HA-REST-API (/api/history/period),
nicht über einen internen pyscript-Mechanismus - dafür gibt es in der
pyscript-Referenz keinen dokumentierten Weg. Das erfordert ein Long-Lived
Access Token, siehe README.
WICHTIGER VORBEHALT: Der GPS-Fallback aus §7.2 (km_quelle="gps") ist hier noch
NICHT umgesetzt, da die Erfassung von Start-/Endposition (device_tracker,
Adressauflösung) ein eigenes Arbeitspaket ist. Fahrten ohne passenden
Kilometerstand bleiben aktuell dauerhaft "offen" statt auf GPS-Distanz
auszuweichen. Siehe die Restarbeiten im Abschlussbericht.
"""
import datetime
import io
import json
import urllib.parse
import urllib.request
import frontend_veroeffentlichung
import profil
BASIS = "/config/audi_dashboard"
TOKEN_PFAD = f"{BASIS}/ha_token.txt"
HA_BASIS_URL = "http://localhost:8123"
# Wie weit vor/zurück in der Historie gesucht wird, ausgehend vom
# Verbindungsabbruch. 48h deckt auch länger stehende Fahrzeuge ab, ohne bei
# jedem Screening die komplette Historie zu laden.
SUCHFENSTER_STUNDEN = 48
# ---------------------------------------------------------- HA-REST-Zugriff
def _token_lesen():
# io.open über task.executor statt bloßem open() - siehe Kopfkommentar
# in profil.py.
f = task.executor(io.open, TOKEN_PFAD, "r")
inhalt = f.read()
f.close()
return inhalt.strip()
def historie_abfragen(entity_id, start_iso, end_iso):
"""Fragt die Verlaufsdaten des Kilometerstand-Sensors über die REST-API ab.
Beide blockierenden Schritte laufen über task.executor. Ein früherer Stand
rief urlopen() direkt auf - das lief jahrelang durch, bis Home Assistant
anfing, blockierende Aufrufe im Ereignisschleifen-Thread zu erkennen und
abzubrechen (real beobachtet ab 2026.8: "Caught blocking call to
putrequest ... inside the event loop"). Seitdem schlug jedes Screening
fehl, und manuell angelegte Fahrten blieben ohne Strecke - sichtbar nur
als Warnung im Protokoll, weil der Aufrufer den Fehler bewusst abfängt.
task.executor braucht echte externe Funktionen; urllib.request.urlopen und
die read-Methode der Antwort sind genau das (dieselbe Einschränkung wie bei
io.open in profil.py)."""
token = _token_lesen()
start_enc = urllib.parse.quote(start_iso)
end_enc = urllib.parse.quote(end_iso)
url = (
f"{HA_BASIS_URL}/api/history/period/{start_enc}"
f"?filter_entity_id={entity_id}&end_time={end_enc}"
)
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
resp = task.executor(urllib.request.urlopen, req, timeout=10)
rohdaten = task.executor(resp.read)
resp.close()
daten = json.loads(rohdaten.decode("utf-8"))
return daten[0] if daten else []
# ------------------------------------------------------------- Kernlogik
def naechster_wert(zielzeit, punkte):
"""Wert aus der Historie, dessen Zeitstempel am nächsten an zielzeit
liegt. Gibt (wert, zeitdifferenz_s) zurück, oder (None, None). Öffentlich,
weil auch belegverarbeitung.py sie für den Tankfüllstand nutzt."""
beste_wert = None
beste_diff = None
for p in punkte:
try:
wert = float(p["state"])
except (ValueError, TypeError, KeyError):
continue
try:
zeit = datetime.datetime.fromisoformat(
p["last_changed"].replace("Z", "+00:00")
)
except (ValueError, KeyError):
continue
diff = abs((zeit - zielzeit).total_seconds())
if beste_diff is None or diff < beste_diff:
beste_wert = wert
beste_diff = diff
return beste_wert, beste_diff
def _zeitpunkt_screenen(km_sensor, zielzeit):
start = (zielzeit - datetime.timedelta(hours=SUCHFENSTER_STUNDEN)).isoformat()
ende = (zielzeit + datetime.timedelta(hours=SUCHFENSTER_STUNDEN)).isoformat()
try:
punkte = historie_abfragen(km_sensor, start, ende)
except Exception as exc:
# Fehlt z. B. der Token (INSTALL.md Schritt 5) oder ist die API kurz
# nicht erreichbar, bleibt die Fahrt einfach offen statt den ganzen
# Screening-Lauf für alle anderen offenen Fahrten abzubrechen.
log.warning(f"audi_dashboard: Kilometerstand-Screening fehlgeschlagen: {exc}")
return None
wert, _ = naechster_wert(zielzeit, punkte)
return wert
def screening_durchfuehren(km_sensor):
"""Versucht, offene Fahrten mit Start-/Endkilometer zu vervollständigen.
Wird sowohl nach Ablauf der Pausenzeit (§7.1) als auch bei jeder Änderung
des Kilometerstand-Sensors aufgerufen."""
fahrten = profil.fahrten_lesen()
offene = [f for f in fahrten if f.get("status") == "offen"]
if not offene:
return
offene.sort(key=lambda f: f.get("ts_start", ""))
for fahrt in offene:
geaendert = False
if fahrt.get("odo_end") is None:
ts_end = datetime.datetime.fromisoformat(fahrt["ts_end"])
wert = _zeitpunkt_screenen(km_sensor, ts_end)
if wert is not None:
fahrt["odo_end"] = wert
geaendert = True
if fahrt.get("odo_start") is None:
ts_start = datetime.datetime.fromisoformat(fahrt["ts_start"])
wert = _zeitpunkt_screenen(km_sensor, ts_start)
if wert is not None:
fahrt["odo_start"] = wert
geaendert = True
if geaendert:
aenderungen = {"odo_start": fahrt.get("odo_start"), "odo_end": fahrt.get("odo_end")}
if fahrt.get("odo_start") is not None and fahrt.get("odo_end") is not None:
aenderungen["distance_km"] = round(fahrt["odo_end"] - fahrt["odo_start"], 1)
aenderungen["km_quelle"] = "odometer"
aenderungen["status"] = "vollständig"
profil.fahrt_aktualisieren(fahrt["trip_id"], aenderungen)
_lueckenlos_verketten()
frontend_veroeffentlichung.fahrten_veroeffentlichen()
def _lueckenlos_verketten():
"""Fahrt-Ende(N) == Fahrt-Start(N+1), solange dazwischen nicht gefahren
wurde (§7.2). Zwischen zwei in der Liste direkt aufeinanderfolgenden
Fahrten steht das Fahrzeug nur - der Kilometerstand ändert sich dabei
nicht, unabhängig davon, wie lange die Standzeit dauert. Ein bestätigter
Wert wird deshalb an den direkten Nachbarn weitergereicht, auch wenn
dessen eigenes Screening noch keinen Treffer hatte.
Annahme: die Fahrten-Liste enthält keine Lücken durch verpasste
Verbindungsereignisse (z. B. durch einen HA-Neustart mitten in einer
Fahrt). Das ist der Normalfall, aber keine Garantie - siehe
Abschlussbericht."""
fahrten = sorted(profil.fahrten_lesen(), key=lambda f: f.get("ts_start", ""))
geaendert_ids = []
for i in range(len(fahrten) - 1):
aktuelle, naechste = fahrten[i], fahrten[i + 1]
if aktuelle.get("odo_end") is not None and naechste.get("odo_start") is None:
naechste["odo_start"] = aktuelle["odo_end"]
geaendert_ids.append(naechste["trip_id"])
elif naechste.get("odo_start") is not None and aktuelle.get("odo_end") is None:
aktuelle["odo_end"] = naechste["odo_start"]
geaendert_ids.append(aktuelle["trip_id"])
for fahrt in fahrten:
if fahrt["trip_id"] not in geaendert_ids:
continue
aenderungen = {"odo_start": fahrt.get("odo_start"), "odo_end": fahrt.get("odo_end")}
if fahrt.get("odo_start") is not None and fahrt.get("odo_end") is not None:
aenderungen["distance_km"] = round(fahrt["odo_end"] - fahrt["odo_start"], 1)
aenderungen["km_quelle"] = "odometer"
aenderungen["status"] = "vollständig"
profil.fahrt_aktualisieren(fahrt["trip_id"], aenderungen)
@@ -1,258 +0,0 @@
"""Veröffentlicht Profil, Fahrten, Tankvorgänge und den Live-Fahrzeugstatus
als pyscript-Zustandsentitäten, damit das Frontend sie über das ganz normale
hass.states lesen kann - kein Service-Aufruf mit Rückgabewert nötig, dessen
genaues Verhalten in der Home-Assistant-Weboberfläche nicht durchgängig
dokumentiert ist (siehe Kopfkommentar in frontend_api.py).
Importierbar mit `import frontend_veroeffentlichung`, aufgerufen sowohl
periodisch (frontend_api.py) als auch direkt nach Schreibaktionen aus
fahrterkennung.py, belegverarbeitung.py und reifenzaehler.py, damit sich die
Oberfläche unmittelbar aktualisiert statt bis zum nächsten Intervall zu
warten.
BEKANNTE GRENZE: Zustands-Attribute in Home Assistant sind nicht für beliebig
große Datenmengen gedacht (~16 KB Richtwert für die Aufzeichnung in der
Recorder-Datenbank). Für das Profil und den aktuellen Stand ist das
unkritisch; die Fahrten- und Tankvorgänge-Archive könnten das nach einigen
Jahren überschreiten. Wird das relevant, müssten fahrten_veroeffentlichen()/
tankvorgaenge_veroeffentlichen() auf einen Zeitraum eingeschränkt werden
(z. B. nur das laufende Jahr), statt hier vorschnell zu vereinfachen.
"""
import io
import json
import os
import einstellungen
import entitaeten
import profil
def profil_veroeffentlichen():
daten = profil.profil_lesen()
if daten is None:
return
state.set("pyscript.audi_dashboard_profil", "aktuell", new_attributes={"daten": daten})
def fahrten_veroeffentlichen():
state.set("pyscript.audi_dashboard_fahrten", "aktuell", new_attributes={"daten": profil.fahrten_lesen()})
def tankvorgaenge_veroeffentlichen():
state.set("pyscript.audi_dashboard_tankvorgaenge", "aktuell", new_attributes={"daten": profil.tankvorgaenge_lesen()})
def batterieverlauf_veroeffentlichen():
state.set("pyscript.audi_dashboard_batterieverlauf", "aktuell", new_attributes={"daten": profil.batterieverlauf_lesen()})
def entitaeten_veroeffentlichen():
"""Katalog + aktuell wirksame Zuordnung fürs Setup-Menü (Einstellungen ->
Fahrzeug einrichten -> Setup) - siehe entitaeten.py."""
state.set("pyscript.audi_dashboard_entitaeten", "aktuell", new_attributes={"daten": entitaeten.aktueller_stand()})
def beleg_ergebnis_veroeffentlichen(daten):
"""Ergebnis eines Beleg-Uploads (geparste Werte fürs Ausfüllen eines
Entwurfs, oder ein Fehler wie 'Falsches Belegdatum') fürs Frontend, nach
demselben Zustands-statt-Rückgabewert-Muster wie die übrigen
veroeffentlichen()-Funktionen hier (siehe Kopfkommentar)."""
state.set("pyscript.audi_dashboard_beleg_ergebnis", "aktuell", new_attributes={"daten": daten})
def zustand_oder_none(entity_id):
"""Sicherer Zustandszugriff: pyscripts state.get() wirft NameError, wenn
die Entity (noch) nicht existiert - real an einer Testinstanz beobachtet,
nicht nur vermutet. Genau das ist hier der Normalfall, solange manche der
in einstellungen.py hinterlegten Entity-IDs noch Platzhalter sind, und
bleibt auch danach relevant: fällt eine Datenquelle aus, soll die
Oberfläche das zeigen (§9), nicht an einem Fehler hängen bleiben."""
if not entity_id:
return None
try:
wert = state.get(entity_id)
except NameError:
return None
if wert in (None, "unknown", "unavailable"):
return None
return wert
def _zu_zahl(wert):
try:
return float(wert)
except (TypeError, ValueError):
return None
def _abs_zahl(wert):
zahl = _zu_zahl(wert)
return None if zahl is None else abs(zahl)
def _zu_bool(wert):
"""binary_sensor-Zustand als echtes True/False, None bei fehlender Meldung.
None ist hier ausdrücklich kein "nein": ohne zugeordneten Sensor weiß die
App schlicht nicht, ob gefahren wird - und muss das anzeigen dürfen (§9),
statt "steht" zu behaupten."""
if wert is None:
return None
return str(wert).lower() in ("on", "true", "1", "open", "yes")
def _standort():
"""Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel) - liest
STANDORT_LAT_SENSOR/STANDORT_LON_SENSOR, zwei eigene sensor-Entities für
Breiten-/Längengrad (flespi liefert Koordinaten so, nicht als Attribute
einer einzelnen device_tracker-Entity). Fehlt eine der beiden Entity-IDs
oder ist der Zustand (noch) nicht verfügbar, liefert diese Funktion
durchgehend None statt eines geratenen Werts - genau das
zustand_oder_none()-Muster von oben, hier als eigene Funktion, weil
zusätzlich zum Zustand noch der Aktualisierungszeitpunkt gebraucht wird:
state.get() liefert dafür ein StateVal (str-Unterklasse) mit
.last_updated als datetime-Attribut - kein eigenständiges
state.last_updated(), das gibt es in pyscript nicht."""
leer = {"lat": None, "lon": None, "genauigkeit_m": None, "zeit": None}
lat_id, lon_id = einstellungen.STANDORT_LAT_SENSOR, einstellungen.STANDORT_LON_SENSOR
if not lat_id or not lon_id:
return leer
try:
lat_wert = state.get(lat_id)
lon_wert = state.get(lon_id)
except NameError:
return leer
lat, lon = _zu_zahl(lat_wert), _zu_zahl(lon_wert)
if lat is None or lon is None:
return leer
zeit = None
try:
zeit = lat_wert.last_updated.isoformat()
except AttributeError:
pass
return {"lat": lat, "lon": lon, "genauigkeit_m": None, "zeit": zeit}
POSITIONEN = ["vorne links", "vorne rechts", "hinten links", "hinten rechts"]
def _sicherheitscheck():
"""Liste der einzeln geprüften Punkte hinter "Sicher abgestellt" (§9),
fürs Frontend (Klick auf den Status öffnet diese Liste mit einem grünen/
roten/grauen Punkt je Zeile). "ok" ist None, wenn der Sensor fehlt oder
nicht verfügbar ist - genau daraus leitet sich auch die zusammengefasste
gesichert-Kennzahl unten ab, damit beide nie auseinanderlaufen können."""
eintraege = []
for pos, sensor in zip(POSITIONEN, einstellungen.TUER_SENSOREN):
w = zustand_oder_none(sensor)
eintraege.append({"label": f"Tür {pos}", "ok": None if w is None else w == "off"})
for pos, sensor in zip(POSITIONEN, einstellungen.FENSTER_SENSOREN):
w = zustand_oder_none(sensor)
eintraege.append({"label": f"Fenster {pos}", "ok": None if w is None else w == "off"})
for label, sensor in [
("Heckklappe", einstellungen.HECKKLAPPE_SENSOR),
("Motorhaube", einstellungen.HAUBE_SENSOR),
]:
w = zustand_oder_none(sensor)
eintraege.append({"label": label, "ok": None if w is None else w == "off"})
return eintraege
def fahrzeugstatus_veroeffentlichen():
"""Bündelt die live aus Home Assistant gelesenen Fahrzeugwerte
Frontend-freundlich, damit die Oberfläche keine Entity-IDs kennen muss.
"""
km = zustand_oder_none(einstellungen.KM_SENSOR)
tankprozent = zustand_oder_none(einstellungen.TANK_SENSOR)
reichweite = zustand_oder_none(einstellungen.RANGE_SENSOR)
batteriespannung = zustand_oder_none(einstellungen.BATTERIE_SENSOR)
# "Sicher abgestellt" (§9) - erst wenn WIRKLICH jeder einzeln geprüfte
# Punkt zu/verriegelt ist, gilt das Fahrzeug als gesichert; fehlt auch
# nur eine Meldung, ist der Status unbekannt statt geraten.
sicherheitscheck = _sicherheitscheck()
if any([e["ok"] is None for e in sicherheitscheck]):
gesichert = None
else:
gesichert = all([e["ok"] for e in sicherheitscheck])
standort = _standort()
daten = {
"km": _zu_zahl(km),
"tankprozent": _zu_zahl(tankprozent),
"reichweite_km": _zu_zahl(reichweite),
"batteriespannung": _zu_zahl(batteriespannung),
"gesichert": gesichert,
"sicherheitscheck": sicherheitscheck,
# Fährt das Fahrzeug gerade? Kommt aus derselben Zündungs-Entität, die
# auch fahrterkennung.py als maßgebliches Signal für Fahrtbeginn/-ende
# nimmt - damit sagen Anzeige und Erfassung zwangsläufig dasselbe.
#
# Vorher wurde dieser Zustand im Frontend aus dem Status der Fahrten
# abgeleitet ("offen" = fährt). Das war falsch: "offen" heißt
# unvollständige Daten (Strecke fehlt noch, siehe
# fahrtabschluss_logik.py), nicht "unterwegs" - eine Fahrt ohne
# Kilometerstand blieb dauerhaft "offen" und das Fahrzeug damit
# dauerhaft "fahrend". None bedeutet: kein Zündungssensor zugeordnet,
# Zustand unbekannt.
"zuendung": _zu_bool(zustand_oder_none(einstellungen.ZUENDUNG_SENSOR)),
"standort_lat": standort["lat"],
"standort_lon": standort["lon"],
"standort_genauigkeit_m": standort["genauigkeit_m"],
"standort_zeit": standort["zeit"],
# Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die
# App-eigene Servicebuch-Berechnung, siehe fahrzeugMeldung() im
# Frontend) - die Streckensensoren liefern negative Restkilometer-
# Werte, hier deshalb der Betrag.
"oelwechsel_faellig_ts": zustand_oder_none(einstellungen.NAECHSTER_OELWECHSEL_SENSOR),
"oelwechsel_faellig_km": _abs_zahl(zustand_oder_none(einstellungen.OELWECHSEL_STRECKE_SENSOR)),
"inspektion_faellig_ts": zustand_oder_none(einstellungen.NAECHSTE_INSPEKTION_SENSOR),
"inspektion_faellig_km": _abs_zahl(zustand_oder_none(einstellungen.INSPEKTION_STRECKE_SENSOR)),
}
state.set("pyscript.audi_dashboard_fahrzeugstatus", "aktuell", new_attributes={"daten": daten})
APP_VERSION_PFAD = "/config/www/audi-dashboard-version.json"
def app_version_veroeffentlichen():
"""Welchen Oberflächen-Stand diese Installation ausliefert.
Gedacht für die Companion-App: die trägt ihre Version fest einkompiliert
(vite setzt __APP_VERSION__ aus der Datei VERSION im Projektstamm) und
kann sie gegen diesen Wert halten. Weicht sie ab, ist die App älter als
der Server und sagt das selbst - siehe VERSIONIERUNG.md.
Das Panel braucht den Vergleich nicht: es lädt seine Dateien bei jedem
Seitenaufruf frisch, sobald sich die Cache-Zahl in derselben Datei
geändert hat, und kann deshalb gar nicht veralten.
Bewusst eine eigene Entität statt eines Felds im Fahrzeugstatus: das hier
ist eine Eigenschaft der Installation, keine des Fahrzeugs."""
version = None
if os.path.exists(APP_VERSION_PFAD):
try:
f = task.executor(io.open, APP_VERSION_PFAD, "r")
inhalt = f.read()
f.close()
version = json.loads(inhalt).get("app")
except (ValueError, OSError) as fehler:
log.warning(f"audi_dashboard: App-Version nicht lesbar ({fehler})")
state.set(
"pyscript.audi_dashboard_app_version",
version or "unbekannt",
new_attributes={"daten": {"app": version}},
)
def alles_veroeffentlichen():
app_version_veroeffentlichen()
profil_veroeffentlichen()
fahrten_veroeffentlichen()
tankvorgaenge_veroeffentlichen()
fahrzeugstatus_veroeffentlichen()
batterieverlauf_veroeffentlichen()
entitaeten_veroeffentlichen()
@@ -1,298 +0,0 @@
"""Datenzugriff für Fahrzeugprofil, Fahrten und Tankvorgänge (Bauauftrag §6).
Importierbar aus anderen pyscript-Dateien mit `import profil`.
Drei getrennte Bestände, wie in §6.1 festgelegt:
- Fahrzeugprofil: eine JSON-Datei, alles Fahrzeugspezifische
- Fahrten: JSON Lines, eine Zeile je Fahrt
- Tankvorgänge: JSON Lines
Drei Eigenheiten von pyscript, alle an einer echten Testinstanz beobachtet,
nicht nur aus der Dokumentation übernommen:
1. Das eingebaute `open()` existiert in pyscript nicht (NameError) - aus
Sicherheitsgründen bewusst nicht freigegeben. Der funktionierende Weg ist
`task.executor(io.open, pfad, modus)`: `io.open` ist eine echte externe
Funktion aus der Standardbibliothek, kein im pyscript-Ordner selbst
definierter Code.
2. task.executor() akzeptiert generell nur solche echten externen Funktionen
- eigene, im pyscript-Ordner definierte Funktionen weist es mit "pyscript
functions can't be called from task.executor" zurück. Deshalb hier nur
`io.open` selbst über den Executor, Lesen/Schreiben/Schließen auf dem
zurückgegebenen Datei-Objekt direkt (das ist kein bare-name-Aufruf mehr,
sondern ein Methodenaufruf auf einem bereits vorhandenen Objekt).
3. Variablen, die innerhalb eines `with ... as f:`-Blocks zugewiesen werden,
waren danach außerhalb nicht mehr auffindbar (NameError), deshalb kein
`with` - offen/lesen/schließen nacheinander.
"""
import io
import json
import os
import uuid
BASIS = "/config/audi_dashboard"
PROFIL_PFAD = f"{BASIS}/fahrzeugprofil.json"
FAHRTEN_PFAD = f"{BASIS}/fahrten.jsonl"
TANKVORGAENGE_PFAD = f"{BASIS}/tankvorgaenge.jsonl"
BATTERIEVERLAUF_PFAD = f"{BASIS}/batteriespannung.jsonl"
BELEGE_ORDNER = f"{BASIS}/belege"
# ---------------------------------------------------------------- Ordner ---
def ordner_sicherstellen():
os.makedirs(BASIS, exist_ok=True)
os.makedirs(BELEGE_ORDNER, exist_ok=True)
# ----------------------------------------------------------- Fahrzeugprofil
def profil_lesen():
"""Liest das Fahrzeugprofil, oder None wenn es fehlt bzw. beschädigt ist.
Ohne diese Prüfung reißt eine fehlende Datei (Installation unvollständig,
siehe INSTALL.md Schritt 2) jeden Trigger und jeden Service mit, der das
Profil braucht — bei laufenden Zeittriggern also im Minutentakt. Jeder
Aufrufer muss den None-Fall abfangen."""
if not os.path.exists(PROFIL_PFAD):
log.error(
f"audi_dashboard: {PROFIL_PFAD} fehlt. Siehe INSTALL.md Schritt 2 — "
"bis dahin bleiben alle Funktionen aus, die das Profil brauchen."
)
return None
f = task.executor(io.open, PROFIL_PFAD, "r")
inhalt = f.read()
f.close()
try:
return json.loads(inhalt)
except ValueError as fehler:
log.error(
f"audi_dashboard: {PROFIL_PFAD} ist kein gültiges JSON ({fehler}). "
"Letztes Backup aus audi_dashboard/backups/ zurückspielen."
)
return None
def profil_schreiben(profil):
tmp = PROFIL_PFAD + ".tmp"
text = json.dumps(profil, ensure_ascii=False, indent=2)
f = task.executor(io.open, tmp, "w")
f.write(text)
f.close()
os.replace(tmp, PROFIL_PFAD)
# --------------------------------------------------------- JSON-Lines-Basis
def _zeilen_lesen(pfad):
if not os.path.exists(pfad):
return []
f = task.executor(io.open, pfad, "r")
inhalt = f.read()
f.close()
datensaetze = []
for zeile in inhalt.splitlines():
zeile = zeile.strip()
if zeile:
datensaetze.append(json.loads(zeile))
return datensaetze
def _zeilen_schreiben(pfad, datensaetze):
tmp = pfad + ".tmp"
zeilen = [json.dumps(d, ensure_ascii=False) for d in datensaetze]
text = "\n".join(zeilen)
if zeilen:
text += "\n"
f = task.executor(io.open, tmp, "w")
f.write(text)
f.close()
os.replace(tmp, pfad)
def _zeile_anhaengen(pfad, datensatz):
f = task.executor(io.open, pfad, "a")
f.write(json.dumps(datensatz, ensure_ascii=False) + "\n")
f.close()
# ------------------------------------------------------------------ Fahrten
def fahrten_lesen():
return _zeilen_lesen(FAHRTEN_PFAD)
def fahrten_schreiben(fahrten):
_zeilen_schreiben(FAHRTEN_PFAD, fahrten)
def fahrt_anhaengen(fahrt):
_zeile_anhaengen(FAHRTEN_PFAD, fahrt)
def offene_fahrten():
"""Alle Fahrten mit status == 'offen', ältere zuerst."""
fahrten = fahrten_lesen()
offen = [f for f in fahrten if f.get("status") == "offen"]
return sorted(offen, key=lambda f: f.get("ts_start", ""))
def _datensatz_aktualisieren(zeilen, id_feld, id_wert, aenderungen, schreiben, schutz=True):
"""Ersetzt ausgewählte Felder eines Datensatzes anhand seiner ID und
schreibt den gesamten Bestand neu. Manuell geänderte Felder
(edited_fields, analog zu §7.7 Regel 3) werden dabei nie überschrieben.
Gemeinsame Grundlage für Fahrten und Tankvorgänge - beide Archive
funktionieren nach demselben Muster.
schutz=False hebt genau diese Sperre auf - nötig für Eingaben aus der
Oberfläche: edited_fields schützt gegen die automatische Ergänzung, nicht
gegen den Menschen, der das Feld gerade selbst korrigiert."""
geaendert = False
for d in zeilen:
if d.get(id_feld) == id_wert:
geschuetzt = set(d.get("edited_fields", [])) if schutz else set()
for feld, wert in aenderungen.items():
if feld not in geschuetzt:
d[feld] = wert
geaendert = True
break
if geaendert:
schreiben(zeilen)
return geaendert
def fahrt_aktualisieren(trip_id, aenderungen):
return _datensatz_aktualisieren(fahrten_lesen(), "trip_id", trip_id, aenderungen, fahrten_schreiben)
def fahrt_bearbeiten(trip_id, aenderungen):
"""Wie fahrt_aktualisieren(), aber für Eingaben aus der Oberfläche: eine
von Hand gesetzte Angabe sticht auch dann, wenn dasselbe Feld schon einmal
von Hand gesetzt wurde (siehe schutz-Parameter oben)."""
return _datensatz_aktualisieren(fahrten_lesen(), "trip_id", trip_id, aenderungen, fahrten_schreiben, schutz=False)
def fahrt_loeschen(trip_id):
fahrten = fahrten_lesen()
uebrig = [f for f in fahrten if f.get("trip_id") != trip_id]
if len(uebrig) == len(fahrten):
return False
fahrten_schreiben(uebrig)
return True
# ------------------------------------------------------------ Tankvorgänge
def tankvorgaenge_lesen():
return _zeilen_lesen(TANKVORGAENGE_PFAD)
def tankvorgaenge_schreiben(tankvorgaenge):
_zeilen_schreiben(TANKVORGAENGE_PFAD, tankvorgaenge)
def tankvorgang_anhaengen(tankvorgang):
_zeile_anhaengen(TANKVORGAENGE_PFAD, tankvorgang)
def tankvorgang_aktualisieren(tank_id, aenderungen):
return _datensatz_aktualisieren(tankvorgaenge_lesen(), "tank_id", tank_id, aenderungen, tankvorgaenge_schreiben)
def tankvorgang_loeschen(tank_id):
tankvorgaenge = tankvorgaenge_lesen()
uebrig = [t for t in tankvorgaenge if t.get("tank_id") != tank_id]
if len(uebrig) == len(tankvorgaenge):
return False
tankvorgaenge_schreiben(uebrig)
return True
def letzter_tankvorgang():
"""Der zeitlich jüngste bereits erfasste Tankvorgang (nach ts), oder None,
falls noch keiner existiert. Grundlage für die Gefahrene-Distanz-Berechnung
beim Anlegen eines neuen Tankvorgangs (§5.5)."""
tankvorgaenge = tankvorgaenge_lesen()
if not tankvorgaenge:
return None
return max(tankvorgaenge, key=lambda t: t.get("ts") or "")
def distanz_seit_letzter_tankung(aktueller_km):
"""Gefahrene Distanz seit dem vorherigen Tankvorgang, als Vorschlag für
das gleichnamige Formularfeld (§5.5) - frei überschreibbar, genau wie
odometer_km selbst. None, wenn kein Kilometerstand oder kein vorheriger
Tankvorgang vorliegt (erster Eintrag überhaupt). Gemeinsame Grundlage für
Beleg-Erfassung (belegverarbeitung.py) und automatische Tankerkennung
(tankerkennung.py)."""
if aktueller_km is None:
return None
letzter = letzter_tankvorgang()
if not letzter or letzter.get("odometer_km") is None:
return None
return round(aktueller_km - letzter["odometer_km"], 1)
def tankvorgang_nach_id(tank_id):
for t in tankvorgaenge_lesen():
if t.get("tank_id") == tank_id:
return t
return None
def tankvorgang_nach_receipt_key(receipt_key):
"""Für §7.7 Regel: derselbe Beleg (receipt_key, minutengenau) erzeugt
keinen zweiten Datensatz."""
for t in tankvorgaenge_lesen():
if t.get("receipt_key") == receipt_key:
return t
return None
# ------------------------------------------------------- Batteriespannung
def batterieverlauf_lesen():
"""Ein Eintrag pro Tag ({datum, min, min_ts, max, max_ts}), älteste
zuerst - siehe batterieverlauf.py für die Aufzeichnungslogik. min_ts/
max_ts sind die Zeitstempel (ISO, UTC) der jeweiligen Einzelmessung, für
die Datum/Uhrzeit-Anzeige beim Antippen des Diagrammpunkts im Frontend -
der Punkt selbst zeigt nur den Minimalwert (siehe dortiger Kommentar,
warum der aussagekräftig für die Entladung ist)."""
return _zeilen_lesen(BATTERIEVERLAUF_PFAD)
def batterieverlauf_tageswert_aktualisieren(datum, ts, spannung):
"""Trägt eine neue Messung in den Tageseintrag für `datum` ein: legt ihn
beim ersten Wert des Tages an, erweitert sonst nur min/max samt dem
Zeitstempel der jeweils neuen Extremmessung. Das Fahrzeug meldet die
Spannung künftig mehrfach pro Stunde (aktive Fahrt) statt nur einmal
täglich - der komplette Bestand wird deshalb bei jeder Messung neu
geschrieben (wie bei den übrigen JSON-Lines-Beständen hier), was bei
einem Eintrag pro Tag über Jahre hinweg unproblematisch bleibt."""
verlauf = batterieverlauf_lesen()
for eintrag in verlauf:
if eintrag.get("datum") == datum:
if spannung < eintrag["min"]:
eintrag["min"] = spannung
eintrag["min_ts"] = ts
if spannung > eintrag["max"]:
eintrag["max"] = spannung
eintrag["max_ts"] = ts
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)
# --------------------------------------------------------------------- IDs
def neue_id(praefix):
return f"{praefix}-{uuid.uuid4().hex[:12]}"
@@ -1,141 +0,0 @@
"""Reifenzähler ohne utility_meter (§7.5, §10 Punkt 6).
ABWEICHUNG VOM URSPRÜNGLICHEN LASTENHEFT (Frontend-Anpassung, nach dem
ursprünglichen Bau nachträglich verlangt): früher war der Zähler = aktueller
Kilometerstand - fester Startwert je Satz (reifen.saetze.<satz>.
start_odo_km). Das war falsch, sobald einmal zwischen Sommer und Winter
umgeschaltet wurde: die Differenz zum Startwert zählt dann alle seither
gefahrenen Kilometer mit, auch die auf dem jeweils anderen Satz. Jetzt gilt
stattdessen: jeder gefahrene Kilometer wird dem Satz zugerechnet, der zum
Zeitpunkt der Fahrt aktiv war. Dafür führt reifen.saetze.<satz>.km selbst den
fortlaufenden Gesamtstand (statt eines Startwerts), und reifen.
referenz_odo_km hält den zuletzt verarbeiteten Kilometerstand fest, gegen den
die nächste Änderung als Delta gemessen wird. start_odo_km/§13 entfällt
damit vollständig.
Ergebnis wird zusätzlich als eigene pyscript-Zustandsvariable bereitgestellt
(nur zur Kontrolle in Entwicklerwerkzeuge → Zustände, das Frontend liest die
Werte direkt aus dem veröffentlichten Profil):
pyscript.reifen_sommer_km, pyscript.reifen_winter_km,
pyscript.reifen_aktiver_satz
Kein Abhängigkeit von total_increasing oder utility_meter-internem Zustand -
die Fortschreibung braucht nur den zuletzt bekannten Absolutwert des
Kilometerstand-Sensors. Ein zeitweise `unavailable` Quellsensor (§4.1)
verliert dadurch nichts: referenz_odo_km bleibt einfach stehen, bis wieder
ein gültiger Wert kommt, und das nächste Delta wird dann gegen diesen alten
Stand berechnet - auch km, die während eines HA-Neustarts/Ausfalls gefahren
wurden, werden so noch dem damals aktiven Satz zugerechnet.
Entity-IDs stehen zentral in pyscript/modules/einstellungen.py.
"""
import einstellungen
import frontend_veroeffentlichung
import profil
def _aktueller_km():
try:
return float(frontend_veroeffentlichung.zustand_oder_none(einstellungen.KM_SENSOR))
except (TypeError, ValueError):
return None
def _zustaende_veroeffentlichen(p):
reifen = p.get("reifen", {})
saetze = reifen.get("saetze", {})
for satz_name in ("sommer", "winter"):
km = saetze.get(satz_name, {}).get("km")
if km is None:
state.set(f"pyscript.reifen_{satz_name}_km", "unbekannt")
else:
state.set(f"pyscript.reifen_{satz_name}_km", km, new_attributes={"unit_of_measurement": "km"})
state.set("pyscript.reifen_aktiver_satz", reifen.get("aktiv", "unbekannt"))
def _fortschreiben_und_veroeffentlichen(neuer_km, neuer_aktiv=None):
"""Rechnet zuerst mit dem bisher aktiven Satz ab (Delta seit
referenz_odo_km), trägt danach optional einen Satzwechsel ein - in dieser
Reihenfolge, damit beim Wechseln selbst keine km dem neuen Satz
zugerechnet werden, die eigentlich noch auf dem alten gefahren wurden."""
p = profil.profil_lesen()
if p is None:
return
reifen = p.setdefault("reifen", {})
saetze = reifen.setdefault("saetze", {})
aktiv = reifen.get("aktiv", "sommer")
referenz = reifen.get("referenz_odo_km")
if referenz is not None and neuer_km is not None:
delta = round(neuer_km - referenz, 1)
if delta > 0:
satz = saetze.setdefault(aktiv, {})
satz["km"] = round((satz.get("km") or 0) + delta, 1)
if neuer_km is not None:
reifen["referenz_odo_km"] = neuer_km
if neuer_aktiv is not None:
reifen["aktiv"] = neuer_aktiv
profil.profil_schreiben(p)
frontend_veroeffentlichung.profil_veroeffentlichen()
_zustaende_veroeffentlichen(p)
@time_trigger("startup")
def beim_start():
_fortschreiben_und_veroeffentlichen(_aktueller_km())
# Ohne konfigurierten Kilometerstand-Sensor keinen Trigger registrieren -
# eine leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes
# Verhalten, siehe gleiches Muster in fahrterkennung.py.
if einstellungen.KM_SENSOR:
@state_trigger(f"{einstellungen.KM_SENSOR}")
def kilometerstand_geaendert(value=None):
_fortschreiben_und_veroeffentlichen(_aktueller_km())
@service
def audi_dashboard_reifen_wechseln(satz=None):
"""Umschalten des aktiven Reifensatzes (§5.2). Aufruf als
pyscript.audi_dashboard_reifen_wechseln mit satz="sommer"|"winter"."""
if satz not in ("sommer", "winter"):
log.warning(f"audi_dashboard: ungültiger Reifensatz '{satz}'")
return
_fortschreiben_und_veroeffentlichen(_aktueller_km(), neuer_aktiv=satz)
log.info(f"audi_dashboard: aktiver Reifensatz auf '{satz}' umgeschaltet")
@service
def audi_dashboard_reifen_km_setzen(satz=None, km=None):
"""Manuelle Korrektur des gefahrenen Kilometerstands eines Reifensatzes
(Editier-Symbol neben der km-Anzeige in der App). Überschreibt nur den
gespeicherten Zähler selbst - referenz_odo_km (der zuletzt verarbeitete
Fahrzeug-Kilometerstand) bleibt unverändert, damit die nächste
Fortschreibung (kilometerstand_geaendert oben) weiterhin korrekt von hier
aus weiterzählt, statt auf den neuen Wert einen erneuten Delta seit dem
alten Stand aufzuschlagen. configCarZuProfil() im Frontend schreibt km nie
selbst mit (siehe Kommentar dort) - genau deshalb braucht es diesen
eigenen Service statt eines einfachen Profil-Überschreibens."""
if satz not in ("sommer", "winter"):
log.warning(f"audi_dashboard: ungültiger Reifensatz '{satz}'")
return
try:
km = round(float(km), 1)
except (TypeError, ValueError):
log.warning(f"audi_dashboard: ungültiger km-Wert '{km}'")
return
if km < 0:
log.warning(f"audi_dashboard: negativer km-Wert '{km}' abgelehnt")
return
p = profil.profil_lesen()
if p is None:
return
reifen = p.setdefault("reifen", {})
saetze = reifen.setdefault("saetze", {})
saetze.setdefault(satz, {})["km"] = km
profil.profil_schreiben(p)
frontend_veroeffentlichung.profil_veroeffentlichen()
_zustaende_veroeffentlichen(p)
log.info(f"audi_dashboard: km-Stand '{satz}' manuell auf {km} gesetzt")
@@ -1,123 +0,0 @@
"""Automatische Tankerkennung über den Füllstandssensor (Erweiterung zu §7.7/§5.5,
nach dem ursprünglichen Bau nachträglich verlangt).
Beobachtung des Nutzers: der Füllstand (TANK_SENSOR, in Prozent) springt beim
Fahren nie nach oben, er sinkt nur - jeder Anstieg ist also ein Tankvorgang.
Schwelle nach Nutzervorgabe: ab +5 Liter ODER +9 Prozentpunkten Anstieg gilt
als nachgetankt. Die 5-Liter-Vorgabe wird über fahrzeug.tankvolumen_liter aus
dem Fahrzeugprofil in Prozentpunkte umgerechnet, damit beide Angaben auf
derselben Einheit (Prozent) verglichen werden können; es gilt jeweils die
empfindlichere (kleinere) der beiden Schwellen.
Tiefststand-Tracking statt einfachem Vorher/Nachher-Vergleich: die
Integration liefert einen Tankvorgang oft in mehreren kleinen Schritten
(z. B. 40% -> 45% -> 60%), von denen keiner allein die Schwelle
überschreiten muss. Deshalb wird der zuletzt bekannte Tiefststand
gespeichert und der Anstieg dagegen gemessen, nicht gegen den unmittelbar
vorherigen Wert. Nach Anlage eines Tankvorgangs wird der Tiefststand auf den
aktuellen Wert zurückgesetzt, damit derselbe Vorgang nicht mehrfach neue
Datensätze erzeugt, wenn der Sensor währenddessen weiter ansteigt.
Der so angelegte Tankvorgang ist bewusst ein unvollständiger Platzhalter
(status "unvollständig", ohne Liter/Kosten/Station) - siehe §10 Punkt 5 der
letzten Rückmeldung: die Erwartung ist ein automatisch erkannter Tankvorgang
mit Zeitstempel, Kilometerstand und gefahrener Distanz, den der Nutzer später
per Beleg-Nachtrag (audi_dashboard_beleg_hochladen mit tank_id, siehe
belegverarbeitung.py) vervollständigt.
EINSCHRÄNKUNG: der Tiefststand (_tiefststand_pct) lebt nur im Arbeitsspeicher
dieses Skripts, analog zur bekannten Einschränkung in fahrterkennung.py
(_fahrt_start_ts). Ein HA-Neustart während oder kurz vor einem Tankvorgang
verliert den bisherigen Tiefststand - der nächste Anstieg wird dann gegen den
ersten nach dem Neustart gemeldeten Wert gemessen. Bewusst nicht gehärtet,
aus demselben Grund wie dort.
Entity-IDs stehen zentral in pyscript/modules/einstellungen.py.
"""
import datetime
import einstellungen
import frontend_veroeffentlichung
import profil
LITER_SCHWELLE = 5
PROZENT_SCHWELLE = 9
STANDARD_TANKVOLUMEN_LITER = 58 # Fallback, falls im Profil nicht hinterlegt
_tiefststand_pct = None
def _als_zahl(wert):
try:
return float(wert)
except (TypeError, ValueError):
return None
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
liter_als_prozent = (LITER_SCHWELLE / tankvolumen) * 100
return min(liter_als_prozent, PROZENT_SCHWELLE)
@time_trigger("startup")
def beim_start():
profil.ordner_sicherstellen()
# Ohne konfigurierten Tankfüllstand-Sensor keinen Trigger registrieren -
# eine leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes
# Verhalten, siehe gleiches Muster in fahrterkennung.py.
if einstellungen.TANK_SENSOR:
@state_trigger(f"{einstellungen.TANK_SENSOR}")
def tankfuellstand_geaendert(value=None, old_value=None):
global _tiefststand_pct
aktuell = _als_zahl(value)
if aktuell is None:
return
if _tiefststand_pct is None:
_tiefststand_pct = aktuell
return
if aktuell <= _tiefststand_pct:
_tiefststand_pct = aktuell
return
anstieg = aktuell - _tiefststand_pct
if anstieg >= _schwelle_prozent():
_tankvorgang_automatisch_anlegen(anstieg)
_tiefststand_pct = aktuell
def _tankvorgang_automatisch_anlegen(anstieg_pct):
odometer_km = frontend_veroeffentlichung.zustand_oder_none(einstellungen.KM_SENSOR)
tankvorgang = {
"tank_id": profil.neue_id("f"),
"receipt_key": None,
"ts": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"liters": None,
"fuel_total_eur": None,
"price_per_l": None,
"discount": None,
"station_name": None,
"odometer_km": odometer_km,
"distance_km": profil.distanz_seit_letzter_tankung(odometer_km),
"fuel_type": None,
"source": "auto",
"status": "unvollständig",
"receipt_file": None,
"edited_fields": [],
}
profil.tankvorgang_anhaengen(tankvorgang)
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
log.info(
f"audi_dashboard: Tankvorgang {tankvorgang['tank_id']} automatisch erkannt "
f"(Füllstandsanstieg {round(anstieg_pct, 1)} Prozentpunkte)"
)
@@ -1,184 +0,0 @@
"""Update-Funktion für die Oberfläche ("Update suchen" in Einstellungen).
Voraussetzung: einstellungen.UPDATE_REPO_URL zeigt auf ein Git-Repository, in
das dieses Projekt (pyscript/ + www/, wie mit update.ps1 auf die eigene
Testinstanz gespielt) gepflegt/gepusht wird - z. B. ein privates GitHub-Repo,
genau wie es für die HACS-Integration bereits genutzt wird. Ohne eingetragene
URL meldet "Update suchen" nur "keine Update-Quelle eingerichtet" (§9-
Fallback), es passiert nichts von selbst.
Kopiert wie update.ps1 bewusst NUR Code:
- pyscript/ komplett (kein Nutzerdaten-Ordner dort)
- www/*.js, www/*.css, www/badges/*
Bewusst NICHT angefasst: www/bilder/ (vom Nutzer hochgeladene Fahrzeugfotos,
siehe bilderverwaltung.py) und alles unter audi_dashboard/ (Fahrzeugprofil,
Fahrten- und Tankvorgänge-Archive) - ein Update darf niemals echte
Fahrzeugdaten oder eigene Fotos überschreiben.
Vor jeder Installation wird der bisherige Code-Stand unter
audi_dashboard/code_backups/<Zeitstempel>/ gesichert (gleiches Muster wie
backup.py für die Nutzerdaten) - kein automatisches Rollback in der
Oberfläche, aber ein manueller Weg zurück, falls ein Update Probleme macht.
git ist im offiziellen Home-Assistant-Container vorhanden (an der
Testinstanz geprüft) und subprocess ist über allow_all_imports bereits
freigegeben (siehe configuration_snippet.yaml), deshalb hier statt eines
selbstgebauten ZIP-Downloads einfach ein flacher Git-Checkout in einen
eigenen Zwischenordner, getrennt von /config/audi_dashboard (Nutzerdaten)
und /config/pyscript, /config/www (Live-Code).
"""
import datetime
import io
import json
import os
import shutil
import subprocess
import einstellungen
import profil
QUELLE_ORDNER = "/config/.audi_dashboard_update_quelle"
BACKUP_ORDNER = f"{profil.BASIS}/code_backups"
LIVE_PYSCRIPT = "/config/pyscript"
LIVE_WWW = "/config/www"
CODE_DATEIEN_WWW = ["audi-dashboard-app.js", "audi-dashboard-panel.js", "audi-dashboard.css"]
def _status_veroeffentlichen(daten):
state.set("pyscript.audi_dashboard_update_status", "aktuell", new_attributes={"daten": daten})
def _git(*args, cwd=None):
# subprocess.run() direkt, ohne task.executor - genau wie der bestehende
# PDF-Parser-Aufruf in belegverarbeitung.py (_parser_aufrufen), an der
# Testinstanz bereits bestätigt funktionsfähig.
return subprocess.run(
["git", *args],
cwd=cwd, capture_output=True, text=True, timeout=60,
)
def _lokale_version():
pfad = f"{LIVE_WWW}/audi-dashboard-version.json"
if not os.path.exists(pfad):
return None
try:
f = task.executor(io.open, pfad, "r")
inhalt = f.read()
f.close()
return json.loads(inhalt).get("version")
except (ValueError, OSError):
return None
def _quelle_aktualisieren():
"""Klont bzw. holt den konfigurierten Branch flach in QUELLE_ORDNER.
Gibt (erfolgreich, fehlertext) zurück."""
zweig = einstellungen.UPDATE_BRANCH or "main"
if not os.path.exists(f"{QUELLE_ORDNER}/.git"):
os.makedirs(os.path.dirname(QUELLE_ORDNER), exist_ok=True)
if os.path.exists(QUELLE_ORDNER):
task.executor(shutil.rmtree, QUELLE_ORDNER)
r = _git("clone", "--depth", "1", "--branch", zweig, einstellungen.UPDATE_REPO_URL, QUELLE_ORDNER)
if r.returncode != 0:
return False, r.stderr.strip() or "git clone fehlgeschlagen"
return True, None
r = _git("fetch", "--depth", "1", "origin", zweig, cwd=QUELLE_ORDNER)
if r.returncode != 0:
return False, r.stderr.strip() or "git fetch fehlgeschlagen"
r = _git("reset", "--hard", f"origin/{zweig}", cwd=QUELLE_ORDNER)
if r.returncode != 0:
return False, r.stderr.strip() or "git reset fehlgeschlagen"
return True, None
@service
def audi_dashboard_update_pruefen():
"""Prüft, ob unter einstellungen.UPDATE_REPO_URL eine neuere Version
vorliegt, ohne etwas zu installieren. Aufruf als
pyscript.audi_dashboard_update_pruefen."""
jetzt = datetime.datetime.now().isoformat()
if not einstellungen.UPDATE_REPO_URL:
_status_veroeffentlichen({"stand": "keine_quelle", "geprueft_um": jetzt})
return
ok, fehler = _quelle_aktualisieren()
if not ok:
_status_veroeffentlichen({
"stand": "fehler", "fehler": fehler, "geprueft_um": jetzt,
"aktuelle_version": _lokale_version(),
})
log.warning(f"audi_dashboard: Update-Prüfung fehlgeschlagen: {fehler}")
return
quell_version_pfad = f"{QUELLE_ORDNER}/www/audi-dashboard-version.json"
neue_version = None
if os.path.exists(quell_version_pfad):
f = task.executor(io.open, quell_version_pfad, "r")
inhalt = f.read()
f.close()
try:
neue_version = json.loads(inhalt).get("version")
except ValueError:
neue_version = None
aktuelle_version = _lokale_version()
verfuegbar = neue_version is not None and aktuelle_version is not None and neue_version > aktuelle_version
_status_veroeffentlichen({
"stand": "geprueft", "verfuegbar": verfuegbar,
"aktuelle_version": aktuelle_version, "neue_version": neue_version,
"geprueft_um": jetzt,
})
log.info(f"audi_dashboard: Update-Prüfung ok - aktuell={aktuelle_version}, verfügbar={neue_version}")
@service
def audi_dashboard_update_installieren():
"""Installiert den zuletzt per audi_dashboard_update_pruefen geholten
Stand aus QUELLE_ORDNER - sichert vorher den bisherigen Code. Aufruf als
pyscript.audi_dashboard_update_installieren."""
if not os.path.exists(f"{QUELLE_ORDNER}/.git"):
log.warning("audi_dashboard: Update-Installation ohne vorherige Prüfung abgelehnt")
return
zeitstempel = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
ziel_backup = f"{BACKUP_ORDNER}/{zeitstempel}"
os.makedirs(ziel_backup, exist_ok=True)
if os.path.exists(LIVE_PYSCRIPT):
task.executor(shutil.copytree, LIVE_PYSCRIPT, f"{ziel_backup}/pyscript")
for name in CODE_DATEIEN_WWW:
quelle = f"{LIVE_WWW}/{name}"
if os.path.exists(quelle):
task.executor(shutil.copy, quelle, f"{ziel_backup}/{name}")
quelle_pyscript = f"{QUELLE_ORDNER}/pyscript"
if os.path.exists(quelle_pyscript):
if os.path.exists(LIVE_PYSCRIPT):
task.executor(shutil.rmtree, LIVE_PYSCRIPT)
task.executor(shutil.copytree, quelle_pyscript, LIVE_PYSCRIPT)
for name in CODE_DATEIEN_WWW:
quelle = f"{QUELLE_ORDNER}/www/{name}"
if os.path.exists(quelle):
task.executor(shutil.copy, quelle, f"{LIVE_WWW}/{name}")
quelle_badges = f"{QUELLE_ORDNER}/www/badges"
if os.path.exists(quelle_badges):
ziel_badges = f"{LIVE_WWW}/badges"
if os.path.exists(ziel_badges):
task.executor(shutil.rmtree, ziel_badges)
task.executor(shutil.copytree, quelle_badges, ziel_badges)
# Version neu schreiben (aktuelle Uhrzeit als Cache-Buster, wie
# update.ps1) statt den im Quell-Repo eingecheckten Wert blind zu
# übernehmen - erzwingt bei jedem Browser einen echten Neuabruf von
# audi-dashboard-app.js/.css (siehe audi-dashboard-panel.js).
version = int(datetime.datetime.now().timestamp())
f = task.executor(io.open, f"{LIVE_WWW}/audi-dashboard-version.json", "w")
f.write(json.dumps({"version": version}))
f.close()
_status_veroeffentlichen({
"stand": "installiert", "verfuegbar": False,
"aktuelle_version": version, "neue_version": None,
"geprueft_um": datetime.datetime.now().isoformat(),
})
log.info(f"audi_dashboard: Update installiert (Backup unter {ziel_backup}), neue Version {version}")
-102
View File
@@ -1,102 +0,0 @@
<#
.SYNOPSIS
Spielt Code-Updates (pyscript + Frontend) in eine laufende Home-Assistant-
Installation ein, ohne Neustart und ohne Cache-Probleme im Browser.
.BESCHREIBUNG
Kopiert nur Code, nie Daten:
- pyscript/* -> <Ziel>/pyscript/ (pyscript lädt das automatisch neu,
siehe Kopfkommentar dort - an
einer Testinstanz bestätigt: eine
geänderte Datei war innerhalb von
Millisekunden aktiv, ganz ohne
Neustart)
- www/*.js, www/*.css, www/badges/*, www/bilder/* -> <Ziel>/www/
(audi-dashboard-ios.css nicht vergessen: die App hängt sich dieses
Overlay-Stylesheet selbst per JS in den Shadow-Root - ohne es fehlt
nur der optische Feinschliff, kein Fehler, aber ein veralteter Stand)
- www/audi-dashboard-version.json wird IMMER neu geschrieben, mit der
aktuellen Uhrzeit als Versionsnummer - der Lade-Stub
(audi-dashboard-panel.js) liest diese Datei bei jedem Seitenaufruf
ohne Cache und lädt den eigentlichen Code darüber immer frisch nach
(siehe Kopfkommentar in www/audi-dashboard-panel.js).
Absichtlich NICHT kopiert: die Datenbestände in data/ - das würde ein
bereits laufendes Fahrzeugprofil, die Fahrten- und Tankvorgänge-Archive
sowie die Sensor-Zuordnung überschreiben. Diese Dateien sind nur die
Erstbefüllung für die einmalige Installation (siehe INSTALL.md Schritt 2).
Eine Ausnahme davon ist shell_beleg_parser.py: die Datei liegt zwar in
data/, ist aber Code, kein Datenbestand - belegverarbeitung.py ruft sie
als Unterprozess aus /config/audi_dashboard/ auf. Ohne diese Ausnahme
käme jede Änderung am Belegleser (dem einzigen getesteten Teil des
Projekts) auf keiner Instanz an.
.PARAMETER Ziel
Pfad zum config-Verzeichnis von Home Assistant, z. B. ein per Samba
eingebundenes Laufwerk (\\<HA-IP>\config) oder ein lokal gemounteter Pfad.
.BEISPIEL
.\update.ps1 -Ziel "\\homeassistant.local\config"
#>
param(
[Parameter(Mandatory = $true)]
[string]$Ziel
)
$ErrorActionPreference = "Stop"
$hier = $PSScriptRoot
if (-not (Test-Path $Ziel)) {
throw "Ziel nicht erreichbar: $Ziel — Samba-Share verbunden? Pfad korrekt?"
}
Write-Host "Kopiere pyscript/ ..." -ForegroundColor Cyan
robocopy "$hier\pyscript" "$Ziel\pyscript" /MIR /NFL /NDL /NJH /NJS | Out-Null
Write-Host "Kopiere www/ (ohne bilder/ und badges/, die separat behandelt werden) ..." -ForegroundColor Cyan
New-Item -ItemType Directory -Force -Path "$Ziel\www" | Out-Null
Copy-Item "$hier\www\audi-dashboard-app.js" "$Ziel\www\audi-dashboard-app.js" -Force
Copy-Item "$hier\www\audi-dashboard-panel.js" "$Ziel\www\audi-dashboard-panel.js" -Force
Copy-Item "$hier\www\audi-dashboard.css" "$Ziel\www\audi-dashboard.css" -Force
Copy-Item "$hier\www\audi-dashboard-ios.css" "$Ziel\www\audi-dashboard-ios.css" -Force
# Code, der in data/ liegt (siehe Kopfkommentar) - der Ordner selbst wird
# bewusst nicht gespiegelt, diese eine Datei aber schon.
if (Test-Path "$Ziel\audi_dashboard") {
Copy-Item "$hier\data\shell_beleg_parser.py" "$Ziel\audi_dashboard\shell_beleg_parser.py" -Force
Write-Host " Belegleser aktualisiert"
}
if (Test-Path "$hier\www\bilder") {
Write-Host "Kopiere www/bilder/ ..." -ForegroundColor Cyan
New-Item -ItemType Directory -Force -Path "$Ziel\www\bilder" | Out-Null
Copy-Item "$hier\www\bilder\*" "$Ziel\www\bilder\" -Force -Recurse
}
if (Test-Path "$hier\www\badges") {
Write-Host "Kopiere www/badges/ ..." -ForegroundColor Cyan
New-Item -ItemType Directory -Force -Path "$Ziel\www\badges" | Out-Null
Copy-Item "$hier\www\badges\*" "$Ziel\www\badges\" -Force -Recurse
}
# Zwei Zahlen mit verschiedenen Aufgaben, siehe VERSIONIERUNG.md:
# version = Unix-Sekunden, bricht den Browser-Cache, bei jedem Deploy neu
# app = identifiziert die Fassung; wird aus der mitgelieferten Datei
# übernommen, nicht neu erfunden
# An der App-Version erkennt die iOS-App, ob sie noch dem ausgelieferten Stand
# entspricht. Ginge sie hier verloren, wäre der Vergleich still abgeschaltet.
$version = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$appVersion = ""
if (Test-Path "$hier\www\audi-dashboard-version.json") {
try { $appVersion = (Get-Content "$hier\www\audi-dashboard-version.json" -Raw | ConvertFrom-Json).app } catch { $appVersion = "" }
}
$inhaltVersion = if ($appVersion) { "{ ""version"": $version, ""app"": ""$appVersion"" }" }
else { "{ ""version"": $version }" }
$inhaltVersion | Out-File -FilePath "$Ziel\www\audi-dashboard-version.json" -Encoding utf8 -NoNewline
Write-Host ""
Write-Host "Fertig. Version $version$(if ($appVersion) { " (App $appVersion)" })." -ForegroundColor Green
Write-Host "pyscript-Änderungen sind bereits aktiv (Auto-Reload, kein Neustart nötig)."
Write-Host "Frontend-Änderungen: im Browser einmal ganz normal neu laden (F5) reicht -"
Write-Host "kein Hard-Refresh, kein HA-Neustart."
File diff suppressed because it is too large Load Diff
@@ -1,344 +0,0 @@
/* ==================================================================
iOS-Auflage über audi-dashboard.css — Audi CI (Rot F50537, Audi Type,
dunkle Fläche) in der Formensprache einer modernen iOS-App:
Große Titel, gruppierte Listen mit 44-px-Zeilen, transluente Tab-Leiste,
iOS-Schalter und -Segmente, gefüllte Aktionsknöpfe.
Reine Auflage: audi-dashboard.css und die App-Logik bleiben unberührt.
================================================================== */
:host{
display:block;height:100%;container-type:inline-size;
font-family:"Audi Type",Helvetica,Arial,sans-serif;
--ios-tint:#F50537;
--ios-sep:rgba(255,255,255,.13);
--ios-grau:#8E8E93;
--ios-fill:rgba(120,120,128,.24);
--r-tile:16px;
/* deckende Kachelfarbe fuer Flaechen, die etwas verdecken muessen
(Wischzeile ueber dem Loeschen-Feld, Kartenfeld) */
--tile-deckend:#171B21;
}
:host([data-theme="nacht"]){
--canvas:#0C1014;
--tile:rgba(255,255,255,.055);
--tile-2:rgba(255,255,255,.10);
--line:rgba(255,255,255,.13);
--line-strong:rgba(255,255,255,.24);
--fg:#FFFFFF; --fg2:#BFC4CC; --fg3:#8E8E93;
--ok:#30D158; --warn:#FFD60A; --bad:#FF453A;
/* Ohne diese Angabe rendert der Browser native Steuerelemente (Dropdown-
Aufklapp-Liste bei <select>, Scrollbars) in seiner Standard-Hellansicht,
egal wie dunkel die App selbst ist - das liess z. B. "Modell" beim
Aufklappen grell-weiss aufblitzen. Betrifft alle <select>-Felder. */
color-scheme: dark;
}
:host([data-theme="tag"]){
--canvas:#F2F2F7;
--tile:#FFFFFF;
--tile-2:#EFEFF4;
--line:rgba(60,60,67,.13);
--line-strong:rgba(60,60,67,.24);
--fg:#000000; --fg2:#3C3C43; --fg3:#8E8E93;
--ok:#34C759; --warn:#FF9F0A; --bad:#FF3B30;
--ios-sep:rgba(60,60,67,.16);
--ios-fill:rgba(120,120,128,.16);
--tile-deckend:#FFFFFF;
color-scheme: light;
}
body{padding:0;background:var(--canvas)}
html,body{height:100%}
/* Der von _aufbauen() eingefuegte Wrapper zwischen Host und .phone. Die
412px-Deckelung aus audi-dashboard.css (fuer die alte Einzelgroesse
gedacht) wird hier unbedingt aufgehoben, nicht nur ab 860px: auf
schmalen Bildschirmen macht der Viewport selbst die Breite eng genug,
:host-Selektoren innerhalb @container werden von diesem Browser aber
offenbar verworfen - deshalb unbedingt statt bedingt.
Die Hoehe bleibt dagegen viewport-verankert (100dvh statt 100%): das ist
der Grund, warum audi-dashboard.css das ueberhaupt so baute - HA gibt der
panel_custom-Kette keine verlaessliche Hoehe weiter, height:100% liess den
Hauptinhalt hier auf 0px kollabieren (Tabbar direkt unter dem Header,
main#view unsichtbar). Erst ab 860px (siehe @container unten) darf die
880px-Deckelung fallen, damit die App auf grossen Bildschirmen mehr als
eine schwebende Telefonkarte ist. */
:host > div{height:min(880px, calc(100dvh - 24px));max-width:none}
.phone{width:100%;max-width:none;height:100%;border-radius:0;
font-family:"Audi Type",Helvetica,Arial,sans-serif;
-webkit-font-smoothing:antialiased;letter-spacing:0}
/* ---------------- Navigationsleiste ---------------- */
/* Sichere Bereiche des Geräterahmens: Statusleiste oben, Home-Indikator unten */
/* Linker Innenabstand = --seitenrand von main#view (16px), damit der Titel
exakt auf der Kante der Kacheln darunter sitzt. */
.topbar{align-items:center;gap:4px;padding:56px 20px 4px 16px}
.head{display:flex;flex-direction:column}
.eyebrow{display:none}
.title{font-size:22px;font-weight:400;letter-spacing:-.01em;line-height:1.2}
.back{width:34px;height:34px;margin-left:-8px;justify-content:center}
/* Design-Audit 2026-08-13, auf allen Breiten nachgezogen 2026-08-17: .back ist
auch inaktiv (opacity:0) noch ein Flex-Element plus Gap - auf Wurzelseiten
ohne Zurueck-Pfeil schob das die Kopfzeile (.head/.title) sichtbar weiter
nach rechts, als main#view darunter beginnt. Das negative margin-right hebt
zusaetzlich den Gap auf, sonst blieben 4px Versatz. Auf Detailseiten
(.back.on) bleibt der Platz bewusst reserviert, das ist dort ein echter,
bedienbarer Knopf. */
.back:not(.on){width:0;min-width:0;margin:0 -4px 0 0;overflow:hidden}
/* Rot bleibt Akzent/destruktiv, nicht Navigationsfarbe (dieselbe Regel wie
bei den Aktions-Buttons und dem aktiven Menü-Icon) - derselbe neutrale Ton
wie die Kopfzeile selbst. */
.back svg{width:22px;height:22px;color:var(--fg)}
.sync{margin-top:3px;font-size:12.5px;color:var(--fg3)}
.sync svg{width:13px;height:13px}
.themebtn{width:36px;height:36px;border:0;background:var(--ios-fill);color:var(--fg2)}
.themebtn:hover{background:var(--tile-2);color:var(--fg)}
/* Telefonbreite: die Ringe stehen oben rechts und oeffnen die Einstellungen
(Stand nach dem Design-Audit). Das Zahnrad entfaellt dort, damit oben rechts
nur ein Element sitzt. */
.rings{order:9;margin-left:8px;margin-right:-10px;cursor:pointer;
min-width:44px;min-height:44px;height:44px;justify-content:flex-end;padding:0 10px}
.rings svg{height:17px}
.profilbtn{display:none}
/* Unterseite: kompakter, zentrierter Titel wie in einer iOS-Navigationsleiste */
.phone:has(.back.on) .topbar{padding:54px 16px 6px;gap:8px}
.phone:has(.back.on) .head{align-items:center}
.phone:has(.back.on) .title{font-size:17px;font-weight:400;letter-spacing:-.01em;text-align:center}
.phone:has(.back.on) .sync{margin-top:2px;font-size:11.5px}
main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smooth}
/* ---------------- Karten und Listen ---------------- */
.tile{padding:16px 18px;margin-bottom:14px;border-radius:var(--r-tile)}
.tile.flat{padding:0}
.tilebtn:hover{background:var(--tile-2)}
/* top/margin-top NICHT von der Basis uebernehmen: alle .go-Kacheln
(Fahrzeug/Service/Versicherung/Reifen/Schutzbrief) sind mehrzeilige
Kacheln mit .label oben plus einer ganzen dl.rows-Liste darunter - eine
vertikale 50%-Zentrierung landet dann mitten in der Zeilenliste statt
neben dem Titel und ueberlappt umgebrochenen Zeilentext (Design-Audit
2026-08-13). Bleibt bei top:var(--sp-5) aus der Basis, nur rechter
Abstand/Farbe werden fuer die iOS-Optik angepasst. */
.tilebtn .go{right:18px;stroke:var(--ios-grau);stroke-width:1.6}
.label{font-size:13px;letter-spacing:.01em;text-transform:none;color:var(--fg3)}
.row{min-height:44px;padding:11px 0;border-bottom:1px solid var(--ios-sep)}
.row dt{font-size:16px;color:var(--fg)}
.row dd{font-size:16px;color:var(--fg2)}
.row dd small{font-size:12.5px;margin-top:2px}
.row-fussnote{font-size:12.5px}
.rows-fest dt{flex:0 0 150px}
.chev{stroke:var(--ios-grau);stroke-width:1.6}
.acc-h{padding:13px 0;border-bottom:1px solid var(--ios-sep)}
.acc-h .k{font-size:16px}
.acc-h .v{font-size:12.5px}
.leaf{padding:12px 0 12px 40px;border-bottom:1px solid var(--ios-sep)}
.leaf .k{font-size:16px}
.leaf .v{font-size:15px}
.leaf .k small,.leaf .v small{font-size:12.5px}
.mark::before,.mark::after{background:var(--ios-grau)}
/* ---------------- Bedienelemente ---------------- */
.seg{gap:0;padding:2px;background:var(--ios-fill);border-radius:9px;margin-top:14px}
.seg button{border:0;border-radius:7px;padding:7px 8px;font-size:13px;font-weight:400;
letter-spacing:0;text-transform:none;color:var(--fg2)}
.seg button.on{background:var(--tile-2);color:var(--fg);box-shadow:0 1px 3px rgba(0,0,0,.25)}
/* Aktive/Primär-Buttons sind grau, nicht rot (Nutzerwunsch 2026-08-16): Rot
bleibt nur noch für destruktive Aktionen (.loeschen). .aktion allein ist
die neutrale Sekundäraktion, .primaer die grau gefüllte Hauptaktion -
dieselbe Fläche wie die Grundauflage (var(--fg)/var(--canvas)). */
.aktion{margin-top:18px;padding:15px;border:0;border-radius:14px;
background:var(--ios-fill);color:var(--fg);font-size:16px;font-weight:400;
letter-spacing:0;text-transform:none}
.aktion:hover{background:var(--tile-2)}
.aktion.primaer{background:var(--fg);color:var(--canvas)}
.aktion.primaer:hover{filter:brightness(1.08)}
.aktion.loeschen{background:var(--ios-fill);color:var(--bad);border:0}
.aktion.loeschen:hover{background:var(--bad);color:#fff;filter:none}
.feld{min-height:44px;padding:9px 0;border-bottom:1px solid var(--ios-sep)}
.feld label{font-size:16px;color:var(--fg)}
.feld input,.feld select{background:transparent;border:0;border-radius:8px;font-size:16px;
padding:8px 4px;color:var(--fg2)}
/* Design-Audit 2026-08-13/16: die graue Flaeche ist das einheitliche Signal
"das hier kann ich aendern". Sie trugen bisher nur die Felder mit Einheit
(.mitEinheit input, z.B. "Pause bis [15] Minuten"); alles andere - <select>
wie "10.000 km"/"1 Jahr", aber auch FIN oder Kennzeichen - stand ohne Kasten
da und war von reinem Anzeigetext nicht zu unterscheiden. Jetzt bekommt
jedes editierbare Feld denselben Kasten. Ausgenommen sind Eingaben mit
eigener Optik (Schalter, Auswahlkaestchen, Regler, Dateiauswahl) - die
behalten die transparente Grundregel darueber.
.mitEinheit input braucht deshalb keine eigene Regel mehr; Breite und
Rechtsbuendigkeit dort kommen weiterhin aus audi-dashboard.css. */
.feld input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]),
.feld select{background:var(--ios-fill);padding:8px 10px}
.feld input:focus,.feld select:focus{color:var(--fg)}
/* Pull-down-Button-Optik (Apple HIG): ein einzelner Abwaerts-Chevron statt
des (durch appearance:none in audi-dashboard.css bereits entfernten)
nativen Browser-Pfeils - macht die Auswahlfelder als solche erkennbar,
ohne ein eigenes Menue-Widget nachzubauen. Farbe fix auf --ios-grau, das
ist bereits die Farbe aller anderen Chevrons (.chev) in beiden Themes. */
.feld select{
padding-right:28px;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 4.5l3.5 3.5 3.5-3.5' fill='none' stroke='%238E8E93' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat:no-repeat;
background-position:right 9px center;
background-size:10px 10px;
}
.notiz{background:var(--ios-fill);border:0;border-radius:12px;font-size:16px}
.switch{width:51px;height:31px}
.switch .track{background:var(--ios-fill);border:0}
.switch .track::before{left:2px;top:2px;width:27px;height:27px;box-shadow:0 2px 6px rgba(0,0,0,.3)}
/* Grün statt Rot: Schalter-Ein-Zustand ist kein destruktiver/Primär-Akzent,
sondern ein neutrales "an" - deckt sich mit dem echten iOS-Systemgrün, das
--ok hier ohnehin schon trägt (Design-Review §B). */
.switch input:checked+.track{background:var(--ok)}
.switch input:checked+.track::before{transform:translateX(20px)}
.pill{font-size:12px;letter-spacing:.01em;text-transform:none;padding:5px 11px;
border:0;background:var(--ios-fill);color:var(--fg2)}
.status{padding:14px 0 2px}
.status .t{font-size:16px}
.dot{width:10px;height:10px}
/* ---------------- Zahlen (Audi CI bleibt führend) ---------------- */
.fig{letter-spacing:-.03em}
.unit{color:var(--fg3)}
.balken{height:6px;background:var(--ios-fill)}
.balken i{background:var(--ok)}
.quad .l{font-size:11px;letter-spacing:.01em;text-transform:none}
.legend span{font-size:12.5px}
/* ---------------- Menüs und Popups ---------------- */
.bildmenu,.sdpopup{border:0;border-radius:14px;background:rgba(40,44,52,.92);
-webkit-backdrop-filter:saturate(180%) blur(22px);backdrop-filter:saturate(180%) blur(22px);
box-shadow:0 12px 40px rgba(0,0,0,.5)}
:host([data-theme="tag"]) .bildmenu,:host([data-theme="tag"]) .sdpopup{background:rgba(250,250,252,.92)}
.bildmenu button{padding:13px 16px;font-size:16px;border-bottom:1px solid var(--ios-sep)}
/* ---------------- Tab-Leiste ---------------- */
.tabbar{border-top:1px solid var(--ios-sep);padding-bottom:26px;
background:rgba(12,16,20,.72);
-webkit-backdrop-filter:saturate(180%) blur(24px);backdrop-filter:saturate(180%) blur(24px)}
:host([data-theme="tag"]) .tabbar{background:rgba(242,242,247,.78)}
.tab{border-top:0;margin-top:0;padding:7px 2px 6px;gap:3px;
font-size:10px;letter-spacing:0;text-transform:none;color:var(--ios-grau)}
/* Beschriftung folgt weiter dem Schalter in den Einstellungen.
:not(.tabpille) ist Pflicht: die Icon-Huelle ist ebenfalls ein <span> (span
class="tabpille"), ein blosses ".tab span" traf also nicht nur das Label
sondern auch die Pille selbst - das Icon verschwand mit, nicht nur der
Text. Genau das war der gemeldete "ganze Menueleiste verschwindet"-Bug. */
.tabbar.ohne .tab span:not(.tabpille){display:none}
.tab svg{width:26px;height:26px;stroke-width:1.5}
/* myAudi-Menueleiste: das aktive Symbol sitzt hell in einer gefuellten Pille,
die uebrigen bleiben grau - kein farbiger Tint, kein Oberkantenstrich. */
.tabpille{width:63px;height:39px;border-radius:20px}
.tab.on{color:var(--fg);border-top-color:transparent}
.tab.on .tabpille{background:rgba(255,255,255,.14)}
:host([data-theme="tag"]) .tab.on .tabpille{background:rgba(0,0,0,.07)}
/* Ist die Beschriftung sichtbar (nicht .tabbar.ohne), soll die graue Flaeche
Icon UND Text umschliessen statt nur die enge Pille ums Icon - die Flaeche
wandert dafuer vom Icon-Symbol auf den ganzen Tab-Knopf. Im reinen
Icon-Modus bleibt die enge Pille wie zuvor. */
.tabbar:not(.ohne) .tab.on{background:rgba(255,255,255,.14);border-radius:14px}
:host([data-theme="tag"]) .tabbar:not(.ohne) .tab.on{background:rgba(0,0,0,.07)}
.tabbar:not(.ohne) .tab.on .tabpille{background:none}
.tabbar.ohne .tab{padding:11px 2px 10px}
:focus-visible{outline:2px solid var(--ios-tint);outline-offset:2px}
/* Die Wischzeile muss das darunterliegende Loeschen-Feld verdecken - das
halbtransparente --tile der Auflage liess es durchscheinen. */
.swipe-wrap .swipe-content{background:var(--tile-deckend)}
.mapbox,.mapbox .leaflet-container{background:var(--tile-deckend)}
/* ==================================================================
Große Bildschirme (Tablet, Desktop): dieselbe App, aber die Tab-Leiste
wird zur seitlichen Navigation und der Inhalt bekommt eine ruhige,
begrenzte Spalte. Container-Abfrage statt @media, weil die App in einem
eigenen Rahmen sitzt und nicht die Fensterbreite messen darf. Schwelle 860 px.
================================================================== */
/* Klon der Ringe in der Menueleiste - nur im Spaltenlayout sichtbar */
.navmarke{display:none}
@container (min-width:860px){
/* Ab hier keine schwebende Telefonkarte mehr - die 880px-Deckelung faellt,
die App fuellt die tatsaechliche (weiterhin viewport-verankerte) Hoehe. */
:host > div{height:calc(100dvh - 24px)}
.phone{display:grid;grid-template-columns:264px minmax(0,1fr);
grid-template-rows:auto auto minmax(0,1fr)}
/* max-width:860px muss zu main#view passen, sonst treibt der Flex-Inhalt
(.head{flex:1} schiebt .profilbtn nach rechts) das Zahnrad bis an den
Rand der vollen Grid-Spalte statt an den Rand der Inhaltsspalte darunter -
auf breiten Bildschirmen klaffte da eine sichtbare Luecke. */
.topbar{grid-column:2;grid-row:1;padding:38px 44px 8px;max-width:860px}
.phone:has(.back.on) .topbar{grid-column:2;grid-row:1;padding:34px 44px 8px;justify-content:flex-start;max-width:860px}
.phone:has(.back.on) .head{align-items:flex-start}
.phone:has(.back.on) .title{font-size:26px;text-align:left}
.title{font-size:34px}
.ptr{grid-column:2;grid-row:2;max-width:860px}
main#view{--seitenrand:44px;grid-column:2;grid-row:3;padding:8px var(--seitenrand) 48px;max-width:860px}
.tabbar{grid-column:1;grid-row:1 / 4;display:flex;flex-direction:column;gap:4px;
align-content:start;width:264px;padding:38px 14px 20px;
border-top:0;border-right:1px solid var(--ios-sep);background:none;
-webkit-backdrop-filter:none;backdrop-filter:none}
.tab{flex-direction:row;justify-content:flex-start;align-items:center;gap:14px;
padding:12px 14px;border-radius:12px;font-size:15px;letter-spacing:0}
.tab span{display:block}
.tab svg{width:22px;height:22px}
.tabpille{width:auto;height:auto;border-radius:0;background:none}
.tab.on .tabpille{background:none}
.tab:hover{background:var(--tile)}
/* Aktives Symbol: dieselbe Farbe wie die Kopfzeile (--fg), nicht mehr Rot -
Fläche bleibt neutral hinterlegt statt rosa (Design-Review §B). */
.tab.on{background:var(--ios-fill);color:var(--fg)}
.tabbar.ohne .tab span{display:block}
.tabbar.ohne .tab{padding:12px 14px;gap:14px}
.tabbar.ohne .tab svg{width:22px;height:22px}
/* Marke am Kopf der Seitenspalte, dafuer keine Ringe in der Kopfzeile
(Design-Audit: die Marke soll nur einmal im Bild stehen). */
.navmarke{display:block;margin:2px 0 26px 14px;color:var(--fg)}
.navmarke svg{width:54px;height:auto;display:block}
/* Grosser Bildschirm: Marke am Kopf der Spalte, Zahnrad wieder oben rechts */
.profilbtn{display:inline-flex}
/* Die Kopf-Ringe setzt die App per Inline-Stil (display:inline-flex),
deshalb hier mit !important. */
.rings{display:none !important}
/* Nur die Box selbst hoeher setzen, nicht jedes Element mit .carfix: die
Klasse haengt auch am <img> und am Platzhalter-<div> (siehe
bildMitPlatzhalter()). Der Platzhalter ist position:absolute;inset:0 -
eine feste Hoehe schlaegt dort das bottom:0 und machte ihn 260px hoch in
einer 132px hohen, overflow:hidden-Box (leerer Zustand), also sichtbar
abgeschnitten. Bild und Platzhalter fuellen die Box ohnehin per 100%. */
.bildbox.carfix{height:260px}
.bildbox.carfix.klein{height:220px}
.bildgrid{grid-template-columns:repeat(4,1fr)}
.quad{gap:14px}
/* Design-Audit 2026-08-13: .setup-popup/.sdpopup/.sheet/.standortmenu sind
position:absolute relativ zu .phone (der vollen Rasterbreite, nicht nur
der Inhaltsspalte) mit left/right auf einen festen Abstand - auf breiten
Bildschirmen ergab das einen fast bildschirmbreiten Dialog statt einer
ruhigen Karte. max-width + margin:auto zentriert sie innerhalb des
bestehenden left/right-Abstands, ohne die transform-basierte
Einblend-Animation dieser Elemente zu beruehren. */
.setup-popup,.sdpopup,.sheet,.beleg-popup{max-width:560px;margin-left:auto;margin-right:auto}
.standortmenu{max-width:640px;margin-left:auto;margin-right:auto}
}
/* Fahrzeugbild randlos wie in einer Fahrzeug-App: der Platzhalter bleibt
sichtbar, aber ohne technischen Strichrahmen. */
.szene{margin-bottom:20px}
/* --tile statt --ios-fill waere hier naheliegend, faellt im Tag-Theme aber
auf reines Weiss (#FFFFFF) - ununterscheidbar von Canvas und den meisten
Kacheln drumherum, die Box verschwand. --ios-fill ist in beiden Themes ein
deutlich sichtbares transluzentes Grau, dieselbe Konvention wie
.feld select/.aktion ("das hier ist eine Flaeche"). */
.bildbox.bild-fehlt .platzhalter{border:0;background:var(--ios-fill);border-radius:var(--r-tile)}
.platzhalter-datei{display:none}
/* Zeilen mit Wert plus Statuspunkt rechts (Sicherheitscheck) ruhiger setzen */
.row dd .dot{width:8px;height:8px}
@@ -1,47 +0,0 @@
/* ================================================================
Audi-Dashboard — Lade-Stub für panel_custom
================================================================
Diese Datei selbst ändert sich praktisch nie und bleibt deshalb auch mit
der langen Standard-Cache-Zeit von Home Assistant für /local/ (31 Tage,
Cache-Control: public, max-age=2678400 - an der Testinstanz nachgemessen,
nicht vermutet) unproblematisch: sie lädt bei jedem vollständigen
Seitenaufruf zuerst audi-dashboard-version.json mit cache:"no-store" -
das erzwingt eine echte Netzwerkanfrage, unabhängig vom HTTP-Cache -, und
reicht deren Versionsnummer als Query-Parameter an den eigentlichen
Code (audi-dashboard-app.js) weiter. Jede neue Versionsnummer ist aus
Sicht des Browsers eine neue URL und wird deshalb nie aus einem alten
Cache bedient.
Update-Ablauf ohne Neustart und ohne Cache-Probleme:
1. audi-dashboard-app.js und/oder audi-dashboard.css ersetzen
2. die Zahl in audi-dashboard-version.json um 1 erhöhen
3. im Browser einmal ganz normal neu laden (kein Hard-Refresh nötig)
Diese Datei hier (audi-dashboard-panel.js) bleibt dabei unangetastet -
configuration.yaml und damit ein HA-Neustart sind für Frontend-Updates
nicht mehr nötig, nur noch für den allerersten Einbau (§10 Punkt 1).
Bekannte HA-Eigenheit (an der Testinstanz reproduziert, nicht vermutet):
ha-panel-custom cached geladene Skripte nur für den alten js_url-Typ,
nicht für module_url (siehe dessen eigener Quellcode - für "js" gibt es
ein url-indiziertes Cache-Objekt, für "module" fehlt das komplett). Bei
einem direkten/harten Seitenaufruf (nicht bei SPA-interner Navigation)
kann das dazu führen, dass dieses Skript zweimal als <script type=module>
eingefügt wird, bevor das erste fertig geladen hat - beobachtet als zwei
Netzwerk-Requests für diese Datei und ein hängenbleibendes "Lädt …" ohne
Fehler in der eigentlichen App. Der Cache unten macht starten() robust
gegen so einen doppelten Aufruf: der zweite Aufruf wartet einfach auf das
Ergebnis des ersten, statt selbst nochmal zu fetchen/zu importieren. */
window.__audiDashboardStarten =
window.__audiDashboardStarten ||
(async () => {
let version = Date.now(); // Fallback, falls version.json nicht erreichbar ist
try {
const antwort = await fetch("/local/audi-dashboard-version.json", { cache: "no-store" });
const daten = await antwort.json();
version = daten.version;
} catch (e) {
console.warn("audi_dashboard: version.json nicht erreichbar, nutze Zeitstempel als Cache-Buster", e);
}
await import(`/local/audi-dashboard-app.js?v=${version}`);
})();
await window.__audiDashboardStarten;
@@ -1 +0,0 @@
{"version": 1787016000, "app": "2026.08.23.1"}
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-02ab3f2e-9065-406b-b3a2-96f948fc1e4e" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 428 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path d="M426.9422,35.25c4.1375-11.3676-4.1296-20.25-20.1296-20.25h-48.5c-26.0164,0-28.4479,5.3519-34.6605,22.5h16.4711c2.8264-7.7654,3.4577-9.5,13.4577-9.5h48.5c4.0569,0,10.4989,1.3767,8.3612,7.25-1.9243,5.287-4.4488,7.25-12.6388,7.25h-43.5l-4.7316,13h48.5c6.2949,0,9.3512,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-54.5c-8.4936,0-7.6956-6.3314-6.1783-10.5h-16.5c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c23.4238,0,27.7979-3.537,34.239-21.25,2.3006-6.3267.3341-11.4665-4.0894-14.8846,5.5286-2.8196,7.1832-7.3654,9.458-13.6154Z" style="fill:#fff;"/></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-3d56eca2-3609-4ab9-a4bc-ffc6d9c94e2a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 428 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path d="M426.9422,35.25c4.1375-11.3676-4.1296-20.25-20.1296-20.25h-48.5c-26.0164,0-28.4479,5.3519-34.6605,22.5h16.4711c2.8264-7.7654,3.4577-9.5,13.4577-9.5h48.5c4.0569,0,10.4989,1.3767,8.3612,7.25-1.9243,5.287-4.4488,7.25-12.6388,7.25h-43.5l-4.7316,13h48.5c6.2949,0,9.3512,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-54.5c-8.4936,0-7.6956-6.3314-6.1783-10.5h-16.5c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c23.4238,0,27.7979-3.537,34.239-21.25,2.3006-6.3267.3341-11.4665-4.0894-14.8846,5.5286-2.8196,7.1832-7.3654,9.458-13.6154Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-3cc43eeb-ac3a-4285-9c6d-681d610de30a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 425 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path id="uuid-b98eb56c-eb39-485f-8e3f-36f1bc8fa622" d="M408.4969,59l16.0201-44h-24.75l-80.2974,39.2816-6.4489,17.7184h74.25l-4.7316,13h16.5s4.7316-13,4.7316-13h16.4977l4.7316-13h-16.5031ZM392.0022,59h-53.6829l64.966-31h0l-11.2831,31Z" style="fill:#fff;"/></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-887f53b2-a25f-4c1a-b207-be4bc0e6cad6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 425 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path id="uuid-2c6229e3-766a-4640-9e66-076dd5bbca77" d="M408.4969,59l16.0201-44h-24.75l-80.2974,39.2816-6.4489,17.7184h74.25l-4.7316,13h16.5s4.7316-13,4.7316-13h16.4977l4.7316-13h-16.5031ZM392.0022,59h-53.6829l64.966-31h0l-11.2831,31Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-bb9d6aae-5c61-449e-a3f7-9dc3eca29a10" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 427 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path d="M324.3052,54l14.1948-39h88.5l-4.7316,13h-72l-4.7316,13h55.5c16.5,0,23.2243,9,18.4927,22-5.8003,15.9362-9.6585,22-34.4519,22h-47.5555c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h47.5555c9.4358,0,11.1533-3.3211,13.2203-9,2.3539-6.4674-.3962-9-6.7243-9h-72Z" style="fill:#fff;"/></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-78d414ea-ca1f-4f07-a6dd-815839547cb5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 427 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path d="M324.3052,54l14.1948-39h88.5l-4.7316,13h-72l-4.7316,13h55.5c16.5,0,23.2243,9,18.4927,22-5.8003,15.9362-9.6585,22-34.4519,22h-47.5555c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h47.5555c9.4358,0,11.1533-3.3211,13.2203-9,2.3539-6.4674-.3962-9-6.7243-9h-72Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-da9a622c-841b-4217-8357-91e7b2ea0fa7" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 436 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path d="M434.7542,37.5c4.6956-12.9009-4.4207-22.5-18.3107-22.5h-51.5045c-26.0165,0-28.7311,6.1298-33.8705,20.25l-9.5543,26.25c-3.3206,9.1232-.1075,23.5,17.9468,23.5h48.5c25.7614,0,32.1276-7.2191,37.2344-21.25,5.6993-15.6587-5.968-21.25-18.7656-21.25h-51.5c-3.8594,0-7.5554.4423-10.3879,1.0658l3.0266-8.3158c1.6863-4.6331,2.6389-7.25,12.6389-7.25h51.5046c4.0569,0,8.7417,3.4572,6.5422,9.5h16.5ZM350.198,55.5h51.5c6.2949,0,9.3513,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-51.5001c-8.4935,0-7.6956-6.3314-6.1783-10.5,2.1838-6,5.1783-6,12.1838-6Z" style="fill:#fff;"/></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-eab43470-9ed9-4ef3-88a5-73d45fc6a05a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 436 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path d="M434.7542,37.5c4.6956-12.9009-4.4207-22.5-18.3107-22.5h-51.5045c-26.0165,0-28.7311,6.1298-33.8705,20.25l-9.5543,26.25c-3.3206,9.1232-.1075,23.5,17.9468,23.5h48.5c25.7614,0,32.1276-7.2191,37.2344-21.25,5.6993-15.6587-5.968-21.25-18.7656-21.25h-51.5c-3.8594,0-7.5554.4423-10.3879,1.0658l3.0266-8.3158c1.6863-4.6331,2.6389-7.25,12.6389-7.25h51.5046c4.0569,0,8.7417,3.4572,6.5422,9.5h16.5ZM350.198,55.5h51.5c6.2949,0,9.3513,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-51.5001c-8.4935,0-7.6956-6.3314-6.1783-10.5,2.1838-6,5.1783-6,12.1838-6Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-441d88e6-447f-439d-bb01-ecd138048170" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><polygon id="uuid-04405ac0-17bc-4ab9-8f1b-d9c06ed1387e" points="430 15 331 15 326.2684 28 408.7684 28 330.2721 85 355.0221 85 421.8096 37.5 430 15" style="fill:#fff;"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-e2ec85d2-1c31-42a4-badb-6dc585c3e159" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><polygon id="uuid-140489d1-1cc4-4640-9981-d5f2efde7a30" points="430 15 331 15 326.2684 28 408.7684 28 330.2721 85 355.0221 85 421.8096 37.5 430 15"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-6be63f45-3fa3-45ba-97e7-17573e0bd3f7" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 312 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M163.5209,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M310.9422,35.25c4.1375-11.3676-4.1296-20.25-20.1296-20.25h-48.5c-26.0164,0-28.4479,5.3519-34.6605,22.5h16.4711c2.8264-7.7654,3.4577-9.5,13.4577-9.5h48.5c4.0569,0,10.4989,1.3767,8.3612,7.25-1.9243,5.287-4.4488,7.25-12.6388,7.25h-43.5l-4.7316,13h48.5c6.2949,0,9.3512,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-54.5c-8.4936,0-7.6956-6.3314-6.1783-10.5h-16.5c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c23.4238,0,27.7979-3.537,34.239-21.25,2.3006-6.3267.3341-11.4665-4.0894-14.8846,5.5286-2.8196,7.1832-7.3654,9.458-13.6154Z" style="fill:#fff;"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-bcf865bd-1403-4135-bc2a-39f0ea8693ef" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 312 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M163.5209,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M310.9422,35.25c4.1375-11.3676-4.1296-20.25-20.1296-20.25h-48.5c-26.0164,0-28.4479,5.3519-34.6605,22.5h16.4711c2.8264-7.7654,3.4577-9.5,13.4577-9.5h48.5c4.0569,0,10.4989,1.3767,8.3612,7.25-1.9243,5.287-4.4488,7.25-12.6388,7.25h-43.5l-4.7316,13h48.5c6.2949,0,9.3512,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-54.5c-8.4936,0-7.6956-6.3314-6.1783-10.5h-16.5c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c23.4238,0,27.7979-3.537,34.239-21.25,2.3006-6.3267.3341-11.4665-4.0894-14.8846,5.5286-2.8196,7.1832-7.3654,9.458-13.6154Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-dcc2e61f-be44-4609-aa28-1b6186c8cb58" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M163.5209,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><polygon points="276.2715 46.0815 254.8152 46.0815 296.6135 85 318.0698 85 276.2715 46.0815" style="fill:#fff;"/><path d="M312.8315,37.5c4.6956-12.9009-4.4207-22.5-18.3105-22.5h-54.5c-26.0165,0-28.731,6.1298-33.8704,20.25l-9.5543,26.25c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c8.5029,0,14.5557-.7916,19.0469-2.3499l-11.4382-10.6501h-57.377c-8.4937,0-7.6956-6.3314-6.1783-10.5l9.5542-26.25c1.6863-4.633,2.6388-7.25,12.6388-7.25h54.5c4.0569,0,8.7417,3.4573,6.5422,9.5l-5.8916,16.1871,12.3236,11.4745,10.068-27.6617Z" style="fill:#fff;"/><polygon id="uuid-4eb1f8b9-3e2f-414b-927c-5027877a6367" points="430 15 331 15 326.2684 28 408.7684 28 330.2721 85 355.0221 85 421.8096 37.5 430 15" style="fill:#fff;"/></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-dcb70d1d-da3e-4814-a4c9-ff91d6217f07" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M163.5209,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><polygon points="276.2715 46.0815 254.8152 46.0815 296.6135 85 318.0698 85 276.2715 46.0815"/><path d="M312.8315,37.5c4.6956-12.9009-4.4207-22.5-18.3105-22.5h-54.5c-26.0165,0-28.731,6.1298-33.8704,20.25l-9.5543,26.25c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c8.5029,0,14.5557-.7916,19.0469-2.3499l-11.4382-10.6501h-57.377c-8.4937,0-7.6956-6.3314-6.1783-10.5l9.5542-26.25c1.6863-4.633,2.6388-7.25,12.6388-7.25h54.5c4.0569,0,8.7417,3.4573,6.5422,9.5l-5.8916,16.1871,12.3236,11.4745,10.068-27.6617Z"/><polygon id="uuid-76d49135-5520-455b-8e33-d0d14e5c227c" points="430 15 331 15 326.2684 28 408.7684 28 330.2721 85 355.0221 85 421.8096 37.5 430 15"/></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,8 +0,0 @@
<svg id="Shell_x5F_2012_x5F_PECTEN_x5F_RGB" version="1.1" viewBox="0 0 24 24" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<rect x="-12.1" y="-103.7" width="141.7" height="141.7" fill="none"/>
<g transform="matrix(.22054 0 0 .22054 -.94555 -.53756)">
<path d="m54.6 87.8h-18l-1.6-13.3-11-7.9c-0.5-2.3-0.7-4.7-0.7-7.1 0-19.6 15.9-35.4 35.4-35.4 19.6 0 35.4 15.9 35.4 35.4 0 2.4-0.2 4.8-0.7 7.1l-10.9 7.9-1.7 13.3h-18l-1 0.8c-0.9 0.7-2 1-3.1 1s-2.2-0.4-3.1-1z" fill="none" stroke="#fff" stroke-miterlimit="40" stroke-width="7"/>
<path d="m54.6 87.8h-18l-1.6-13.3-11-7.9c-0.5-2.3-0.7-4.7-0.7-7.1 0-19.6 15.9-35.4 35.4-35.4 19.6 0 35.4 15.9 35.4 35.4 0 2.4-0.2 4.8-0.7 7.1l-10.9 7.9-1.7 13.3h-18l-1 0.8c-0.9 0.7-2 1-3.1 1s-2.2-0.4-3.1-1z" fill="#fbce07"/>
<path d="m54.6 87.8h-18l-1.6-13.3-11-7.9c-0.5-2.3-0.7-4.7-0.7-7.1 0-19.6 15.9-35.4 35.4-35.4s35.4 15.9 35.4 35.4c0 2.4-0.2 4.8-0.7 7.1l-10.9 7.9-1.7 13.3h-18l-1 0.8c-0.9 0.7-2 1-3.1 1s-2.2-0.4-3.1-1zm0.6-6.2h-13.1l-1.3-10.6-10.8-7.8c-0.3-1.2-0.5-2.5-0.5-3.8 0-2.1 0.4-4.2 1.3-6.1l19.1 19.3-18.5-22.9c0.7-3 2.3-5.6 4.6-7.7l16.6 28.7-14.7-31.3c1.9-2.5 4.6-4.4 7.6-5.3l10.2 35.6-7.4-37.1c2.3-1.3 4.8-1.9 7.4-1.9 0.5 0 1.1 0 1.6 0.1l1.5 38.5 1.5-38.5c0.5-0.1 1.1-0.1 1.6-0.1 2.6 0 5.1 0.7 7.4 1.9l-7.4 37.1 10.2-35.7c3 0.9 5.7 2.8 7.6 5.3l-14.8 31.4 16.6-28.7c2.3 2 3.9 4.7 4.6 7.7l-18.5 22.9 19.1-19.3c0.9 1.9 1.3 4 1.3 6.1 0 1.3-0.2 2.5-0.5 3.8l-10.8 7.8-1.3 10.6h-13l-2 1.4c-0.5 0.4-1.1 0.5-1.7 0.5s-1.2-0.2-1.7-0.5z" fill="#dd1d21" fill-rule="evenodd"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,115 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DataMetric360 — Gerät verbinden</title>
<!--
Erzeugt den Einrichtungs-QR-Code für die App, vollständig auf dem Gerät.
Der Punkt dabei: Ein QR-Generator im Internet bekäme den Zugangstoken zu
sehen. Diese Seite lädt ihre Bibliothek aus dem eigenen Home Assistant
(dm360-qrcode-lib.js daneben) und stellt keine einzige Netzanfrage — der
Token verlässt das eigene Netz nicht.
Ablegen unter /config/www/, aufrufbar als /local/dm360-qr.html.
Gegenstück in der App: UMSETZUNGSPLAN.md Phase 10 Stufe 2.
-->
<script src="./dm360-qrcode-lib.js"></script>
<style>
:root { color-scheme: dark; }
body {
margin: 0; min-height: 100vh; display: grid; place-items: center;
background: #161b23; color: #fff;
font: 400 15px/1.55 Helvetica, Arial, sans-serif;
}
main { width: min(420px, 92vw); padding: 24px; }
h1 { font-size: 22px; font-weight: 300; margin: 0 0 6px; }
p { color: #9aa1ad; font-size: 13.5px; margin: 0 0 18px; text-wrap: pretty; }
label { display: block; font-size: 10px; letter-spacing: .2em;
text-transform: uppercase; color: #8a94a3; margin: 14px 0 6px; }
input {
width: 100%; box-sizing: border-box; padding: 11px 12px; font-size: 16px;
background: #1f2733; color: #fff; border: 1px solid rgba(255,255,255,.1);
border-radius: 8px; outline: none;
}
input:focus-visible { outline: 2px solid #F50537; outline-offset: 2px; }
button {
margin-top: 18px; width: 100%; padding: 13px; font-size: 15px;
background: #fff; color: #000; border: 0; border-radius: 999px; cursor: pointer;
}
#ausgabe { margin-top: 22px; display: none; text-align: center; }
#ausgabe.sichtbar { display: block; }
#ausgabe img { width: 100%; max-width: 320px; height: auto;
image-rendering: pixelated; background: #fff;
padding: 12px; border-radius: 12px; box-sizing: border-box; }
.warnung { color: #ffaa00; font-size: 12px; margin-top: 14px; }
.fehler { color: #fd2c4e; font-size: 13px; margin-top: 14px; }
</style>
</head>
<body>
<main>
<h1>Gerät verbinden</h1>
<p>
Trag die von außen erreichbare Adresse und einen langlebigen Zugriffstoken ein.
Der Code entsteht hier auf dem Gerät; nichts davon wird irgendwohin gesendet.
</p>
<label for="adresse">Server-Adresse</label>
<input id="adresse" type="url" placeholder="https://api.datametric360.app" />
<label for="token">Zugriffstoken</label>
<input id="token" type="password" placeholder="in Home Assistant unter Profil → Sicherheit" />
<button id="erzeugen" type="button">Code anzeigen</button>
<div id="ausgabe"></div>
<p id="meldung" class="fehler" hidden></p>
</main>
<script>
const ausgabe = document.getElementById("ausgabe")
const meldung = document.getElementById("meldung")
function melde(text) {
meldung.textContent = text
meldung.hidden = !text
}
document.getElementById("erzeugen").addEventListener("click", () => {
melde("")
ausgabe.classList.remove("sichtbar")
const url = document.getElementById("adresse").value.trim().replace(/\/+$/, "")
const token = document.getElementById("token").value.trim()
if (!url || !token) {
melde("Bitte Adresse und Token eintragen.")
return
}
const inhalt = JSON.stringify({ url, token })
try {
// Version 0 heißt: kleinstmögliche Version selbst wählen.
// Fehlerkorrektur L, weil der Code direkt vom Bildschirm abgelesen
// wird — es gibt keinen zerkratzten Aufkleber zu retten.
const qr = qrcode(0, "L")
qr.addData(inhalt)
qr.make()
ausgabe.innerHTML = qr.createImgTag(6, 0, "Einrichtungs-Code")
ausgabe.insertAdjacentHTML(
"beforeend",
'<p class="warnung">Wer diesen Code abfotografiert, hat vollen Zugriff auf die ' +
"Schnittstelle. Nach dem Einrichten die Seite schließen.</p>",
)
ausgabe.classList.add("sichtbar")
} catch (fehler) {
melde(
"Der Code konnte nicht erzeugt werden: " +
String((fehler && fehler.message) || fehler) +
". Ist der Token vollständig eingefügt?",
)
}
})
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
-113
View File
@@ -1,113 +0,0 @@
"""Backup-Funktion für Fahrzeugprofil, Fahrten und Tankvorgänge.
Manuell über "Backup jetzt erstellen" in den Einstellungen oder automatisch
je nach einstellungen.backup_intervall im Fahrzeugprofil (aus/täglich/
wöchentlich/monatlich) - immer um 04:00 Uhr, wenn eingestellt. Ein einzelner
täglicher Trigger um 04:00 genügt für alle drei Intervalle: wöchentlich läuft
nur montags, monatlich nur am Monatsersten, täglich jeden Tag - so reicht eine
einzige Trigger-Registrierung statt drei.
Jedes Backup ist ein eigener Zeitstempel-Unterordner mit Kopien der drei
Bestände (siehe profil.py), keine ZIP-Datei - das würde den vorhandenen
task.executor(io.open, ...)-Weg (siehe dessen Kopfkommentar in profil.py)
unnötig verkomplizieren. shutil.copy() ist wie io.open eine echte externe
Funktion und läuft deshalb über denselben task.executor-Weg.
"Backup exportieren"/"importieren" in der Oberfläche laufen dagegen rein
über den Browser (aktuell geladenes Profil + Fahrten + Tankvorgänge als eine
JSON-Datei, siehe audi-dashboard-app.js) - audi_dashboard_backup_wiederherstellen()
hier ist nur das Gegenstück zum Import, das die drei Bestände wieder
komplett überschreibt.
"""
import datetime
import json
import os
import shutil
import entitaeten
import frontend_veroeffentlichung
import profil
BACKUP_ORDNER = f"{profil.BASIS}/backups"
# entitaeten.json gehört dazu: Ohne sie wäre nach einer Wiederherstellung die
# komplette Sensor-Zuordnung aus dem Setup-Menü verloren, und weil sie sonst
# nirgends abgelegt ist, gäbe es nichts zurückzuholen.
_DATEIEN = [
"fahrzeugprofil.json",
"fahrten.jsonl",
"tankvorgaenge.jsonl",
"entitaeten.json",
]
def _backup_jetzt():
ziel = f"{BACKUP_ORDNER}/{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
os.makedirs(ziel, exist_ok=True)
for name in _DATEIEN:
quelle = f"{profil.BASIS}/{name}"
if os.path.exists(quelle):
task.executor(shutil.copy, quelle, f"{ziel}/{name}")
return ziel
def _letztes_backup_vermerken():
p = profil.profil_lesen()
if p is None:
return
p.setdefault("einstellungen", {})["letztes_backup"] = datetime.datetime.now().isoformat()
profil.profil_schreiben(p)
frontend_veroeffentlichung.profil_veroeffentlichen()
@service
def audi_dashboard_backup_jetzt():
"""Erstellt sofort ein Backup. Aufruf als pyscript.audi_dashboard_backup_jetzt."""
ziel = _backup_jetzt()
_letztes_backup_vermerken()
log.info(f"audi_dashboard: Backup erstellt unter {ziel}")
@time_trigger("cron(0 4 * * *)")
def _automatisches_backup():
p = profil.profil_lesen()
if p is None:
return
intervall = p.get("einstellungen", {}).get("backup_intervall", "aus")
heute = datetime.date.today()
faellig = (
intervall == "taeglich"
or (intervall == "woechentlich" and heute.weekday() == 0)
or (intervall == "monatlich" and heute.day == 1)
)
if not faellig:
return
ziel = _backup_jetzt()
_letztes_backup_vermerken()
log.info(f"audi_dashboard: automatisches Backup ({intervall}) erstellt unter {ziel}")
@service
def audi_dashboard_backup_wiederherstellen(profil_json=None, fahrten_json=None, tankvorgaenge_json=None, entitaeten_json=None):
"""Stellt ein über "Backup exportieren" heruntergeladenes Backup wieder
her - überschreibt Profil, Fahrten, Tankvorgänge und die Sensor-Zuordnung
komplett. Aufruf als pyscript.audi_dashboard_backup_wiederherstellen.
entitaeten_json ist optional: ältere Sicherungen, die vor dem Setup-Menü
entstanden sind, enthalten den Abschnitt nicht - dann bleibt die aktuelle
Zuordnung unangetastet, statt sie zu leeren."""
if profil_json:
p = json.loads(profil_json) if isinstance(profil_json, str) else profil_json
profil.profil_schreiben(p)
if fahrten_json is not None:
fahrten = json.loads(fahrten_json) if isinstance(fahrten_json, str) else fahrten_json
profil.fahrten_schreiben(fahrten)
if tankvorgaenge_json is not None:
tankvorgaenge = json.loads(tankvorgaenge_json) if isinstance(tankvorgaenge_json, str) else tankvorgaenge_json
profil.tankvorgaenge_schreiben(tankvorgaenge)
if entitaeten_json:
zuordnung = json.loads(entitaeten_json) if isinstance(entitaeten_json, str) else entitaeten_json
entitaeten.overrides_schreiben(zuordnung)
entitaeten.overrides_anwenden()
frontend_veroeffentlichung.alles_veroeffentlichen()
log.info("audi_dashboard: Backup wiederhergestellt")
-62
View File
@@ -1,62 +0,0 @@
"""Langfristige Aufzeichnung der 12V-Batteriespannung (Mein Audi -> Zustand
-> Batteriespannung antippen). Eine über die Jahre absinkende Spannung
deutet auf eine alternde Batterie hin - dafür braucht es einen Verlauf,
nicht nur den aktuellen Wert.
Ein Eintrag pro Tag ({datum, min, min_ts, max, max_ts}) reicht für diesen
Zweck aus und hält batteriespannung.jsonl auch über viele Jahre klein - auch
wenn der Sensor selbst künftig mehrfach pro Stunde meldet (aktive Fahrt:
Motor an/aus, Ladezustand des Alternators wechselt laufend).
batterie_verlauf_pruefen() läuft deshalb häufig (alle 5 Minuten) und trägt
jede Messung in den Tageseintrag ein, statt wie zuvor nur einmal täglich
einen Einzelwert zu speichern - profil.batterieverlauf_tageswert_aktualisieren()
erweitert dabei nur min/max samt dem genauen Zeitstempel der jeweiligen
Extremmessung, robust gegen Neustarts und schwankende Erreichbarkeit.
Der Tagesminimalwert ist die für die Entladung aussagekräftige Größe: er
fällt typischerweise auf den Moment kurz nach dem Start, bevor der
Alternator die Ladespannung hochfährt - also am nächsten an die "echte"
Ruhespannung heran, ohne 2+ Stunden Standzeit abwarten zu müssen. Deshalb
verwendet auch die Ladezustands-/Gesundheitsauswertung im Frontend
(audi-dashboard-app.js, AGM_RUHE_MAX_V) den Tagesminimalwert als
repräsentativen Wert des Tages, nicht den Maximalwert.
Anders als in der ersten Fassung wird hier NICHT mehr gefiltert, welche
Messwerte gespeichert werden - min UND max jedes Tages werden roh
aufgezeichnet (der Maximalwert ist z. B. informativ beim Antippen eines
Punkts im Diagramm sichtbar). Die AGM-Ruhespannungs-Grenze wird stattdessen
erst beim Anzeigen angewendet (siehe AGM_RUHE_MAX_V im Frontend): Tage, an
denen selbst der Minimalwert über der Grenze liegt (Fahrzeug nie im
Ruhezustand beobachtet, z. B. bei einer sehr langen Fahrt), tauchen dort
nicht in der Ladezustands-Statistik auf.
Ohne eingetragenen einstellungen.BATTERIE_SENSOR (aktuell der Fall - die
genutzte Integration liefert dafür keinen Sensor, siehe dortiger Kommentar)
passiert hier bewusst nichts, statt Platzhalter-Nullwerte aufzuzeichnen.
"""
import datetime
import einstellungen
import frontend_veroeffentlichung
import profil
@time_trigger("startup")
@time_trigger("period(now, 5 minutes)")
def batterie_verlauf_pruefen():
if not einstellungen.BATTERIE_SENSOR:
return
wert = frontend_veroeffentlichung.zustand_oder_none(einstellungen.BATTERIE_SENSOR)
if wert is None:
return
try:
spannung = float(wert)
except (TypeError, ValueError):
return
heute = datetime.date.today().isoformat()
ts = datetime.datetime.now(datetime.timezone.utc).isoformat()
profil.batterieverlauf_tageswert_aktualisieren(heute, ts, spannung)
frontend_veroeffentlichung.batterieverlauf_veroeffentlichen()
log.info(f"audi_dashboard: Batteriespannung erfasst ({heute}: {spannung} V)")
-275
View File
@@ -1,275 +0,0 @@
"""Belegverarbeitung für Shell-Tankbelege (§7.7, §10 Punkt 7).
Beleg-Upload direkt in der App statt Postfachanbindung - kein IMAP-Zugriff.
shell_beleg_parser.py, im Lastenheft (§2) als mitgeliefert genannt, lag
beim Bau nicht im Projektordner vor (siehe Ersteinschätzung) und wurde
nachträglich unter audi_dashboard/shell_beleg_parser.py gegen einen echten
Shell-eReceipt neu geschrieben (siehe dessen Kopfkommentar). Aufruf über
eine Kommandozeilen-Schnittstelle: `python3 shell_beleg_parser.py
<pfad-zur-pdf>`, Ausgabe als JSON auf stdout mit den Feldern aus §6.4
(receipt_key, ts, station_name, liters, fuel_total_eur, discount, ...).
Muss unter /config/audi_dashboard/shell_beleg_parser.py liegen (siehe
INSTALL.md) - eigener Prozess mit dem System-python3 des Containers, nicht
die pyscript-Sandbox, deshalb dort einmalig `pip install pypdf` nötig.
ABWEICHUNG VOM URSPRÜNGLICHEN LASTENHEFT (Frontend-Anpassung, nach dem
ursprünglichen Bau nachträglich verlangt):
- Volltankung (§7.4) ist entfernt - weder Berechnung noch Anzeige. Damit
entfallen auch level_before_pct/level_after_pct, die nur dafür gebraucht
wurden.
- fuel_total_eur ist immer der reale, bereits rabattierte Betrag.
Die SmartDeal-Ersparnis steht separat in discount. price_per_l wird
grundsätzlich aus fuel_total_eur ÷ liters berechnet, nie aus einem vom
Beleg übernommenen Wert übernommen - siehe Kopfkommentar in
audi-dashboard-app.js (tankFelder()) für dieselbe Regel im Frontend.
Entity-IDs stehen zentral in pyscript/modules/einstellungen.py.
"""
import base64
import datetime
import io
import json
import os
import subprocess
import einstellungen
import frontend_veroeffentlichung
import profil
PARSER_SKRIPT = "/config/audi_dashboard/shell_beleg_parser.py"
# --------------------------------------------------------------- Hilfsfunktionen
def _pdf_speichern(inhalt_bytes, dateiname):
# io.open über task.executor, kein with, kein bloßes open() - siehe
# Kopfkommentar in profil.py für alle drei Gründe.
os.makedirs(profil.BELEGE_ORDNER, exist_ok=True)
pfad = f"{profil.BELEGE_ORDNER}/{dateiname}"
f = task.executor(io.open, pfad, "wb")
f.write(inhalt_bytes)
f.close()
return pfad
def _parser_aufrufen(pdf_pfad):
ergebnis = subprocess.run(
["python3", PARSER_SKRIPT, pdf_pfad],
capture_output=True, text=True, timeout=30,
)
if ergebnis.returncode != 0:
raise RuntimeError(f"shell_beleg_parser.py fehlgeschlagen: {ergebnis.stderr}")
return json.loads(ergebnis.stdout)
def _preis_je_liter(fuel_total_eur, liters):
if not liters:
return None
return round(fuel_total_eur / liters, 3)
# -------------------------------------------------------------------- Service
@service
def audi_dashboard_beleg_hochladen(pdf_base64=None, dateiname=None, tank_id=None):
"""Nimmt einen per Base64 codierten PDF-Beleg entgegen (Upload aus der
Oberfläche über hass.callService).
Mit tank_id: trägt die Belegdaten direkt in einen bereits bestehenden
Tankvorgang nach - egal ob der ursprünglich manuell oder automatisch
angelegt wurde. Der Kilometerstand/die gefahrene Distanz dieses
Tankvorgangs bleiben dabei unangetastet (die kommen weiterhin vom
Fahrzeug, nie vom Beleg, §7.7 Regel 2), nur die Beleg-Felder (Liter,
Kosten, Ersparnis, Station, Zeitstempel, ...) werden ergänzt
beziehungsweise überschrieben. Passt das Belegdatum nicht zum bereits
erfassten Tankvorgang (unterschiedlicher Kalendertag), wird nichts
übernommen - stattdessen ein Fehler veröffentlicht, damit in der
Oberfläche nicht versehentlich ein falscher Beleg einem bestehenden
Eintrag zugeordnet wird.
Ohne tank_id: legt KEINEN eigenständigen Tankvorgang mehr an (frühere
Abweichung vom Lastenheft, auf Wunsch entfernt), sondern parst den Beleg
nur und veröffentlicht das Ergebnis - die Oberfläche übernimmt die Werte
in den gerade offenen Entwurf (neuer oder bearbeiteter Tankvorgang) und
speichert erst auf einen expliziten Klick auf "Speichern".
Aufruf als pyscript.audi_dashboard_beleg_hochladen."""
if not pdf_base64:
log.warning("audi_dashboard: kein pdf_base64 übergeben")
return
# task.executor entfällt hier bewusst: _pdf_speichern/_parser_aufrufen sind
# in dieser pyscript-Datei definiert, task.executor akzeptiert aber nur
# echte externe Python-Funktionen (siehe Kopfkommentar in profil.py).
#
# Alles bis zum geparsten Ergebnis in einem try: eine unbehandelte
# Exception hier (kaputtes Base64, volle Festplatte, ...) wäre für die
# Oberfläche unsichtbar geblieben - serviceRufen() im Frontend loggt einen
# rejizierten Service-Aufruf nur in die Browser-Konsole, zeigt aber nichts
# an (siehe audi-dashboard-app.js). Bisher war nur der Parser-Aufruf
# selbst so abgesichert; b64decode/_pdf_speichern fehlten.
try:
inhalt_bytes = base64.b64decode(pdf_base64)
dateiname = dateiname or f"beleg_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
pdf_pfad = _pdf_speichern(inhalt_bytes, dateiname)
geparst = _parser_aufrufen(pdf_pfad)
except Exception as exc:
log.error(f"audi_dashboard: Beleg konnte nicht verarbeitet werden: {exc}")
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg konnte nicht gelesen werden", "tank_id": tank_id})
return
receipt_key = geparst.get("receipt_key")
if not receipt_key:
log.error("audi_dashboard: Parser lieferte keinen receipt_key")
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg konnte nicht gelesen werden", "tank_id": tank_id})
return
# §7.7 Regel 3: derselbe Beleg (receipt_key, minutengenau) erzeugt
# keinen zweiten Datensatz
vorhandener_beleg = profil.tankvorgang_nach_receipt_key(receipt_key)
if vorhandener_beleg is not None and vorhandener_beleg.get("tank_id") != tank_id:
log.info(f"audi_dashboard: Beleg {receipt_key} bereits vorhanden, übersprungen")
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg bereits erfasst", "tank_id": tank_id})
return
liters = geparst.get("liters")
fuel_total_eur = geparst.get("fuel_total_eur") # real, bereits rabattiert
beleg_felder = {
"receipt_key": receipt_key,
"receipt_no": geparst.get("receipt_no"),
"tse_beleg_nr": geparst.get("tse_beleg_nr"),
"ts": geparst.get("ts"),
"ts_payment": geparst.get("ts_payment"),
"ts_tse": geparst.get("ts_tse"),
"station_id": geparst.get("station_id"),
"station_name": geparst.get("station_name"),
"station_address": geparst.get("station_address"),
"article_no": geparst.get("article_no"),
"product_name": geparst.get("product_name"),
"fuel_type": geparst.get("fuel_type"),
# Liter, Kosten und Ersparnis kommen vom Beleg (§7.7 Regel 2).
# price_per_l wird nie vom Beleg übernommen, sondern immer berechnet.
"liters": liters,
"fuel_total_eur": fuel_total_eur,
"price_per_l": _preis_je_liter(fuel_total_eur, liters),
"discount": geparst.get("discount"), # SmartDeal-Ersparnis
"discount_per_l": geparst.get("discount_per_l"),
"list_price_per_l": geparst.get("list_price_per_l"),
"receipt_total_eur": geparst.get("receipt_total_eur"),
"net_eur": geparst.get("net_eur"),
"vat_eur": geparst.get("vat_eur"),
"receipt_file": pdf_pfad,
}
if tank_id:
vorhanden = profil.tankvorgang_nach_id(tank_id)
if not vorhanden:
log.warning(f"audi_dashboard: Tankvorgang {tank_id} für Beleg-Nachtrag nicht gefunden")
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Tankvorgang nicht gefunden", "tank_id": tank_id})
return
vorhandenes_datum = (vorhanden.get("ts") or "")[:10]
belegdatum = (geparst.get("ts") or "")[:10]
if vorhandenes_datum and belegdatum and vorhandenes_datum != belegdatum:
log.warning(f"audi_dashboard: Belegdatum {belegdatum} passt nicht zu Tankvorgang {tank_id} (Datum {vorhandenes_datum})")
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Falsches Belegdatum", "tank_id": tank_id})
return
aenderungen = dict(beleg_felder)
aenderungen["status"] = "vollständig"
if profil.tankvorgang_aktualisieren(tank_id, aenderungen):
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"erfolg": True, "tank_id": tank_id})
log.info(f"audi_dashboard: Beleg {receipt_key} in Tankvorgang {tank_id} nachgetragen")
else:
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Tankvorgang nicht gefunden", "tank_id": tank_id})
return
# Kein tank_id: nur parsen und dem gerade offenen Entwurf in der
# Oberfläche zum Ausfüllen bereitstellen - legt selbst KEINEN
# Tankvorgang an (siehe Kopfkommentar oben).
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({**beleg_felder, "tank_id": None})
log.info(f"audi_dashboard: Beleg {receipt_key} geparst, wartet auf Speichern in der Oberfläche")
@service
def audi_dashboard_tankvorgang_manuell(ts=None, liter=None, kosten=None, km=None, ersparnis=None, station=None, distanz=None, kraftstoff=None, receipt_key=None, receipt_file=None):
"""Erfassung eines Tankvorgangs aus dem Formular "Neuer Tankvorgang" -
Fallback aus §7.7 / §10 Punkt 7, unter „Tanken" vorgesehen (§5.5).
Dieselben Felder wie beim Bearbeiten eines bestehenden Vorgangs (siehe
audi_dashboard_tankvorgang_aktualisieren). receipt_key/receipt_file sind
optional: kommen sie mit (weil zuvor ein Beleg über
audi_dashboard_beleg_hochladen ohne tank_id in den Entwurf übernommen
wurde), zählt der Tankvorgang als per Beleg erfasst, sonst als manuell.
Aufruf als pyscript.audi_dashboard_tankvorgang_manuell."""
if liter is None or kosten is None:
log.warning("audi_dashboard: liter und kosten sind Pflichtfelder")
return
odometer_km = km if km is not None else frontend_veroeffentlichung.zustand_oder_none(einstellungen.KM_SENSOR)
tankvorgang = {
"tank_id": profil.neue_id("f"),
"receipt_key": receipt_key,
"ts": ts or datetime.datetime.now(datetime.timezone.utc).isoformat(),
"liters": liter,
"fuel_total_eur": kosten,
"price_per_l": _preis_je_liter(kosten, liter),
"discount": ersparnis,
"station_name": station,
"odometer_km": odometer_km,
# Vorschlag aus der Differenz zum letzten Tankvorgang, aber wie
# odometer_km selbst frei überschreibbar - deshalb nimmt ein manuell
# übergebener Wert immer Vorrang vor der Berechnung.
"distance_km": distanz if distanz is not None else profil.distanz_seit_letzter_tankung(odometer_km),
"fuel_type": kraftstoff,
"source": "beleg" if receipt_key else "manual",
"status": "vollständig",
"receipt_file": receipt_file,
"edited_fields": [],
}
profil.tankvorgang_anhaengen(tankvorgang)
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
log.info(f"audi_dashboard: Tankvorgang {tankvorgang['tank_id']} angelegt")
@service
def audi_dashboard_tankvorgang_aktualisieren(tank_id=None, ts=None, liter=None, kosten=None, km=None, ersparnis=None, station=None, distanz=None, kraftstoff=None, receipt_key=None, receipt_file=None):
"""Bearbeitet einen bestehenden Tankvorgang (Einzelbeleg-Seite, egal ob
ursprünglich per Beleg oder manuell angelegt). receipt_key/receipt_file
sind optional: kommen sie mit (ein im Bearbeiten-Formular hochgeladener
Beleg wurde in den Entwurf übernommen), wird der Tankvorgang zusätzlich
als per Beleg erfasst markiert. Aufruf als
pyscript.audi_dashboard_tankvorgang_aktualisieren."""
if not tank_id:
log.warning("audi_dashboard: tankvorgang_aktualisieren ohne tank_id aufgerufen")
return
aenderungen = {
"ts": ts, "liters": liter, "fuel_total_eur": kosten,
"price_per_l": _preis_je_liter(kosten, liter),
"odometer_km": km, "discount": ersparnis, "station_name": station,
"distance_km": distanz, "fuel_type": kraftstoff,
}
if receipt_key:
aenderungen["receipt_key"] = receipt_key
aenderungen["receipt_file"] = receipt_file
aenderungen["source"] = "beleg"
if profil.tankvorgang_aktualisieren(tank_id, aenderungen):
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
log.info(f"audi_dashboard: Tankvorgang {tank_id} aktualisiert")
else:
log.warning(f"audi_dashboard: Tankvorgang {tank_id} nicht gefunden")
@service
def audi_dashboard_tankvorgang_loeschen(tank_id=None):
"""Löscht einen Tankvorgang unwiderruflich (Löschfunktion in der
Oberfläche). Aufruf als pyscript.audi_dashboard_tankvorgang_loeschen."""
if not tank_id:
log.warning("audi_dashboard: tankvorgang_loeschen ohne tank_id aufgerufen")
return
if profil.tankvorgang_loeschen(tank_id):
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
log.info(f"audi_dashboard: Tankvorgang {tank_id} gelöscht")
else:
log.warning(f"audi_dashboard: Tankvorgang {tank_id} nicht gefunden")
@@ -1,71 +0,0 @@
"""Fahrzeugbilder direkt aus der Oberfläche hochladen/löschen (Einstellungen
-> Fahrzeugbilder, siehe BILDER_UPLOAD_SLOTS in audi-dashboard-app.js).
Nur die festen, im Frontend fest verdrahteten Dateinamen sind erlaubt - eine
Positivliste, damit ein manipulierter Dateiname aus dem Frontend niemals
außerhalb von www/bilder/ schreiben kann (kein Pfad-Traversal über "../" oder
absolute Pfade). Die Bytes werden unverändert unter dem erwarteten Namen
abgelegt, unabhängig vom tatsächlichen Bildformat des Originalfotos (z. B.
JPEG von einem iPhone unter einem ".webp"-Namen) - Browser stellen <img> nach
den tatsächlichen Bilddaten dar, nicht nach der Dateiendung, das funktioniert
also trotzdem. Eine Größenbeschränkung gibt es bewusst nicht (Vorgabe: jede
Fotogröße muss hochladbar sein).
"""
import base64
import io
import os
BILDER_ORDNER = "/config/www/bilder"
ERLAUBTE_DATEINAMEN = {
"seitenansicht.webp",
"seitenansicht-winter.webp",
"front-schraeg.webp",
"frontansicht.webp",
"heckansicht.webp",
"cockpit.webp",
"scheinwerfer.webp",
"sitze.webp",
"rad-sommer.webp",
"rad-winter.webp",
}
def _pfad(dateiname):
if dateiname not in ERLAUBTE_DATEINAMEN:
return None
return f"{BILDER_ORDNER}/{dateiname}"
@service
def audi_dashboard_bild_hochladen(dateiname=None, daten_base64=None):
"""Speichert ein aus der Oberfläche hochgeladenes Fahrzeugfoto unter
einem der festen erwarteten Namen. Aufruf als
pyscript.audi_dashboard_bild_hochladen."""
pfad = _pfad(dateiname)
if not pfad or not daten_base64:
log.warning(f"audi_dashboard: Bild-Upload abgelehnt (dateiname={dateiname})")
return
os.makedirs(BILDER_ORDNER, exist_ok=True)
rohdaten = base64.b64decode(daten_base64)
tmp = pfad + ".tmp"
f = task.executor(io.open, tmp, "wb")
f.write(rohdaten)
f.close()
os.replace(tmp, pfad)
log.info(f"audi_dashboard: Fahrzeugbild gespeichert ({dateiname}, {len(rohdaten)} Bytes)")
@service
def audi_dashboard_bild_loeschen(dateiname=None):
"""Entfernt ein zuvor hochgeladenes Fahrzeugfoto wieder - die Ansicht
fällt danach auf den Platzhalter zurück. Aufruf als
pyscript.audi_dashboard_bild_loeschen."""
pfad = _pfad(dateiname)
if not pfad:
log.warning(f"audi_dashboard: Bild-Löschen abgelehnt (dateiname={dateiname})")
return
if os.path.exists(pfad):
os.remove(pfad)
log.info(f"audi_dashboard: Fahrzeugbild gelöscht ({dateiname})")
-31
View File
@@ -1,31 +0,0 @@
"""Trigger-Registrierung für den zweistufigen Fahrtabschluss (§7.2).
Die eigentliche Logik liegt in pyscript/modules/fahrtabschluss_logik.py
(importierbares Modul, auch von fahrterkennung.py genutzt). Diese Datei
sorgt nur dafür, dass ein Screening auch dann läuft, wenn der Kilometerstand-
Sensor sich ändert - unabhängig vom Fahrtende-Ereignis selbst, weil der Wert
laut §10 Punkt 4 auch erst mit der nächsten Fahrt eintreffen kann.
Entity-IDs stehen zentral in pyscript/modules/einstellungen.py.
"""
import einstellungen
import fahrtabschluss_logik
# Ohne konfigurierten Kilometerstand-Sensor keinen Trigger registrieren -
# eine leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes
# Verhalten, siehe gleiches Muster in fahrterkennung.py.
if einstellungen.KM_SENSOR:
@state_trigger(f"{einstellungen.KM_SENSOR}")
def kilometerstand_geaendert(value=None, old_value=None):
if value is None or old_value is None:
return
fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR)
@service
def audi_dashboard_screening_jetzt():
"""Manueller Anstoß des Screenings, z. B. aus der Oberfläche heraus
(pyscript.audi_dashboard_screening_jetzt)."""
fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR)
-244
View File
@@ -1,244 +0,0 @@
"""Fahrterkennung über den Zündungs-/ACC-Status des FMM003 (§7.1).
Start: die Zündungs-Entität (einstellungen.ZUENDUNG_SENSOR) wechselt auf
"on". Ende: sie wechselt auf "off" und kommt binnen der Pausenzeit nicht
zurück.
2026-08-12: ersetzt die frühere WLAN-basierte Erkennung (Vergleich des vom
iPhone gemeldeten verbundenen WLANs gegen den im Fahrzeugprofil hinterlegten
Namen) - die Zündung ist ein direkteres, von jeder Companion-App-Eigenheit
unabhängiges Signal.
Die Pausenregel wird über task.unique() umgesetzt: jede neue Änderung der
Zündungs-Entität killt eine noch laufende, wartende Bestätigung des
vorherigen Ereignisses. Kommt die Zündung während der Wartezeit zurück, wird
dadurch die wartende Ende-Bestätigung automatisch abgebrochen, ohne dass die
Fahrt beendet wird - das entspricht der Pausenregel aus §7.1.
Die Entity-IDs stehen zentral in pyscript/modules/einstellungen.py - dort vor
der Installation einmalig anpassen (siehe INSTALL.md), oder über das
Setup-Menü in der Oberfläche zuordnen (siehe entitaeten.py; Änderungen dort
wirken für DIESEN Trigger allerdings erst nach einem Neustart, da
@state_trigger die Entity-ID einmalig beim Modul-Laden einliest).
EINSCHRÄNKUNG: der Zustand einer laufenden Fahrt (_fahrt_start_ts) lebt nur
im Arbeitsspeicher dieses Skripts. Ein HA-Neustart mitten in einer Fahrt oder
während der Wartezeit verliert diesen Zwischenstand - die Fahrt würde dann
nicht aufgezeichnet. Das ist ein bekannter Punkt für eine spätere Härtung
(z. B. Zwischenstand in einer input_datetime-Helper-Entität sichern), hier
bewusst nicht mitgebaut, um die Kernlogik nicht zu verkomplizieren, bevor sie
sich im echten Betrieb bewährt hat.
"""
import datetime
import einstellungen
import fahrtabschluss_logik
import frontend_veroeffentlichung
import profil
_fahrt_start_ts = None
def _pausenzeit_sekunden():
p = profil.profil_lesen()
if p is None:
return 15 * 60
return p.get("einstellungen", {}).get("fahrten_pausenzeit_min", 15) * 60
@time_trigger("startup")
def beim_start():
profil.ordner_sicherstellen()
# Ohne konfigurierte Zündungs-Entität keinen Trigger registrieren - eine
# leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes Verhalten
# (siehe zustand_oder_none()-Kommentar in frontend_veroeffentlichung.py zur
# Grundhaltung "kein geratener Wert"), hier stattdessen ganz weggelassen.
if einstellungen.ZUENDUNG_SENSOR:
@state_trigger(f"{einstellungen.ZUENDUNG_SENSOR}")
def zuendung_geaendert(value=None, old_value=None):
# Killt eine noch laufende Ende-Bestätigung aus einer vorherigen
# Änderung - das ist der Mechanismus hinter der Pausenregel.
task.unique("audi_dashboard_fahrt_ende_pruefung")
global _fahrt_start_ts
an_jetzt = (value == "on")
an_vorher = (old_value == "on")
if an_jetzt and not an_vorher:
if _fahrt_start_ts is None:
_fahrt_start_ts = datetime.datetime.now(datetime.timezone.utc)
log.info(f"audi_dashboard: Fahrt gestartet um {_fahrt_start_ts.isoformat()}")
return
if an_vorher and not an_jetzt and _fahrt_start_ts is not None:
start_ts = _fahrt_start_ts
abbruch_ts = datetime.datetime.now(datetime.timezone.utc)
wartezeit_s = _pausenzeit_sekunden()
# task.unique() oben markiert diese Ausführung als die aktuell
# gültige. Kommt die Zündung vor Ablauf der Wartezeit zurück,
# ruft der nächste Trigger erneut task.unique() auf und killt
# genau diesen task.sleep() - der Code danach läuft dann nie.
task.sleep(wartezeit_s)
_fahrt_beenden(start_ts, abbruch_ts)
def _fahrt_beenden(start_ts, ende_ts):
global _fahrt_start_ts
dauer_s = int((ende_ts - start_ts).total_seconds())
fahrt = {
"trip_id": profil.neue_id("t"),
"ts_start": start_ts.isoformat(),
"ts_end": ende_ts.isoformat(),
"duration_s": dauer_s,
"distance_km": None,
"km_quelle": None,
"odo_start": None,
"odo_end": None,
"avg_speed_kmh": None,
"start_lat": None,
"start_lon": None,
"end_lat": None,
"end_lon": None,
"start_address": None,
"end_address": None,
"art": "privat",
"route": None,
"pausen": [],
"source": "ha",
"status": "offen",
"edited_fields": [],
}
profil.fahrt_anhaengen(fahrt)
frontend_veroeffentlichung.fahrten_veroeffentlichen()
log.info(f"audi_dashboard: Fahrt {fahrt['trip_id']} beendet, Status 'offen', Screening angestoßen")
_fahrt_start_ts = None
fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR)
def _handfelder(start_ort, ziel_ort, odo_start, odo_end, distanz):
"""Die von Hand befüllbaren Felder einer Fahrt auf ihre Datensatz-Namen
abgebildet, ohne die leer gelassenen. Der Aufrufer schreibt die Schlüssel
zusätzlich nach edited_fields - das schützt sie vor dem automatischen
Kilometerstand-Screening (§7.2), das sonst später darüberschreiben würde."""
werte = {
"start_address": start_ort or None,
"end_address": ziel_ort or None,
"odo_start": odo_start,
"odo_end": odo_end,
"distance_km": distanz,
}
return {k: v for k, v in werte.items() if v is not None}
def _strecke_ergaenzen(felder):
"""Fehlt die Distanz, ergibt sie sich aus den beiden Kilometerständen -
dieselbe Rechnung wie im Screening (fahrtabschluss_logik)."""
if "distance_km" not in felder and "odo_start" in felder and "odo_end" in felder:
felder["distance_km"] = round(felder["odo_end"] - felder["odo_start"], 1)
return felder
@service
def audi_dashboard_fahrt_manuell_anlegen(ts_start=None, ts_end=None, art="privat",
start_ort=None, ziel_ort=None,
odo_start=None, odo_end=None, distanz=None):
"""Manuelle Fahrterfassung aus der Oberfläche (§5.3), unabhängig von der
automatischen Zündungs-Erkennung. Dieselben Felder wie beim Bearbeiten
einer bestehenden Fahrt (siehe audi_dashboard_fahrt_aktualisieren); alles
außer Start- und Endzeitpunkt ist optional und bleibt leer, wenn es das
Screening später selbst ergänzen soll. Aufruf als
pyscript.audi_dashboard_fahrt_manuell_anlegen."""
start_ts = datetime.datetime.fromisoformat(ts_start)
ende_ts = datetime.datetime.fromisoformat(ts_end)
dauer_s = int((ende_ts - start_ts).total_seconds())
hand = _strecke_ergaenzen(_handfelder(start_ort, ziel_ort, odo_start, odo_end, distanz))
fahrt = {
"trip_id": profil.neue_id("t"),
"ts_start": start_ts.isoformat(),
"ts_end": ende_ts.isoformat(),
"duration_s": dauer_s,
"distance_km": None,
"km_quelle": None,
"odo_start": None,
"odo_end": None,
"avg_speed_kmh": None,
"start_lat": None,
"start_lon": None,
"end_lat": None,
"end_lon": None,
"start_address": None,
"end_address": None,
"art": art,
"route": None,
"pausen": [],
"source": "manual",
"status": "offen",
"edited_fields": [],
}
fahrt.update(hand)
fahrt["edited_fields"] = sorted(hand)
if fahrt["distance_km"] is not None:
fahrt["km_quelle"] = "manuell"
fahrt["status"] = "vollständig"
profil.fahrt_anhaengen(fahrt)
frontend_veroeffentlichung.fahrten_veroeffentlichen()
fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR)
@service
def audi_dashboard_fahrt_aktualisieren(trip_id=None, ts_start=None, ts_end=None, art=None,
start_ort=None, ziel_ort=None,
odo_start=None, odo_end=None, distanz=None):
"""Bearbeitet eine bestehende Fahrt (Einzelfahrt-Seite), egal ob sie
automatisch erkannt oder von Hand angelegt wurde. Aufruf als
pyscript.audi_dashboard_fahrt_aktualisieren."""
if not trip_id:
log.warning("audi_dashboard: fahrt_aktualisieren ohne trip_id aufgerufen")
return
aenderungen = _strecke_ergaenzen(_handfelder(start_ort, ziel_ort, odo_start, odo_end, distanz))
if art:
aenderungen["art"] = art
if ts_start and ts_end:
start_ts = datetime.datetime.fromisoformat(ts_start)
ende_ts = datetime.datetime.fromisoformat(ts_end)
aenderungen["ts_start"] = start_ts.isoformat()
aenderungen["ts_end"] = ende_ts.isoformat()
aenderungen["duration_s"] = int((ende_ts - start_ts).total_seconds())
if aenderungen.get("distance_km") is not None:
aenderungen["km_quelle"] = "manuell"
aenderungen["status"] = "vollständig"
# Von Hand gesetzte Felder bleiben von Hand gesetzt: die Liste wächst mit,
# damit ein späteres Screening sie nicht wieder überschreibt.
# Bewusst eine Listen-Abstraktion statt next(<Generator>): pyscript kann
# keine Generator-Ausdrücke auswerten ("not implemented ast
# ast_generatorexp"), Listen-Abstraktionen dagegen schon.
passend = [f for f in profil.fahrten_lesen() if f.get("trip_id") == trip_id]
handfelder = {"start_address", "end_address", "odo_start", "odo_end", "distance_km"}
bisher = set(passend[0].get("edited_fields", [])) if passend else set()
aenderungen["edited_fields"] = sorted(bisher | (handfelder & set(aenderungen)))
if profil.fahrt_bearbeiten(trip_id, aenderungen):
frontend_veroeffentlichung.fahrten_veroeffentlichen()
log.info(f"audi_dashboard: Fahrt {trip_id} aktualisiert")
else:
log.warning(f"audi_dashboard: Fahrt {trip_id} nicht gefunden")
@service
def audi_dashboard_fahrt_loeschen(trip_id=None):
"""Löscht eine Fahrt unwiderruflich (Löschfunktion in der Oberfläche,
§Löschfunktion). Aufruf als pyscript.audi_dashboard_fahrt_loeschen."""
if not trip_id:
log.warning("audi_dashboard: fahrt_loeschen ohne trip_id aufgerufen")
return
if profil.fahrt_loeschen(trip_id):
frontend_veroeffentlichung.fahrten_veroeffentlichen()
log.info(f"audi_dashboard: Fahrt {trip_id} gelöscht")
else:
log.warning(f"audi_dashboard: Fahrt {trip_id} nicht gefunden")
-112
View File
@@ -1,112 +0,0 @@
"""Verbindung zwischen Frontend und Backend.
Lesen: das Frontend liest hass.states["pyscript.audi_dashboard_profil"] usw.
direkt - kein Service-Aufruf mit Rückgabewert. Die Frontend-Dokumentation von
Home Assistant zu response-fähigen Services beschreibt das Verhalten aus
Sicht des Browsers nicht abschließend (Community-Diskussionen deuten auf
Einschränkungen hin); Zustände lesen ist dagegen ein seit Jahren stabiler,
einfacher Weg. Veröffentlicht wird periodisch UND sofort nach jeder
Schreibaktion (siehe frontend_veroeffentlichung.py, dort auch der Hinweis zur
Größengrenze von Zustands-Attributen).
Schreiben: einzig audi_dashboard_profil_schreiben() als generischer Weg für
das gesamte Profil - das Frontend hält Profil, Fahrzeugdaten und
Einstellungen ohnehin als einen zusammenhängenden Stand im Speicher (wie
schon im Prototyp) und schickt ihn nach jeder Änderung komplett zurück,
statt für jedes einzelne Feld einen eigenen Service zu brauchen.
"""
import json
import einstellungen
import entitaeten
import frontend_veroeffentlichung
import profil
@time_trigger("startup")
def beim_start():
profil.ordner_sicherstellen()
# Vor der ersten Veröffentlichung anwenden, damit sie die im Setup-Menü
# gespeicherten Zuordnungen (data/entitaeten.json) schon zeigt, siehe
# entitaeten.py-Kopfkommentar.
entitaeten.overrides_anwenden()
frontend_veroeffentlichung.alles_veroeffentlichen()
@time_trigger("period(now, 20 seconds)")
def periodisch_aktualisieren():
frontend_veroeffentlichung.fahrzeugstatus_veroeffentlichen()
@time_trigger("period(now, 60 seconds)")
def periodisch_daten_aktualisieren():
# seltener als der Fahrzeugstatus - Profil/Fahrten/Tankvorgänge ändern
# sich normalerweise nur durch Aktionen, die ohnehin sofort selbst
# veröffentlichen (siehe unten); das hier ist nur das Sicherheitsnetz
# für Änderungen direkt im Dateisystem (§6.2, dritter Bearbeitungsweg).
frontend_veroeffentlichung.profil_veroeffentlichen()
frontend_veroeffentlichung.fahrten_veroeffentlichen()
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
@service
def audi_dashboard_jetzt_aktualisieren():
"""Gegenstück zum "Ziehen zum Aktualisieren" auf der Übersicht: stößt den
von der Integration bereitgestellten Refresh-Knopf an (siehe
einstellungen.REFRESH_BUTTON) statt auf den nächsten periodischen Takt zu
warten, und veröffentlicht den Status danach sofort neu. Ohne
eingetragenen Knopf (leerer Platzhalter) nur die direkte Neu-
veröffentlichung - besser als gar nichts, auch wenn dann kein echter neuer
Abruf beim Fahrzeug passiert. Aufruf als
pyscript.audi_dashboard_jetzt_aktualisieren."""
if einstellungen.REFRESH_BUTTON:
button.press(entity_id=einstellungen.REFRESH_BUTTON)
task.sleep(3)
frontend_veroeffentlichung.fahrzeugstatus_veroeffentlichen()
log.info("audi_dashboard: manuelle Aktualisierung (Ziehen zum Aktualisieren) ausgelöst")
@service
def audi_dashboard_profil_schreiben(profil_json=None):
"""Überschreibt das komplette Fahrzeugprofil. Aufruf als
pyscript.audi_dashboard_profil_schreiben."""
if not profil_json:
log.warning("audi_dashboard: profil_schreiben ohne profil_json aufgerufen")
return
p = json.loads(profil_json) if isinstance(profil_json, str) else profil_json
profil.profil_schreiben(p)
frontend_veroeffentlichung.profil_veroeffentlichen()
log.info("audi_dashboard: Profil aktualisiert")
@service
def audi_dashboard_entitaeten_schreiben(zuordnung_json=None):
"""Speichert die im Setup-Menü (Einstellungen -> Fahrzeug einrichten ->
Setup) gewählte Sensor-zu-Entität-Zuordnung, wendet sie sofort an und
veröffentlicht Katalog+Status neu, damit Übersicht/Mein Audi ohne
Neustart die neuen Werte zeigen (Ausnahme: die drei trigger-gebundenen
Felder, siehe entitaeten.py). Aufruf als
pyscript.audi_dashboard_entitaeten_schreiben."""
if not zuordnung_json:
log.warning("audi_dashboard: entitaeten_schreiben ohne zuordnung_json aufgerufen")
return
mapping = json.loads(zuordnung_json) if isinstance(zuordnung_json, str) else zuordnung_json
entitaeten.overrides_schreiben(mapping)
entitaeten.overrides_anwenden()
frontend_veroeffentlichung.entitaeten_veroeffentlichen()
frontend_veroeffentlichung.fahrzeugstatus_veroeffentlichen()
log.info("audi_dashboard: Entitäten-Zuordnung aktualisiert")
@service
def audi_dashboard_neustart():
"""Startet Home Assistant neu. Einziger Zweck: eine Änderung an einem der
drei trigger-gebundenen Setup-Felder (ZUENDUNG_SENSOR/KM_SENSOR/
TANK_SENSOR, siehe entitaeten.py) wird erst nach einem Neustart wirksam,
weil @state_trigger die Entity-ID beim Modul-Laden einmalig einliest.
Wird vom Setup-Popup als expliziter, vom Nutzer bestätigter Knopf
angeboten - kein automatischer Selbst-Reload direkt nach dem Speichern.
Aufruf als pyscript.audi_dashboard_neustart."""
log.info("audi_dashboard: Neustart über das Setup-Menü ausgelöst")
homeassistant.restart()
-511
View File
@@ -1,511 +0,0 @@
"""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}")
@@ -1,112 +0,0 @@
"""Technische Konfiguration — die einzige Stelle, die vor der Installation
angepasst werden muss (siehe INSTALL.md Schritt 4).
Alles andere (WLAN-Name des Fahrzeugs, Pausenzeit, Reifendaten, ...) ist Teil
des Fahrzeugprofils (data/fahrzeugprofil.json) und über die Oberfläche
änderbar. Diese Entity-IDs sind es bewusst nicht: @state_trigger und die
Fahrzeugstatus-Abfrage brauchen sie als festen Wert, bevor überhaupt ein
Profil gelesen werden kann.
In Home Assistant unter Entwicklerwerkzeuge -> Zustände nachschlagen. Werte
lassen sich außerdem über das Setup-Menü in der Oberfläche zuordnen (siehe
entitaeten.py) - Änderungen von dort werden zur Laufzeit auf diese Variablen
angewendet (überschreiben also die hier hinterlegten Standardwerte), ohne
diese Datei anzufassen.
**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).
#
# 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 - 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
# Tankgeber).
TANK_SENSOR = ""
# Reichweite (§5.1 Übersicht) - keine Quelle mehr vorhanden.
RANGE_SENSOR = ""
# 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).
# 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.
REFRESH_BUTTON = ""
# Türen (§4.1) - keine Quelle mehr vorhanden.
TUER_SENSOREN = []
# Fenster (§4.1) - keine Quelle mehr vorhanden.
FENSTER_SENSOREN = []
# Heckklappe und Motorhaube - keine Quelle mehr vorhanden.
HECKKLAPPE_SENSOR = ""
HAUBE_SENSOR = ""
# Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die App-eigene,
# aus dem Servicebuch berechnete Prognose) - keine Quelle mehr vorhanden.
NAECHSTER_OELWECHSEL_SENSOR = ""
OELWECHSEL_STRECKE_SENSOR = ""
NAECHSTE_INSPEKTION_SENSOR = ""
INSPEKTION_STRECKE_SENSOR = ""
# Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel). Breiten-/
# Längengrad als zwei eigene sensor-Entities (flespi liefert Koordinaten so,
# nicht als Attribute einer device_tracker-Entity - siehe _standort() in
# frontend_veroeffentlichung.py).
STANDORT_LAT_SENSOR = ""
STANDORT_LON_SENSOR = ""
# Update-Funktion (Einstellungen -> "Update suchen", siehe
# updateverwaltung.py): Git-Repository, in das dieses Projekt gepflegt wird -
# z. B. ein privates GitHub-Repo, genau wie für die HACS-Integration bereits
# verwendet. Leer lassen, solange es keins gibt - "Update suchen" meldet dann
# nur "keine Update-Quelle eingerichtet", ohne etwas zu tun.
UPDATE_REPO_URL = ""
UPDATE_BRANCH = "main"
@@ -1,241 +0,0 @@
"""Sensor-zu-Entität-Zuordnung fürs Setup-Menü (Einstellungen -> Fahrzeug
einrichten -> Setup).
Ersetzt NICHT das manuelle Eintragen in einstellungen.py (dessen Kommentare
sind die eigentliche Dokumentation der Integration und bleiben unangetastet),
sondern legt eine zusätzliche, zur Laufzeit angewendete Override-Ebene
darüber: data/entitaeten.json enthält nur die vom Nutzer über das Setup-Menü
geänderten Zuordnungen, keyed nach den einstellungen.py-Variablennamen.
Warum das funktioniert, ohne jede Verbraucher-Datei anzufassen: Alle
bestehenden Leser (frontend_veroeffentlichung.py, fahrterkennung.py, ...)
machen `import einstellungen` und lesen dann `einstellungen.KM_SENSOR` -
ein lebendiger Attributzugriff auf das Modul-Objekt, kein `from einstellungen
import KM_SENSOR` (das würde den Wert beim Import einfrieren). overrides_
anwenden() unten nutzt genau das: setattr(einstellungen, key, wert) ändert
das Attribut auf dem bereits importierten Modul-Objekt, jeder spätere
Zugriff `einstellungen.KM_SENSOR` liefert sofort den neuen Wert.
Eine Ausnahme (siehe TRIGGER_GEBUNDEN unten): mehrere @state_trigger(f"...")-
Dekoratoren backen ZUENDUNG_SENSOR/KM_SENSOR/TANK_SENSOR einmalig beim
Modul-Laden in die Trigger-Registrierung ein. Eine Änderung dieser drei
Felder wirkt für die Fahrterkennung selbst erst nach einem Neustart von
Home Assistant (bzw. pyscript.reload) - das Setup-Menü weist deshalb bei
diesen drei Feldern ausdrücklich darauf hin, statt automatisch und riskant
einen Selbst-Reload aus einer laufenden pyscript-Funktion heraus zu
versuchen.
"""
import io
import json
import os
import einstellungen
BASIS = "/config/audi_dashboard"
OVERRIDES_PFAD = f"{BASIS}/entitaeten.json"
# Vier gleich aufgebaute Positionen, wie schon in frontend_veroeffentlichung.py
# (dort POSITIONEN genannt) - hier dupliziert statt importiert, damit
# entitaeten.py nicht von frontend_veroeffentlichung.py abhängt (wäre die
# falsche Richtung: die Veröffentlichung importiert diese Datei, nicht
# umgekehrt).
POSITIONEN = ["vorne links", "vorne rechts", "hinten links", "hinten rechts"]
# Katalog aller von der App genutzten Entity-Rollen, fürs Setup-Menü im
# Frontend. Reihenfolge = Anzeige-Reihenfolge innerhalb jeder Gruppe.
# domains/device_classes/units sind Erwartungen fürs Frontend-seitige
# Filtern+Vorschlagen (Schalter "Nur passende Sensoren anzeigen") - eine
# fehlende oder leere Liste bedeutet "keine Einschränkung" bzw. "diese Rolle
# hat üblicherweise keine Einheit" (binary_sensor/button/device_tracker).
FELDER = [
{"key": "ZUENDUNG_SENSOR", "label": "Zündung/ACC-Status", "gruppe": "fahrterkennung",
"hinweis": "on = Fahrt läuft, erkennt Fahrtbeginn/-ende (ersetzt die frühere WLAN-Erkennung).",
"domains": ["binary_sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": True,
"stichworte": ["zündung", "ignition", "acc", "motor", "engine"]},
{"key": "KM_SENSOR", "label": "Kilometerstand", "gruppe": "fahrterkennung",
"hinweis": "Gesamt-Kilometerstand des Fahrzeugs - für Fahrtabschluss-Screening, Reifenzähler und Ölwechsel-Prognose.",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
"stichworte": ["kilometer", "km", "mileage", "odometer", "tacho", "laufleistung"]},
{"key": "TANK_SENSOR", "label": "Tankfüllstand", "gruppe": "fahrterkennung",
"hinweis": "Füllstand in Prozent - für die automatische Tankerkennung.",
"domains": ["sensor"], "device_classes": [], "units": ["%"], "liste": False, "pflicht": False,
"stichworte": ["tank", "fuel", "kraftstoff", "füllstand", "level"]},
{"key": "RANGE_SENSOR", "label": "Reichweite", "gruppe": "uebersicht",
"hinweis": "Für die Übersicht - bleibt leer, zeigt die Oberfläche \"unbekannt\".",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
"stichworte": ["reichweite", "range"]},
{"key": "BATTERIE_SENSOR", "label": "12V-Batteriespannung", "gruppe": "uebersicht",
"hinweis": "Nicht jede Integration liefert diesen Wert - bleibt er leer, zeigt \"Mein Audi\" \"unbekannt\".",
"domains": ["sensor"], "device_classes": ["voltage"], "units": ["V"], "liste": False, "pflicht": False,
"stichworte": ["batterie", "battery", "spannung", "volt", "12v"]},
{"key": "REFRESH_BUTTON", "label": "Sofort-Aktualisierung (Knopf)", "gruppe": "uebersicht",
"hinweis": "Der von der Integration bereitgestellte Knopf für \"Ziehen zum Aktualisieren\".",
"domains": ["button"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
"stichworte": ["refresh", "aktualisieren", "update", "abrufen", "poll"]},
{"key": "TUER_SENSOREN", "label": "Türen", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu. Für \"Sicher abgestellt\" wichtig.",
"domains": ["binary_sensor"], "device_classes": ["door"], "units": [], "liste": True, "positionen": POSITIONEN,
"pflicht": False, "stichworte": ["tür", "door"]},
{"key": "FENSTER_SENSOREN", "label": "Fenster", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["window"], "units": [], "liste": True, "positionen": POSITIONEN,
"pflicht": False, "stichworte": ["fenster", "window"]},
{"key": "HECKKLAPPE_SENSOR", "label": "Heckklappe", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
"stichworte": ["heckklappe", "tailgate", "kofferraum", "trunk"]},
{"key": "HAUBE_SENSOR", "label": "Motorhaube", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
"stichworte": ["haube", "hood", "bonnet", "motorhaube"]},
{"key": "NAECHSTER_OELWECHSEL_SENSOR", "label": "Nächster Ölwechsel (Datum)", "gruppe": "uebersicht",
"hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit, ergänzt die App-eigene Servicebuch-Prognose.",
"domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False,
"stichworte": ["ölwechsel", "oil", "service", "change"]},
{"key": "OELWECHSEL_STRECKE_SENSOR", "label": "Nächster Ölwechsel (Restkilometer)", "gruppe": "uebersicht",
"hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
"stichworte": ["ölwechsel", "oil", "distance", "strecke"]},
{"key": "NAECHSTE_INSPEKTION_SENSOR", "label": "Nächste Inspektion (Datum)", "gruppe": "uebersicht",
"hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit.",
"domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False,
"stichworte": ["inspektion", "inspection", "service"]},
{"key": "INSPEKTION_STRECKE_SENSOR", "label": "Nächste Inspektion (Restkilometer)", "gruppe": "uebersicht",
"hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
"stichworte": ["inspektion", "inspection", "distance", "strecke"]},
{"key": "STANDORT_LAT_SENSOR", "label": "GPS-Standort: Breitengrad", "gruppe": "standort",
"hinweis": "Breitengrad als eigener Sensor (z. B. flespi).",
"domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
"stichworte": ["breitengrad", "latitude", "lat", "standort", "gps", "position", "ortung"]},
{"key": "STANDORT_LON_SENSOR", "label": "GPS-Standort: Längengrad", "gruppe": "standort",
"hinweis": "Längengrad als eigener Sensor (z. B. flespi).",
"domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
"stichworte": ["längengrad", "longitude", "lon", "lng", "standort", "gps", "position", "ortung"]},
]
# Mehrere @state_trigger(f"...")-Dekoratoren (fahrterkennung.py,
# fahrtabschluss.py, tankerkennung.py, reifenzaehler.py) backen diese drei
# Felder beim Modul-Laden ein (siehe Kopfkommentar) - im Setup-Menü als
# "wirkt erst nach Neustart"-Hinweis markiert.
_TRIGGER_GEBUNDEN = {"ZUENDUNG_SENSOR", "KM_SENSOR", "TANK_SENSOR"}
for _feld in FELDER:
_feld["trigger_gebunden"] = _feld["key"] in _TRIGGER_GEBUNDEN
_SCHLUESSEL = {feld["key"] for feld in FELDER}
# Schnappschuss der eingebauten Standardwerte aus einstellungen.py, genommen
# BEVOR overrides_anwenden() jemals lief (Modul-Ebene, läuft beim `import
# entitaeten` in frontend_api.py - vor dem @time_trigger("startup"), der
# overrides_anwenden() erstmals aufruft). Notwendig fürs Setup-Menü
# "Zurücksetzen": setattr() in overrides_anwenden() überschreibt das Attribut
# auf dem laufenden einstellungen-Modul dauerhaft, ein einfaches Weglassen
# des Overrides beim nächsten Speichern stellt den eingebauten Wert NICHT
# wieder her (das Attribut bleibt ja gesetzt) - "Zurücksetzen" muss den
# Standardwert stattdessen aktiv zurückschreiben.
_STANDARDWERTE = {feld["key"]: getattr(einstellungen, feld["key"], None) for feld in FELDER}
# --------------------------------------------------------------- JSON-I/O ---
# Gleiches Muster wie profil.profil_lesen()/profil_schreiben(): io.open nur
# über task.executor (pyscript-Einschränkung, siehe Kopfkommentar dort),
# atomares Schreiben über eine .tmp-Datei + os.replace.
def overrides_lesen():
"""Liest die gespeicherten Zuordnungen, oder {} wenn die Datei fehlt bzw.
beschädigt ist.
Der try/except ist nicht Vorsicht um ihrer selbst willen: overrides_
anwenden() läuft in beim_start() VOR alles_veroeffentlichen(). Ohne die
Absicherung reißt eine einzige unlesbare Zeile in dieser Datei den
gesamten Startvorgang mit - das Panel bliebe komplett leer, ohne dass
irgendetwas auf die Ursache hindeutet."""
if not os.path.exists(OVERRIDES_PFAD):
return {}
f = task.executor(io.open, OVERRIDES_PFAD, "r")
inhalt = f.read()
f.close()
if not inhalt.strip():
return {}
try:
gelesen = json.loads(inhalt)
except ValueError as fehler:
log.error(
f"audi_dashboard: {OVERRIDES_PFAD} ist kein gültiges JSON ({fehler}). "
"Die eingebauten Standardwerte gelten weiter; die Zuordnung lässt sich "
"im Setup-Menü neu speichern."
)
return {}
if not isinstance(gelesen, dict):
log.error(f"audi_dashboard: {OVERRIDES_PFAD} enthält kein Objekt - wird ignoriert.")
return {}
return gelesen
def overrides_schreiben(mapping):
"""Schreibt nur bekannte Schlüssel (siehe FELDER); unbekannte werden
stillschweigend verworfen, damit ein späterer Katalog-Umbau keine
Datenleichen in entitaeten.json hinterlässt."""
bereinigt = {k: v for k, v in mapping.items() if k in _SCHLUESSEL}
os.makedirs(BASIS, exist_ok=True)
tmp = OVERRIDES_PFAD + ".tmp"
text = json.dumps(bereinigt, ensure_ascii=False, indent=2)
f = task.executor(io.open, tmp, "w")
f.write(text)
f.close()
os.replace(tmp, OVERRIDES_PFAD)
# ------------------------------------------------------- Anwenden/Lesen ---
def _ist_leer(wert):
"""Leer heißt: keine Zuordnung. Auch eine Liste, die nur leere Einträge
enthält - das Setup-Menü schickt für unbelegte Positionen ["","",...],
und das ist inhaltlich dasselbe wie gar keine Liste.
Die Schleife steht hier bewusst statt eines any(...)-Generatorausdrucks:
pyscript kennt Generatorausdrücke nicht und wirft zur Laufzeit
"not implemented ast ast_generatorexp". Mengen-, Listen- und
Dict-Comprehensions funktionieren dagegen (siehe _SCHLUESSEL oben)."""
if wert in (None, ""):
return True
if isinstance(wert, list):
for eintrag in wert:
if eintrag:
return False
return True
return False
def overrides_anwenden():
"""Setzt für JEDES bekannte Feld den wirksamen Wert auf dem laufenden
einstellungen-Modul - den Override, wenn einer hinterlegt ist, sonst den
eingebauten Standardwert.
Wichtig ist das "jedes": Eine frühere Fassung übersprang leere Werte und
setzte nur die belegten. Weil setattr() das Modul-Attribut aber dauerhaft
verändert, blieb ein einmal gesetzter Wert danach für immer stehen -
"Zurücksetzen" im Setup-Menü hatte bei allen Feldern mit leerem
Standardwert (15 von 17) schlicht keine Wirkung, und die Oberfläche zeigte
beim nächsten Zeichnen wieder den alten Wert, als sei das Speichern
fehlgeschlagen. Umgekehrt wurde eine Liste aus leeren Einträgen gesetzt
statt übersprungen, was den Sicherheitscheck mit zwölf "unbekannt"-Zeilen
füllte und "Sicher abgestellt" dauerhaft grau werden ließ.
Jedes Feld bei jedem Durchlauf zu schreiben ist damit nicht nur korrekt,
sondern auch idempotent: der Zustand hängt allein an entitaeten.json,
nicht an der Reihenfolge vorheriger Aufrufe."""
overrides = overrides_lesen()
for key in _SCHLUESSEL:
wert = overrides.get(key)
setattr(einstellungen, key, _STANDARDWERTE[key] if _ist_leer(wert) else wert)
def aktueller_stand():
"""Katalog + aktuell wirksamer Wert je Feld (aus dem einstellungen-Modul
selbst gelesen, nicht direkt aus der JSON-Datei) - zeigt damit sowohl
bereits angewendete Overrides als auch die eingebauten Standardwerte
einheitlich. Grundlage für pyscript.audi_dashboard_entitaeten."""
werte = {feld["key"]: getattr(einstellungen, feld["key"], None) for feld in FELDER}
return {"katalog": FELDER, "werte": werte, "standardwerte": _STANDARDWERTE}
@@ -1,196 +0,0 @@
"""Screening-Logik für den zweistufigen Fahrtabschluss (Bauauftrag §7.2).
Importierbar aus anderen pyscript-Dateien mit `import fahrtabschluss_logik`.
Der Kilometerstand kommt laut Datenquelle nicht sicher mit Fahrtende, sondern
teils erst mit Beginn oder während der nächsten Fahrt (§10 Punkt 4). Statt auf
einen festen Zeitpunkt zu warten, wird deshalb die Recorder-Historie des
Kilometerstand-Sensors nach dem Wert durchsucht, dessen Zeitstempel am
nächsten am Verbindungsabbruch liegt.
Zugriff auf die Historie läuft über die HA-REST-API (/api/history/period),
nicht über einen internen pyscript-Mechanismus - dafür gibt es in der
pyscript-Referenz keinen dokumentierten Weg. Das erfordert ein Long-Lived
Access Token, siehe README.
WICHTIGER VORBEHALT: Der GPS-Fallback aus §7.2 (km_quelle="gps") ist hier noch
NICHT umgesetzt, da die Erfassung von Start-/Endposition (device_tracker,
Adressauflösung) ein eigenes Arbeitspaket ist. Fahrten ohne passenden
Kilometerstand bleiben aktuell dauerhaft "offen" statt auf GPS-Distanz
auszuweichen. Siehe die Restarbeiten im Abschlussbericht.
"""
import datetime
import io
import json
import urllib.parse
import urllib.request
import frontend_veroeffentlichung
import profil
BASIS = "/config/audi_dashboard"
TOKEN_PFAD = f"{BASIS}/ha_token.txt"
HA_BASIS_URL = "http://localhost:8123"
# Wie weit vor/zurück in der Historie gesucht wird, ausgehend vom
# Verbindungsabbruch. 48h deckt auch länger stehende Fahrzeuge ab, ohne bei
# jedem Screening die komplette Historie zu laden.
SUCHFENSTER_STUNDEN = 48
# ---------------------------------------------------------- HA-REST-Zugriff
def _token_lesen():
# io.open über task.executor statt bloßem open() - siehe Kopfkommentar
# in profil.py.
f = task.executor(io.open, TOKEN_PFAD, "r")
inhalt = f.read()
f.close()
return inhalt.strip()
def historie_abfragen(entity_id, start_iso, end_iso):
"""Fragt die Verlaufsdaten des Kilometerstand-Sensors über die REST-API ab.
Beide blockierenden Schritte laufen über task.executor. Ein früherer Stand
rief urlopen() direkt auf - das lief jahrelang durch, bis Home Assistant
anfing, blockierende Aufrufe im Ereignisschleifen-Thread zu erkennen und
abzubrechen (real beobachtet ab 2026.8: "Caught blocking call to
putrequest ... inside the event loop"). Seitdem schlug jedes Screening
fehl, und manuell angelegte Fahrten blieben ohne Strecke - sichtbar nur
als Warnung im Protokoll, weil der Aufrufer den Fehler bewusst abfängt.
task.executor braucht echte externe Funktionen; urllib.request.urlopen und
die read-Methode der Antwort sind genau das (dieselbe Einschränkung wie bei
io.open in profil.py)."""
token = _token_lesen()
start_enc = urllib.parse.quote(start_iso)
end_enc = urllib.parse.quote(end_iso)
url = (
f"{HA_BASIS_URL}/api/history/period/{start_enc}"
f"?filter_entity_id={entity_id}&end_time={end_enc}"
)
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
resp = task.executor(urllib.request.urlopen, req, timeout=10)
rohdaten = task.executor(resp.read)
resp.close()
daten = json.loads(rohdaten.decode("utf-8"))
return daten[0] if daten else []
# ------------------------------------------------------------- Kernlogik
def naechster_wert(zielzeit, punkte):
"""Wert aus der Historie, dessen Zeitstempel am nächsten an zielzeit
liegt. Gibt (wert, zeitdifferenz_s) zurück, oder (None, None). Öffentlich,
weil auch belegverarbeitung.py sie für den Tankfüllstand nutzt."""
beste_wert = None
beste_diff = None
for p in punkte:
try:
wert = float(p["state"])
except (ValueError, TypeError, KeyError):
continue
try:
zeit = datetime.datetime.fromisoformat(
p["last_changed"].replace("Z", "+00:00")
)
except (ValueError, KeyError):
continue
diff = abs((zeit - zielzeit).total_seconds())
if beste_diff is None or diff < beste_diff:
beste_wert = wert
beste_diff = diff
return beste_wert, beste_diff
def _zeitpunkt_screenen(km_sensor, zielzeit):
start = (zielzeit - datetime.timedelta(hours=SUCHFENSTER_STUNDEN)).isoformat()
ende = (zielzeit + datetime.timedelta(hours=SUCHFENSTER_STUNDEN)).isoformat()
try:
punkte = historie_abfragen(km_sensor, start, ende)
except Exception as exc:
# Fehlt z. B. der Token (INSTALL.md Schritt 5) oder ist die API kurz
# nicht erreichbar, bleibt die Fahrt einfach offen statt den ganzen
# Screening-Lauf für alle anderen offenen Fahrten abzubrechen.
log.warning(f"audi_dashboard: Kilometerstand-Screening fehlgeschlagen: {exc}")
return None
wert, _ = naechster_wert(zielzeit, punkte)
return wert
def screening_durchfuehren(km_sensor):
"""Versucht, offene Fahrten mit Start-/Endkilometer zu vervollständigen.
Wird sowohl nach Ablauf der Pausenzeit (§7.1) als auch bei jeder Änderung
des Kilometerstand-Sensors aufgerufen."""
fahrten = profil.fahrten_lesen()
offene = [f for f in fahrten if f.get("status") == "offen"]
if not offene:
return
offene.sort(key=lambda f: f.get("ts_start", ""))
for fahrt in offene:
geaendert = False
if fahrt.get("odo_end") is None:
ts_end = datetime.datetime.fromisoformat(fahrt["ts_end"])
wert = _zeitpunkt_screenen(km_sensor, ts_end)
if wert is not None:
fahrt["odo_end"] = wert
geaendert = True
if fahrt.get("odo_start") is None:
ts_start = datetime.datetime.fromisoformat(fahrt["ts_start"])
wert = _zeitpunkt_screenen(km_sensor, ts_start)
if wert is not None:
fahrt["odo_start"] = wert
geaendert = True
if geaendert:
aenderungen = {"odo_start": fahrt.get("odo_start"), "odo_end": fahrt.get("odo_end")}
if fahrt.get("odo_start") is not None and fahrt.get("odo_end") is not None:
aenderungen["distance_km"] = round(fahrt["odo_end"] - fahrt["odo_start"], 1)
aenderungen["km_quelle"] = "odometer"
aenderungen["status"] = "vollständig"
profil.fahrt_aktualisieren(fahrt["trip_id"], aenderungen)
_lueckenlos_verketten()
frontend_veroeffentlichung.fahrten_veroeffentlichen()
def _lueckenlos_verketten():
"""Fahrt-Ende(N) == Fahrt-Start(N+1), solange dazwischen nicht gefahren
wurde (§7.2). Zwischen zwei in der Liste direkt aufeinanderfolgenden
Fahrten steht das Fahrzeug nur - der Kilometerstand ändert sich dabei
nicht, unabhängig davon, wie lange die Standzeit dauert. Ein bestätigter
Wert wird deshalb an den direkten Nachbarn weitergereicht, auch wenn
dessen eigenes Screening noch keinen Treffer hatte.
Annahme: die Fahrten-Liste enthält keine Lücken durch verpasste
Verbindungsereignisse (z. B. durch einen HA-Neustart mitten in einer
Fahrt). Das ist der Normalfall, aber keine Garantie - siehe
Abschlussbericht."""
fahrten = sorted(profil.fahrten_lesen(), key=lambda f: f.get("ts_start", ""))
geaendert_ids = []
for i in range(len(fahrten) - 1):
aktuelle, naechste = fahrten[i], fahrten[i + 1]
if aktuelle.get("odo_end") is not None and naechste.get("odo_start") is None:
naechste["odo_start"] = aktuelle["odo_end"]
geaendert_ids.append(naechste["trip_id"])
elif naechste.get("odo_start") is not None and aktuelle.get("odo_end") is None:
aktuelle["odo_end"] = naechste["odo_start"]
geaendert_ids.append(aktuelle["trip_id"])
for fahrt in fahrten:
if fahrt["trip_id"] not in geaendert_ids:
continue
aenderungen = {"odo_start": fahrt.get("odo_start"), "odo_end": fahrt.get("odo_end")}
if fahrt.get("odo_start") is not None and fahrt.get("odo_end") is not None:
aenderungen["distance_km"] = round(fahrt["odo_end"] - fahrt["odo_start"], 1)
aenderungen["km_quelle"] = "odometer"
aenderungen["status"] = "vollständig"
profil.fahrt_aktualisieren(fahrt["trip_id"], aenderungen)
@@ -1,258 +0,0 @@
"""Veröffentlicht Profil, Fahrten, Tankvorgänge und den Live-Fahrzeugstatus
als pyscript-Zustandsentitäten, damit das Frontend sie über das ganz normale
hass.states lesen kann - kein Service-Aufruf mit Rückgabewert nötig, dessen
genaues Verhalten in der Home-Assistant-Weboberfläche nicht durchgängig
dokumentiert ist (siehe Kopfkommentar in frontend_api.py).
Importierbar mit `import frontend_veroeffentlichung`, aufgerufen sowohl
periodisch (frontend_api.py) als auch direkt nach Schreibaktionen aus
fahrterkennung.py, belegverarbeitung.py und reifenzaehler.py, damit sich die
Oberfläche unmittelbar aktualisiert statt bis zum nächsten Intervall zu
warten.
BEKANNTE GRENZE: Zustands-Attribute in Home Assistant sind nicht für beliebig
große Datenmengen gedacht (~16 KB Richtwert für die Aufzeichnung in der
Recorder-Datenbank). Für das Profil und den aktuellen Stand ist das
unkritisch; die Fahrten- und Tankvorgänge-Archive könnten das nach einigen
Jahren überschreiten. Wird das relevant, müssten fahrten_veroeffentlichen()/
tankvorgaenge_veroeffentlichen() auf einen Zeitraum eingeschränkt werden
(z. B. nur das laufende Jahr), statt hier vorschnell zu vereinfachen.
"""
import io
import json
import os
import einstellungen
import entitaeten
import profil
def profil_veroeffentlichen():
daten = profil.profil_lesen()
if daten is None:
return
state.set("pyscript.audi_dashboard_profil", "aktuell", new_attributes={"daten": daten})
def fahrten_veroeffentlichen():
state.set("pyscript.audi_dashboard_fahrten", "aktuell", new_attributes={"daten": profil.fahrten_lesen()})
def tankvorgaenge_veroeffentlichen():
state.set("pyscript.audi_dashboard_tankvorgaenge", "aktuell", new_attributes={"daten": profil.tankvorgaenge_lesen()})
def batterieverlauf_veroeffentlichen():
state.set("pyscript.audi_dashboard_batterieverlauf", "aktuell", new_attributes={"daten": profil.batterieverlauf_lesen()})
def entitaeten_veroeffentlichen():
"""Katalog + aktuell wirksame Zuordnung fürs Setup-Menü (Einstellungen ->
Fahrzeug einrichten -> Setup) - siehe entitaeten.py."""
state.set("pyscript.audi_dashboard_entitaeten", "aktuell", new_attributes={"daten": entitaeten.aktueller_stand()})
def beleg_ergebnis_veroeffentlichen(daten):
"""Ergebnis eines Beleg-Uploads (geparste Werte fürs Ausfüllen eines
Entwurfs, oder ein Fehler wie 'Falsches Belegdatum') fürs Frontend, nach
demselben Zustands-statt-Rückgabewert-Muster wie die übrigen
veroeffentlichen()-Funktionen hier (siehe Kopfkommentar)."""
state.set("pyscript.audi_dashboard_beleg_ergebnis", "aktuell", new_attributes={"daten": daten})
def zustand_oder_none(entity_id):
"""Sicherer Zustandszugriff: pyscripts state.get() wirft NameError, wenn
die Entity (noch) nicht existiert - real an einer Testinstanz beobachtet,
nicht nur vermutet. Genau das ist hier der Normalfall, solange manche der
in einstellungen.py hinterlegten Entity-IDs noch Platzhalter sind, und
bleibt auch danach relevant: fällt eine Datenquelle aus, soll die
Oberfläche das zeigen (§9), nicht an einem Fehler hängen bleiben."""
if not entity_id:
return None
try:
wert = state.get(entity_id)
except NameError:
return None
if wert in (None, "unknown", "unavailable"):
return None
return wert
def _zu_zahl(wert):
try:
return float(wert)
except (TypeError, ValueError):
return None
def _abs_zahl(wert):
zahl = _zu_zahl(wert)
return None if zahl is None else abs(zahl)
def _zu_bool(wert):
"""binary_sensor-Zustand als echtes True/False, None bei fehlender Meldung.
None ist hier ausdrücklich kein "nein": ohne zugeordneten Sensor weiß die
App schlicht nicht, ob gefahren wird - und muss das anzeigen dürfen (§9),
statt "steht" zu behaupten."""
if wert is None:
return None
return str(wert).lower() in ("on", "true", "1", "open", "yes")
def _standort():
"""Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel) - liest
STANDORT_LAT_SENSOR/STANDORT_LON_SENSOR, zwei eigene sensor-Entities für
Breiten-/Längengrad (flespi liefert Koordinaten so, nicht als Attribute
einer einzelnen device_tracker-Entity). Fehlt eine der beiden Entity-IDs
oder ist der Zustand (noch) nicht verfügbar, liefert diese Funktion
durchgehend None statt eines geratenen Werts - genau das
zustand_oder_none()-Muster von oben, hier als eigene Funktion, weil
zusätzlich zum Zustand noch der Aktualisierungszeitpunkt gebraucht wird:
state.get() liefert dafür ein StateVal (str-Unterklasse) mit
.last_updated als datetime-Attribut - kein eigenständiges
state.last_updated(), das gibt es in pyscript nicht."""
leer = {"lat": None, "lon": None, "genauigkeit_m": None, "zeit": None}
lat_id, lon_id = einstellungen.STANDORT_LAT_SENSOR, einstellungen.STANDORT_LON_SENSOR
if not lat_id or not lon_id:
return leer
try:
lat_wert = state.get(lat_id)
lon_wert = state.get(lon_id)
except NameError:
return leer
lat, lon = _zu_zahl(lat_wert), _zu_zahl(lon_wert)
if lat is None or lon is None:
return leer
zeit = None
try:
zeit = lat_wert.last_updated.isoformat()
except AttributeError:
pass
return {"lat": lat, "lon": lon, "genauigkeit_m": None, "zeit": zeit}
POSITIONEN = ["vorne links", "vorne rechts", "hinten links", "hinten rechts"]
def _sicherheitscheck():
"""Liste der einzeln geprüften Punkte hinter "Sicher abgestellt" (§9),
fürs Frontend (Klick auf den Status öffnet diese Liste mit einem grünen/
roten/grauen Punkt je Zeile). "ok" ist None, wenn der Sensor fehlt oder
nicht verfügbar ist - genau daraus leitet sich auch die zusammengefasste
gesichert-Kennzahl unten ab, damit beide nie auseinanderlaufen können."""
eintraege = []
for pos, sensor in zip(POSITIONEN, einstellungen.TUER_SENSOREN):
w = zustand_oder_none(sensor)
eintraege.append({"label": f"Tür {pos}", "ok": None if w is None else w == "off"})
for pos, sensor in zip(POSITIONEN, einstellungen.FENSTER_SENSOREN):
w = zustand_oder_none(sensor)
eintraege.append({"label": f"Fenster {pos}", "ok": None if w is None else w == "off"})
for label, sensor in [
("Heckklappe", einstellungen.HECKKLAPPE_SENSOR),
("Motorhaube", einstellungen.HAUBE_SENSOR),
]:
w = zustand_oder_none(sensor)
eintraege.append({"label": label, "ok": None if w is None else w == "off"})
return eintraege
def fahrzeugstatus_veroeffentlichen():
"""Bündelt die live aus Home Assistant gelesenen Fahrzeugwerte
Frontend-freundlich, damit die Oberfläche keine Entity-IDs kennen muss.
"""
km = zustand_oder_none(einstellungen.KM_SENSOR)
tankprozent = zustand_oder_none(einstellungen.TANK_SENSOR)
reichweite = zustand_oder_none(einstellungen.RANGE_SENSOR)
batteriespannung = zustand_oder_none(einstellungen.BATTERIE_SENSOR)
# "Sicher abgestellt" (§9) - erst wenn WIRKLICH jeder einzeln geprüfte
# Punkt zu/verriegelt ist, gilt das Fahrzeug als gesichert; fehlt auch
# nur eine Meldung, ist der Status unbekannt statt geraten.
sicherheitscheck = _sicherheitscheck()
if any([e["ok"] is None for e in sicherheitscheck]):
gesichert = None
else:
gesichert = all([e["ok"] for e in sicherheitscheck])
standort = _standort()
daten = {
"km": _zu_zahl(km),
"tankprozent": _zu_zahl(tankprozent),
"reichweite_km": _zu_zahl(reichweite),
"batteriespannung": _zu_zahl(batteriespannung),
"gesichert": gesichert,
"sicherheitscheck": sicherheitscheck,
# Fährt das Fahrzeug gerade? Kommt aus derselben Zündungs-Entität, die
# auch fahrterkennung.py als maßgebliches Signal für Fahrtbeginn/-ende
# nimmt - damit sagen Anzeige und Erfassung zwangsläufig dasselbe.
#
# Vorher wurde dieser Zustand im Frontend aus dem Status der Fahrten
# abgeleitet ("offen" = fährt). Das war falsch: "offen" heißt
# unvollständige Daten (Strecke fehlt noch, siehe
# fahrtabschluss_logik.py), nicht "unterwegs" - eine Fahrt ohne
# Kilometerstand blieb dauerhaft "offen" und das Fahrzeug damit
# dauerhaft "fahrend". None bedeutet: kein Zündungssensor zugeordnet,
# Zustand unbekannt.
"zuendung": _zu_bool(zustand_oder_none(einstellungen.ZUENDUNG_SENSOR)),
"standort_lat": standort["lat"],
"standort_lon": standort["lon"],
"standort_genauigkeit_m": standort["genauigkeit_m"],
"standort_zeit": standort["zeit"],
# Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die
# App-eigene Servicebuch-Berechnung, siehe fahrzeugMeldung() im
# Frontend) - die Streckensensoren liefern negative Restkilometer-
# Werte, hier deshalb der Betrag.
"oelwechsel_faellig_ts": zustand_oder_none(einstellungen.NAECHSTER_OELWECHSEL_SENSOR),
"oelwechsel_faellig_km": _abs_zahl(zustand_oder_none(einstellungen.OELWECHSEL_STRECKE_SENSOR)),
"inspektion_faellig_ts": zustand_oder_none(einstellungen.NAECHSTE_INSPEKTION_SENSOR),
"inspektion_faellig_km": _abs_zahl(zustand_oder_none(einstellungen.INSPEKTION_STRECKE_SENSOR)),
}
state.set("pyscript.audi_dashboard_fahrzeugstatus", "aktuell", new_attributes={"daten": daten})
APP_VERSION_PFAD = "/config/www/audi-dashboard-version.json"
def app_version_veroeffentlichen():
"""Welchen Oberflächen-Stand diese Installation ausliefert.
Gedacht für die Companion-App: die trägt ihre Version fest einkompiliert
(vite setzt __APP_VERSION__ aus der Datei VERSION im Projektstamm) und
kann sie gegen diesen Wert halten. Weicht sie ab, ist die App älter als
der Server und sagt das selbst - siehe VERSIONIERUNG.md.
Das Panel braucht den Vergleich nicht: es lädt seine Dateien bei jedem
Seitenaufruf frisch, sobald sich die Cache-Zahl in derselben Datei
geändert hat, und kann deshalb gar nicht veralten.
Bewusst eine eigene Entität statt eines Felds im Fahrzeugstatus: das hier
ist eine Eigenschaft der Installation, keine des Fahrzeugs."""
version = None
if os.path.exists(APP_VERSION_PFAD):
try:
f = task.executor(io.open, APP_VERSION_PFAD, "r")
inhalt = f.read()
f.close()
version = json.loads(inhalt).get("app")
except (ValueError, OSError) as fehler:
log.warning(f"audi_dashboard: App-Version nicht lesbar ({fehler})")
state.set(
"pyscript.audi_dashboard_app_version",
version or "unbekannt",
new_attributes={"daten": {"app": version}},
)
def alles_veroeffentlichen():
app_version_veroeffentlichen()
profil_veroeffentlichen()
fahrten_veroeffentlichen()
tankvorgaenge_veroeffentlichen()
fahrzeugstatus_veroeffentlichen()
batterieverlauf_veroeffentlichen()
entitaeten_veroeffentlichen()
-298
View File
@@ -1,298 +0,0 @@
"""Datenzugriff für Fahrzeugprofil, Fahrten und Tankvorgänge (Bauauftrag §6).
Importierbar aus anderen pyscript-Dateien mit `import profil`.
Drei getrennte Bestände, wie in §6.1 festgelegt:
- Fahrzeugprofil: eine JSON-Datei, alles Fahrzeugspezifische
- Fahrten: JSON Lines, eine Zeile je Fahrt
- Tankvorgänge: JSON Lines
Drei Eigenheiten von pyscript, alle an einer echten Testinstanz beobachtet,
nicht nur aus der Dokumentation übernommen:
1. Das eingebaute `open()` existiert in pyscript nicht (NameError) - aus
Sicherheitsgründen bewusst nicht freigegeben. Der funktionierende Weg ist
`task.executor(io.open, pfad, modus)`: `io.open` ist eine echte externe
Funktion aus der Standardbibliothek, kein im pyscript-Ordner selbst
definierter Code.
2. task.executor() akzeptiert generell nur solche echten externen Funktionen
- eigene, im pyscript-Ordner definierte Funktionen weist es mit "pyscript
functions can't be called from task.executor" zurück. Deshalb hier nur
`io.open` selbst über den Executor, Lesen/Schreiben/Schließen auf dem
zurückgegebenen Datei-Objekt direkt (das ist kein bare-name-Aufruf mehr,
sondern ein Methodenaufruf auf einem bereits vorhandenen Objekt).
3. Variablen, die innerhalb eines `with ... as f:`-Blocks zugewiesen werden,
waren danach außerhalb nicht mehr auffindbar (NameError), deshalb kein
`with` - offen/lesen/schließen nacheinander.
"""
import io
import json
import os
import uuid
BASIS = "/config/audi_dashboard"
PROFIL_PFAD = f"{BASIS}/fahrzeugprofil.json"
FAHRTEN_PFAD = f"{BASIS}/fahrten.jsonl"
TANKVORGAENGE_PFAD = f"{BASIS}/tankvorgaenge.jsonl"
BATTERIEVERLAUF_PFAD = f"{BASIS}/batteriespannung.jsonl"
BELEGE_ORDNER = f"{BASIS}/belege"
# ---------------------------------------------------------------- Ordner ---
def ordner_sicherstellen():
os.makedirs(BASIS, exist_ok=True)
os.makedirs(BELEGE_ORDNER, exist_ok=True)
# ----------------------------------------------------------- Fahrzeugprofil
def profil_lesen():
"""Liest das Fahrzeugprofil, oder None wenn es fehlt bzw. beschädigt ist.
Ohne diese Prüfung reißt eine fehlende Datei (Installation unvollständig,
siehe INSTALL.md Schritt 2) jeden Trigger und jeden Service mit, der das
Profil braucht — bei laufenden Zeittriggern also im Minutentakt. Jeder
Aufrufer muss den None-Fall abfangen."""
if not os.path.exists(PROFIL_PFAD):
log.error(
f"audi_dashboard: {PROFIL_PFAD} fehlt. Siehe INSTALL.md Schritt 2 — "
"bis dahin bleiben alle Funktionen aus, die das Profil brauchen."
)
return None
f = task.executor(io.open, PROFIL_PFAD, "r")
inhalt = f.read()
f.close()
try:
return json.loads(inhalt)
except ValueError as fehler:
log.error(
f"audi_dashboard: {PROFIL_PFAD} ist kein gültiges JSON ({fehler}). "
"Letztes Backup aus audi_dashboard/backups/ zurückspielen."
)
return None
def profil_schreiben(profil):
tmp = PROFIL_PFAD + ".tmp"
text = json.dumps(profil, ensure_ascii=False, indent=2)
f = task.executor(io.open, tmp, "w")
f.write(text)
f.close()
os.replace(tmp, PROFIL_PFAD)
# --------------------------------------------------------- JSON-Lines-Basis
def _zeilen_lesen(pfad):
if not os.path.exists(pfad):
return []
f = task.executor(io.open, pfad, "r")
inhalt = f.read()
f.close()
datensaetze = []
for zeile in inhalt.splitlines():
zeile = zeile.strip()
if zeile:
datensaetze.append(json.loads(zeile))
return datensaetze
def _zeilen_schreiben(pfad, datensaetze):
tmp = pfad + ".tmp"
zeilen = [json.dumps(d, ensure_ascii=False) for d in datensaetze]
text = "\n".join(zeilen)
if zeilen:
text += "\n"
f = task.executor(io.open, tmp, "w")
f.write(text)
f.close()
os.replace(tmp, pfad)
def _zeile_anhaengen(pfad, datensatz):
f = task.executor(io.open, pfad, "a")
f.write(json.dumps(datensatz, ensure_ascii=False) + "\n")
f.close()
# ------------------------------------------------------------------ Fahrten
def fahrten_lesen():
return _zeilen_lesen(FAHRTEN_PFAD)
def fahrten_schreiben(fahrten):
_zeilen_schreiben(FAHRTEN_PFAD, fahrten)
def fahrt_anhaengen(fahrt):
_zeile_anhaengen(FAHRTEN_PFAD, fahrt)
def offene_fahrten():
"""Alle Fahrten mit status == 'offen', ältere zuerst."""
fahrten = fahrten_lesen()
offen = [f for f in fahrten if f.get("status") == "offen"]
return sorted(offen, key=lambda f: f.get("ts_start", ""))
def _datensatz_aktualisieren(zeilen, id_feld, id_wert, aenderungen, schreiben, schutz=True):
"""Ersetzt ausgewählte Felder eines Datensatzes anhand seiner ID und
schreibt den gesamten Bestand neu. Manuell geänderte Felder
(edited_fields, analog zu §7.7 Regel 3) werden dabei nie überschrieben.
Gemeinsame Grundlage für Fahrten und Tankvorgänge - beide Archive
funktionieren nach demselben Muster.
schutz=False hebt genau diese Sperre auf - nötig für Eingaben aus der
Oberfläche: edited_fields schützt gegen die automatische Ergänzung, nicht
gegen den Menschen, der das Feld gerade selbst korrigiert."""
geaendert = False
for d in zeilen:
if d.get(id_feld) == id_wert:
geschuetzt = set(d.get("edited_fields", [])) if schutz else set()
for feld, wert in aenderungen.items():
if feld not in geschuetzt:
d[feld] = wert
geaendert = True
break
if geaendert:
schreiben(zeilen)
return geaendert
def fahrt_aktualisieren(trip_id, aenderungen):
return _datensatz_aktualisieren(fahrten_lesen(), "trip_id", trip_id, aenderungen, fahrten_schreiben)
def fahrt_bearbeiten(trip_id, aenderungen):
"""Wie fahrt_aktualisieren(), aber für Eingaben aus der Oberfläche: eine
von Hand gesetzte Angabe sticht auch dann, wenn dasselbe Feld schon einmal
von Hand gesetzt wurde (siehe schutz-Parameter oben)."""
return _datensatz_aktualisieren(fahrten_lesen(), "trip_id", trip_id, aenderungen, fahrten_schreiben, schutz=False)
def fahrt_loeschen(trip_id):
fahrten = fahrten_lesen()
uebrig = [f for f in fahrten if f.get("trip_id") != trip_id]
if len(uebrig) == len(fahrten):
return False
fahrten_schreiben(uebrig)
return True
# ------------------------------------------------------------ Tankvorgänge
def tankvorgaenge_lesen():
return _zeilen_lesen(TANKVORGAENGE_PFAD)
def tankvorgaenge_schreiben(tankvorgaenge):
_zeilen_schreiben(TANKVORGAENGE_PFAD, tankvorgaenge)
def tankvorgang_anhaengen(tankvorgang):
_zeile_anhaengen(TANKVORGAENGE_PFAD, tankvorgang)
def tankvorgang_aktualisieren(tank_id, aenderungen):
return _datensatz_aktualisieren(tankvorgaenge_lesen(), "tank_id", tank_id, aenderungen, tankvorgaenge_schreiben)
def tankvorgang_loeschen(tank_id):
tankvorgaenge = tankvorgaenge_lesen()
uebrig = [t for t in tankvorgaenge if t.get("tank_id") != tank_id]
if len(uebrig) == len(tankvorgaenge):
return False
tankvorgaenge_schreiben(uebrig)
return True
def letzter_tankvorgang():
"""Der zeitlich jüngste bereits erfasste Tankvorgang (nach ts), oder None,
falls noch keiner existiert. Grundlage für die Gefahrene-Distanz-Berechnung
beim Anlegen eines neuen Tankvorgangs (§5.5)."""
tankvorgaenge = tankvorgaenge_lesen()
if not tankvorgaenge:
return None
return max(tankvorgaenge, key=lambda t: t.get("ts") or "")
def distanz_seit_letzter_tankung(aktueller_km):
"""Gefahrene Distanz seit dem vorherigen Tankvorgang, als Vorschlag für
das gleichnamige Formularfeld (§5.5) - frei überschreibbar, genau wie
odometer_km selbst. None, wenn kein Kilometerstand oder kein vorheriger
Tankvorgang vorliegt (erster Eintrag überhaupt). Gemeinsame Grundlage für
Beleg-Erfassung (belegverarbeitung.py) und automatische Tankerkennung
(tankerkennung.py)."""
if aktueller_km is None:
return None
letzter = letzter_tankvorgang()
if not letzter or letzter.get("odometer_km") is None:
return None
return round(aktueller_km - letzter["odometer_km"], 1)
def tankvorgang_nach_id(tank_id):
for t in tankvorgaenge_lesen():
if t.get("tank_id") == tank_id:
return t
return None
def tankvorgang_nach_receipt_key(receipt_key):
"""Für §7.7 Regel: derselbe Beleg (receipt_key, minutengenau) erzeugt
keinen zweiten Datensatz."""
for t in tankvorgaenge_lesen():
if t.get("receipt_key") == receipt_key:
return t
return None
# ------------------------------------------------------- Batteriespannung
def batterieverlauf_lesen():
"""Ein Eintrag pro Tag ({datum, min, min_ts, max, max_ts}), älteste
zuerst - siehe batterieverlauf.py für die Aufzeichnungslogik. min_ts/
max_ts sind die Zeitstempel (ISO, UTC) der jeweiligen Einzelmessung, für
die Datum/Uhrzeit-Anzeige beim Antippen des Diagrammpunkts im Frontend -
der Punkt selbst zeigt nur den Minimalwert (siehe dortiger Kommentar,
warum der aussagekräftig für die Entladung ist)."""
return _zeilen_lesen(BATTERIEVERLAUF_PFAD)
def batterieverlauf_tageswert_aktualisieren(datum, ts, spannung):
"""Trägt eine neue Messung in den Tageseintrag für `datum` ein: legt ihn
beim ersten Wert des Tages an, erweitert sonst nur min/max samt dem
Zeitstempel der jeweils neuen Extremmessung. Das Fahrzeug meldet die
Spannung künftig mehrfach pro Stunde (aktive Fahrt) statt nur einmal
täglich - der komplette Bestand wird deshalb bei jeder Messung neu
geschrieben (wie bei den übrigen JSON-Lines-Beständen hier), was bei
einem Eintrag pro Tag über Jahre hinweg unproblematisch bleibt."""
verlauf = batterieverlauf_lesen()
for eintrag in verlauf:
if eintrag.get("datum") == datum:
if spannung < eintrag["min"]:
eintrag["min"] = spannung
eintrag["min_ts"] = ts
if spannung > eintrag["max"]:
eintrag["max"] = spannung
eintrag["max_ts"] = ts
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)
# --------------------------------------------------------------------- IDs
def neue_id(praefix):
return f"{praefix}-{uuid.uuid4().hex[:12]}"
-141
View File
@@ -1,141 +0,0 @@
"""Reifenzähler ohne utility_meter (§7.5, §10 Punkt 6).
ABWEICHUNG VOM URSPRÜNGLICHEN LASTENHEFT (Frontend-Anpassung, nach dem
ursprünglichen Bau nachträglich verlangt): früher war der Zähler = aktueller
Kilometerstand - fester Startwert je Satz (reifen.saetze.<satz>.
start_odo_km). Das war falsch, sobald einmal zwischen Sommer und Winter
umgeschaltet wurde: die Differenz zum Startwert zählt dann alle seither
gefahrenen Kilometer mit, auch die auf dem jeweils anderen Satz. Jetzt gilt
stattdessen: jeder gefahrene Kilometer wird dem Satz zugerechnet, der zum
Zeitpunkt der Fahrt aktiv war. Dafür führt reifen.saetze.<satz>.km selbst den
fortlaufenden Gesamtstand (statt eines Startwerts), und reifen.
referenz_odo_km hält den zuletzt verarbeiteten Kilometerstand fest, gegen den
die nächste Änderung als Delta gemessen wird. start_odo_km/§13 entfällt
damit vollständig.
Ergebnis wird zusätzlich als eigene pyscript-Zustandsvariable bereitgestellt
(nur zur Kontrolle in Entwicklerwerkzeuge → Zustände, das Frontend liest die
Werte direkt aus dem veröffentlichten Profil):
pyscript.reifen_sommer_km, pyscript.reifen_winter_km,
pyscript.reifen_aktiver_satz
Kein Abhängigkeit von total_increasing oder utility_meter-internem Zustand -
die Fortschreibung braucht nur den zuletzt bekannten Absolutwert des
Kilometerstand-Sensors. Ein zeitweise `unavailable` Quellsensor (§4.1)
verliert dadurch nichts: referenz_odo_km bleibt einfach stehen, bis wieder
ein gültiger Wert kommt, und das nächste Delta wird dann gegen diesen alten
Stand berechnet - auch km, die während eines HA-Neustarts/Ausfalls gefahren
wurden, werden so noch dem damals aktiven Satz zugerechnet.
Entity-IDs stehen zentral in pyscript/modules/einstellungen.py.
"""
import einstellungen
import frontend_veroeffentlichung
import profil
def _aktueller_km():
try:
return float(frontend_veroeffentlichung.zustand_oder_none(einstellungen.KM_SENSOR))
except (TypeError, ValueError):
return None
def _zustaende_veroeffentlichen(p):
reifen = p.get("reifen", {})
saetze = reifen.get("saetze", {})
for satz_name in ("sommer", "winter"):
km = saetze.get(satz_name, {}).get("km")
if km is None:
state.set(f"pyscript.reifen_{satz_name}_km", "unbekannt")
else:
state.set(f"pyscript.reifen_{satz_name}_km", km, new_attributes={"unit_of_measurement": "km"})
state.set("pyscript.reifen_aktiver_satz", reifen.get("aktiv", "unbekannt"))
def _fortschreiben_und_veroeffentlichen(neuer_km, neuer_aktiv=None):
"""Rechnet zuerst mit dem bisher aktiven Satz ab (Delta seit
referenz_odo_km), trägt danach optional einen Satzwechsel ein - in dieser
Reihenfolge, damit beim Wechseln selbst keine km dem neuen Satz
zugerechnet werden, die eigentlich noch auf dem alten gefahren wurden."""
p = profil.profil_lesen()
if p is None:
return
reifen = p.setdefault("reifen", {})
saetze = reifen.setdefault("saetze", {})
aktiv = reifen.get("aktiv", "sommer")
referenz = reifen.get("referenz_odo_km")
if referenz is not None and neuer_km is not None:
delta = round(neuer_km - referenz, 1)
if delta > 0:
satz = saetze.setdefault(aktiv, {})
satz["km"] = round((satz.get("km") or 0) + delta, 1)
if neuer_km is not None:
reifen["referenz_odo_km"] = neuer_km
if neuer_aktiv is not None:
reifen["aktiv"] = neuer_aktiv
profil.profil_schreiben(p)
frontend_veroeffentlichung.profil_veroeffentlichen()
_zustaende_veroeffentlichen(p)
@time_trigger("startup")
def beim_start():
_fortschreiben_und_veroeffentlichen(_aktueller_km())
# Ohne konfigurierten Kilometerstand-Sensor keinen Trigger registrieren -
# eine leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes
# Verhalten, siehe gleiches Muster in fahrterkennung.py.
if einstellungen.KM_SENSOR:
@state_trigger(f"{einstellungen.KM_SENSOR}")
def kilometerstand_geaendert(value=None):
_fortschreiben_und_veroeffentlichen(_aktueller_km())
@service
def audi_dashboard_reifen_wechseln(satz=None):
"""Umschalten des aktiven Reifensatzes (§5.2). Aufruf als
pyscript.audi_dashboard_reifen_wechseln mit satz="sommer"|"winter"."""
if satz not in ("sommer", "winter"):
log.warning(f"audi_dashboard: ungültiger Reifensatz '{satz}'")
return
_fortschreiben_und_veroeffentlichen(_aktueller_km(), neuer_aktiv=satz)
log.info(f"audi_dashboard: aktiver Reifensatz auf '{satz}' umgeschaltet")
@service
def audi_dashboard_reifen_km_setzen(satz=None, km=None):
"""Manuelle Korrektur des gefahrenen Kilometerstands eines Reifensatzes
(Editier-Symbol neben der km-Anzeige in der App). Überschreibt nur den
gespeicherten Zähler selbst - referenz_odo_km (der zuletzt verarbeitete
Fahrzeug-Kilometerstand) bleibt unverändert, damit die nächste
Fortschreibung (kilometerstand_geaendert oben) weiterhin korrekt von hier
aus weiterzählt, statt auf den neuen Wert einen erneuten Delta seit dem
alten Stand aufzuschlagen. configCarZuProfil() im Frontend schreibt km nie
selbst mit (siehe Kommentar dort) - genau deshalb braucht es diesen
eigenen Service statt eines einfachen Profil-Überschreibens."""
if satz not in ("sommer", "winter"):
log.warning(f"audi_dashboard: ungültiger Reifensatz '{satz}'")
return
try:
km = round(float(km), 1)
except (TypeError, ValueError):
log.warning(f"audi_dashboard: ungültiger km-Wert '{km}'")
return
if km < 0:
log.warning(f"audi_dashboard: negativer km-Wert '{km}' abgelehnt")
return
p = profil.profil_lesen()
if p is None:
return
reifen = p.setdefault("reifen", {})
saetze = reifen.setdefault("saetze", {})
saetze.setdefault(satz, {})["km"] = km
profil.profil_schreiben(p)
frontend_veroeffentlichung.profil_veroeffentlichen()
_zustaende_veroeffentlichen(p)
log.info(f"audi_dashboard: km-Stand '{satz}' manuell auf {km} gesetzt")
-123
View File
@@ -1,123 +0,0 @@
"""Automatische Tankerkennung über den Füllstandssensor (Erweiterung zu §7.7/§5.5,
nach dem ursprünglichen Bau nachträglich verlangt).
Beobachtung des Nutzers: der Füllstand (TANK_SENSOR, in Prozent) springt beim
Fahren nie nach oben, er sinkt nur - jeder Anstieg ist also ein Tankvorgang.
Schwelle nach Nutzervorgabe: ab +5 Liter ODER +9 Prozentpunkten Anstieg gilt
als nachgetankt. Die 5-Liter-Vorgabe wird über fahrzeug.tankvolumen_liter aus
dem Fahrzeugprofil in Prozentpunkte umgerechnet, damit beide Angaben auf
derselben Einheit (Prozent) verglichen werden können; es gilt jeweils die
empfindlichere (kleinere) der beiden Schwellen.
Tiefststand-Tracking statt einfachem Vorher/Nachher-Vergleich: die
Integration liefert einen Tankvorgang oft in mehreren kleinen Schritten
(z. B. 40% -> 45% -> 60%), von denen keiner allein die Schwelle
überschreiten muss. Deshalb wird der zuletzt bekannte Tiefststand
gespeichert und der Anstieg dagegen gemessen, nicht gegen den unmittelbar
vorherigen Wert. Nach Anlage eines Tankvorgangs wird der Tiefststand auf den
aktuellen Wert zurückgesetzt, damit derselbe Vorgang nicht mehrfach neue
Datensätze erzeugt, wenn der Sensor währenddessen weiter ansteigt.
Der so angelegte Tankvorgang ist bewusst ein unvollständiger Platzhalter
(status "unvollständig", ohne Liter/Kosten/Station) - siehe §10 Punkt 5 der
letzten Rückmeldung: die Erwartung ist ein automatisch erkannter Tankvorgang
mit Zeitstempel, Kilometerstand und gefahrener Distanz, den der Nutzer später
per Beleg-Nachtrag (audi_dashboard_beleg_hochladen mit tank_id, siehe
belegverarbeitung.py) vervollständigt.
EINSCHRÄNKUNG: der Tiefststand (_tiefststand_pct) lebt nur im Arbeitsspeicher
dieses Skripts, analog zur bekannten Einschränkung in fahrterkennung.py
(_fahrt_start_ts). Ein HA-Neustart während oder kurz vor einem Tankvorgang
verliert den bisherigen Tiefststand - der nächste Anstieg wird dann gegen den
ersten nach dem Neustart gemeldeten Wert gemessen. Bewusst nicht gehärtet,
aus demselben Grund wie dort.
Entity-IDs stehen zentral in pyscript/modules/einstellungen.py.
"""
import datetime
import einstellungen
import frontend_veroeffentlichung
import profil
LITER_SCHWELLE = 5
PROZENT_SCHWELLE = 9
STANDARD_TANKVOLUMEN_LITER = 58 # Fallback, falls im Profil nicht hinterlegt
_tiefststand_pct = None
def _als_zahl(wert):
try:
return float(wert)
except (TypeError, ValueError):
return None
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
liter_als_prozent = (LITER_SCHWELLE / tankvolumen) * 100
return min(liter_als_prozent, PROZENT_SCHWELLE)
@time_trigger("startup")
def beim_start():
profil.ordner_sicherstellen()
# Ohne konfigurierten Tankfüllstand-Sensor keinen Trigger registrieren -
# eine leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes
# Verhalten, siehe gleiches Muster in fahrterkennung.py.
if einstellungen.TANK_SENSOR:
@state_trigger(f"{einstellungen.TANK_SENSOR}")
def tankfuellstand_geaendert(value=None, old_value=None):
global _tiefststand_pct
aktuell = _als_zahl(value)
if aktuell is None:
return
if _tiefststand_pct is None:
_tiefststand_pct = aktuell
return
if aktuell <= _tiefststand_pct:
_tiefststand_pct = aktuell
return
anstieg = aktuell - _tiefststand_pct
if anstieg >= _schwelle_prozent():
_tankvorgang_automatisch_anlegen(anstieg)
_tiefststand_pct = aktuell
def _tankvorgang_automatisch_anlegen(anstieg_pct):
odometer_km = frontend_veroeffentlichung.zustand_oder_none(einstellungen.KM_SENSOR)
tankvorgang = {
"tank_id": profil.neue_id("f"),
"receipt_key": None,
"ts": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"liters": None,
"fuel_total_eur": None,
"price_per_l": None,
"discount": None,
"station_name": None,
"odometer_km": odometer_km,
"distance_km": profil.distanz_seit_letzter_tankung(odometer_km),
"fuel_type": None,
"source": "auto",
"status": "unvollständig",
"receipt_file": None,
"edited_fields": [],
}
profil.tankvorgang_anhaengen(tankvorgang)
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
log.info(
f"audi_dashboard: Tankvorgang {tankvorgang['tank_id']} automatisch erkannt "
f"(Füllstandsanstieg {round(anstieg_pct, 1)} Prozentpunkte)"
)
-184
View File
@@ -1,184 +0,0 @@
"""Update-Funktion für die Oberfläche ("Update suchen" in Einstellungen).
Voraussetzung: einstellungen.UPDATE_REPO_URL zeigt auf ein Git-Repository, in
das dieses Projekt (pyscript/ + www/, wie mit update.ps1 auf die eigene
Testinstanz gespielt) gepflegt/gepusht wird - z. B. ein privates GitHub-Repo,
genau wie es für die HACS-Integration bereits genutzt wird. Ohne eingetragene
URL meldet "Update suchen" nur "keine Update-Quelle eingerichtet" (§9-
Fallback), es passiert nichts von selbst.
Kopiert wie update.ps1 bewusst NUR Code:
- pyscript/ komplett (kein Nutzerdaten-Ordner dort)
- www/*.js, www/*.css, www/badges/*
Bewusst NICHT angefasst: www/bilder/ (vom Nutzer hochgeladene Fahrzeugfotos,
siehe bilderverwaltung.py) und alles unter audi_dashboard/ (Fahrzeugprofil,
Fahrten- und Tankvorgänge-Archive) - ein Update darf niemals echte
Fahrzeugdaten oder eigene Fotos überschreiben.
Vor jeder Installation wird der bisherige Code-Stand unter
audi_dashboard/code_backups/<Zeitstempel>/ gesichert (gleiches Muster wie
backup.py für die Nutzerdaten) - kein automatisches Rollback in der
Oberfläche, aber ein manueller Weg zurück, falls ein Update Probleme macht.
git ist im offiziellen Home-Assistant-Container vorhanden (an der
Testinstanz geprüft) und subprocess ist über allow_all_imports bereits
freigegeben (siehe configuration_snippet.yaml), deshalb hier statt eines
selbstgebauten ZIP-Downloads einfach ein flacher Git-Checkout in einen
eigenen Zwischenordner, getrennt von /config/audi_dashboard (Nutzerdaten)
und /config/pyscript, /config/www (Live-Code).
"""
import datetime
import io
import json
import os
import shutil
import subprocess
import einstellungen
import profil
QUELLE_ORDNER = "/config/.audi_dashboard_update_quelle"
BACKUP_ORDNER = f"{profil.BASIS}/code_backups"
LIVE_PYSCRIPT = "/config/pyscript"
LIVE_WWW = "/config/www"
CODE_DATEIEN_WWW = ["audi-dashboard-app.js", "audi-dashboard-panel.js", "audi-dashboard.css"]
def _status_veroeffentlichen(daten):
state.set("pyscript.audi_dashboard_update_status", "aktuell", new_attributes={"daten": daten})
def _git(*args, cwd=None):
# subprocess.run() direkt, ohne task.executor - genau wie der bestehende
# PDF-Parser-Aufruf in belegverarbeitung.py (_parser_aufrufen), an der
# Testinstanz bereits bestätigt funktionsfähig.
return subprocess.run(
["git", *args],
cwd=cwd, capture_output=True, text=True, timeout=60,
)
def _lokale_version():
pfad = f"{LIVE_WWW}/audi-dashboard-version.json"
if not os.path.exists(pfad):
return None
try:
f = task.executor(io.open, pfad, "r")
inhalt = f.read()
f.close()
return json.loads(inhalt).get("version")
except (ValueError, OSError):
return None
def _quelle_aktualisieren():
"""Klont bzw. holt den konfigurierten Branch flach in QUELLE_ORDNER.
Gibt (erfolgreich, fehlertext) zurück."""
zweig = einstellungen.UPDATE_BRANCH or "main"
if not os.path.exists(f"{QUELLE_ORDNER}/.git"):
os.makedirs(os.path.dirname(QUELLE_ORDNER), exist_ok=True)
if os.path.exists(QUELLE_ORDNER):
task.executor(shutil.rmtree, QUELLE_ORDNER)
r = _git("clone", "--depth", "1", "--branch", zweig, einstellungen.UPDATE_REPO_URL, QUELLE_ORDNER)
if r.returncode != 0:
return False, r.stderr.strip() or "git clone fehlgeschlagen"
return True, None
r = _git("fetch", "--depth", "1", "origin", zweig, cwd=QUELLE_ORDNER)
if r.returncode != 0:
return False, r.stderr.strip() or "git fetch fehlgeschlagen"
r = _git("reset", "--hard", f"origin/{zweig}", cwd=QUELLE_ORDNER)
if r.returncode != 0:
return False, r.stderr.strip() or "git reset fehlgeschlagen"
return True, None
@service
def audi_dashboard_update_pruefen():
"""Prüft, ob unter einstellungen.UPDATE_REPO_URL eine neuere Version
vorliegt, ohne etwas zu installieren. Aufruf als
pyscript.audi_dashboard_update_pruefen."""
jetzt = datetime.datetime.now().isoformat()
if not einstellungen.UPDATE_REPO_URL:
_status_veroeffentlichen({"stand": "keine_quelle", "geprueft_um": jetzt})
return
ok, fehler = _quelle_aktualisieren()
if not ok:
_status_veroeffentlichen({
"stand": "fehler", "fehler": fehler, "geprueft_um": jetzt,
"aktuelle_version": _lokale_version(),
})
log.warning(f"audi_dashboard: Update-Prüfung fehlgeschlagen: {fehler}")
return
quell_version_pfad = f"{QUELLE_ORDNER}/www/audi-dashboard-version.json"
neue_version = None
if os.path.exists(quell_version_pfad):
f = task.executor(io.open, quell_version_pfad, "r")
inhalt = f.read()
f.close()
try:
neue_version = json.loads(inhalt).get("version")
except ValueError:
neue_version = None
aktuelle_version = _lokale_version()
verfuegbar = neue_version is not None and aktuelle_version is not None and neue_version > aktuelle_version
_status_veroeffentlichen({
"stand": "geprueft", "verfuegbar": verfuegbar,
"aktuelle_version": aktuelle_version, "neue_version": neue_version,
"geprueft_um": jetzt,
})
log.info(f"audi_dashboard: Update-Prüfung ok - aktuell={aktuelle_version}, verfügbar={neue_version}")
@service
def audi_dashboard_update_installieren():
"""Installiert den zuletzt per audi_dashboard_update_pruefen geholten
Stand aus QUELLE_ORDNER - sichert vorher den bisherigen Code. Aufruf als
pyscript.audi_dashboard_update_installieren."""
if not os.path.exists(f"{QUELLE_ORDNER}/.git"):
log.warning("audi_dashboard: Update-Installation ohne vorherige Prüfung abgelehnt")
return
zeitstempel = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
ziel_backup = f"{BACKUP_ORDNER}/{zeitstempel}"
os.makedirs(ziel_backup, exist_ok=True)
if os.path.exists(LIVE_PYSCRIPT):
task.executor(shutil.copytree, LIVE_PYSCRIPT, f"{ziel_backup}/pyscript")
for name in CODE_DATEIEN_WWW:
quelle = f"{LIVE_WWW}/{name}"
if os.path.exists(quelle):
task.executor(shutil.copy, quelle, f"{ziel_backup}/{name}")
quelle_pyscript = f"{QUELLE_ORDNER}/pyscript"
if os.path.exists(quelle_pyscript):
if os.path.exists(LIVE_PYSCRIPT):
task.executor(shutil.rmtree, LIVE_PYSCRIPT)
task.executor(shutil.copytree, quelle_pyscript, LIVE_PYSCRIPT)
for name in CODE_DATEIEN_WWW:
quelle = f"{QUELLE_ORDNER}/www/{name}"
if os.path.exists(quelle):
task.executor(shutil.copy, quelle, f"{LIVE_WWW}/{name}")
quelle_badges = f"{QUELLE_ORDNER}/www/badges"
if os.path.exists(quelle_badges):
ziel_badges = f"{LIVE_WWW}/badges"
if os.path.exists(ziel_badges):
task.executor(shutil.rmtree, ziel_badges)
task.executor(shutil.copytree, quelle_badges, ziel_badges)
# Version neu schreiben (aktuelle Uhrzeit als Cache-Buster, wie
# update.ps1) statt den im Quell-Repo eingecheckten Wert blind zu
# übernehmen - erzwingt bei jedem Browser einen echten Neuabruf von
# audi-dashboard-app.js/.css (siehe audi-dashboard-panel.js).
version = int(datetime.datetime.now().timestamp())
f = task.executor(io.open, f"{LIVE_WWW}/audi-dashboard-version.json", "w")
f.write(json.dumps({"version": version}))
f.close()
_status_veroeffentlichen({
"stand": "installiert", "verfuegbar": False,
"aktuelle_version": version, "neue_version": None,
"geprueft_um": datetime.datetime.now().isoformat(),
})
log.info(f"audi_dashboard: Update installiert (Backup unter {ziel_backup}), neue Version {version}")
-100
View File
@@ -1,100 +0,0 @@
<#
.SYNOPSIS
Spielt Code-Updates (pyscript + Frontend) in eine laufende Home-Assistant-
Installation ein, ohne Neustart und ohne Cache-Probleme im Browser.
.BESCHREIBUNG
Kopiert nur Code, nie Daten:
- pyscript/* -> <Ziel>/pyscript/ (pyscript lädt das automatisch neu,
siehe Kopfkommentar dort - an
einer Testinstanz bestätigt: eine
geänderte Datei war innerhalb von
Millisekunden aktiv, ganz ohne
Neustart)
- www/*.js, www/*.css, www/badges/*, www/bilder/* -> <Ziel>/www/
(audi-dashboard-ios.css nicht vergessen: die App hängt sich dieses
Overlay-Stylesheet selbst per JS in den Shadow-Root - ohne es fehlt
nur der optische Feinschliff, kein Fehler, aber ein veralteter Stand)
- www/audi-dashboard-version.json wird IMMER neu geschrieben, mit der
aktuellen Uhrzeit als Versionsnummer - der Lade-Stub
(audi-dashboard-panel.js) liest diese Datei bei jedem Seitenaufruf
ohne Cache und lädt den eigentlichen Code darüber immer frisch nach
(siehe Kopfkommentar in www/audi-dashboard-panel.js).
Absichtlich NICHT kopiert: die Datenbestände in data/ - das würde ein
bereits laufendes Fahrzeugprofil, die Fahrten- und Tankvorgänge-Archive
sowie die Sensor-Zuordnung überschreiben. Diese Dateien sind nur die
Erstbefüllung für die einmalige Installation (siehe INSTALL.md Schritt 2).
Eine Ausnahme davon ist shell_beleg_parser.py: die Datei liegt zwar in
data/, ist aber Code, kein Datenbestand - belegverarbeitung.py ruft sie
als Unterprozess aus /config/audi_dashboard/ auf. Ohne diese Ausnahme
käme jede Änderung am Belegleser (dem einzigen getesteten Teil des
Projekts) auf keiner Instanz an.
.PARAMETER Ziel
Pfad zum config-Verzeichnis von Home Assistant, z. B. ein per Samba
eingebundenes Laufwerk (\\<HA-IP>\config) oder ein lokal gemounteter Pfad.
.BEISPIEL
.\update.ps1 -Ziel "\\homeassistant.local\config"
#>
param(
[Parameter(Mandatory = $true)]
[string]$Ziel
)
$ErrorActionPreference = "Stop"
$hier = $PSScriptRoot
if (-not (Test-Path $Ziel)) {
throw "Ziel nicht erreichbar: $Ziel — Samba-Share verbunden? Pfad korrekt?"
}
Write-Host "Kopiere pyscript/ ..." -ForegroundColor Cyan
robocopy "$hier\pyscript" "$Ziel\pyscript" /MIR /NFL /NDL /NJH /NJS | Out-Null
Write-Host "Kopiere www/ (ohne bilder/ und badges/, die separat behandelt werden) ..." -ForegroundColor Cyan
New-Item -ItemType Directory -Force -Path "$Ziel\www" | Out-Null
Copy-Item "$hier\www\audi-dashboard-app.js" "$Ziel\www\audi-dashboard-app.js" -Force
Copy-Item "$hier\www\audi-dashboard-panel.js" "$Ziel\www\audi-dashboard-panel.js" -Force
Copy-Item "$hier\www\audi-dashboard.css" "$Ziel\www\audi-dashboard.css" -Force
Copy-Item "$hier\www\audi-dashboard-ios.css" "$Ziel\www\audi-dashboard-ios.css" -Force
# Code, der in data/ liegt (siehe Kopfkommentar) - der Ordner selbst wird
# bewusst nicht gespiegelt, diese eine Datei aber schon.
if (Test-Path "$Ziel\audi_dashboard") {
Copy-Item "$hier\data\shell_beleg_parser.py" "$Ziel\audi_dashboard\shell_beleg_parser.py" -Force
Write-Host " Belegleser aktualisiert"
}
if (Test-Path "$hier\www\bilder") {
Write-Host "Kopiere www/bilder/ ..." -ForegroundColor Cyan
New-Item -ItemType Directory -Force -Path "$Ziel\www\bilder" | Out-Null
Copy-Item "$hier\www\bilder\*" "$Ziel\www\bilder\" -Force -Recurse
}
if (Test-Path "$hier\www\badges") {
Write-Host "Kopiere www/badges/ ..." -ForegroundColor Cyan
New-Item -ItemType Directory -Force -Path "$Ziel\www\badges" | Out-Null
Copy-Item "$hier\www\badges\*" "$Ziel\www\badges\" -Force -Recurse
}
# Zwei Zahlen mit verschiedenen Aufgaben, siehe VERSIONIERUNG.md:
# version = Unix-Sekunden, bricht den Browser-Cache, bei jedem Deploy neu
# app = Inhalt der Datei VERSION im Projektstamm, identifiziert die Fassung
# Die App-Version muss mitgeschrieben werden: an ihr erkennt die iOS-App, ob sie
# noch dem ausgelieferten Stand entspricht. Ginge sie hier verloren, wäre der
# Vergleich still abgeschaltet - genau die Veraltung, die er verhindern soll.
$version = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$appVersion = ""
if (Test-Path "$hier\..\VERSION") { $appVersion = (Get-Content "$hier\..\VERSION" -Raw).Trim() }
$inhaltVersion = if ($appVersion) { "{ ""version"": $version, ""app"": ""$appVersion"" }" }
else { "{ ""version"": $version }" }
$inhaltVersion | Out-File -FilePath "$Ziel\www\audi-dashboard-version.json" -Encoding utf8 -NoNewline
Write-Host ""
Write-Host "Fertig. Version $version$(if ($appVersion) { " (App $appVersion)" })." -ForegroundColor Green
Write-Host "pyscript-Änderungen sind bereits aktiv (Auto-Reload, kein Neustart nötig)."
Write-Host "Frontend-Änderungen: im Browser einmal ganz normal neu laden (F5) reicht -"
Write-Host "kein Hard-Refresh, kein HA-Neustart."
File diff suppressed because it is too large Load Diff
-344
View File
@@ -1,344 +0,0 @@
/* ==================================================================
iOS-Auflage über audi-dashboard.css — Audi CI (Rot F50537, Audi Type,
dunkle Fläche) in der Formensprache einer modernen iOS-App:
Große Titel, gruppierte Listen mit 44-px-Zeilen, transluente Tab-Leiste,
iOS-Schalter und -Segmente, gefüllte Aktionsknöpfe.
Reine Auflage: audi-dashboard.css und die App-Logik bleiben unberührt.
================================================================== */
:host{
display:block;height:100%;container-type:inline-size;
font-family:"Audi Type",Helvetica,Arial,sans-serif;
--ios-tint:#F50537;
--ios-sep:rgba(255,255,255,.13);
--ios-grau:#8E8E93;
--ios-fill:rgba(120,120,128,.24);
--r-tile:16px;
/* deckende Kachelfarbe fuer Flaechen, die etwas verdecken muessen
(Wischzeile ueber dem Loeschen-Feld, Kartenfeld) */
--tile-deckend:#171B21;
}
:host([data-theme="nacht"]){
--canvas:#0C1014;
--tile:rgba(255,255,255,.055);
--tile-2:rgba(255,255,255,.10);
--line:rgba(255,255,255,.13);
--line-strong:rgba(255,255,255,.24);
--fg:#FFFFFF; --fg2:#BFC4CC; --fg3:#8E8E93;
--ok:#30D158; --warn:#FFD60A; --bad:#FF453A;
/* Ohne diese Angabe rendert der Browser native Steuerelemente (Dropdown-
Aufklapp-Liste bei <select>, Scrollbars) in seiner Standard-Hellansicht,
egal wie dunkel die App selbst ist - das liess z. B. "Modell" beim
Aufklappen grell-weiss aufblitzen. Betrifft alle <select>-Felder. */
color-scheme: dark;
}
:host([data-theme="tag"]){
--canvas:#F2F2F7;
--tile:#FFFFFF;
--tile-2:#EFEFF4;
--line:rgba(60,60,67,.13);
--line-strong:rgba(60,60,67,.24);
--fg:#000000; --fg2:#3C3C43; --fg3:#8E8E93;
--ok:#34C759; --warn:#FF9F0A; --bad:#FF3B30;
--ios-sep:rgba(60,60,67,.16);
--ios-fill:rgba(120,120,128,.16);
--tile-deckend:#FFFFFF;
color-scheme: light;
}
body{padding:0;background:var(--canvas)}
html,body{height:100%}
/* Der von _aufbauen() eingefuegte Wrapper zwischen Host und .phone. Die
412px-Deckelung aus audi-dashboard.css (fuer die alte Einzelgroesse
gedacht) wird hier unbedingt aufgehoben, nicht nur ab 860px: auf
schmalen Bildschirmen macht der Viewport selbst die Breite eng genug,
:host-Selektoren innerhalb @container werden von diesem Browser aber
offenbar verworfen - deshalb unbedingt statt bedingt.
Die Hoehe bleibt dagegen viewport-verankert (100dvh statt 100%): das ist
der Grund, warum audi-dashboard.css das ueberhaupt so baute - HA gibt der
panel_custom-Kette keine verlaessliche Hoehe weiter, height:100% liess den
Hauptinhalt hier auf 0px kollabieren (Tabbar direkt unter dem Header,
main#view unsichtbar). Erst ab 860px (siehe @container unten) darf die
880px-Deckelung fallen, damit die App auf grossen Bildschirmen mehr als
eine schwebende Telefonkarte ist. */
:host > div{height:min(880px, calc(100dvh - 24px));max-width:none}
.phone{width:100%;max-width:none;height:100%;border-radius:0;
font-family:"Audi Type",Helvetica,Arial,sans-serif;
-webkit-font-smoothing:antialiased;letter-spacing:0}
/* ---------------- Navigationsleiste ---------------- */
/* Sichere Bereiche des Geräterahmens: Statusleiste oben, Home-Indikator unten */
/* Linker Innenabstand = --seitenrand von main#view (16px), damit der Titel
exakt auf der Kante der Kacheln darunter sitzt. */
.topbar{align-items:center;gap:4px;padding:56px 20px 4px 16px}
.head{display:flex;flex-direction:column}
.eyebrow{display:none}
.title{font-size:22px;font-weight:400;letter-spacing:-.01em;line-height:1.2}
.back{width:34px;height:34px;margin-left:-8px;justify-content:center}
/* Design-Audit 2026-08-13, auf allen Breiten nachgezogen 2026-08-17: .back ist
auch inaktiv (opacity:0) noch ein Flex-Element plus Gap - auf Wurzelseiten
ohne Zurueck-Pfeil schob das die Kopfzeile (.head/.title) sichtbar weiter
nach rechts, als main#view darunter beginnt. Das negative margin-right hebt
zusaetzlich den Gap auf, sonst blieben 4px Versatz. Auf Detailseiten
(.back.on) bleibt der Platz bewusst reserviert, das ist dort ein echter,
bedienbarer Knopf. */
.back:not(.on){width:0;min-width:0;margin:0 -4px 0 0;overflow:hidden}
/* Rot bleibt Akzent/destruktiv, nicht Navigationsfarbe (dieselbe Regel wie
bei den Aktions-Buttons und dem aktiven Menü-Icon) - derselbe neutrale Ton
wie die Kopfzeile selbst. */
.back svg{width:22px;height:22px;color:var(--fg)}
.sync{margin-top:3px;font-size:12.5px;color:var(--fg3)}
.sync svg{width:13px;height:13px}
.themebtn{width:36px;height:36px;border:0;background:var(--ios-fill);color:var(--fg2)}
.themebtn:hover{background:var(--tile-2);color:var(--fg)}
/* Telefonbreite: die Ringe stehen oben rechts und oeffnen die Einstellungen
(Stand nach dem Design-Audit). Das Zahnrad entfaellt dort, damit oben rechts
nur ein Element sitzt. */
.rings{order:9;margin-left:8px;margin-right:-10px;cursor:pointer;
min-width:44px;min-height:44px;height:44px;justify-content:flex-end;padding:0 10px}
.rings svg{height:17px}
.profilbtn{display:none}
/* Unterseite: kompakter, zentrierter Titel wie in einer iOS-Navigationsleiste */
.phone:has(.back.on) .topbar{padding:54px 16px 6px;gap:8px}
.phone:has(.back.on) .head{align-items:center}
.phone:has(.back.on) .title{font-size:17px;font-weight:400;letter-spacing:-.01em;text-align:center}
.phone:has(.back.on) .sync{margin-top:2px;font-size:11.5px}
main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smooth}
/* ---------------- Karten und Listen ---------------- */
.tile{padding:16px 18px;margin-bottom:14px;border-radius:var(--r-tile)}
.tile.flat{padding:0}
.tilebtn:hover{background:var(--tile-2)}
/* top/margin-top NICHT von der Basis uebernehmen: alle .go-Kacheln
(Fahrzeug/Service/Versicherung/Reifen/Schutzbrief) sind mehrzeilige
Kacheln mit .label oben plus einer ganzen dl.rows-Liste darunter - eine
vertikale 50%-Zentrierung landet dann mitten in der Zeilenliste statt
neben dem Titel und ueberlappt umgebrochenen Zeilentext (Design-Audit
2026-08-13). Bleibt bei top:var(--sp-5) aus der Basis, nur rechter
Abstand/Farbe werden fuer die iOS-Optik angepasst. */
.tilebtn .go{right:18px;stroke:var(--ios-grau);stroke-width:1.6}
.label{font-size:13px;letter-spacing:.01em;text-transform:none;color:var(--fg3)}
.row{min-height:44px;padding:11px 0;border-bottom:1px solid var(--ios-sep)}
.row dt{font-size:16px;color:var(--fg)}
.row dd{font-size:16px;color:var(--fg2)}
.row dd small{font-size:12.5px;margin-top:2px}
.row-fussnote{font-size:12.5px}
.rows-fest dt{flex:0 0 150px}
.chev{stroke:var(--ios-grau);stroke-width:1.6}
.acc-h{padding:13px 0;border-bottom:1px solid var(--ios-sep)}
.acc-h .k{font-size:16px}
.acc-h .v{font-size:12.5px}
.leaf{padding:12px 0 12px 40px;border-bottom:1px solid var(--ios-sep)}
.leaf .k{font-size:16px}
.leaf .v{font-size:15px}
.leaf .k small,.leaf .v small{font-size:12.5px}
.mark::before,.mark::after{background:var(--ios-grau)}
/* ---------------- Bedienelemente ---------------- */
.seg{gap:0;padding:2px;background:var(--ios-fill);border-radius:9px;margin-top:14px}
.seg button{border:0;border-radius:7px;padding:7px 8px;font-size:13px;font-weight:400;
letter-spacing:0;text-transform:none;color:var(--fg2)}
.seg button.on{background:var(--tile-2);color:var(--fg);box-shadow:0 1px 3px rgba(0,0,0,.25)}
/* Aktive/Primär-Buttons sind grau, nicht rot (Nutzerwunsch 2026-08-16): Rot
bleibt nur noch für destruktive Aktionen (.loeschen). .aktion allein ist
die neutrale Sekundäraktion, .primaer die grau gefüllte Hauptaktion -
dieselbe Fläche wie die Grundauflage (var(--fg)/var(--canvas)). */
.aktion{margin-top:18px;padding:15px;border:0;border-radius:14px;
background:var(--ios-fill);color:var(--fg);font-size:16px;font-weight:400;
letter-spacing:0;text-transform:none}
.aktion:hover{background:var(--tile-2)}
.aktion.primaer{background:var(--fg);color:var(--canvas)}
.aktion.primaer:hover{filter:brightness(1.08)}
.aktion.loeschen{background:var(--ios-fill);color:var(--bad);border:0}
.aktion.loeschen:hover{background:var(--bad);color:#fff;filter:none}
.feld{min-height:44px;padding:9px 0;border-bottom:1px solid var(--ios-sep)}
.feld label{font-size:16px;color:var(--fg)}
.feld input,.feld select{background:transparent;border:0;border-radius:8px;font-size:16px;
padding:8px 4px;color:var(--fg2)}
/* Design-Audit 2026-08-13/16: die graue Flaeche ist das einheitliche Signal
"das hier kann ich aendern". Sie trugen bisher nur die Felder mit Einheit
(.mitEinheit input, z.B. "Pause bis [15] Minuten"); alles andere - <select>
wie "10.000 km"/"1 Jahr", aber auch FIN oder Kennzeichen - stand ohne Kasten
da und war von reinem Anzeigetext nicht zu unterscheiden. Jetzt bekommt
jedes editierbare Feld denselben Kasten. Ausgenommen sind Eingaben mit
eigener Optik (Schalter, Auswahlkaestchen, Regler, Dateiauswahl) - die
behalten die transparente Grundregel darueber.
.mitEinheit input braucht deshalb keine eigene Regel mehr; Breite und
Rechtsbuendigkeit dort kommen weiterhin aus audi-dashboard.css. */
.feld input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]),
.feld select{background:var(--ios-fill);padding:8px 10px}
.feld input:focus,.feld select:focus{color:var(--fg)}
/* Pull-down-Button-Optik (Apple HIG): ein einzelner Abwaerts-Chevron statt
des (durch appearance:none in audi-dashboard.css bereits entfernten)
nativen Browser-Pfeils - macht die Auswahlfelder als solche erkennbar,
ohne ein eigenes Menue-Widget nachzubauen. Farbe fix auf --ios-grau, das
ist bereits die Farbe aller anderen Chevrons (.chev) in beiden Themes. */
.feld select{
padding-right:28px;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 4.5l3.5 3.5 3.5-3.5' fill='none' stroke='%238E8E93' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat:no-repeat;
background-position:right 9px center;
background-size:10px 10px;
}
.notiz{background:var(--ios-fill);border:0;border-radius:12px;font-size:16px}
.switch{width:51px;height:31px}
.switch .track{background:var(--ios-fill);border:0}
.switch .track::before{left:2px;top:2px;width:27px;height:27px;box-shadow:0 2px 6px rgba(0,0,0,.3)}
/* Grün statt Rot: Schalter-Ein-Zustand ist kein destruktiver/Primär-Akzent,
sondern ein neutrales "an" - deckt sich mit dem echten iOS-Systemgrün, das
--ok hier ohnehin schon trägt (Design-Review §B). */
.switch input:checked+.track{background:var(--ok)}
.switch input:checked+.track::before{transform:translateX(20px)}
.pill{font-size:12px;letter-spacing:.01em;text-transform:none;padding:5px 11px;
border:0;background:var(--ios-fill);color:var(--fg2)}
.status{padding:14px 0 2px}
.status .t{font-size:16px}
.dot{width:10px;height:10px}
/* ---------------- Zahlen (Audi CI bleibt führend) ---------------- */
.fig{letter-spacing:-.03em}
.unit{color:var(--fg3)}
.balken{height:6px;background:var(--ios-fill)}
.balken i{background:var(--ok)}
.quad .l{font-size:11px;letter-spacing:.01em;text-transform:none}
.legend span{font-size:12.5px}
/* ---------------- Menüs und Popups ---------------- */
.bildmenu,.sdpopup{border:0;border-radius:14px;background:rgba(40,44,52,.92);
-webkit-backdrop-filter:saturate(180%) blur(22px);backdrop-filter:saturate(180%) blur(22px);
box-shadow:0 12px 40px rgba(0,0,0,.5)}
:host([data-theme="tag"]) .bildmenu,:host([data-theme="tag"]) .sdpopup{background:rgba(250,250,252,.92)}
.bildmenu button{padding:13px 16px;font-size:16px;border-bottom:1px solid var(--ios-sep)}
/* ---------------- Tab-Leiste ---------------- */
.tabbar{border-top:1px solid var(--ios-sep);padding-bottom:26px;
background:rgba(12,16,20,.72);
-webkit-backdrop-filter:saturate(180%) blur(24px);backdrop-filter:saturate(180%) blur(24px)}
:host([data-theme="tag"]) .tabbar{background:rgba(242,242,247,.78)}
.tab{border-top:0;margin-top:0;padding:7px 2px 6px;gap:3px;
font-size:10px;letter-spacing:0;text-transform:none;color:var(--ios-grau)}
/* Beschriftung folgt weiter dem Schalter in den Einstellungen.
:not(.tabpille) ist Pflicht: die Icon-Huelle ist ebenfalls ein <span> (span
class="tabpille"), ein blosses ".tab span" traf also nicht nur das Label
sondern auch die Pille selbst - das Icon verschwand mit, nicht nur der
Text. Genau das war der gemeldete "ganze Menueleiste verschwindet"-Bug. */
.tabbar.ohne .tab span:not(.tabpille){display:none}
.tab svg{width:26px;height:26px;stroke-width:1.5}
/* myAudi-Menueleiste: das aktive Symbol sitzt hell in einer gefuellten Pille,
die uebrigen bleiben grau - kein farbiger Tint, kein Oberkantenstrich. */
.tabpille{width:63px;height:39px;border-radius:20px}
.tab.on{color:var(--fg);border-top-color:transparent}
.tab.on .tabpille{background:rgba(255,255,255,.14)}
:host([data-theme="tag"]) .tab.on .tabpille{background:rgba(0,0,0,.07)}
/* Ist die Beschriftung sichtbar (nicht .tabbar.ohne), soll die graue Flaeche
Icon UND Text umschliessen statt nur die enge Pille ums Icon - die Flaeche
wandert dafuer vom Icon-Symbol auf den ganzen Tab-Knopf. Im reinen
Icon-Modus bleibt die enge Pille wie zuvor. */
.tabbar:not(.ohne) .tab.on{background:rgba(255,255,255,.14);border-radius:14px}
:host([data-theme="tag"]) .tabbar:not(.ohne) .tab.on{background:rgba(0,0,0,.07)}
.tabbar:not(.ohne) .tab.on .tabpille{background:none}
.tabbar.ohne .tab{padding:11px 2px 10px}
:focus-visible{outline:2px solid var(--ios-tint);outline-offset:2px}
/* Die Wischzeile muss das darunterliegende Loeschen-Feld verdecken - das
halbtransparente --tile der Auflage liess es durchscheinen. */
.swipe-wrap .swipe-content{background:var(--tile-deckend)}
.mapbox,.mapbox .leaflet-container{background:var(--tile-deckend)}
/* ==================================================================
Große Bildschirme (Tablet, Desktop): dieselbe App, aber die Tab-Leiste
wird zur seitlichen Navigation und der Inhalt bekommt eine ruhige,
begrenzte Spalte. Container-Abfrage statt @media, weil die App in einem
eigenen Rahmen sitzt und nicht die Fensterbreite messen darf. Schwelle 860 px.
================================================================== */
/* Klon der Ringe in der Menueleiste - nur im Spaltenlayout sichtbar */
.navmarke{display:none}
@container (min-width:860px){
/* Ab hier keine schwebende Telefonkarte mehr - die 880px-Deckelung faellt,
die App fuellt die tatsaechliche (weiterhin viewport-verankerte) Hoehe. */
:host > div{height:calc(100dvh - 24px)}
.phone{display:grid;grid-template-columns:264px minmax(0,1fr);
grid-template-rows:auto auto minmax(0,1fr)}
/* max-width:860px muss zu main#view passen, sonst treibt der Flex-Inhalt
(.head{flex:1} schiebt .profilbtn nach rechts) das Zahnrad bis an den
Rand der vollen Grid-Spalte statt an den Rand der Inhaltsspalte darunter -
auf breiten Bildschirmen klaffte da eine sichtbare Luecke. */
.topbar{grid-column:2;grid-row:1;padding:38px 44px 8px;max-width:860px}
.phone:has(.back.on) .topbar{grid-column:2;grid-row:1;padding:34px 44px 8px;justify-content:flex-start;max-width:860px}
.phone:has(.back.on) .head{align-items:flex-start}
.phone:has(.back.on) .title{font-size:26px;text-align:left}
.title{font-size:34px}
.ptr{grid-column:2;grid-row:2;max-width:860px}
main#view{--seitenrand:44px;grid-column:2;grid-row:3;padding:8px var(--seitenrand) 48px;max-width:860px}
.tabbar{grid-column:1;grid-row:1 / 4;display:flex;flex-direction:column;gap:4px;
align-content:start;width:264px;padding:38px 14px 20px;
border-top:0;border-right:1px solid var(--ios-sep);background:none;
-webkit-backdrop-filter:none;backdrop-filter:none}
.tab{flex-direction:row;justify-content:flex-start;align-items:center;gap:14px;
padding:12px 14px;border-radius:12px;font-size:15px;letter-spacing:0}
.tab span{display:block}
.tab svg{width:22px;height:22px}
.tabpille{width:auto;height:auto;border-radius:0;background:none}
.tab.on .tabpille{background:none}
.tab:hover{background:var(--tile)}
/* Aktives Symbol: dieselbe Farbe wie die Kopfzeile (--fg), nicht mehr Rot -
Fläche bleibt neutral hinterlegt statt rosa (Design-Review §B). */
.tab.on{background:var(--ios-fill);color:var(--fg)}
.tabbar.ohne .tab span{display:block}
.tabbar.ohne .tab{padding:12px 14px;gap:14px}
.tabbar.ohne .tab svg{width:22px;height:22px}
/* Marke am Kopf der Seitenspalte, dafuer keine Ringe in der Kopfzeile
(Design-Audit: die Marke soll nur einmal im Bild stehen). */
.navmarke{display:block;margin:2px 0 26px 14px;color:var(--fg)}
.navmarke svg{width:54px;height:auto;display:block}
/* Grosser Bildschirm: Marke am Kopf der Spalte, Zahnrad wieder oben rechts */
.profilbtn{display:inline-flex}
/* Die Kopf-Ringe setzt die App per Inline-Stil (display:inline-flex),
deshalb hier mit !important. */
.rings{display:none !important}
/* Nur die Box selbst hoeher setzen, nicht jedes Element mit .carfix: die
Klasse haengt auch am <img> und am Platzhalter-<div> (siehe
bildMitPlatzhalter()). Der Platzhalter ist position:absolute;inset:0 -
eine feste Hoehe schlaegt dort das bottom:0 und machte ihn 260px hoch in
einer 132px hohen, overflow:hidden-Box (leerer Zustand), also sichtbar
abgeschnitten. Bild und Platzhalter fuellen die Box ohnehin per 100%. */
.bildbox.carfix{height:260px}
.bildbox.carfix.klein{height:220px}
.bildgrid{grid-template-columns:repeat(4,1fr)}
.quad{gap:14px}
/* Design-Audit 2026-08-13: .setup-popup/.sdpopup/.sheet/.standortmenu sind
position:absolute relativ zu .phone (der vollen Rasterbreite, nicht nur
der Inhaltsspalte) mit left/right auf einen festen Abstand - auf breiten
Bildschirmen ergab das einen fast bildschirmbreiten Dialog statt einer
ruhigen Karte. max-width + margin:auto zentriert sie innerhalb des
bestehenden left/right-Abstands, ohne die transform-basierte
Einblend-Animation dieser Elemente zu beruehren. */
.setup-popup,.sdpopup,.sheet,.beleg-popup{max-width:560px;margin-left:auto;margin-right:auto}
.standortmenu{max-width:640px;margin-left:auto;margin-right:auto}
}
/* Fahrzeugbild randlos wie in einer Fahrzeug-App: der Platzhalter bleibt
sichtbar, aber ohne technischen Strichrahmen. */
.szene{margin-bottom:20px}
/* --tile statt --ios-fill waere hier naheliegend, faellt im Tag-Theme aber
auf reines Weiss (#FFFFFF) - ununterscheidbar von Canvas und den meisten
Kacheln drumherum, die Box verschwand. --ios-fill ist in beiden Themes ein
deutlich sichtbares transluzentes Grau, dieselbe Konvention wie
.feld select/.aktion ("das hier ist eine Flaeche"). */
.bildbox.bild-fehlt .platzhalter{border:0;background:var(--ios-fill);border-radius:var(--r-tile)}
.platzhalter-datei{display:none}
/* Zeilen mit Wert plus Statuspunkt rechts (Sicherheitscheck) ruhiger setzen */
.row dd .dot{width:8px;height:8px}
-47
View File
@@ -1,47 +0,0 @@
/* ================================================================
Audi-Dashboard — Lade-Stub für panel_custom
================================================================
Diese Datei selbst ändert sich praktisch nie und bleibt deshalb auch mit
der langen Standard-Cache-Zeit von Home Assistant für /local/ (31 Tage,
Cache-Control: public, max-age=2678400 - an der Testinstanz nachgemessen,
nicht vermutet) unproblematisch: sie lädt bei jedem vollständigen
Seitenaufruf zuerst audi-dashboard-version.json mit cache:"no-store" -
das erzwingt eine echte Netzwerkanfrage, unabhängig vom HTTP-Cache -, und
reicht deren Versionsnummer als Query-Parameter an den eigentlichen
Code (audi-dashboard-app.js) weiter. Jede neue Versionsnummer ist aus
Sicht des Browsers eine neue URL und wird deshalb nie aus einem alten
Cache bedient.
Update-Ablauf ohne Neustart und ohne Cache-Probleme:
1. audi-dashboard-app.js und/oder audi-dashboard.css ersetzen
2. die Zahl in audi-dashboard-version.json um 1 erhöhen
3. im Browser einmal ganz normal neu laden (kein Hard-Refresh nötig)
Diese Datei hier (audi-dashboard-panel.js) bleibt dabei unangetastet -
configuration.yaml und damit ein HA-Neustart sind für Frontend-Updates
nicht mehr nötig, nur noch für den allerersten Einbau (§10 Punkt 1).
Bekannte HA-Eigenheit (an der Testinstanz reproduziert, nicht vermutet):
ha-panel-custom cached geladene Skripte nur für den alten js_url-Typ,
nicht für module_url (siehe dessen eigener Quellcode - für "js" gibt es
ein url-indiziertes Cache-Objekt, für "module" fehlt das komplett). Bei
einem direkten/harten Seitenaufruf (nicht bei SPA-interner Navigation)
kann das dazu führen, dass dieses Skript zweimal als <script type=module>
eingefügt wird, bevor das erste fertig geladen hat - beobachtet als zwei
Netzwerk-Requests für diese Datei und ein hängenbleibendes "Lädt …" ohne
Fehler in der eigentlichen App. Der Cache unten macht starten() robust
gegen so einen doppelten Aufruf: der zweite Aufruf wartet einfach auf das
Ergebnis des ersten, statt selbst nochmal zu fetchen/zu importieren. */
window.__audiDashboardStarten =
window.__audiDashboardStarten ||
(async () => {
let version = Date.now(); // Fallback, falls version.json nicht erreichbar ist
try {
const antwort = await fetch("/local/audi-dashboard-version.json", { cache: "no-store" });
const daten = await antwort.json();
version = daten.version;
} catch (e) {
console.warn("audi_dashboard: version.json nicht erreichbar, nutze Zeitstempel als Cache-Buster", e);
}
await import(`/local/audi-dashboard-app.js?v=${version}`);
})();
await window.__audiDashboardStarten;
@@ -1 +0,0 @@
{"version": 1787016000, "app": "2026.08.23.1"}
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-02ab3f2e-9065-406b-b3a2-96f948fc1e4e" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 428 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path d="M426.9422,35.25c4.1375-11.3676-4.1296-20.25-20.1296-20.25h-48.5c-26.0164,0-28.4479,5.3519-34.6605,22.5h16.4711c2.8264-7.7654,3.4577-9.5,13.4577-9.5h48.5c4.0569,0,10.4989,1.3767,8.3612,7.25-1.9243,5.287-4.4488,7.25-12.6388,7.25h-43.5l-4.7316,13h48.5c6.2949,0,9.3512,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-54.5c-8.4936,0-7.6956-6.3314-6.1783-10.5h-16.5c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c23.4238,0,27.7979-3.537,34.239-21.25,2.3006-6.3267.3341-11.4665-4.0894-14.8846,5.5286-2.8196,7.1832-7.3654,9.458-13.6154Z" style="fill:#fff;"/></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-3d56eca2-3609-4ab9-a4bc-ffc6d9c94e2a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 428 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path d="M426.9422,35.25c4.1375-11.3676-4.1296-20.25-20.1296-20.25h-48.5c-26.0164,0-28.4479,5.3519-34.6605,22.5h16.4711c2.8264-7.7654,3.4577-9.5,13.4577-9.5h48.5c4.0569,0,10.4989,1.3767,8.3612,7.25-1.9243,5.287-4.4488,7.25-12.6388,7.25h-43.5l-4.7316,13h48.5c6.2949,0,9.3512,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-54.5c-8.4936,0-7.6956-6.3314-6.1783-10.5h-16.5c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c23.4238,0,27.7979-3.537,34.239-21.25,2.3006-6.3267.3341-11.4665-4.0894-14.8846,5.5286-2.8196,7.1832-7.3654,9.458-13.6154Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-3cc43eeb-ac3a-4285-9c6d-681d610de30a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 425 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path id="uuid-b98eb56c-eb39-485f-8e3f-36f1bc8fa622" d="M408.4969,59l16.0201-44h-24.75l-80.2974,39.2816-6.4489,17.7184h74.25l-4.7316,13h16.5s4.7316-13,4.7316-13h16.4977l4.7316-13h-16.5031ZM392.0022,59h-53.6829l64.966-31h0l-11.2831,31Z" style="fill:#fff;"/></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-887f53b2-a25f-4c1a-b207-be4bc0e6cad6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 425 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path id="uuid-2c6229e3-766a-4640-9e66-076dd5bbca77" d="M408.4969,59l16.0201-44h-24.75l-80.2974,39.2816-6.4489,17.7184h74.25l-4.7316,13h16.5s4.7316-13,4.7316-13h16.4977l4.7316-13h-16.5031ZM392.0022,59h-53.6829l64.966-31h0l-11.2831,31Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-bb9d6aae-5c61-449e-a3f7-9dc3eca29a10" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 427 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path d="M324.3052,54l14.1948-39h88.5l-4.7316,13h-72l-4.7316,13h55.5c16.5,0,23.2243,9,18.4927,22-5.8003,15.9362-9.6585,22-34.4519,22h-47.5555c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h47.5555c9.4358,0,11.1533-3.3211,13.2203-9,2.3539-6.4674-.3962-9-6.7243-9h-72Z" style="fill:#fff;"/></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-78d414ea-ca1f-4f07-a6dd-815839547cb5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 427 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path d="M324.3052,54l14.1948-39h88.5l-4.7316,13h-72l-4.7316,13h55.5c16.5,0,23.2243,9,18.4927,22-5.8003,15.9362-9.6585,22-34.4519,22h-47.5555c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h47.5555c9.4358,0,11.1533-3.3211,13.2203-9,2.3539-6.4674-.3962-9-6.7243-9h-72Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-da9a622c-841b-4217-8357-91e7b2ea0fa7" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 436 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path d="M434.7542,37.5c4.6956-12.9009-4.4207-22.5-18.3107-22.5h-51.5045c-26.0165,0-28.7311,6.1298-33.8705,20.25l-9.5543,26.25c-3.3206,9.1232-.1075,23.5,17.9468,23.5h48.5c25.7614,0,32.1276-7.2191,37.2344-21.25,5.6993-15.6587-5.968-21.25-18.7656-21.25h-51.5c-3.8594,0-7.5554.4423-10.3879,1.0658l3.0266-8.3158c1.6863-4.6331,2.6389-7.25,12.6389-7.25h51.5046c4.0569,0,8.7417,3.4572,6.5422,9.5h16.5ZM350.198,55.5h51.5c6.2949,0,9.3513,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-51.5001c-8.4935,0-7.6956-6.3314-6.1783-10.5,2.1838-6,5.1783-6,12.1838-6Z" style="fill:#fff;"/></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-eab43470-9ed9-4ef3-88a5-73d45fc6a05a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 436 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path d="M434.7542,37.5c4.6956-12.9009-4.4207-22.5-18.3107-22.5h-51.5045c-26.0165,0-28.7311,6.1298-33.8705,20.25l-9.5543,26.25c-3.3206,9.1232-.1075,23.5,17.9468,23.5h48.5c25.7614,0,32.1276-7.2191,37.2344-21.25,5.6993-15.6587-5.968-21.25-18.7656-21.25h-51.5c-3.8594,0-7.5554.4423-10.3879,1.0658l3.0266-8.3158c1.6863-4.6331,2.6389-7.25,12.6389-7.25h51.5046c4.0569,0,8.7417,3.4572,6.5422,9.5h16.5ZM350.198,55.5h51.5c6.2949,0,9.3513,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-51.5001c-8.4935,0-7.6956-6.3314-6.1783-10.5,2.1838-6,5.1783-6,12.1838-6Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-441d88e6-447f-439d-bb01-ecd138048170" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><polygon id="uuid-04405ac0-17bc-4ab9-8f1b-d9c06ed1387e" points="430 15 331 15 326.2684 28 408.7684 28 330.2721 85 355.0221 85 421.8096 37.5 430 15" style="fill:#fff;"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-e2ec85d2-1c31-42a4-badb-6dc585c3e159" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><polygon id="uuid-140489d1-1cc4-4640-9981-d5f2efde7a30" points="430 15 331 15 326.2684 28 408.7684 28 330.2721 85 355.0221 85 421.8096 37.5 430 15"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

-1
View File
@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-6be63f45-3fa3-45ba-97e7-17573e0bd3f7" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 312 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M163.5209,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M310.9422,35.25c4.1375-11.3676-4.1296-20.25-20.1296-20.25h-48.5c-26.0164,0-28.4479,5.3519-34.6605,22.5h16.4711c2.8264-7.7654,3.4577-9.5,13.4577-9.5h48.5c4.0569,0,10.4989,1.3767,8.3612,7.25-1.9243,5.287-4.4488,7.25-12.6388,7.25h-43.5l-4.7316,13h48.5c6.2949,0,9.3512,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-54.5c-8.4936,0-7.6956-6.3314-6.1783-10.5h-16.5c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c23.4238,0,27.7979-3.537,34.239-21.25,2.3006-6.3267.3341-11.4665-4.0894-14.8846,5.5286-2.8196,7.1832-7.3654,9.458-13.6154Z" style="fill:#fff;"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-bcf865bd-1403-4135-bc2a-39f0ea8693ef" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 312 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M163.5209,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M310.9422,35.25c4.1375-11.3676-4.1296-20.25-20.1296-20.25h-48.5c-26.0164,0-28.4479,5.3519-34.6605,22.5h16.4711c2.8264-7.7654,3.4577-9.5,13.4577-9.5h48.5c4.0569,0,10.4989,1.3767,8.3612,7.25-1.9243,5.287-4.4488,7.25-12.6388,7.25h-43.5l-4.7316,13h48.5c6.2949,0,9.3512,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-54.5c-8.4936,0-7.6956-6.3314-6.1783-10.5h-16.5c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c23.4238,0,27.7979-3.537,34.239-21.25,2.3006-6.3267.3341-11.4665-4.0894-14.8846,5.5286-2.8196,7.1832-7.3654,9.458-13.6154Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-dcc2e61f-be44-4609-aa28-1b6186c8cb58" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M163.5209,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><polygon points="276.2715 46.0815 254.8152 46.0815 296.6135 85 318.0698 85 276.2715 46.0815" style="fill:#fff;"/><path d="M312.8315,37.5c4.6956-12.9009-4.4207-22.5-18.3105-22.5h-54.5c-26.0165,0-28.731,6.1298-33.8704,20.25l-9.5543,26.25c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c8.5029,0,14.5557-.7916,19.0469-2.3499l-11.4382-10.6501h-57.377c-8.4937,0-7.6956-6.3314-6.1783-10.5l9.5542-26.25c1.6863-4.633,2.6388-7.25,12.6388-7.25h54.5c4.0569,0,8.7417,3.4573,6.5422,9.5l-5.8916,16.1871,12.3236,11.4745,10.068-27.6617Z" style="fill:#fff;"/><polygon id="uuid-4eb1f8b9-3e2f-414b-927c-5027877a6367" points="430 15 331 15 326.2684 28 408.7684 28 330.2721 85 355.0221 85 421.8096 37.5 430 15" style="fill:#fff;"/></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-dcb70d1d-da3e-4814-a4c9-ff91d6217f07" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M163.5209,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><polygon points="276.2715 46.0815 254.8152 46.0815 296.6135 85 318.0698 85 276.2715 46.0815"/><path d="M312.8315,37.5c4.6956-12.9009-4.4207-22.5-18.3105-22.5h-54.5c-26.0165,0-28.731,6.1298-33.8704,20.25l-9.5543,26.25c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c8.5029,0,14.5557-.7916,19.0469-2.3499l-11.4382-10.6501h-57.377c-8.4937,0-7.6956-6.3314-6.1783-10.5l9.5542-26.25c1.6863-4.633,2.6388-7.25,12.6388-7.25h54.5c4.0569,0,8.7417,3.4573,6.5422,9.5l-5.8916,16.1871,12.3236,11.4745,10.068-27.6617Z"/><polygon id="uuid-76d49135-5520-455b-8e33-d0d14e5c227c" points="430 15 331 15 326.2684 28 408.7684 28 330.2721 85 355.0221 85 421.8096 37.5 430 15"/></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

-8
View File
@@ -1,8 +0,0 @@
<svg id="Shell_x5F_2012_x5F_PECTEN_x5F_RGB" version="1.1" viewBox="0 0 24 24" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<rect x="-12.1" y="-103.7" width="141.7" height="141.7" fill="none"/>
<g transform="matrix(.22054 0 0 .22054 -.94555 -.53756)">
<path d="m54.6 87.8h-18l-1.6-13.3-11-7.9c-0.5-2.3-0.7-4.7-0.7-7.1 0-19.6 15.9-35.4 35.4-35.4 19.6 0 35.4 15.9 35.4 35.4 0 2.4-0.2 4.8-0.7 7.1l-10.9 7.9-1.7 13.3h-18l-1 0.8c-0.9 0.7-2 1-3.1 1s-2.2-0.4-3.1-1z" fill="none" stroke="#fff" stroke-miterlimit="40" stroke-width="7"/>
<path d="m54.6 87.8h-18l-1.6-13.3-11-7.9c-0.5-2.3-0.7-4.7-0.7-7.1 0-19.6 15.9-35.4 35.4-35.4 19.6 0 35.4 15.9 35.4 35.4 0 2.4-0.2 4.8-0.7 7.1l-10.9 7.9-1.7 13.3h-18l-1 0.8c-0.9 0.7-2 1-3.1 1s-2.2-0.4-3.1-1z" fill="#fbce07"/>
<path d="m54.6 87.8h-18l-1.6-13.3-11-7.9c-0.5-2.3-0.7-4.7-0.7-7.1 0-19.6 15.9-35.4 35.4-35.4s35.4 15.9 35.4 35.4c0 2.4-0.2 4.8-0.7 7.1l-10.9 7.9-1.7 13.3h-18l-1 0.8c-0.9 0.7-2 1-3.1 1s-2.2-0.4-3.1-1zm0.6-6.2h-13.1l-1.3-10.6-10.8-7.8c-0.3-1.2-0.5-2.5-0.5-3.8 0-2.1 0.4-4.2 1.3-6.1l19.1 19.3-18.5-22.9c0.7-3 2.3-5.6 4.6-7.7l16.6 28.7-14.7-31.3c1.9-2.5 4.6-4.4 7.6-5.3l10.2 35.6-7.4-37.1c2.3-1.3 4.8-1.9 7.4-1.9 0.5 0 1.1 0 1.6 0.1l1.5 38.5 1.5-38.5c0.5-0.1 1.1-0.1 1.6-0.1 2.6 0 5.1 0.7 7.4 1.9l-7.4 37.1 10.2-35.7c3 0.9 5.7 2.8 7.6 5.3l-14.8 31.4 16.6-28.7c2.3 2 3.9 4.7 4.6 7.7l-18.5 22.9 19.1-19.3c0.9 1.9 1.3 4 1.3 6.1 0 1.3-0.2 2.5-0.5 3.8l-10.8 7.8-1.3 10.6h-13l-2 1.4c-0.5 0.4-1.1 0.5-1.7 0.5s-1.2-0.2-1.7-0.5z" fill="#dd1d21" fill-rule="evenodd"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB