Initialer Import: HA-Panel, Design-System, Companion-App

Drei zusammengehörige Teile in einem Repository:

- homeassistant/  Das fertige, im Einsatz befindliche Home-Assistant-Panel
  (panel_custom Custom Element + pyscript-Backend). Echte Fahrzeug- und
  Personendaten (fahrzeugprofil.json, fahrten.jsonl, tankvorgaenge.jsonl,
  Tankbelege) bleiben per .gitignore außen vor; die anonymisierte Vorlage
  fahrzeugprofil.example.json ist mit dabei.

- design-system/  Eigenständige React-Komponentenbibliothek (@audi-dash/ui),
  die die visuelle Sprache des Panels nachbildet - ohne Audi-Markenzeichen
  und ohne die lizenzierte Hausschrift. Dient als Grundlage für Claude
  Design. War bis hierher ein eigenes Repository und ist in dieses
  eingeschmolzen worden.

- companion-app/  Datenschicht der neuen App DataMetric360 (iOS/Android via
  Capacitor, zusätzlich als Iframe im HA-Dashboard). Noch ohne Oberfläche:
  REST- und WebSocket-Zugriff auf Home Assistant plus Warteschlange für
  Änderungen ohne Netz. Ersetzt das eingespritzte hass-Objekt, das nur
  innerhalb des HA-Frontends existiert.

Dazu die Projektdokumentation: SPECIFICATION.md (Ist-Stand des Panels),
COMPANION_APP_ARCHITECTURE.md (Architekturentscheidungen der neuen App),
AUDIT_2026-08-10.md, DESIGN_BRIEF_DATAMETRIC360.md und der ursprüngliche
Bauauftrag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 00:10:53 +02:00
commit e1d570992e
151 changed files with 18162 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# 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/tests/belege/*.pdf
+274
View File
@@ -0,0 +1,274 @@
# 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").
**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 (ergibt sich aus der
laufenden Integration `TommiG1/HA_VAG-EU-Data-Act`).
Zeitaufwand: ca. 3040 Minuten, größtenteils Warten auf Neustarts.
---
## Schritt 1 — pyscript installieren
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**)
## Schritt 2 — Dateien auf den Home-Assistant-Rechner kopieren
Drei Ordner müssen in das `config`-Verzeichnis von Home Assistant kopiert
werden. Wählt den Weg, der zu eurer Installation passt:
**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.
**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)
**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"
**Alternative: SSH & Terminal Add-on**, falls bereits eingerichtet — dann
reicht `scp`/`rsync` vom gewohnten Terminal aus.
## Schritt 3 — configuration.yaml ergänzen
`configuration_snippet.yaml` aus diesem Ordner öffnen. Die zwei Blöcke
(`pyscript:` und `panel_custom:`) in die bestehende `configuration.yaml`
übernehmen — **nicht** die Datei komplett ersetzen. Falls dort schon ein
`pyscript:`-Block existiert, nur die Zeile `allow_all_imports: true` darin
ergänzen statt einen zweiten Block anzulegen.
Der `panel_custom:`-Block kann schon jetzt mit rein; er wird erst mit dem
Frontend-Baustein wirksam und stört bis dahin nicht.
## Schritt 4 — die Entity-IDs eintragen
Das ist die einzige Stelle, die inhaltlich angepasst werden muss — Datei
`pyscript/modules/einstellungen.py`.
**Zwingend fürs Backend** (ohne diese drei läuft die Kernlogik nicht):
1. In Home Assistant: **Entwicklerwerkzeuge → Zustände**
2. Dort suchen und die genauen Entity-IDs notieren für:
- den Kilometerstand-Sensor (aus `TommiG1/HA_VAG-EU-Data-Act`) → `KM_SENSOR`
- den Tankfüllstand-Sensor (dieselbe Integration) → `TANK_SENSOR`
- den WLAN-Verbindungssensor des iPhones → `WLAN_SENSOR` — dafür vorher
in der iOS-Companion-App unter **Einstellungen → Companion App →
Sensoren** den Sensor für das verbundene WLAN aktivieren, falls er dort
noch grau ist
**Optional fürs Frontend** (nur für die Übersicht/Zustand-Seite, ohne sie
zeigt die Oberfläche „unbekannt" statt eines Werts — kein Absturz):
3. Ebenfalls in Entwicklerwerkzeuge → Zustände suchen:
- Reichweite → `RANGE_SENSOR`
- Türstatus (falls als ein Sammel-Sensor vorhanden) → `DOORS_SENSOR`
- Fensterstatus → `WINDOWS_SENSOR`
- Verriegelung (Domäne `lock.`) → `LOCK_ENTITY`
- Batteriespannung, falls überhaupt vorhanden → `BATTERY_VOLTAGE_SENSOR`
4. Datei `pyscript/modules/einstellungen.py` öffnen (Samba: direkt im
Explorer, Studio Code Server: im Editor) und alle gefundenen Werte
eintragen. Was ihr nicht findet, einfach auf dem Platzhalter stehen
lassen — siehe Kommentar in der Datei dazu.
## 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 das WLAN am Handy kurz ausgeschaltet und
wieder eingeschaltet wird (Pausenregel greift) bzw. länger als die
eingestellte Pausenzeit ausgeschaltet bleibt (Fahrt wird angelegt) — 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
## 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 Entity-IDs aus Schritt 4 nachtragen.
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** zeigt weiterhin Beispielzahlen aus dem Prototyp,
keine echte Auswertung der eigenen Fahrten — eigenes Arbeitspaket.
## 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.
---
## 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 | `WLAN_SENSOR` in `einstellungen.py` zeigt auf die falsche Entity, oder `fahrzeug.wlan_name` im Profil passt nicht exakt zum Sensorwert (Groß-/Kleinschreibung zählt) |
| 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` in `einstellungen.py` liefert keinen Wert, 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`/... in `einstellungen.py` noch Platzhalter 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) |
+158
View File
@@ -0,0 +1,158 @@
# Audi-Dashboard — Backend und Frontend, Stand nach dritter Bauphase
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.
**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).
## Getestet, nicht nur geprüft
Beides lief in einer Wegwerf-Testinstanz (Home Assistant in Docker), nicht
nur gegen Dokumentation abgeglichen:
- **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.
Dabei wurden drei echte pyscript-Eigenheiten gefunden und behoben, die in
keiner Dokumentation standen:
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.
## 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.
## 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.
- **Statistik-Seite** zeigt weiterhin Beispielzahlen aus dem Prototyp, keine
echte Auswertung der erfassten Fahrten.
- **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 WLAN mit Pausenregel (`task.unique` + `task.sleep`)
- Zweistufiger Fahrtabschluss per Recorder-Historie-Screening, inklusive
Verkettung direkt anschließender Fahrten
- Reifenzähler per direkter Subtraktion, kein `utility_meter`
- Belegverarbeitung inklusive 97-%-Volltankungsregel und Duplikatserkennung
über `receipt_key`
- 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`
## 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)
│ │ ├── fahrtabschluss_logik.py Screening-Logik (§7.2)
│ │ └── frontend_veroeffentlichung.py Zustände fürs Frontend (§10 Punkt 7 Ersatz)
│ ├── fahrterkennung.py §7.1
│ ├── fahrtabschluss.py §7.2 (Trigger-Registrierung)
│ ├── reifenzaehler.py §7.5
│ ├── belegverarbeitung.py §7.4, §7.7
│ └── 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).
+38
View File
@@ -0,0 +1,38 @@
# 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).
# 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.
pyscript:
allow_all_imports: 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
@@ -0,0 +1,185 @@
{
"_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": "",
"wlan_name": "",
"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"] }
]
}
+216
View File
@@ -0,0 +1,216 @@
#!/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 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.,]+"
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,
"station_name": 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 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)
ergebnis = _parsen(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()
@@ -0,0 +1,133 @@
#!/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 Betreiber/Städte in den zehn Belegen - stellt sicher,
# dass die Stationserkennung nicht zufällig nur für einen Betreiber passt.
namen = {d["station_name"] for d in self.ergebnisse.values()}
erwartet = {
"A. Zrenner GmbH", "Thomas Bauch GmbH",
"TC Sengül GmbH", "Hermann Mogler Mineralölg. GmbH",
}
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)
+92
View File
@@ -0,0 +1,92 @@
"""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 frontend_veroeffentlichung
import profil
BACKUP_ORDNER = f"{profil.BASIS}/backups"
_DATEIEN = ["fahrzeugprofil.json", "fahrten.jsonl", "tankvorgaenge.jsonl"]
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()
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()
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):
"""Stellt ein über "Backup exportieren" heruntergeladenes Backup wieder
her - überschreibt Profil, Fahrten und Tankvorgänge komplett. Aufruf als
pyscript.audi_dashboard_backup_wiederherstellen."""
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)
frontend_veroeffentlichung.alles_veroeffentlichen()
log.info("audi_dashboard: Backup wiederhergestellt")
+62
View File
@@ -0,0 +1,62 @@
"""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)")
+269
View File
@@ -0,0 +1,269 @@
"""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" # TODO: Datei ablegen
# --------------------------------------------------------------- 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).
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)
try:
geparst = _parser_aufrufen(pdf_pfad)
except Exception as exc:
log.error(f"audi_dashboard: Beleg konnte nicht geparst 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")
@@ -0,0 +1,71 @@
"""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})")
+27
View File
@@ -0,0 +1,27 @@
"""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
@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)
+166
View File
@@ -0,0 +1,166 @@
"""Fahrterkennung über die WLAN-Verbindung zum Fahrzeug (§7.1).
Start: der WLAN-Sensor der Companion App meldet den in den Einstellungen
hinterlegten Fahrzeug-WLAN-Namen (Fahrzeugprofil, Feld fahrzeug.wlan_name).
Ende: die Verbindung bricht ab und kommt binnen der Pausenzeit nicht zurück.
Die Pausenregel wird über task.unique() umgesetzt: jede neue Änderung des
WLAN-Sensors killt eine noch laufende, wartende Bestätigung des vorherigen
Ereignisses. Kommt die Verbindung 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).
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 _profil_wlan_name():
p = profil.profil_lesen()
return p.get("fahrzeug", {}).get("wlan_name")
def _pausenzeit_sekunden():
p = profil.profil_lesen()
return p.get("einstellungen", {}).get("fahrten_pausenzeit_min", 15) * 60
@time_trigger("startup")
def beim_start():
profil.ordner_sicherstellen()
@state_trigger(f"{einstellungen.WLAN_SENSOR}")
def wlan_verbindung_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")
ziel = _profil_wlan_name()
if not ziel:
log.warning("audi_dashboard: kein fahrzeug.wlan_name im Fahrzeugprofil hinterlegt")
return
global _fahrt_start_ts
verbunden_jetzt = (value == ziel)
verbunden_vorher = (old_value == ziel)
if verbunden_jetzt and not verbunden_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 verbunden_vorher and not verbunden_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 Verbindung 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)
@service
def audi_dashboard_fahrt_manuell_anlegen(ts_start=None, ts_end=None, art="privat"):
"""Manuelle Fahrterfassung aus der Oberfläche (§5.3), unabhängig von der
automatischen WLAN-Erkennung. 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())
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": [],
}
profil.fahrt_anhaengen(fahrt)
frontend_veroeffentlichung.fahrten_veroeffentlichen()
fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR)
@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")
+75
View File
@@ -0,0 +1,75 @@
"""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 frontend_veroeffentlichung
import profil
@time_trigger("startup")
def beim_start():
profil.ordner_sicherstellen()
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")
@@ -0,0 +1,109 @@
"""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.
Die drei ersten (WLAN, Kilometerstand, Tankfüllstand) werden vom Backend
zwingend gebraucht (§7.1, §7.2, §7.4, §7.5). Alles danach speist nur die
Übersicht im Frontend (§5.1, §5.2, §9) — bleibt ein Wert leer ("") oder passt
eine Entity-ID nicht zur tatsächlichen Integration, liefert
zustand_oder_none() in frontend_veroeffentlichung.py für das jeweilige Feld
None statt abzustürzen; die Oberfläche zeigt dann „unbekannt" statt eines
falschen Werts.
Alle Entity-IDs unten sind an der echten Integration TommiG1/HA_VAG-EU-Data-Act
(Version 0.6.35, per HACS installiert) real geprüft, nicht nur vermutet: das
Gerät heißt dort "Audi RS 4 Avant" (mit Leerzeichen vor der "4") - deshalb
"rs_4" mit Unterstrich im Slug, nicht "rs4". Eine ältere Integrationsversion
hatte hier noch deutsche Feldnamen (z. B. "..._tur_vorne_links"); die aktuelle
Version benennt englisch (z. B. "..._front_left_door").
"""
# Companion-App-Sensor für das aktuell verbundene WLAN (iOS-App:
# Einstellungen -> Companion App -> Sensoren -> "Verbundenes WLAN")
WLAN_SENSOR = "sensor.iphone_wifi_connection"
# Kilometerstand aus der HACS-Integration TommiG1/HA_VAG-EU-Data-Act
KM_SENSOR = "sensor.audi_rs_4_avant_mileage"
# Tankfüllstand aus derselben Integration (Prozent)
TANK_SENSOR = "sensor.audi_rs_4_avant_fuel_level"
# Reichweite (§5.1 Übersicht)
RANGE_SENSOR = "sensor.audi_rs_4_avant_range_primary"
# 12V-Batteriespannung (Mein Audi -> Zustand). Die aktuell genutzte
# HACS-Integration (TommiG1/HA_VAG-EU-Data-Act 0.6.35) liefert dafür KEINEN
# Sensor (real geprüft: kein "battery"/"voltage"-Entity unter diesem Gerät) -
# deshalb hier bewusst leer gelassen. Liefert später eine andere Quelle (z. B.
# ein OBD-Adapter) eine Spannung, einfach die Entity-ID eintragen; bis dahin
# zeigt die Oberfläche "unbekannt" statt eines falschen Werts (siehe
# zustand_oder_none() in frontend_veroeffentlichung.py).
BATTERIE_SENSOR = ""
# Der von der Integration selbst bereitgestellte Knopf für eine sofortige
# Neuabfrage beim Fahrzeug (Entity-Domäne "button", real geprüft) - genau der
# Weg, den "Ziehen zum Aktualisieren" im Frontend auslöst (siehe
# audi_dashboard_jetzt_aktualisieren() in frontend_api.py), statt eines
# generischen homeassistant.update_entity auf einzelne Sensoren.
REFRESH_BUTTON = "button.audi_rs_4_avant_refresh_now"
# Türen (§4.1) - die Integration liefert vier Einzelsensoren statt eines
# Summensensors. binary_sensor, device_class "door": "off" = zu (real
# geprüft).
TUER_SENSOREN = [
"binary_sensor.audi_rs_4_avant_front_left_door",
"binary_sensor.audi_rs_4_avant_front_right_door",
"binary_sensor.audi_rs_4_avant_rear_left_door",
"binary_sensor.audi_rs_4_avant_rear_right_door",
]
# Fenster (§4.1) - device_class "window": "off" = zu (real geprüft)
FENSTER_SENSOREN = [
"binary_sensor.audi_rs_4_avant_front_left_window",
"binary_sensor.audi_rs_4_avant_front_right_window",
"binary_sensor.audi_rs_4_avant_rear_left_window",
"binary_sensor.audi_rs_4_avant_rear_right_window",
]
# Verriegelung - kein lock.*-Entity vorhanden, sondern vier
# Türschloss-Einzelsensoren. binary_sensor mit device_class "lock" (real
# geprüft): "on" = entriegelt, "off" = verriegelt (Home-Assistant-Konvention
# für diese device_class).
TUERSCHLOSS_SENSOREN = [
"binary_sensor.audi_rs_4_avant_front_left_door_lock",
"binary_sensor.audi_rs_4_avant_front_right_door_lock",
"binary_sensor.audi_rs_4_avant_rear_left_door_lock",
"binary_sensor.audi_rs_4_avant_rear_right_door_lock",
]
# Heckklappe und Motorhaube - von der Integration separat von den vier Türen
# geführt, für "Sicher abgestellt" (§9) aber ebenso relevant wie diese
HECKKLAPPE_SENSOR = "binary_sensor.audi_rs_4_avant_tailgate"
HECKKLAPPENSCHLOSS_SENSOR = "binary_sensor.audi_rs_4_avant_tailgate_lock"
HAUBE_SENSOR = "binary_sensor.audi_rs_4_avant_hood"
HAUBENSCHLOSS_SENSOR = "binary_sensor.audi_rs_4_avant_hood_lock"
# Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die App-eigene,
# aus dem Servicebuch berechnete Prognose - siehe fahrzeugMeldung() im
# Frontend). Je ein Datum (ISO mit Zeitzone) und eine Streckenangabe in km;
# die Streckensensoren liefern negative Werte (Restkilometer als Countdown),
# im Frontend wird davon nur der Betrag angezeigt.
NAECHSTER_OELWECHSEL_SENSOR = "sensor.audi_rs_4_avant_oil_change_due"
OELWECHSEL_STRECKE_SENSOR = "sensor.audi_rs_4_avant_oil_change_distance"
NAECHSTE_INSPEKTION_SENSOR = "sensor.audi_rs_4_avant_inspection_due"
INSPEKTION_STRECKE_SENSOR = "sensor.audi_rs_4_avant_inspection_distance"
# 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"
@@ -0,0 +1,187 @@
"""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):
"""urllib.request.urlopen läuft direkt, nicht über task.executor - anders
als ein bloßes open() ist das ein modulqualifizierter Aufruf einer echten
externen Funktion, der an der Testinstanz ohne Fehler durchlief. Blockiert
kurz, läuft nur beim Fahrtabschluss-Screening, nicht bei jedem Tick."""
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 = urllib.request.urlopen(req, timeout=10)
rohtext = resp.read().decode("utf-8")
resp.close()
daten = json.loads(rohtext)
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)
@@ -0,0 +1,152 @@
"""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 einstellungen
import profil
def profil_veroeffentlichen():
state.set("pyscript.audi_dashboard_profil", "aktuell", new_attributes={"daten": profil.profil_lesen()})
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 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)
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 pos, sensor in zip(POSITIONEN, einstellungen.TUERSCHLOSS_SENSOREN):
w = zustand_oder_none(sensor)
eintraege.append({"label": f"Türschloss {pos}", "ok": None if w is None else w == "off"})
for label, sensor in [
("Heckklappe", einstellungen.HECKKLAPPE_SENSOR),
("Heckklappenschloss", einstellungen.HECKKLAPPENSCHLOSS_SENSOR),
("Motorhaube", einstellungen.HAUBE_SENSOR),
("Haubenschloss", einstellungen.HAUBENSCHLOSS_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])
daten = {
"km": _zu_zahl(km),
"tankprozent": _zu_zahl(tankprozent),
"reichweite_km": _zu_zahl(reichweite),
"batteriespannung": _zu_zahl(batteriespannung),
"gesichert": gesichert,
"sicherheitscheck": sicherheitscheck,
# 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})
def alles_veroeffentlichen():
profil_veroeffentlichen()
fahrten_veroeffentlichen()
tankvorgaenge_veroeffentlichen()
fahrzeugstatus_veroeffentlichen()
batterieverlauf_veroeffentlichen()
+261
View File
@@ -0,0 +1,261 @@
"""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():
f = task.executor(io.open, PROFIL_PFAD, "r")
inhalt = f.read()
f.close()
return json.loads(inhalt)
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):
"""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."""
geaendert = False
for d in zeilen:
if d.get(id_feld) == id_wert:
geschuetzt = set(d.get("edited_fields", []))
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_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})
_zeilen_schreiben(BATTERIEVERLAUF_PFAD, verlauf)
# --------------------------------------------------------------------- IDs
def neue_id(praefix):
return f"{praefix}-{uuid.uuid4().hex[:12]}"
+101
View File
@@ -0,0 +1,101 @@
"""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()
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())
@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")
+116
View File
@@ -0,0 +1,116 @@
"""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()
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()
@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
@@ -0,0 +1,184 @@
"""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}")
+67
View File
@@ -0,0 +1,67 @@
<#
.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/bilder/* -> <Ziel>/www/
- 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: data/ - das würde ein bereits laufendes
Fahrzeugprofil, die Fahrten- und Tankvorgänge-Archive überschreiben. Die
data/-Dateien sind nur die Erstbefüllung für die einmalige Installation
(siehe INSTALL.md Schritt 2).
.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/, 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
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
}
$version = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
"{ ""version"": $version }" | Out-File -FilePath "$Ziel\www\audi-dashboard-version.json" -Encoding utf8 -NoNewline
Write-Host ""
Write-Host "Fertig. Version $version." -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
+47
View File
@@ -0,0 +1,47 @@
/* ================================================================
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;
@@ -0,0 +1 @@
{"version": 1786377886}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
<?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>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1 @@
<?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>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1 @@
<?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>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<?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>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<?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>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<?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>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<?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>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1 @@
<?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>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1 @@
<?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>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<?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>

After

Width:  |  Height:  |  Size: 1.2 KiB

+1
View File
@@ -0,0 +1 @@
<?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>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<?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>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<?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>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<?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>

After

Width:  |  Height:  |  Size: 1.4 KiB

+8
View File
@@ -0,0 +1,8 @@
<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>

After

Width:  |  Height:  |  Size: 1.5 KiB