Station name as "brand, street, city", grey box on all editable fields
The "grey box around the icons" report turned out not to be a defect: the box on "10.000 km"/"1 Jahr"/"15" is the wanted pattern, and every value the user can change should carry it. All .feld inputs and selects now get the grey iOS fill; inputs with their own visual language (checkbox/radio/range/file) keep the transparent base rule, so the switches are unaffected. Fuel stations are now displayed as "Shell, Pascalstr. 8, Ingolstadt" instead of the bare operator name. New _marke()/_ist_strasse()/_tankstelle() helpers are shared by both parser paths so Shell and non-Shell receipts format the same. The brand is only matched against the receipt header - searching the whole text would let "Total" hit a totals line. Missing parts are dropped instead of leaving empty comma slots, and without a known brand the operator name takes its place. station_address still carries the full street and postcode. test_bekannte_stationen updated to the new format; suite stays green. installationspaket/ is versioned from now on (user request). It contains no real vehicle data - only the example profile with empty FIN/plate placeholders. Keep it in sync whenever pyscript/ or www/ changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@@ -775,6 +775,35 @@ wraps the web app for iPhone; a PWA home-screen install is the accepted intermed
|
||||
spelled-out "Liter" now matches too. Result on that receipt: 42,13 l · 92,60 € · 2,198 €/l ·
|
||||
no discount · `AUTO B. FRISCHMANN GMBH`. The 10-receipt Shell regression suite still passes.
|
||||
All deployed to `audi_ha_test` (version `1786765000`) and synced to `installationspaket/`.
|
||||
- [x] Fourth round the same day (2026-08-16):
|
||||
(1) **The "grey box around the icons" report is resolved — it was never a defect.** After five
|
||||
fruitless reproduction attempts across two sessions, the user clarified what they actually
|
||||
meant: the grey box seen on "10.000 km", "1 Jahr", "15" is the *desired* pattern, and **every
|
||||
value the user can change should carry it** to signal editability. `.feld input`/`.feld select`
|
||||
in `audi-dashboard-ios.css` now all get `background:var(--ios-fill);padding:8px 10px`;
|
||||
inputs with their own visual language (`checkbox`/`radio`/`range`/`file`) are excluded and keep
|
||||
the transparent base rule, so the three switches stay untouched. The separate
|
||||
`.mitEinheit input` rule became redundant and was removed (its width/alignment still come from
|
||||
`audi-dashboard.css`). Verified live: FIN, Kennzeichen, Erstzulassung, Ausführung, Modell,
|
||||
both Ölwechsel selects, Ansicht, Pause bis and Autom. Backup all boxed; the three checkboxes
|
||||
not. **Do not re-open this as a bug.**
|
||||
(2) **Station display name is now "Marke, Straße, Ort"** (e.g. `Shell, Pascalstr. 8,
|
||||
Ingolstadt`) instead of the bare operator name (`A. Zrenner GmbH`) — the brand and place say
|
||||
more when scrolling the Tankvorgänge list. New `_marke()`/`_ist_strasse()`/`_tankstelle()`
|
||||
helpers in `shell_beleg_parser.py`, shared by both parser paths so Shell and non-Shell
|
||||
receipts format identically. The brand is matched only against the first 8 lines (the receipt
|
||||
header) — searching the whole text would let "Total" hit a totals line. Missing parts are
|
||||
dropped rather than left as empty comma slots (the Austrian receipt prints no street:
|
||||
`Shell, SÖLDEN`), and without a recognizable brand the operator name takes the brand's place.
|
||||
`station_address` keeps the full street + postcode + city as before. The regression suite's
|
||||
`test_bekannte_stationen` expectations were updated to the new format (intended change, not a
|
||||
break); all 8 tests / 164 subtests pass.
|
||||
(3) **`installationspaket/` is versioned from now on** (user request) — removed from
|
||||
`homeassistant/.gitignore`. It carries no real vehicle data, only
|
||||
`data/fahrzeugprofil.example.json` with empty FIN/Kennzeichen placeholders; verified before
|
||||
un-ignoring. **Keep it in sync whenever `pyscript/` or `www/` changes** — that was already the
|
||||
working rule, but it is now visible in the repo when it is forgotten.
|
||||
Deployed to `audi_ha_test` (version `1786766000`).
|
||||
- [ ] Fix remaining documentation drift (statistics claim, README gaps, obsolete TODO comment) —
|
||||
text-only changes; INSTALL.md's WLAN/TommiG1 drift and stale variable names were fixed
|
||||
2026-08-12 (see section B); `DESIGN_REVIEW_2026-08-13.md` and `REVIEW_main_2026-08-13.md`
|
||||
|
||||
@@ -8,7 +8,9 @@ data/tankvorgaenge.jsonl
|
||||
data/entitaeten.json
|
||||
data/tests/belege/*.pdf
|
||||
|
||||
# Generiertes Installations-Bundle (nur eine Kopie der bereits getrackten
|
||||
# pyscript/www/Vorlage-Dateien für den Netzwerk-Kopiervorgang auf eine neue
|
||||
# HA-Instanz) - bei Bedarf neu zusammenstellen, nicht versionieren.
|
||||
installationspaket/
|
||||
# installationspaket/ wird seit 2026-08-16 mitversioniert (vorher ignoriert):
|
||||
# es ist zwar nur eine Kopie der bereits getrackten pyscript/www/Vorlage-
|
||||
# Dateien, soll aber direkt aus dem Repo auf eine neue HA-Instanz kopierbar
|
||||
# sein, ohne es vorher von Hand zusammenzustellen. Es enthält bewusst keine
|
||||
# echten Fahrzeugdaten - nur data/fahrzeugprofil.example.json (Platzhalter,
|
||||
# siehe oben). Beim Ändern von pyscript/ oder www/ immer mitsynchronisieren.
|
||||
|
||||
@@ -99,6 +99,46 @@ _GESAMT_WOERTER = ("GESAMT", "ABSOLUT", "ENDBETRAG", "ZUZAHLEN", "TOTAL", "SUMME
|
||||
# nennen aber nie den bezahlten Rechnungsbetrag.
|
||||
_STEUER_WOERTER = ("MWST", "UST", "VAT", "STEUER", "NETTO")
|
||||
|
||||
# Kraftstoffmarken, wie sie im Belegkopf stehen. Bewusst nur dort gesucht (die
|
||||
# ersten Zeilen), nicht im ganzen Text: "Total" wäre sonst z. B. auch als
|
||||
# Summenzeile ein Treffer. Längere Namen zuerst, damit "TotalEnergies" nicht
|
||||
# als "Total" endet. Die Schreibweise hier ist die, die angezeigt wird - auf
|
||||
# Bons steht die Marke meist in Großbuchstaben ("SHELL STATION").
|
||||
_MARKEN = (
|
||||
"TotalEnergies", "Total", "Aral", "Shell", "Eni", "Agip", "Esso", "OMV",
|
||||
"Avia", "Turmöl", "Turmoel", "Orlen", "Tamoil", "Westfalen", "Allguth",
|
||||
"Classic", "Sprint", "Raiffeisen", "BayWa", "Elan", "HEM", "Star", "JET",
|
||||
"bft", "BP", "Q1",
|
||||
)
|
||||
_STRASSE_WOERTER = r"(?:stra(?:ss|ß)e|str\.|gasse|weg|platz|allee|ring|damm|chaussee)"
|
||||
|
||||
|
||||
def _marke(zeilen):
|
||||
"""Markenname aus dem Belegkopf ("SHELL STATION" -> "Shell"). None, wenn
|
||||
keine bekannte Marke draufsteht - dann bleibt es beim Betreibernamen."""
|
||||
kopf = "\n".join(zeilen[:8])
|
||||
for marke in _MARKEN:
|
||||
if re.search(r"\b" + re.escape(marke) + r"\b", kopf, re.IGNORECASE):
|
||||
return marke
|
||||
return None
|
||||
|
||||
|
||||
def _ist_strasse(zeile):
|
||||
"""Straßenzeilen tragen eine Hausnummer oder ein Straßenwort - der
|
||||
Betreibername ("AUTO B. FRISCHMANN GMBH") tut beides nicht."""
|
||||
if not zeile:
|
||||
return False
|
||||
return bool(re.search(r"\d", zeile) or re.search(_STRASSE_WOERTER, zeile, re.IGNORECASE))
|
||||
|
||||
|
||||
def _tankstelle(marke, strasse, ort, ersatzname=None):
|
||||
"""Anzeigename der Tankstelle im Format "Marke, Straße, Ort" (§ Anzeige im
|
||||
Frontend, Feld station_name). Fehlende Teile fallen weg statt als leere
|
||||
Kommastelle stehen zu bleiben - nicht jeder Beleg nennt eine Straße. Ohne
|
||||
erkennbare Marke tritt der Betreibername an ihre Stelle."""
|
||||
teile = [t for t in (marke or ersatzname, strasse, ort) if t]
|
||||
return ", ".join(teile) if teile else None
|
||||
|
||||
|
||||
def _parsen(text):
|
||||
def suchen(muster, *, pflicht=True, flags=re.MULTILINE):
|
||||
@@ -191,7 +231,15 @@ def _parsen(text):
|
||||
"ts_payment": ts_payment,
|
||||
"ts_tse": ts_tse,
|
||||
"station_id": station_id,
|
||||
"station_name": station.group("name").strip(),
|
||||
# Anzeigename "Marke, Straße, Ort" (siehe _tankstelle). Der Ort steht
|
||||
# auf dem Bon als "85057 Ingolstadt" - die PLZ gehört nur in die
|
||||
# ausführliche station_address darunter, nicht in den Anzeigenamen.
|
||||
"station_name": _tankstelle(
|
||||
_marke(text.splitlines()),
|
||||
station.group("strasse").strip(),
|
||||
re.sub(r"^\d{4,5}\s+", "", station.group("ort").strip()),
|
||||
ersatzname=station.group("name").strip(),
|
||||
),
|
||||
"station_address": f"{station.group('strasse').strip()}, {station.group('ort').strip()}",
|
||||
"article_no": article_no,
|
||||
"product_name": product_name,
|
||||
@@ -242,24 +290,42 @@ def _parsen_generisch(text):
|
||||
# fünfstellig (AT/DE). Deshalb nicht mehr drei feste Zeilen hintereinander
|
||||
# verlangen, sondern im Belegkopf die erste Zeile mit PLZ+Ort suchen; der
|
||||
# Name ist die erste Textzeile darüber.
|
||||
plz_treffer = None
|
||||
plz_zeile = None
|
||||
for i, zeile in enumerate(zeilen[:12]):
|
||||
if re.search(r"\b\d{4,5}\s+[A-Za-zÄÖÜäöüß]", zeile):
|
||||
plz_zeile = i
|
||||
treffer = re.search(r"\b\d{4,5}\s+([A-Za-zÄÖÜäöüß][^\n]*)$", zeile)
|
||||
if treffer:
|
||||
plz_treffer, plz_zeile = treffer, i
|
||||
break
|
||||
station_name = None
|
||||
|
||||
ort = None
|
||||
strasse = None
|
||||
betreiber = None
|
||||
station_address = None
|
||||
if plz_zeile is not None:
|
||||
if plz_treffer is not None:
|
||||
ort = plz_treffer.group(1).strip(" ,.-")
|
||||
station_address = zeilen[plz_zeile].strip()
|
||||
# Die Straße steht entweder vor der PLZ auf derselben Zeile
|
||||
# ("Musterstr. 5, 80331 München") oder auf der Zeile darüber.
|
||||
vor_plz = zeilen[plz_zeile][: plz_treffer.start()].strip(" ,.-")
|
||||
if _ist_strasse(vor_plz):
|
||||
strasse = vor_plz
|
||||
for zeile in reversed(zeilen[:plz_zeile]):
|
||||
if zeile.strip():
|
||||
station_name = zeile.strip()
|
||||
break
|
||||
if station_name is None:
|
||||
if not zeile.strip():
|
||||
continue
|
||||
if strasse is None and _ist_strasse(zeile.strip()):
|
||||
strasse = zeile.strip()
|
||||
continue
|
||||
betreiber = zeile.strip()
|
||||
break
|
||||
if betreiber is None:
|
||||
for zeile in zeilen[:5]:
|
||||
if zeile.strip():
|
||||
station_name = zeile.strip()
|
||||
betreiber = zeile.strip()
|
||||
break
|
||||
if station_address and strasse and strasse not in station_address:
|
||||
station_address = f"{strasse}, {station_address}"
|
||||
station_name = _tankstelle(_marke(zeilen), strasse, ort, ersatzname=betreiber)
|
||||
|
||||
# ---- Gesamtbetrag ------------------------------------------------------
|
||||
# Zeilenweise statt über den ganzen Text, aus zwei Gründen, die beide an
|
||||
|
||||
@@ -76,12 +76,17 @@ class AlleBelegeParsenOhneFehler(unittest.TestCase):
|
||||
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.
|
||||
# Vier verschiedene Standorte in den zehn Belegen - stellt sicher, dass
|
||||
# die Stationserkennung nicht zufällig nur für einen Beleg passt.
|
||||
# Anzeigeformat seit 2026-08-16: "Marke, Straße, Ort" statt des reinen
|
||||
# Betreibernamens ("A. Zrenner GmbH" o. ä.) - der Betreiber sagt beim
|
||||
# Durchblättern der Tankvorgänge weniger als Marke und Ort.
|
||||
namen = {d["station_name"] for d in self.ergebnisse.values()}
|
||||
erwartet = {
|
||||
"A. Zrenner GmbH", "Thomas Bauch GmbH",
|
||||
"TC Sengül GmbH", "Hermann Mogler Mineralölg. GmbH",
|
||||
"Shell, Pascalstr. 8, Ingolstadt",
|
||||
"Shell, Münchener Str.15a, Ingolstadt",
|
||||
"Shell, Nürnberger Str.74, Ansbach",
|
||||
"Shell, Heidenheimerstr.6, Königsbronn",
|
||||
}
|
||||
self.assertEqual(namen, erwartet)
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Kurzanleitung — Installation auf einer neuen Home-Assistant-Instanz
|
||||
|
||||
Alle Dateien, die dafür nötig sind, liegen schon in diesem Ordner. Ausführliche
|
||||
Erklärungen, Troubleshooting und Hintergründe stehen in `../INSTALL.md` — hier
|
||||
nur die Checkliste.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- HACS installiert, darüber **pyscript** installiert (HACS → Integrationen →
|
||||
„pyscript" suchen → Download → HA neu starten)
|
||||
|
||||
## 1. Dateien kopieren
|
||||
|
||||
Ziel ist das `config`-Verzeichnis der neuen HA-Instanz, z. B. über das
|
||||
Samba-Share-Add-on (`\\<HA-IP>\config`):
|
||||
|
||||
| Aus diesem Ordner | Nach |
|
||||
|---|---|
|
||||
| `pyscript\` | `\\<HA-IP>\config\pyscript\` |
|
||||
| `data\` | `\\<HA-IP>\config\audi_dashboard\` (**umbenennen** von `data` zu `audi_dashboard`) |
|
||||
| `www\` | `\\<HA-IP>\config\www\` |
|
||||
|
||||
In `audi_dashboard\` danach `fahrzeugprofil.example.json` zu
|
||||
`fahrzeugprofil.json` umbenennen — die Platzhalter darin lassen sich nach dem
|
||||
ersten Start direkt in der App unter **Einstellungen → Fahrzeug einrichten**
|
||||
eintragen.
|
||||
|
||||
## 2. configuration.yaml ergänzen
|
||||
|
||||
Die beiden Blöcke aus `configuration_snippet.yaml` (`pyscript:` und
|
||||
`panel_custom:`) in die bestehende `configuration.yaml` übernehmen — nicht
|
||||
ersetzen. Falls dort schon `pyscript:` existiert, nur `allow_all_imports: true`
|
||||
ergänzen.
|
||||
|
||||
## 3. Long-Lived Access Token
|
||||
|
||||
Profil-Avatar (unten links) → „Long-lived access tokens" → Token erstellen →
|
||||
Wert in eine neue Datei `audi_dashboard\ha_token.txt` einfügen (nur der Token,
|
||||
keine Anführungszeichen). Wird fürs Kilometerstand-Screening gebraucht.
|
||||
|
||||
## 4. Neu starten
|
||||
|
||||
**Einstellungen → System → Neu starten.**
|
||||
|
||||
## 5. Sensoren zuordnen
|
||||
|
||||
Nach dem Neustart sollte „Mein Audi" in der Sidebar erscheinen. Dort:
|
||||
**Einstellungen → Fahrzeug einrichten → Einrichten → „Setup — Sensoren
|
||||
zuordnen"**. Zwingend für die Fahrterkennung: der Zündungs-/ACC-Sensor
|
||||
(`binary_sensor`, meist vom Teltonika FMM003). Alles Weitere ist optional —
|
||||
ohne zugeordneten Sensor zeigt die App „unbekannt" statt eines Werts.
|
||||
|
||||
## 6. Optional: Beleg-Parser
|
||||
|
||||
Nur nötig, wenn Shell-Tankbelege hochgeladen werden sollen: im Terminal &
|
||||
SSH-Add-on (oder `docker exec`) `pip install pypdf` ausführen.
|
||||
|
||||
## Künftige Updates
|
||||
|
||||
Nicht diesen Ordner erneut kopieren — stattdessen `..\update.ps1` aus dem
|
||||
Hauptprojekt verwenden:
|
||||
|
||||
```powershell
|
||||
..\update.ps1 -Ziel "\\<HA-IP-Adresse>\config"
|
||||
```
|
||||
|
||||
Kopiert `pyscript\` und alle `www\`-Dateien (inkl. `audi-dashboard-ios.css`
|
||||
und `badges\`, die früher hier im Skript fehlten), lässt `data\`/
|
||||
`audi_dashboard\` unangetastet. pyscript lädt automatisch neu, fürs Frontend
|
||||
reicht ein normales Neuladen der Seite (F5).
|
||||
|
||||
---
|
||||
|
||||
Details, Screenshots-Ersatz-Erklärungen und die vollständige
|
||||
Troubleshooting-Tabelle: siehe `../INSTALL.md`.
|
||||
@@ -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,184 @@
|
||||
{
|
||||
"_hinweis": "Vorlage ohne echte Fahrzeug-/Personendaten. Vor der Installation nach fahrzeugprofil.json kopieren und die Platzhalter unten ersetzen (VIN, Kennzeichen, Erstzulassung, HU-Termin, WLAN-Name, Versicherung, Werkstatt, Servicebuch) - am einfachsten über den Button 'Einrichten' in den Einstellungen der App, sobald sie einmal läuft. Diese Datei selbst bleibt bewusst im Repo (siehe .gitignore), die echte fahrzeugprofil.json nicht.",
|
||||
|
||||
"fahrzeug": {
|
||||
"fin": "",
|
||||
"kennzeichen": "",
|
||||
"titel": "RS 4 Avant competition",
|
||||
"zusatz": "Avant competition",
|
||||
"untertitel": "quattro · 331 kW",
|
||||
"details": "2.9 TFSI quattro competition · 331 kW · 450 PS",
|
||||
"erstzulassung": "",
|
||||
"hauptuntersuchung_faellig": "",
|
||||
"tankvolumen_liter": 58,
|
||||
"ausfuehrung": "competition"
|
||||
},
|
||||
|
||||
"einstellungen": {
|
||||
"nacht_von": "19:00",
|
||||
"nacht_bis": "06:00",
|
||||
"fahrten_pausenzeit_min": 15,
|
||||
"uebersichtsbild": "Frontansicht",
|
||||
"kraftstoffanbieter": "Shell",
|
||||
"smartdeal": { "aktiv": false, "laeuft_ab": "" },
|
||||
"oelwechsel_intervall": {
|
||||
"modus": "hersteller",
|
||||
"km": 15000,
|
||||
"monate": 12,
|
||||
"hersteller_km": 30000,
|
||||
"hersteller_monate": 24
|
||||
},
|
||||
"backup_intervall": "aus",
|
||||
"letztes_backup": null
|
||||
},
|
||||
|
||||
"versicherung": {
|
||||
"gesellschaft": "",
|
||||
"vertrag": "",
|
||||
"nummer": "",
|
||||
"service": "",
|
||||
"notfall": "",
|
||||
"beitrag": 0,
|
||||
"steueranteil": 0,
|
||||
"steuersatz": "19 %",
|
||||
"gueltigAb": "",
|
||||
"zahlweise": "jährlich",
|
||||
"teile": [
|
||||
{ "bez": "Haftpflicht", "betrag": 0, "sf": "", "sfAlt": "", "satz": "", "satzAlt": "" },
|
||||
{ "bez": "Vollkasko inklusive Teilkasko", "betrag": 0, "sf": "", "sfAlt": "", "satz": "", "satzAlt": "" },
|
||||
{ "bez": "Schutzbrief Mobilität", "betrag": 0, "sf": null, "sfAlt": null, "satz": null, "satzAlt": null }
|
||||
],
|
||||
"selbst": [["Vollkasko", "0 €"], ["Teilkasko", "0 €"]],
|
||||
"schutzbrief": {
|
||||
"stand": "",
|
||||
"gilt": "",
|
||||
"versichert": "",
|
||||
"ausloeser": [],
|
||||
"entweder": [],
|
||||
"gruppen": [],
|
||||
"nicht": [],
|
||||
"pflichten": [],
|
||||
"rang": ""
|
||||
},
|
||||
"leistungen": []
|
||||
},
|
||||
|
||||
"steuer": {
|
||||
"betrag": 0,
|
||||
"zeitraum": "jährlich",
|
||||
"faellig": null,
|
||||
"grundlage": "Hubraum und CO₂-Ausstoß",
|
||||
"lastschrift": true,
|
||||
"finanzamt": ""
|
||||
},
|
||||
|
||||
"reifen": {
|
||||
"aktiv": "sommer",
|
||||
"anzugsmoment_nm": 120,
|
||||
"wechsel": { "datum": "", "vereinbart": false },
|
||||
"referenz_odo_km": null,
|
||||
"saetze": {
|
||||
"sommer": {
|
||||
"km": 0,
|
||||
"bild": "/local/bilder/rad-sommer.webp",
|
||||
"marke": "",
|
||||
"modell": "",
|
||||
"dot": "",
|
||||
"mass": "275/30 R20 97Y XL",
|
||||
"druck_vorne": "2,5",
|
||||
"druck_hinten": "2,4",
|
||||
"kommentar": ""
|
||||
},
|
||||
"winter": {
|
||||
"km": 0,
|
||||
"bild": "/local/bilder/rad-winter.webp",
|
||||
"marke": "",
|
||||
"modell": "",
|
||||
"dot": "",
|
||||
"mass": "265/35 R19",
|
||||
"druck_vorne": "2,6",
|
||||
"druck_hinten": "2,5",
|
||||
"kommentar": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"service": {
|
||||
"vereinbart": {},
|
||||
"autohaus": {
|
||||
"name": "",
|
||||
"adresse": "",
|
||||
"tel": "",
|
||||
"mail": "",
|
||||
"pos": null
|
||||
},
|
||||
"buch": []
|
||||
},
|
||||
|
||||
"technik": [
|
||||
{ "gruppe": "Motor", "posten": [
|
||||
["Bauart", "V6 Ottomotor, Direkteinspritzung, Biturbo"],
|
||||
["Hubraum", "2 894 cm³"],
|
||||
["Leistung", "331 kW / 450 PS bei 5 700–6 700 min⁻¹"],
|
||||
["Maximales Drehmoment", "600 Nm bei 2 000–5 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", "100–150 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"] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parser für Shell-Tankbelege (PDF), aufgerufen von belegverarbeitung.py.
|
||||
|
||||
Diese Datei fehlte beim Bau im Projektordner (siehe Kopfkommentar in
|
||||
pyscript/belegverarbeitung.py und README.md, Abschnitt "Bekannte Lücken").
|
||||
Sie ist hier gegen echte Shell-eReceipts neu geschrieben und gegen deren
|
||||
tatsächlich von pypdf extrahierten Text geprüft - nicht gegen eine
|
||||
angenommene Struktur. Regressionstest mit zehn realen Belegen aus vier
|
||||
Stationen liegt unter tests/test_shell_beleg_parser.py (Aufruf: python3
|
||||
tests/test_shell_beleg_parser.py aus diesem Ordner). Die Belege
|
||||
unterscheiden sich u. a. in der Markenzeile ("SHELL STATION" vs.
|
||||
"Shell Station" vs. "Shell-Station") und der Rabattbezeichnung
|
||||
("V-Power Smart Deal" vs. "ClubSmartRabatt") - deshalb ist die
|
||||
Stations- und Rabatterkennung unten bewusst nicht an deren genauen
|
||||
Wortlaut gebunden, sondern an feste Beleg-Struktur-Marker (Obj.-Nr.,
|
||||
Betragsmuster vor "EUR #A").
|
||||
|
||||
Abhängigkeit: pypdf (reine Textextraktion, der Beleg ist ein normales
|
||||
Text-PDF, kein Scan/Bild - kein OCR nötig). Läuft als eigener Prozess über
|
||||
`python3 shell_beleg_parser.py <pfad>` (system-python3 im Container, nicht
|
||||
die pyscript-Sandbox - siehe _parser_aufrufen() in belegverarbeitung.py),
|
||||
deshalb reicht `pip install pypdf` einmalig im selben Python, das
|
||||
`python3` in configuration.yaml/Docker auch sonst benutzt.
|
||||
|
||||
Kommandozeilen-Schnittstelle wie von belegverarbeitung.py angenommen:
|
||||
Ein Pfad zu einer PDF-Datei als einziges Argument, JSON-Objekt auf stdout
|
||||
bei Erfolg, Exit-Code ungleich 0 und Fehlertext auf stderr bei Misserfolg
|
||||
(z. B. wenn der Beleg keiner der erkannten Vorlagen entspricht).
|
||||
|
||||
Aufbau eines Shell-eReceipts (aus dem echten Beispiel, Reihenfolge fix):
|
||||
SHELL STATION
|
||||
<Betreibername, z.B. "A. Zrenner GmbH">
|
||||
<Straße>
|
||||
<PLZ Ort>
|
||||
Obj.-Nr.: <Stations-ID> Tel. ...
|
||||
Beleg-Nr. <receipt_no> <TT.MM.JJ> <HH:MM>
|
||||
*<article_no> <Produktname> <Listenbetrag> EUR #A*
|
||||
*Zp <Zapfsäule> <Liter> l <Listenpreis/l> EUR/l # *
|
||||
[<Rabattname> -<Rabattbetrag> EUR #A -- optional, nur mit SmartDeal
|
||||
<Rabatt/l> EUR/Liter -- optional]
|
||||
Gesamtbetrag <Endbetrag> EUR
|
||||
Typ Netto Mwst Brutto
|
||||
A:<Mwst-Satz>% <Netto> <Mwst> <Brutto>
|
||||
...
|
||||
Mobile Payment
|
||||
<TT.MM.JJJJ> <HH:MM:SS> -- Zahlungszeitstempel
|
||||
...
|
||||
(Seite 2) POS:...-...-... TSE-Beleg-Nr: <tse_beleg_nr>
|
||||
Start/Ende: <TT.MM.JJJJ HH:MM:SS> / ...
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
try:
|
||||
import pypdf
|
||||
except ImportError:
|
||||
print(
|
||||
"pypdf fehlt. Einmalig installieren mit: pip install pypdf "
|
||||
"(im selben Python, das diesen Parser ausführt, siehe Kopfkommentar).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _text_aus_pdf(pdf_pfad):
|
||||
leser = pypdf.PdfReader(pdf_pfad)
|
||||
return "\n".join(seite.extract_text() or "" for seite in leser.pages)
|
||||
|
||||
|
||||
def _de_zahl(text):
|
||||
""""103,06" -> 103.06 / "2,399" -> 2.399. Kein Tausenderpunkt bei
|
||||
Tankbeträgen dieser Größenordnung zu erwarten, trotzdem robust: Punkte
|
||||
vor dem Umwandeln entfernen, nicht nur das Komma ersetzen."""
|
||||
if text is None:
|
||||
return None
|
||||
return float(text.strip().replace(".", "").replace(",", "."))
|
||||
|
||||
|
||||
def _iso(tag, monat, jahr, stunde, minute, sekunde="0"):
|
||||
jahr = int(jahr)
|
||||
if jahr < 100:
|
||||
jahr += 2000
|
||||
return datetime.datetime(
|
||||
jahr, int(monat), int(tag), int(stunde), int(minute), int(sekunde)
|
||||
).isoformat()
|
||||
|
||||
|
||||
_ZAHL = r"[\d.,]+"
|
||||
|
||||
# Echte Geldangabe: Nachkommastellen sind Pflicht, damit im stationsunabhängigen
|
||||
# Parser (_parsen_generisch) keine Artikel-/Belegnummer als Betrag durchgeht.
|
||||
_GELD = r"\d{1,3}(?:[ .]\d{3})*,\d{2}"
|
||||
_GESAMT_WOERTER = ("GESAMT", "ABSOLUT", "ENDBETRAG", "ZUZAHLEN", "TOTAL", "SUMME")
|
||||
# Steuer- und Nettozeilen tragen dieselben Schlüsselwörter ("MWST GESAMT"),
|
||||
# nennen aber nie den bezahlten Rechnungsbetrag.
|
||||
_STEUER_WOERTER = ("MWST", "UST", "VAT", "STEUER", "NETTO")
|
||||
|
||||
# Kraftstoffmarken, wie sie im Belegkopf stehen. Bewusst nur dort gesucht (die
|
||||
# ersten Zeilen), nicht im ganzen Text: "Total" wäre sonst z. B. auch als
|
||||
# Summenzeile ein Treffer. Längere Namen zuerst, damit "TotalEnergies" nicht
|
||||
# als "Total" endet. Die Schreibweise hier ist die, die angezeigt wird - auf
|
||||
# Bons steht die Marke meist in Großbuchstaben ("SHELL STATION").
|
||||
_MARKEN = (
|
||||
"TotalEnergies", "Total", "Aral", "Shell", "Eni", "Agip", "Esso", "OMV",
|
||||
"Avia", "Turmöl", "Turmoel", "Orlen", "Tamoil", "Westfalen", "Allguth",
|
||||
"Classic", "Sprint", "Raiffeisen", "BayWa", "Elan", "HEM", "Star", "JET",
|
||||
"bft", "BP", "Q1",
|
||||
)
|
||||
_STRASSE_WOERTER = r"(?:stra(?:ss|ß)e|str\.|gasse|weg|platz|allee|ring|damm|chaussee)"
|
||||
|
||||
|
||||
def _marke(zeilen):
|
||||
"""Markenname aus dem Belegkopf ("SHELL STATION" -> "Shell"). None, wenn
|
||||
keine bekannte Marke draufsteht - dann bleibt es beim Betreibernamen."""
|
||||
kopf = "\n".join(zeilen[:8])
|
||||
for marke in _MARKEN:
|
||||
if re.search(r"\b" + re.escape(marke) + r"\b", kopf, re.IGNORECASE):
|
||||
return marke
|
||||
return None
|
||||
|
||||
|
||||
def _ist_strasse(zeile):
|
||||
"""Straßenzeilen tragen eine Hausnummer oder ein Straßenwort - der
|
||||
Betreibername ("AUTO B. FRISCHMANN GMBH") tut beides nicht."""
|
||||
if not zeile:
|
||||
return False
|
||||
return bool(re.search(r"\d", zeile) or re.search(_STRASSE_WOERTER, zeile, re.IGNORECASE))
|
||||
|
||||
|
||||
def _tankstelle(marke, strasse, ort, ersatzname=None):
|
||||
"""Anzeigename der Tankstelle im Format "Marke, Straße, Ort" (§ Anzeige im
|
||||
Frontend, Feld station_name). Fehlende Teile fallen weg statt als leere
|
||||
Kommastelle stehen zu bleiben - nicht jeder Beleg nennt eine Straße. Ohne
|
||||
erkennbare Marke tritt der Betreibername an ihre Stelle."""
|
||||
teile = [t for t in (marke or ersatzname, strasse, ort) if t]
|
||||
return ", ".join(teile) if teile else None
|
||||
|
||||
|
||||
def _parsen(text):
|
||||
def suchen(muster, *, pflicht=True, flags=re.MULTILINE):
|
||||
treffer = re.search(muster, text, flags)
|
||||
if treffer is None and pflicht:
|
||||
raise ValueError(f"Muster nicht gefunden: {muster!r}")
|
||||
return treffer
|
||||
|
||||
# ---- Station ---------------------------------------------------------
|
||||
# Nicht auf den Wortlaut der Markenzeile selbst verankert ("SHELL
|
||||
# STATION" vs. "Shell-STATION" - beide an echten Belegen gesehen,
|
||||
# offenbar je nach Pächter/Kassensystem unterschiedlich geschrieben).
|
||||
# Stattdessen einfach die drei Zeilen direkt vor "Obj.-Nr." nehmen - die
|
||||
# Reihenfolge Betreiber/Straße/Ort direkt davor ist auf beiden echten
|
||||
# Belegen gleich.
|
||||
station = suchen(
|
||||
r"(?P<name>.+?)\s*\n\s*(?P<strasse>.+?)\s*\n\s*(?P<ort>.+?)\s*\n\s*Obj\.-Nr\."
|
||||
)
|
||||
station_id = suchen(r"Obj\.-Nr\.:\s*(\d+)").group(1)
|
||||
|
||||
# ---- Beleg-Kopf (Beleg-Nr., Datum/Zeit auf dem Kassenbon) ----------
|
||||
kopf = suchen(
|
||||
r"Beleg-Nr\.\s*(?P<receipt_no>[\d/]+)\s+(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{2})\s+(?P<stunde>\d{2}):(?P<minute>\d{2})"
|
||||
)
|
||||
receipt_no = kopf.group("receipt_no")
|
||||
ts = _iso(kopf.group("tag"), kopf.group("monat"), kopf.group("jahr"),
|
||||
kopf.group("stunde"), kopf.group("minute"))
|
||||
|
||||
# ---- Kraftstoff-Position --------------------------------------------
|
||||
position = suchen(
|
||||
r"\*(?P<article_no>\d+)\s+(?P<produkt>.+?)\s+" + _ZAHL + r"\s*EUR\s*#A\*"
|
||||
)
|
||||
article_no = position.group("article_no")
|
||||
product_name = position.group("produkt").strip()
|
||||
|
||||
zapfsaeule = suchen(
|
||||
r"\*Zp\s*\d+\s+(?P<liter>" + _ZAHL + r")\s*l\s+(?P<preis>" + _ZAHL + r")\s*EUR/l"
|
||||
)
|
||||
liters = _de_zahl(zapfsaeule.group("liter"))
|
||||
list_price_per_l = _de_zahl(zapfsaeule.group("preis"))
|
||||
|
||||
# ---- SmartDeal-Rabatt (optional - nicht jeder Tankvorgang hat einen) --
|
||||
rabatt_treffer = suchen(r"-(" + _ZAHL + r")\s*EUR\s*#A\s*$", pflicht=False)
|
||||
discount = _de_zahl(rabatt_treffer.group(1)) if rabatt_treffer else None
|
||||
rabatt_l_treffer = suchen(r"(" + _ZAHL + r")\s*EUR/Liter", pflicht=False)
|
||||
discount_per_l = _de_zahl(rabatt_l_treffer.group(1)) if rabatt_l_treffer else None
|
||||
|
||||
# ---- Beträge ----------------------------------------------------------
|
||||
fuel_total_eur = _de_zahl(suchen(r"Gesamtbetrag\s+(" + _ZAHL + r")\s*EUR").group(1))
|
||||
steuerzeile = suchen(
|
||||
r"A:" + _ZAHL + r"%\s+(?P<netto>" + _ZAHL + r")\s+(?P<mwst>" + _ZAHL + r")\s+(?P<brutto>" + _ZAHL + r")"
|
||||
)
|
||||
net_eur = _de_zahl(steuerzeile.group("netto"))
|
||||
vat_eur = _de_zahl(steuerzeile.group("mwst"))
|
||||
receipt_total_eur = _de_zahl(steuerzeile.group("brutto"))
|
||||
|
||||
# ---- Zahlungszeitstempel (Mobile Payment) - optional, falls anderes
|
||||
# Zahlungsverfahren auf dem Beleg steht ---------------------------------
|
||||
zahlung = suchen(
|
||||
r"Mobile Payment\s*\n+\s*(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{4})\s+(?P<stunde>\d{2}):(?P<minute>\d{2}):(?P<sekunde>\d{2})",
|
||||
pflicht=False,
|
||||
)
|
||||
ts_payment = (
|
||||
_iso(zahlung.group("tag"), zahlung.group("monat"), zahlung.group("jahr"),
|
||||
zahlung.group("stunde"), zahlung.group("minute"), zahlung.group("sekunde"))
|
||||
if zahlung else None
|
||||
)
|
||||
|
||||
# ---- TSE-Signatur (Seite 2) - optional -------------------------------
|
||||
tse_nr = suchen(r"TSE-Beleg-Nr:\s*(\d+)", pflicht=False)
|
||||
tse_beleg_nr = tse_nr.group(1) if tse_nr else None
|
||||
tse_zeit = suchen(
|
||||
r"Start/Ende:\s*(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{4})\s+(?P<stunde>\d{2}):(?P<minute>\d{2}):(?P<sekunde>\d{2})",
|
||||
pflicht=False,
|
||||
)
|
||||
ts_tse = (
|
||||
_iso(tse_zeit.group("tag"), tse_zeit.group("monat"), tse_zeit.group("jahr"),
|
||||
tse_zeit.group("stunde"), tse_zeit.group("minute"), tse_zeit.group("sekunde"))
|
||||
if tse_zeit else None
|
||||
)
|
||||
|
||||
# receipt_key: minutengenauer Deduplizierungs-Schlüssel (§6.4/§7.7 Regel 3)
|
||||
receipt_key = f"{station_id}_{receipt_no.replace('/', '-')}_{ts[:16]}"
|
||||
|
||||
return {
|
||||
"receipt_key": receipt_key,
|
||||
"receipt_no": receipt_no,
|
||||
"tse_beleg_nr": tse_beleg_nr,
|
||||
"ts": ts,
|
||||
"ts_payment": ts_payment,
|
||||
"ts_tse": ts_tse,
|
||||
"station_id": station_id,
|
||||
# Anzeigename "Marke, Straße, Ort" (siehe _tankstelle). Der Ort steht
|
||||
# auf dem Bon als "85057 Ingolstadt" - die PLZ gehört nur in die
|
||||
# ausführliche station_address darunter, nicht in den Anzeigenamen.
|
||||
"station_name": _tankstelle(
|
||||
_marke(text.splitlines()),
|
||||
station.group("strasse").strip(),
|
||||
re.sub(r"^\d{4,5}\s+", "", station.group("ort").strip()),
|
||||
ersatzname=station.group("name").strip(),
|
||||
),
|
||||
"station_address": f"{station.group('strasse').strip()}, {station.group('ort').strip()}",
|
||||
"article_no": article_no,
|
||||
"product_name": product_name,
|
||||
"fuel_type": product_name,
|
||||
"liters": liters,
|
||||
"list_price_per_l": list_price_per_l,
|
||||
"discount": discount,
|
||||
"discount_per_l": discount_per_l,
|
||||
"fuel_total_eur": fuel_total_eur,
|
||||
"receipt_total_eur": receipt_total_eur,
|
||||
"net_eur": net_eur,
|
||||
"vat_eur": vat_eur,
|
||||
}
|
||||
|
||||
|
||||
def _parsen_generisch(text):
|
||||
"""Stationsunabhängiger Fallback für Belege, die keinem der oben fest an
|
||||
Shells Beleg-Struktur verankerten Muster entsprechen (_parsen() wirft in
|
||||
dem Fall ValueError - siehe main()). Arbeitet nach demselben Verfahren,
|
||||
das an einem echten Beleg einer bislang unbekannten Tankstelle von Hand
|
||||
nachvollzogen wurde: Adresse suchen, Gesamtbetrag suchen ("Gesamt.../
|
||||
Absolut..."), Literangabe suchen ("Menge"/"Amount" oder eine an "l"/
|
||||
"Liter" hängende Zahl), daraus den tatsächlich bezahlten Preis/Liter
|
||||
errechnen (Gesamtbetrag / Liter). Steht daneben auch ein aufgedruckter
|
||||
Preis/Liter auf dem Beleg und weicht er vom errechneten ab, wurde ein
|
||||
Rabatt gewährt - auf dem Beleg meist als eigener Betrag mit einem Minus
|
||||
davor erkennbar, danach wird zuerst gesucht, sonst aus der Preisdifferenz
|
||||
hergeleitet. Ob ein so gefundener Rabatt als "SmartDeal" behandelt wird,
|
||||
entscheidet einzig station_name (Shell-Logo/-Label im Frontend, siehe
|
||||
frontend_veroeffentlichung.py) - kein Sonderfall hier nötig, die Frontend-
|
||||
Anzeige hängt nur an einem gesetzten discount, nicht an einem eigenen
|
||||
Kennzeichen.
|
||||
|
||||
Liefert deutlich weniger Felder als _parsen() (kein Bon-Kleingedrucktes
|
||||
wie TSE-Nummer/Zahlungszeitstempel) - unproblematisch, denn
|
||||
belegverarbeitung.py liest nur receipt_key als echtes Pflichtfeld,
|
||||
alles andere per .get()."""
|
||||
|
||||
def suchen(muster, flags=re.IGNORECASE | re.MULTILINE):
|
||||
return re.search(muster, text, flags)
|
||||
|
||||
zeilen = [z.rstrip() for z in text.splitlines()]
|
||||
|
||||
# ---- Adresse -----------------------------------------------------------
|
||||
# Die PLZ-Ort-Angabe steht nicht zuverlässig auf einer eigenen Zeile (ein
|
||||
# österreichischer Beleg führt sie als "SHELL TANKSTELLE, 6450 SÖLDEN"
|
||||
# zusammen mit der Filialbezeichnung) und PLZ sind vier- *oder*
|
||||
# fünfstellig (AT/DE). Deshalb nicht mehr drei feste Zeilen hintereinander
|
||||
# verlangen, sondern im Belegkopf die erste Zeile mit PLZ+Ort suchen; der
|
||||
# Name ist die erste Textzeile darüber.
|
||||
plz_treffer = None
|
||||
plz_zeile = None
|
||||
for i, zeile in enumerate(zeilen[:12]):
|
||||
treffer = re.search(r"\b\d{4,5}\s+([A-Za-zÄÖÜäöüß][^\n]*)$", zeile)
|
||||
if treffer:
|
||||
plz_treffer, plz_zeile = treffer, i
|
||||
break
|
||||
|
||||
ort = None
|
||||
strasse = None
|
||||
betreiber = None
|
||||
station_address = None
|
||||
if plz_treffer is not None:
|
||||
ort = plz_treffer.group(1).strip(" ,.-")
|
||||
station_address = zeilen[plz_zeile].strip()
|
||||
# Die Straße steht entweder vor der PLZ auf derselben Zeile
|
||||
# ("Musterstr. 5, 80331 München") oder auf der Zeile darüber.
|
||||
vor_plz = zeilen[plz_zeile][: plz_treffer.start()].strip(" ,.-")
|
||||
if _ist_strasse(vor_plz):
|
||||
strasse = vor_plz
|
||||
for zeile in reversed(zeilen[:plz_zeile]):
|
||||
if not zeile.strip():
|
||||
continue
|
||||
if strasse is None and _ist_strasse(zeile.strip()):
|
||||
strasse = zeile.strip()
|
||||
continue
|
||||
betreiber = zeile.strip()
|
||||
break
|
||||
if betreiber is None:
|
||||
for zeile in zeilen[:5]:
|
||||
if zeile.strip():
|
||||
betreiber = zeile.strip()
|
||||
break
|
||||
if station_address and strasse and strasse not in station_address:
|
||||
station_address = f"{strasse}, {station_address}"
|
||||
station_name = _tankstelle(_marke(zeilen), strasse, ort, ersatzname=betreiber)
|
||||
|
||||
# ---- Gesamtbetrag ------------------------------------------------------
|
||||
# Zeilenweise statt über den ganzen Text, aus zwei Gründen, die beide an
|
||||
# einem echten Beleg aufgefallen sind: Bondrucker sperren Überschriften
|
||||
# gern buchstabenweise ("G E S A M T BETRAG EUR: 92,60"), und ein frei
|
||||
# laufendes Muster fand vorher die Spaltenüberschrift "SUMME-EUR" und
|
||||
# las die Artikelnummer der Folgezeile als Betrag. Deshalb: Schlüsselwort
|
||||
# auf der leerzeichenfreien Zeile prüfen, Betrag nur als echte Geldangabe
|
||||
# (mit Nachkommastellen) akzeptieren und Steuerzeilen ausschließen.
|
||||
kandidaten = []
|
||||
for zeile in zeilen:
|
||||
kompakt = re.sub(r"\s+", "", zeile).upper()
|
||||
if not any(wort in kompakt for wort in _GESAMT_WOERTER):
|
||||
continue
|
||||
if any(wort in kompakt for wort in _STEUER_WOERTER):
|
||||
continue
|
||||
betraege = re.findall(_GELD, zeile)
|
||||
if betraege:
|
||||
kandidaten.append(_de_zahl(betraege[-1]))
|
||||
if not kandidaten:
|
||||
raise ValueError("Kein Gesamtbetrag gefunden (weder 'Gesamt' noch 'Absolut')")
|
||||
# Mehrere Treffer (z. B. Summenzeile und Kartenzahlungszeile): der
|
||||
# Rechnungsbetrag ist der größte - Teilbeträge sind nie größer.
|
||||
fuel_total_eur = max(kandidaten)
|
||||
|
||||
# ---- Menge in Litern ----------------------------------------------------
|
||||
menge = suchen(r"(?:Menge|Amount)\D{0,15}?(" + _ZAHL + r")\s*(?:l\b|L\b|Liter|Ltr\.?)")
|
||||
if not menge:
|
||||
menge = suchen(r"(" + _ZAHL + r")\s*(?:Liter|Ltr\.?|l)\b")
|
||||
if not menge:
|
||||
raise ValueError("Keine Literangabe gefunden (weder 'Menge'/'Amount' noch 'X l')")
|
||||
liters = _de_zahl(menge.group(1))
|
||||
if not liters:
|
||||
raise ValueError("Literangabe ist 0 oder unlesbar")
|
||||
|
||||
berechneter_preis_l = round(fuel_total_eur / liters, 3)
|
||||
|
||||
# ---- Aufgedruckter Preis/Liter - Abweichung verrät einen Rabatt -------
|
||||
# "Preis/L" (ohne ausgeschriebenes "Liter") kommt auf echten Belegen vor,
|
||||
# deshalb ist alles nach dem L optional.
|
||||
aufgedruckt = suchen(
|
||||
r"(?:Preis\s*/?\s*(?:je\s*)?L(?:iter|tr\.?)?|Price\s*/?\s*L(?:iter)?|€\s*/\s*l|EUR\s*/\s*l)\D{0,10}?(" + _ZAHL + r")"
|
||||
)
|
||||
list_price_per_l = _de_zahl(aufgedruckt.group(1)) if aufgedruckt else berechneter_preis_l
|
||||
|
||||
discount = None
|
||||
discount_per_l = None
|
||||
if aufgedruckt and abs(list_price_per_l - berechneter_preis_l) > 0.005:
|
||||
minus = suchen(r"-\s*(" + _ZAHL + r")\s*(?:€|EUR)")
|
||||
if minus:
|
||||
discount = _de_zahl(minus.group(1))
|
||||
discount_per_l = round(discount / liters, 3)
|
||||
else:
|
||||
discount_per_l = round(list_price_per_l - berechneter_preis_l, 3)
|
||||
discount = round(discount_per_l * liters, 2)
|
||||
|
||||
# ---- Zeitstempel: irgendein Datum+Uhrzeit auf dem Beleg ----------------
|
||||
zeit = suchen(r"(\d{2})\.(\d{2})\.(\d{2,4})\D{0,10}?(\d{2}):(\d{2})")
|
||||
ts = _iso(zeit.group(1), zeit.group(2), zeit.group(3), zeit.group(4), zeit.group(5)) if zeit else None
|
||||
|
||||
# ---- Belegnummer, sonst deterministischer Ersatzschlüssel --------------
|
||||
# Wichtig: kein eingebautes hash() (pro Prozess zufällig gesalzen, siehe
|
||||
# PYTHONHASHSEED) - der Parser läuft für jeden Beleg als eigener Prozess
|
||||
# (Kopfkommentar oben), ein instabiler Schlüssel würde den §7.7-Regel-3-
|
||||
# Dedup-Vergleich in belegverarbeitung.py unbrauchbar machen.
|
||||
beleg_nr = suchen(
|
||||
r"(?:Beleg-?Nr\.?|Bon-?Nr\.?|Rechnungs-?Nr\.?|Receipt\s*No\.?|Trans(?:aktions)?-?Nr\.?)\D{0,5}?([\dA-Za-z/\-]+)"
|
||||
)
|
||||
receipt_no = beleg_nr.group(1) if beleg_nr else None
|
||||
schluessel_basis = "|".join(
|
||||
str(teil) for teil in (receipt_no, ts, station_address, fuel_total_eur, liters) if teil
|
||||
) or text[:80]
|
||||
receipt_key = "generisch_" + hashlib.sha1(schluessel_basis.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
return {
|
||||
"receipt_key": receipt_key,
|
||||
"receipt_no": receipt_no,
|
||||
"tse_beleg_nr": None,
|
||||
"ts": ts,
|
||||
"ts_payment": None,
|
||||
"ts_tse": None,
|
||||
"station_id": None,
|
||||
"station_name": station_name,
|
||||
"station_address": station_address,
|
||||
"article_no": None,
|
||||
"product_name": None,
|
||||
"fuel_type": None,
|
||||
"liters": liters,
|
||||
"list_price_per_l": list_price_per_l,
|
||||
"discount": discount,
|
||||
"discount_per_l": discount_per_l,
|
||||
"fuel_total_eur": fuel_total_eur,
|
||||
"receipt_total_eur": fuel_total_eur,
|
||||
"net_eur": None,
|
||||
"vat_eur": None,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
print("Aufruf: python3 shell_beleg_parser.py <pfad-zur-pdf>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
pdf_pfad = sys.argv[1]
|
||||
try:
|
||||
text = _text_aus_pdf(pdf_pfad)
|
||||
except Exception as exc:
|
||||
print(f"PDF konnte nicht gelesen werden ({type(exc).__name__}): {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
ergebnis = _parsen(text)
|
||||
except Exception:
|
||||
# Kein Shell-Beleg im bekannten Format - Versuch mit dem
|
||||
# stationsunabhängigen Fallback (_parsen_generisch() oben), bevor
|
||||
# endgültig aufgegeben wird.
|
||||
try:
|
||||
ergebnis = _parsen_generisch(text)
|
||||
except Exception as exc:
|
||||
print(f"Beleg konnte nicht geparst werden ({type(exc).__name__}): {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(json.dumps(ergebnis, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Backup-Funktion für Fahrzeugprofil, Fahrten und Tankvorgänge.
|
||||
|
||||
Manuell über "Backup jetzt erstellen" in den Einstellungen oder automatisch
|
||||
je nach einstellungen.backup_intervall im Fahrzeugprofil (aus/täglich/
|
||||
wöchentlich/monatlich) - immer um 04:00 Uhr, wenn eingestellt. Ein einzelner
|
||||
täglicher Trigger um 04:00 genügt für alle drei Intervalle: wöchentlich läuft
|
||||
nur montags, monatlich nur am Monatsersten, täglich jeden Tag - so reicht eine
|
||||
einzige Trigger-Registrierung statt drei.
|
||||
|
||||
Jedes Backup ist ein eigener Zeitstempel-Unterordner mit Kopien der drei
|
||||
Bestände (siehe profil.py), keine ZIP-Datei - das würde den vorhandenen
|
||||
task.executor(io.open, ...)-Weg (siehe dessen Kopfkommentar in profil.py)
|
||||
unnötig verkomplizieren. shutil.copy() ist wie io.open eine echte externe
|
||||
Funktion und läuft deshalb über denselben task.executor-Weg.
|
||||
|
||||
"Backup exportieren"/"importieren" in der Oberfläche laufen dagegen rein
|
||||
über den Browser (aktuell geladenes Profil + Fahrten + Tankvorgänge als eine
|
||||
JSON-Datei, siehe audi-dashboard-app.js) - audi_dashboard_backup_wiederherstellen()
|
||||
hier ist nur das Gegenstück zum Import, das die drei Bestände wieder
|
||||
komplett überschreibt.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
|
||||
import entitaeten
|
||||
import frontend_veroeffentlichung
|
||||
import profil
|
||||
|
||||
BACKUP_ORDNER = f"{profil.BASIS}/backups"
|
||||
# entitaeten.json gehört dazu: Ohne sie wäre nach einer Wiederherstellung die
|
||||
# komplette Sensor-Zuordnung aus dem Setup-Menü verloren, und weil sie sonst
|
||||
# nirgends abgelegt ist, gäbe es nichts zurückzuholen.
|
||||
_DATEIEN = [
|
||||
"fahrzeugprofil.json",
|
||||
"fahrten.jsonl",
|
||||
"tankvorgaenge.jsonl",
|
||||
"entitaeten.json",
|
||||
]
|
||||
|
||||
|
||||
def _backup_jetzt():
|
||||
ziel = f"{BACKUP_ORDNER}/{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
os.makedirs(ziel, exist_ok=True)
|
||||
for name in _DATEIEN:
|
||||
quelle = f"{profil.BASIS}/{name}"
|
||||
if os.path.exists(quelle):
|
||||
task.executor(shutil.copy, quelle, f"{ziel}/{name}")
|
||||
return ziel
|
||||
|
||||
|
||||
def _letztes_backup_vermerken():
|
||||
p = profil.profil_lesen()
|
||||
if p is None:
|
||||
return
|
||||
p.setdefault("einstellungen", {})["letztes_backup"] = datetime.datetime.now().isoformat()
|
||||
profil.profil_schreiben(p)
|
||||
frontend_veroeffentlichung.profil_veroeffentlichen()
|
||||
|
||||
|
||||
@service
|
||||
def audi_dashboard_backup_jetzt():
|
||||
"""Erstellt sofort ein Backup. Aufruf als pyscript.audi_dashboard_backup_jetzt."""
|
||||
ziel = _backup_jetzt()
|
||||
_letztes_backup_vermerken()
|
||||
log.info(f"audi_dashboard: Backup erstellt unter {ziel}")
|
||||
|
||||
|
||||
@time_trigger("cron(0 4 * * *)")
|
||||
def _automatisches_backup():
|
||||
p = profil.profil_lesen()
|
||||
if p is None:
|
||||
return
|
||||
intervall = p.get("einstellungen", {}).get("backup_intervall", "aus")
|
||||
heute = datetime.date.today()
|
||||
faellig = (
|
||||
intervall == "taeglich"
|
||||
or (intervall == "woechentlich" and heute.weekday() == 0)
|
||||
or (intervall == "monatlich" and heute.day == 1)
|
||||
)
|
||||
if not faellig:
|
||||
return
|
||||
ziel = _backup_jetzt()
|
||||
_letztes_backup_vermerken()
|
||||
log.info(f"audi_dashboard: automatisches Backup ({intervall}) erstellt unter {ziel}")
|
||||
|
||||
|
||||
@service
|
||||
def audi_dashboard_backup_wiederherstellen(profil_json=None, fahrten_json=None, tankvorgaenge_json=None, entitaeten_json=None):
|
||||
"""Stellt ein über "Backup exportieren" heruntergeladenes Backup wieder
|
||||
her - überschreibt Profil, Fahrten, Tankvorgänge und die Sensor-Zuordnung
|
||||
komplett. Aufruf als pyscript.audi_dashboard_backup_wiederherstellen.
|
||||
|
||||
entitaeten_json ist optional: ältere Sicherungen, die vor dem Setup-Menü
|
||||
entstanden sind, enthalten den Abschnitt nicht - dann bleibt die aktuelle
|
||||
Zuordnung unangetastet, statt sie zu leeren."""
|
||||
if profil_json:
|
||||
p = json.loads(profil_json) if isinstance(profil_json, str) else profil_json
|
||||
profil.profil_schreiben(p)
|
||||
if fahrten_json is not None:
|
||||
fahrten = json.loads(fahrten_json) if isinstance(fahrten_json, str) else fahrten_json
|
||||
profil.fahrten_schreiben(fahrten)
|
||||
if tankvorgaenge_json is not None:
|
||||
tankvorgaenge = json.loads(tankvorgaenge_json) if isinstance(tankvorgaenge_json, str) else tankvorgaenge_json
|
||||
profil.tankvorgaenge_schreiben(tankvorgaenge)
|
||||
if entitaeten_json:
|
||||
zuordnung = json.loads(entitaeten_json) if isinstance(entitaeten_json, str) else entitaeten_json
|
||||
entitaeten.overrides_schreiben(zuordnung)
|
||||
entitaeten.overrides_anwenden()
|
||||
frontend_veroeffentlichung.alles_veroeffentlichen()
|
||||
log.info("audi_dashboard: Backup wiederhergestellt")
|
||||
@@ -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)")
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Belegverarbeitung für Shell-Tankbelege (§7.7, §10 Punkt 7).
|
||||
|
||||
Beleg-Upload direkt in der App statt Postfachanbindung - kein IMAP-Zugriff.
|
||||
|
||||
shell_beleg_parser.py, im Lastenheft (§2) als mitgeliefert genannt, lag
|
||||
beim Bau nicht im Projektordner vor (siehe Ersteinschätzung) und wurde
|
||||
nachträglich unter audi_dashboard/shell_beleg_parser.py gegen einen echten
|
||||
Shell-eReceipt neu geschrieben (siehe dessen Kopfkommentar). Aufruf über
|
||||
eine Kommandozeilen-Schnittstelle: `python3 shell_beleg_parser.py
|
||||
<pfad-zur-pdf>`, Ausgabe als JSON auf stdout mit den Feldern aus §6.4
|
||||
(receipt_key, ts, station_name, liters, fuel_total_eur, discount, ...).
|
||||
Muss unter /config/audi_dashboard/shell_beleg_parser.py liegen (siehe
|
||||
INSTALL.md) - eigener Prozess mit dem System-python3 des Containers, nicht
|
||||
die pyscript-Sandbox, deshalb dort einmalig `pip install pypdf` nötig.
|
||||
|
||||
ABWEICHUNG VOM URSPRÜNGLICHEN LASTENHEFT (Frontend-Anpassung, nach dem
|
||||
ursprünglichen Bau nachträglich verlangt):
|
||||
- Volltankung (§7.4) ist entfernt - weder Berechnung noch Anzeige. Damit
|
||||
entfallen auch level_before_pct/level_after_pct, die nur dafür gebraucht
|
||||
wurden.
|
||||
- fuel_total_eur ist immer der reale, bereits rabattierte Betrag.
|
||||
Die SmartDeal-Ersparnis steht separat in discount. price_per_l wird
|
||||
grundsätzlich aus fuel_total_eur ÷ liters berechnet, nie aus einem vom
|
||||
Beleg übernommenen Wert übernommen - siehe Kopfkommentar in
|
||||
audi-dashboard-app.js (tankFelder()) für dieselbe Regel im Frontend.
|
||||
|
||||
Entity-IDs stehen zentral in pyscript/modules/einstellungen.py.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import einstellungen
|
||||
import frontend_veroeffentlichung
|
||||
import profil
|
||||
|
||||
PARSER_SKRIPT = "/config/audi_dashboard/shell_beleg_parser.py"
|
||||
|
||||
|
||||
# --------------------------------------------------------------- Hilfsfunktionen
|
||||
|
||||
def _pdf_speichern(inhalt_bytes, dateiname):
|
||||
# io.open über task.executor, kein with, kein bloßes open() - siehe
|
||||
# Kopfkommentar in profil.py für alle drei Gründe.
|
||||
os.makedirs(profil.BELEGE_ORDNER, exist_ok=True)
|
||||
pfad = f"{profil.BELEGE_ORDNER}/{dateiname}"
|
||||
f = task.executor(io.open, pfad, "wb")
|
||||
f.write(inhalt_bytes)
|
||||
f.close()
|
||||
return pfad
|
||||
|
||||
|
||||
def _parser_aufrufen(pdf_pfad):
|
||||
ergebnis = subprocess.run(
|
||||
["python3", PARSER_SKRIPT, pdf_pfad],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if ergebnis.returncode != 0:
|
||||
raise RuntimeError(f"shell_beleg_parser.py fehlgeschlagen: {ergebnis.stderr}")
|
||||
return json.loads(ergebnis.stdout)
|
||||
|
||||
|
||||
def _preis_je_liter(fuel_total_eur, liters):
|
||||
if not liters:
|
||||
return None
|
||||
return round(fuel_total_eur / liters, 3)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- Service
|
||||
|
||||
@service
|
||||
def audi_dashboard_beleg_hochladen(pdf_base64=None, dateiname=None, tank_id=None):
|
||||
"""Nimmt einen per Base64 codierten PDF-Beleg entgegen (Upload aus der
|
||||
Oberfläche über hass.callService).
|
||||
|
||||
Mit tank_id: trägt die Belegdaten direkt in einen bereits bestehenden
|
||||
Tankvorgang nach - egal ob der ursprünglich manuell oder automatisch
|
||||
angelegt wurde. Der Kilometerstand/die gefahrene Distanz dieses
|
||||
Tankvorgangs bleiben dabei unangetastet (die kommen weiterhin vom
|
||||
Fahrzeug, nie vom Beleg, §7.7 Regel 2), nur die Beleg-Felder (Liter,
|
||||
Kosten, Ersparnis, Station, Zeitstempel, ...) werden ergänzt
|
||||
beziehungsweise überschrieben. Passt das Belegdatum nicht zum bereits
|
||||
erfassten Tankvorgang (unterschiedlicher Kalendertag), wird nichts
|
||||
übernommen - stattdessen ein Fehler veröffentlicht, damit in der
|
||||
Oberfläche nicht versehentlich ein falscher Beleg einem bestehenden
|
||||
Eintrag zugeordnet wird.
|
||||
|
||||
Ohne tank_id: legt KEINEN eigenständigen Tankvorgang mehr an (frühere
|
||||
Abweichung vom Lastenheft, auf Wunsch entfernt), sondern parst den Beleg
|
||||
nur und veröffentlicht das Ergebnis - die Oberfläche übernimmt die Werte
|
||||
in den gerade offenen Entwurf (neuer oder bearbeiteter Tankvorgang) und
|
||||
speichert erst auf einen expliziten Klick auf "Speichern".
|
||||
|
||||
Aufruf als pyscript.audi_dashboard_beleg_hochladen."""
|
||||
if not pdf_base64:
|
||||
log.warning("audi_dashboard: kein pdf_base64 übergeben")
|
||||
return
|
||||
|
||||
# task.executor entfällt hier bewusst: _pdf_speichern/_parser_aufrufen sind
|
||||
# in dieser pyscript-Datei definiert, task.executor akzeptiert aber nur
|
||||
# echte externe Python-Funktionen (siehe Kopfkommentar in profil.py).
|
||||
#
|
||||
# Alles bis zum geparsten Ergebnis in einem try: eine unbehandelte
|
||||
# Exception hier (kaputtes Base64, volle Festplatte, ...) wäre für die
|
||||
# Oberfläche unsichtbar geblieben - serviceRufen() im Frontend loggt einen
|
||||
# rejizierten Service-Aufruf nur in die Browser-Konsole, zeigt aber nichts
|
||||
# an (siehe audi-dashboard-app.js). Bisher war nur der Parser-Aufruf
|
||||
# selbst so abgesichert; b64decode/_pdf_speichern fehlten.
|
||||
try:
|
||||
inhalt_bytes = base64.b64decode(pdf_base64)
|
||||
dateiname = dateiname or f"beleg_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
||||
pdf_pfad = _pdf_speichern(inhalt_bytes, dateiname)
|
||||
geparst = _parser_aufrufen(pdf_pfad)
|
||||
except Exception as exc:
|
||||
log.error(f"audi_dashboard: Beleg konnte nicht verarbeitet werden: {exc}")
|
||||
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg konnte nicht gelesen werden", "tank_id": tank_id})
|
||||
return
|
||||
|
||||
receipt_key = geparst.get("receipt_key")
|
||||
if not receipt_key:
|
||||
log.error("audi_dashboard: Parser lieferte keinen receipt_key")
|
||||
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg konnte nicht gelesen werden", "tank_id": tank_id})
|
||||
return
|
||||
|
||||
# §7.7 Regel 3: derselbe Beleg (receipt_key, minutengenau) erzeugt
|
||||
# keinen zweiten Datensatz
|
||||
vorhandener_beleg = profil.tankvorgang_nach_receipt_key(receipt_key)
|
||||
if vorhandener_beleg is not None and vorhandener_beleg.get("tank_id") != tank_id:
|
||||
log.info(f"audi_dashboard: Beleg {receipt_key} bereits vorhanden, übersprungen")
|
||||
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg bereits erfasst", "tank_id": tank_id})
|
||||
return
|
||||
|
||||
liters = geparst.get("liters")
|
||||
fuel_total_eur = geparst.get("fuel_total_eur") # real, bereits rabattiert
|
||||
|
||||
beleg_felder = {
|
||||
"receipt_key": receipt_key,
|
||||
"receipt_no": geparst.get("receipt_no"),
|
||||
"tse_beleg_nr": geparst.get("tse_beleg_nr"),
|
||||
"ts": geparst.get("ts"),
|
||||
"ts_payment": geparst.get("ts_payment"),
|
||||
"ts_tse": geparst.get("ts_tse"),
|
||||
"station_id": geparst.get("station_id"),
|
||||
"station_name": geparst.get("station_name"),
|
||||
"station_address": geparst.get("station_address"),
|
||||
"article_no": geparst.get("article_no"),
|
||||
"product_name": geparst.get("product_name"),
|
||||
"fuel_type": geparst.get("fuel_type"),
|
||||
# Liter, Kosten und Ersparnis kommen vom Beleg (§7.7 Regel 2).
|
||||
# price_per_l wird nie vom Beleg übernommen, sondern immer berechnet.
|
||||
"liters": liters,
|
||||
"fuel_total_eur": fuel_total_eur,
|
||||
"price_per_l": _preis_je_liter(fuel_total_eur, liters),
|
||||
"discount": geparst.get("discount"), # SmartDeal-Ersparnis
|
||||
"discount_per_l": geparst.get("discount_per_l"),
|
||||
"list_price_per_l": geparst.get("list_price_per_l"),
|
||||
"receipt_total_eur": geparst.get("receipt_total_eur"),
|
||||
"net_eur": geparst.get("net_eur"),
|
||||
"vat_eur": geparst.get("vat_eur"),
|
||||
"receipt_file": pdf_pfad,
|
||||
}
|
||||
|
||||
if tank_id:
|
||||
vorhanden = profil.tankvorgang_nach_id(tank_id)
|
||||
if not vorhanden:
|
||||
log.warning(f"audi_dashboard: Tankvorgang {tank_id} für Beleg-Nachtrag nicht gefunden")
|
||||
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Tankvorgang nicht gefunden", "tank_id": tank_id})
|
||||
return
|
||||
vorhandenes_datum = (vorhanden.get("ts") or "")[:10]
|
||||
belegdatum = (geparst.get("ts") or "")[:10]
|
||||
if vorhandenes_datum and belegdatum and vorhandenes_datum != belegdatum:
|
||||
log.warning(f"audi_dashboard: Belegdatum {belegdatum} passt nicht zu Tankvorgang {tank_id} (Datum {vorhandenes_datum})")
|
||||
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Falsches Belegdatum", "tank_id": tank_id})
|
||||
return
|
||||
aenderungen = dict(beleg_felder)
|
||||
aenderungen["status"] = "vollständig"
|
||||
if profil.tankvorgang_aktualisieren(tank_id, aenderungen):
|
||||
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
|
||||
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"erfolg": True, "tank_id": tank_id})
|
||||
log.info(f"audi_dashboard: Beleg {receipt_key} in Tankvorgang {tank_id} nachgetragen")
|
||||
else:
|
||||
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Tankvorgang nicht gefunden", "tank_id": tank_id})
|
||||
return
|
||||
|
||||
# Kein tank_id: nur parsen und dem gerade offenen Entwurf in der
|
||||
# Oberfläche zum Ausfüllen bereitstellen - legt selbst KEINEN
|
||||
# Tankvorgang an (siehe Kopfkommentar oben).
|
||||
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({**beleg_felder, "tank_id": None})
|
||||
log.info(f"audi_dashboard: Beleg {receipt_key} geparst, wartet auf Speichern in der Oberfläche")
|
||||
|
||||
|
||||
@service
|
||||
def audi_dashboard_tankvorgang_manuell(ts=None, liter=None, kosten=None, km=None, ersparnis=None, station=None, distanz=None, kraftstoff=None, receipt_key=None, receipt_file=None):
|
||||
"""Erfassung eines Tankvorgangs aus dem Formular "Neuer Tankvorgang" -
|
||||
Fallback aus §7.7 / §10 Punkt 7, unter „Tanken" vorgesehen (§5.5).
|
||||
Dieselben Felder wie beim Bearbeiten eines bestehenden Vorgangs (siehe
|
||||
audi_dashboard_tankvorgang_aktualisieren). receipt_key/receipt_file sind
|
||||
optional: kommen sie mit (weil zuvor ein Beleg über
|
||||
audi_dashboard_beleg_hochladen ohne tank_id in den Entwurf übernommen
|
||||
wurde), zählt der Tankvorgang als per Beleg erfasst, sonst als manuell.
|
||||
Aufruf als pyscript.audi_dashboard_tankvorgang_manuell."""
|
||||
if liter is None or kosten is None:
|
||||
log.warning("audi_dashboard: liter und kosten sind Pflichtfelder")
|
||||
return
|
||||
|
||||
odometer_km = km if km is not None else frontend_veroeffentlichung.zustand_oder_none(einstellungen.KM_SENSOR)
|
||||
tankvorgang = {
|
||||
"tank_id": profil.neue_id("f"),
|
||||
"receipt_key": receipt_key,
|
||||
"ts": ts or datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"liters": liter,
|
||||
"fuel_total_eur": kosten,
|
||||
"price_per_l": _preis_je_liter(kosten, liter),
|
||||
"discount": ersparnis,
|
||||
"station_name": station,
|
||||
"odometer_km": odometer_km,
|
||||
# Vorschlag aus der Differenz zum letzten Tankvorgang, aber wie
|
||||
# odometer_km selbst frei überschreibbar - deshalb nimmt ein manuell
|
||||
# übergebener Wert immer Vorrang vor der Berechnung.
|
||||
"distance_km": distanz if distanz is not None else profil.distanz_seit_letzter_tankung(odometer_km),
|
||||
"fuel_type": kraftstoff,
|
||||
"source": "beleg" if receipt_key else "manual",
|
||||
"status": "vollständig",
|
||||
"receipt_file": receipt_file,
|
||||
"edited_fields": [],
|
||||
}
|
||||
profil.tankvorgang_anhaengen(tankvorgang)
|
||||
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
|
||||
log.info(f"audi_dashboard: Tankvorgang {tankvorgang['tank_id']} angelegt")
|
||||
|
||||
|
||||
@service
|
||||
def audi_dashboard_tankvorgang_aktualisieren(tank_id=None, ts=None, liter=None, kosten=None, km=None, ersparnis=None, station=None, distanz=None, kraftstoff=None, receipt_key=None, receipt_file=None):
|
||||
"""Bearbeitet einen bestehenden Tankvorgang (Einzelbeleg-Seite, egal ob
|
||||
ursprünglich per Beleg oder manuell angelegt). receipt_key/receipt_file
|
||||
sind optional: kommen sie mit (ein im Bearbeiten-Formular hochgeladener
|
||||
Beleg wurde in den Entwurf übernommen), wird der Tankvorgang zusätzlich
|
||||
als per Beleg erfasst markiert. Aufruf als
|
||||
pyscript.audi_dashboard_tankvorgang_aktualisieren."""
|
||||
if not tank_id:
|
||||
log.warning("audi_dashboard: tankvorgang_aktualisieren ohne tank_id aufgerufen")
|
||||
return
|
||||
aenderungen = {
|
||||
"ts": ts, "liters": liter, "fuel_total_eur": kosten,
|
||||
"price_per_l": _preis_je_liter(kosten, liter),
|
||||
"odometer_km": km, "discount": ersparnis, "station_name": station,
|
||||
"distance_km": distanz, "fuel_type": kraftstoff,
|
||||
}
|
||||
if receipt_key:
|
||||
aenderungen["receipt_key"] = receipt_key
|
||||
aenderungen["receipt_file"] = receipt_file
|
||||
aenderungen["source"] = "beleg"
|
||||
if profil.tankvorgang_aktualisieren(tank_id, aenderungen):
|
||||
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
|
||||
log.info(f"audi_dashboard: Tankvorgang {tank_id} aktualisiert")
|
||||
else:
|
||||
log.warning(f"audi_dashboard: Tankvorgang {tank_id} nicht gefunden")
|
||||
|
||||
|
||||
@service
|
||||
def audi_dashboard_tankvorgang_loeschen(tank_id=None):
|
||||
"""Löscht einen Tankvorgang unwiderruflich (Löschfunktion in der
|
||||
Oberfläche). Aufruf als pyscript.audi_dashboard_tankvorgang_loeschen."""
|
||||
if not tank_id:
|
||||
log.warning("audi_dashboard: tankvorgang_loeschen ohne tank_id aufgerufen")
|
||||
return
|
||||
if profil.tankvorgang_loeschen(tank_id):
|
||||
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
|
||||
log.info(f"audi_dashboard: Tankvorgang {tank_id} gelöscht")
|
||||
else:
|
||||
log.warning(f"audi_dashboard: Tankvorgang {tank_id} nicht gefunden")
|
||||
@@ -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})")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Trigger-Registrierung für den zweistufigen Fahrtabschluss (§7.2).
|
||||
|
||||
Die eigentliche Logik liegt in pyscript/modules/fahrtabschluss_logik.py
|
||||
(importierbares Modul, auch von fahrterkennung.py genutzt). Diese Datei
|
||||
sorgt nur dafür, dass ein Screening auch dann läuft, wenn der Kilometerstand-
|
||||
Sensor sich ändert - unabhängig vom Fahrtende-Ereignis selbst, weil der Wert
|
||||
laut §10 Punkt 4 auch erst mit der nächsten Fahrt eintreffen kann.
|
||||
|
||||
Entity-IDs stehen zentral in pyscript/modules/einstellungen.py.
|
||||
"""
|
||||
|
||||
import einstellungen
|
||||
import fahrtabschluss_logik
|
||||
|
||||
|
||||
# Ohne konfigurierten Kilometerstand-Sensor keinen Trigger registrieren -
|
||||
# eine leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes
|
||||
# Verhalten, siehe gleiches Muster in fahrterkennung.py.
|
||||
if einstellungen.KM_SENSOR:
|
||||
@state_trigger(f"{einstellungen.KM_SENSOR}")
|
||||
def kilometerstand_geaendert(value=None, old_value=None):
|
||||
if value is None or old_value is None:
|
||||
return
|
||||
fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR)
|
||||
|
||||
|
||||
@service
|
||||
def audi_dashboard_screening_jetzt():
|
||||
"""Manueller Anstoß des Screenings, z. B. aus der Oberfläche heraus
|
||||
(pyscript.audi_dashboard_screening_jetzt)."""
|
||||
fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR)
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Fahrterkennung über den Zündungs-/ACC-Status des FMM003 (§7.1).
|
||||
|
||||
Start: die Zündungs-Entität (einstellungen.ZUENDUNG_SENSOR) wechselt auf
|
||||
"on". Ende: sie wechselt auf "off" und kommt binnen der Pausenzeit nicht
|
||||
zurück.
|
||||
|
||||
2026-08-12: ersetzt die frühere WLAN-basierte Erkennung (Vergleich des vom
|
||||
iPhone gemeldeten verbundenen WLANs gegen den im Fahrzeugprofil hinterlegten
|
||||
Namen) - die Zündung ist ein direkteres, von jeder Companion-App-Eigenheit
|
||||
unabhängiges Signal.
|
||||
|
||||
Die Pausenregel wird über task.unique() umgesetzt: jede neue Änderung der
|
||||
Zündungs-Entität killt eine noch laufende, wartende Bestätigung des
|
||||
vorherigen Ereignisses. Kommt die Zündung während der Wartezeit zurück, wird
|
||||
dadurch die wartende Ende-Bestätigung automatisch abgebrochen, ohne dass die
|
||||
Fahrt beendet wird - das entspricht der Pausenregel aus §7.1.
|
||||
|
||||
Die Entity-IDs stehen zentral in pyscript/modules/einstellungen.py - dort vor
|
||||
der Installation einmalig anpassen (siehe INSTALL.md), oder über das
|
||||
Setup-Menü in der Oberfläche zuordnen (siehe entitaeten.py; Änderungen dort
|
||||
wirken für DIESEN Trigger allerdings erst nach einem Neustart, da
|
||||
@state_trigger die Entity-ID einmalig beim Modul-Laden einliest).
|
||||
|
||||
EINSCHRÄNKUNG: der Zustand einer laufenden Fahrt (_fahrt_start_ts) lebt nur
|
||||
im Arbeitsspeicher dieses Skripts. Ein HA-Neustart mitten in einer Fahrt oder
|
||||
während der Wartezeit verliert diesen Zwischenstand - die Fahrt würde dann
|
||||
nicht aufgezeichnet. Das ist ein bekannter Punkt für eine spätere Härtung
|
||||
(z. B. Zwischenstand in einer input_datetime-Helper-Entität sichern), hier
|
||||
bewusst nicht mitgebaut, um die Kernlogik nicht zu verkomplizieren, bevor sie
|
||||
sich im echten Betrieb bewährt hat.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
import einstellungen
|
||||
import fahrtabschluss_logik
|
||||
import frontend_veroeffentlichung
|
||||
import profil
|
||||
|
||||
_fahrt_start_ts = None
|
||||
|
||||
|
||||
def _pausenzeit_sekunden():
|
||||
p = profil.profil_lesen()
|
||||
if p is None:
|
||||
return 15 * 60
|
||||
return p.get("einstellungen", {}).get("fahrten_pausenzeit_min", 15) * 60
|
||||
|
||||
|
||||
@time_trigger("startup")
|
||||
def beim_start():
|
||||
profil.ordner_sicherstellen()
|
||||
|
||||
|
||||
# Ohne konfigurierte Zündungs-Entität keinen Trigger registrieren - eine
|
||||
# leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes Verhalten
|
||||
# (siehe zustand_oder_none()-Kommentar in frontend_veroeffentlichung.py zur
|
||||
# Grundhaltung "kein geratener Wert"), hier stattdessen ganz weggelassen.
|
||||
if einstellungen.ZUENDUNG_SENSOR:
|
||||
@state_trigger(f"{einstellungen.ZUENDUNG_SENSOR}")
|
||||
def zuendung_geaendert(value=None, old_value=None):
|
||||
# Killt eine noch laufende Ende-Bestätigung aus einer vorherigen
|
||||
# Änderung - das ist der Mechanismus hinter der Pausenregel.
|
||||
task.unique("audi_dashboard_fahrt_ende_pruefung")
|
||||
|
||||
global _fahrt_start_ts
|
||||
an_jetzt = (value == "on")
|
||||
an_vorher = (old_value == "on")
|
||||
|
||||
if an_jetzt and not an_vorher:
|
||||
if _fahrt_start_ts is None:
|
||||
_fahrt_start_ts = datetime.datetime.now(datetime.timezone.utc)
|
||||
log.info(f"audi_dashboard: Fahrt gestartet um {_fahrt_start_ts.isoformat()}")
|
||||
return
|
||||
|
||||
if an_vorher and not an_jetzt and _fahrt_start_ts is not None:
|
||||
start_ts = _fahrt_start_ts
|
||||
abbruch_ts = datetime.datetime.now(datetime.timezone.utc)
|
||||
wartezeit_s = _pausenzeit_sekunden()
|
||||
|
||||
# task.unique() oben markiert diese Ausführung als die aktuell
|
||||
# gültige. Kommt die Zündung vor Ablauf der Wartezeit zurück,
|
||||
# ruft der nächste Trigger erneut task.unique() auf und killt
|
||||
# genau diesen task.sleep() - der Code danach läuft dann nie.
|
||||
task.sleep(wartezeit_s)
|
||||
|
||||
_fahrt_beenden(start_ts, abbruch_ts)
|
||||
|
||||
|
||||
def _fahrt_beenden(start_ts, ende_ts):
|
||||
global _fahrt_start_ts
|
||||
dauer_s = int((ende_ts - start_ts).total_seconds())
|
||||
|
||||
fahrt = {
|
||||
"trip_id": profil.neue_id("t"),
|
||||
"ts_start": start_ts.isoformat(),
|
||||
"ts_end": ende_ts.isoformat(),
|
||||
"duration_s": dauer_s,
|
||||
"distance_km": None,
|
||||
"km_quelle": None,
|
||||
"odo_start": None,
|
||||
"odo_end": None,
|
||||
"avg_speed_kmh": None,
|
||||
"start_lat": None,
|
||||
"start_lon": None,
|
||||
"end_lat": None,
|
||||
"end_lon": None,
|
||||
"start_address": None,
|
||||
"end_address": None,
|
||||
"art": "privat",
|
||||
"route": None,
|
||||
"pausen": [],
|
||||
"source": "ha",
|
||||
"status": "offen",
|
||||
"edited_fields": [],
|
||||
}
|
||||
profil.fahrt_anhaengen(fahrt)
|
||||
frontend_veroeffentlichung.fahrten_veroeffentlichen()
|
||||
log.info(f"audi_dashboard: Fahrt {fahrt['trip_id']} beendet, Status 'offen', Screening angestoßen")
|
||||
|
||||
_fahrt_start_ts = None
|
||||
fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR)
|
||||
|
||||
|
||||
@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 Zündungs-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")
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Verbindung zwischen Frontend und Backend.
|
||||
|
||||
Lesen: das Frontend liest hass.states["pyscript.audi_dashboard_profil"] usw.
|
||||
direkt - kein Service-Aufruf mit Rückgabewert. Die Frontend-Dokumentation von
|
||||
Home Assistant zu response-fähigen Services beschreibt das Verhalten aus
|
||||
Sicht des Browsers nicht abschließend (Community-Diskussionen deuten auf
|
||||
Einschränkungen hin); Zustände lesen ist dagegen ein seit Jahren stabiler,
|
||||
einfacher Weg. Veröffentlicht wird periodisch UND sofort nach jeder
|
||||
Schreibaktion (siehe frontend_veroeffentlichung.py, dort auch der Hinweis zur
|
||||
Größengrenze von Zustands-Attributen).
|
||||
|
||||
Schreiben: einzig audi_dashboard_profil_schreiben() als generischer Weg für
|
||||
das gesamte Profil - das Frontend hält Profil, Fahrzeugdaten und
|
||||
Einstellungen ohnehin als einen zusammenhängenden Stand im Speicher (wie
|
||||
schon im Prototyp) und schickt ihn nach jeder Änderung komplett zurück,
|
||||
statt für jedes einzelne Feld einen eigenen Service zu brauchen.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import einstellungen
|
||||
import entitaeten
|
||||
import frontend_veroeffentlichung
|
||||
import profil
|
||||
|
||||
|
||||
@time_trigger("startup")
|
||||
def beim_start():
|
||||
profil.ordner_sicherstellen()
|
||||
# Vor der ersten Veröffentlichung anwenden, damit sie die im Setup-Menü
|
||||
# gespeicherten Zuordnungen (data/entitaeten.json) schon zeigt, siehe
|
||||
# entitaeten.py-Kopfkommentar.
|
||||
entitaeten.overrides_anwenden()
|
||||
frontend_veroeffentlichung.alles_veroeffentlichen()
|
||||
|
||||
|
||||
@time_trigger("period(now, 20 seconds)")
|
||||
def periodisch_aktualisieren():
|
||||
frontend_veroeffentlichung.fahrzeugstatus_veroeffentlichen()
|
||||
|
||||
|
||||
@time_trigger("period(now, 60 seconds)")
|
||||
def periodisch_daten_aktualisieren():
|
||||
# seltener als der Fahrzeugstatus - Profil/Fahrten/Tankvorgänge ändern
|
||||
# sich normalerweise nur durch Aktionen, die ohnehin sofort selbst
|
||||
# veröffentlichen (siehe unten); das hier ist nur das Sicherheitsnetz
|
||||
# für Änderungen direkt im Dateisystem (§6.2, dritter Bearbeitungsweg).
|
||||
frontend_veroeffentlichung.profil_veroeffentlichen()
|
||||
frontend_veroeffentlichung.fahrten_veroeffentlichen()
|
||||
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
|
||||
|
||||
|
||||
@service
|
||||
def audi_dashboard_jetzt_aktualisieren():
|
||||
"""Gegenstück zum "Ziehen zum Aktualisieren" auf der Übersicht: stößt den
|
||||
von der Integration bereitgestellten Refresh-Knopf an (siehe
|
||||
einstellungen.REFRESH_BUTTON) statt auf den nächsten periodischen Takt zu
|
||||
warten, und veröffentlicht den Status danach sofort neu. Ohne
|
||||
eingetragenen Knopf (leerer Platzhalter) nur die direkte Neu-
|
||||
veröffentlichung - besser als gar nichts, auch wenn dann kein echter neuer
|
||||
Abruf beim Fahrzeug passiert. Aufruf als
|
||||
pyscript.audi_dashboard_jetzt_aktualisieren."""
|
||||
if einstellungen.REFRESH_BUTTON:
|
||||
button.press(entity_id=einstellungen.REFRESH_BUTTON)
|
||||
task.sleep(3)
|
||||
frontend_veroeffentlichung.fahrzeugstatus_veroeffentlichen()
|
||||
log.info("audi_dashboard: manuelle Aktualisierung (Ziehen zum Aktualisieren) ausgelöst")
|
||||
|
||||
|
||||
@service
|
||||
def audi_dashboard_profil_schreiben(profil_json=None):
|
||||
"""Überschreibt das komplette Fahrzeugprofil. Aufruf als
|
||||
pyscript.audi_dashboard_profil_schreiben."""
|
||||
if not profil_json:
|
||||
log.warning("audi_dashboard: profil_schreiben ohne profil_json aufgerufen")
|
||||
return
|
||||
p = json.loads(profil_json) if isinstance(profil_json, str) else profil_json
|
||||
profil.profil_schreiben(p)
|
||||
frontend_veroeffentlichung.profil_veroeffentlichen()
|
||||
log.info("audi_dashboard: Profil aktualisiert")
|
||||
|
||||
|
||||
@service
|
||||
def audi_dashboard_entitaeten_schreiben(zuordnung_json=None):
|
||||
"""Speichert die im Setup-Menü (Einstellungen -> Fahrzeug einrichten ->
|
||||
Setup) gewählte Sensor-zu-Entität-Zuordnung, wendet sie sofort an und
|
||||
veröffentlicht Katalog+Status neu, damit Übersicht/Mein Audi ohne
|
||||
Neustart die neuen Werte zeigen (Ausnahme: die drei trigger-gebundenen
|
||||
Felder, siehe entitaeten.py). Aufruf als
|
||||
pyscript.audi_dashboard_entitaeten_schreiben."""
|
||||
if not zuordnung_json:
|
||||
log.warning("audi_dashboard: entitaeten_schreiben ohne zuordnung_json aufgerufen")
|
||||
return
|
||||
mapping = json.loads(zuordnung_json) if isinstance(zuordnung_json, str) else zuordnung_json
|
||||
entitaeten.overrides_schreiben(mapping)
|
||||
entitaeten.overrides_anwenden()
|
||||
frontend_veroeffentlichung.entitaeten_veroeffentlichen()
|
||||
frontend_veroeffentlichung.fahrzeugstatus_veroeffentlichen()
|
||||
log.info("audi_dashboard: Entitäten-Zuordnung aktualisiert")
|
||||
|
||||
|
||||
@service
|
||||
def audi_dashboard_neustart():
|
||||
"""Startet Home Assistant neu. Einziger Zweck: eine Änderung an einem der
|
||||
drei trigger-gebundenen Setup-Felder (ZUENDUNG_SENSOR/KM_SENSOR/
|
||||
TANK_SENSOR, siehe entitaeten.py) wird erst nach einem Neustart wirksam,
|
||||
weil @state_trigger die Entity-ID beim Modul-Laden einmalig einliest.
|
||||
Wird vom Setup-Popup als expliziter, vom Nutzer bestätigter Knopf
|
||||
angeboten - kein automatischer Selbst-Reload direkt nach dem Speichern.
|
||||
Aufruf als pyscript.audi_dashboard_neustart."""
|
||||
log.info("audi_dashboard: Neustart über das Setup-Menü ausgelöst")
|
||||
homeassistant.restart()
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Technische Konfiguration — die einzige Stelle, die vor der Installation
|
||||
angepasst werden muss (siehe INSTALL.md Schritt 4).
|
||||
|
||||
Alles andere (WLAN-Name des Fahrzeugs, Pausenzeit, Reifendaten, ...) ist Teil
|
||||
des Fahrzeugprofils (data/fahrzeugprofil.json) und über die Oberfläche
|
||||
änderbar. Diese Entity-IDs sind es bewusst nicht: @state_trigger und die
|
||||
Fahrzeugstatus-Abfrage brauchen sie als festen Wert, bevor überhaupt ein
|
||||
Profil gelesen werden kann.
|
||||
|
||||
In Home Assistant unter Entwicklerwerkzeuge -> Zustände nachschlagen. Werte
|
||||
lassen sich außerdem über das Setup-Menü in der Oberfläche zuordnen (siehe
|
||||
entitaeten.py) - Änderungen von dort werden zur Laufzeit auf diese Variablen
|
||||
angewendet (überschreiben also die hier hinterlegten Standardwerte), ohne
|
||||
diese Datei anzufassen.
|
||||
|
||||
2026-08-12: Die HACS-Integration TommiG1/HA_VAG-EU-Data-Act (bisherige
|
||||
Quelle für Kilometerstand, Tankfüllstand, Türen/Fenster/Schlösser,
|
||||
Ölwechsel-/Inspektionsdaten) wird nicht mehr verwendet - die zugehörigen
|
||||
Entity-IDs sind deshalb unten bewusst leer. Neue Datenquelle ist der
|
||||
Teltonika FMM003 (GPS-Tracker mit CAN-Anbindung, siehe AGENTS.md Abschnitt
|
||||
B); er liefert Standort, Zündungsstatus und Batteriespannung, aber keinen
|
||||
Tankfüllstand, keine Tür-/Fenster-/Schlossdaten und keine Ölwechsel-/
|
||||
Inspektionstermine - die entsprechenden Kacheln zeigen deshalb bis auf
|
||||
Weiteres "unbekannt" statt eines falschen Werts (siehe zustand_oder_none()
|
||||
in frontend_veroeffentlichung.py). Bleibt ein Wert leer ("") oder passt eine
|
||||
Entity-ID nicht zur tatsächlichen Integration, liefert zustand_oder_none()
|
||||
für das jeweilige Feld None statt abzustürzen.
|
||||
"""
|
||||
|
||||
# Fahrterkennung (§7.1): Start/Ende einer Fahrt wird über den Zündungs-/ACC-
|
||||
# Status des FMM003 erkannt (on = Fahrt läuft), nicht mehr über die WLAN-
|
||||
# Verbindung des iPhones zum Fahrzeug (siehe fahrterkennung.py).
|
||||
ZUENDUNG_SENSOR = "binary_sensor.testzone_fmm003_engine_ignition_or_acc_status"
|
||||
|
||||
# Kilometerstand - bisher aus der TommiG1-Integration, aktuell keine Quelle
|
||||
# vorhanden. Der FMM003 liefert unter sensor.testzone_fmm003_total_calculated_mileage
|
||||
# einen selbst berechneten Wert, der aber auf einer anderen Zählbasis beruht
|
||||
# als der echte Fahrzeug-Kilometerstand (GPS-Streckenberechnung statt
|
||||
# Tacho) - bewusst NICHT automatisch übernommen, um Reifenzähler,
|
||||
# Ölwechsel-Prognose und Fahrtabschluss-Screening nicht mit einem
|
||||
# inkonsistenten Basiswert zu verfälschen. Bei Bedarf über das Setup-Menü
|
||||
# gezielt zuordnen.
|
||||
KM_SENSOR = ""
|
||||
|
||||
# Tankfüllstand (Prozent) - keine Quelle mehr vorhanden (der FMM003 ist kein
|
||||
# Tankgeber).
|
||||
TANK_SENSOR = ""
|
||||
|
||||
# Reichweite (§5.1 Übersicht) - keine Quelle mehr vorhanden.
|
||||
RANGE_SENSOR = ""
|
||||
|
||||
# 12V-Batteriespannung (Mein Audi -> Zustand). Vom FMM003 geliefert -
|
||||
# external_power_voltage ist die vom Gerät gemessene Bordnetzspannung des
|
||||
# Fahrzeugs, NICHT battery_voltage (das ist die interne Pufferbatterie des
|
||||
# Trackers selbst und hat mit der Fahrzeugbatterie nichts zu tun).
|
||||
BATTERIE_SENSOR = "sensor.testzone_fmm003_external_power_voltage"
|
||||
|
||||
# Knopf für eine sofortige Neuabfrage beim Fahrzeug - kam aus der
|
||||
# TommiG1-Integration, keine Entsprechung beim FMM003 vorhanden.
|
||||
REFRESH_BUTTON = ""
|
||||
|
||||
# Türen (§4.1) - keine Quelle mehr vorhanden.
|
||||
TUER_SENSOREN = []
|
||||
|
||||
# Fenster (§4.1) - keine Quelle mehr vorhanden.
|
||||
FENSTER_SENSOREN = []
|
||||
|
||||
# Heckklappe und Motorhaube - keine Quelle mehr vorhanden.
|
||||
HECKKLAPPE_SENSOR = ""
|
||||
HECKKLAPPENSCHLOSS_SENSOR = ""
|
||||
HAUBE_SENSOR = ""
|
||||
|
||||
# Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die App-eigene,
|
||||
# aus dem Servicebuch berechnete Prognose) - keine Quelle mehr vorhanden.
|
||||
NAECHSTER_OELWECHSEL_SENSOR = ""
|
||||
OELWECHSEL_STRECKE_SENSOR = ""
|
||||
NAECHSTE_INSPEKTION_SENSOR = ""
|
||||
INSPEKTION_STRECKE_SENSOR = ""
|
||||
|
||||
# Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel). Breiten-/
|
||||
# Längengrad als zwei eigene sensor-Entities (flespi liefert Koordinaten so,
|
||||
# nicht als Attribute einer device_tracker-Entity - siehe _standort() in
|
||||
# frontend_veroeffentlichung.py).
|
||||
STANDORT_LAT_SENSOR = ""
|
||||
STANDORT_LON_SENSOR = ""
|
||||
|
||||
# Update-Funktion (Einstellungen -> "Update suchen", siehe
|
||||
# updateverwaltung.py): Git-Repository, in das dieses Projekt gepflegt wird -
|
||||
# z. B. ein privates GitHub-Repo, genau wie für die HACS-Integration bereits
|
||||
# verwendet. Leer lassen, solange es keins gibt - "Update suchen" meldet dann
|
||||
# nur "keine Update-Quelle eingerichtet", ohne etwas zu tun.
|
||||
UPDATE_REPO_URL = ""
|
||||
UPDATE_BRANCH = "main"
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Sensor-zu-Entität-Zuordnung fürs Setup-Menü (Einstellungen -> Fahrzeug
|
||||
einrichten -> Setup).
|
||||
|
||||
Ersetzt NICHT das manuelle Eintragen in einstellungen.py (dessen Kommentare
|
||||
sind die eigentliche Dokumentation der Integration und bleiben unangetastet),
|
||||
sondern legt eine zusätzliche, zur Laufzeit angewendete Override-Ebene
|
||||
darüber: data/entitaeten.json enthält nur die vom Nutzer über das Setup-Menü
|
||||
geänderten Zuordnungen, keyed nach den einstellungen.py-Variablennamen.
|
||||
|
||||
Warum das funktioniert, ohne jede Verbraucher-Datei anzufassen: Alle
|
||||
bestehenden Leser (frontend_veroeffentlichung.py, fahrterkennung.py, ...)
|
||||
machen `import einstellungen` und lesen dann `einstellungen.KM_SENSOR` -
|
||||
ein lebendiger Attributzugriff auf das Modul-Objekt, kein `from einstellungen
|
||||
import KM_SENSOR` (das würde den Wert beim Import einfrieren). overrides_
|
||||
anwenden() unten nutzt genau das: setattr(einstellungen, key, wert) ändert
|
||||
das Attribut auf dem bereits importierten Modul-Objekt, jeder spätere
|
||||
Zugriff `einstellungen.KM_SENSOR` liefert sofort den neuen Wert.
|
||||
|
||||
Eine Ausnahme (siehe TRIGGER_GEBUNDEN unten): mehrere @state_trigger(f"...")-
|
||||
Dekoratoren backen ZUENDUNG_SENSOR/KM_SENSOR/TANK_SENSOR einmalig beim
|
||||
Modul-Laden in die Trigger-Registrierung ein. Eine Änderung dieser drei
|
||||
Felder wirkt für die Fahrterkennung selbst erst nach einem Neustart von
|
||||
Home Assistant (bzw. pyscript.reload) - das Setup-Menü weist deshalb bei
|
||||
diesen drei Feldern ausdrücklich darauf hin, statt automatisch und riskant
|
||||
einen Selbst-Reload aus einer laufenden pyscript-Funktion heraus zu
|
||||
versuchen.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
||||
import einstellungen
|
||||
|
||||
BASIS = "/config/audi_dashboard"
|
||||
OVERRIDES_PFAD = f"{BASIS}/entitaeten.json"
|
||||
|
||||
# Vier gleich aufgebaute Positionen, wie schon in frontend_veroeffentlichung.py
|
||||
# (dort POSITIONEN genannt) - hier dupliziert statt importiert, damit
|
||||
# entitaeten.py nicht von frontend_veroeffentlichung.py abhängt (wäre die
|
||||
# falsche Richtung: die Veröffentlichung importiert diese Datei, nicht
|
||||
# umgekehrt).
|
||||
POSITIONEN = ["vorne links", "vorne rechts", "hinten links", "hinten rechts"]
|
||||
|
||||
# Katalog aller von der App genutzten Entity-Rollen, fürs Setup-Menü im
|
||||
# Frontend. Reihenfolge = Anzeige-Reihenfolge innerhalb jeder Gruppe.
|
||||
# domains/device_classes/units sind Erwartungen fürs Frontend-seitige
|
||||
# Filtern+Vorschlagen (Schalter "Nur passende Sensoren anzeigen") - eine
|
||||
# fehlende oder leere Liste bedeutet "keine Einschränkung" bzw. "diese Rolle
|
||||
# hat üblicherweise keine Einheit" (binary_sensor/button/device_tracker).
|
||||
FELDER = [
|
||||
{"key": "ZUENDUNG_SENSOR", "label": "Zündung/ACC-Status", "gruppe": "fahrterkennung",
|
||||
"hinweis": "on = Fahrt läuft, erkennt Fahrtbeginn/-ende (ersetzt die frühere WLAN-Erkennung).",
|
||||
"domains": ["binary_sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": True,
|
||||
"stichworte": ["zündung", "ignition", "acc", "motor", "engine"]},
|
||||
{"key": "KM_SENSOR", "label": "Kilometerstand", "gruppe": "fahrterkennung",
|
||||
"hinweis": "Gesamt-Kilometerstand des Fahrzeugs - für Fahrtabschluss-Screening, Reifenzähler und Ölwechsel-Prognose.",
|
||||
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
|
||||
"stichworte": ["kilometer", "km", "mileage", "odometer", "tacho", "laufleistung"]},
|
||||
{"key": "TANK_SENSOR", "label": "Tankfüllstand", "gruppe": "fahrterkennung",
|
||||
"hinweis": "Füllstand in Prozent - für die automatische Tankerkennung.",
|
||||
"domains": ["sensor"], "device_classes": [], "units": ["%"], "liste": False, "pflicht": False,
|
||||
"stichworte": ["tank", "fuel", "kraftstoff", "füllstand", "level"]},
|
||||
{"key": "RANGE_SENSOR", "label": "Reichweite", "gruppe": "uebersicht",
|
||||
"hinweis": "Für die Übersicht - bleibt leer, zeigt die Oberfläche \"unbekannt\".",
|
||||
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
|
||||
"stichworte": ["reichweite", "range"]},
|
||||
{"key": "BATTERIE_SENSOR", "label": "12V-Batteriespannung", "gruppe": "uebersicht",
|
||||
"hinweis": "Nicht jede Integration liefert diesen Wert - bleibt er leer, zeigt \"Mein Audi\" \"unbekannt\".",
|
||||
"domains": ["sensor"], "device_classes": ["voltage"], "units": ["V"], "liste": False, "pflicht": False,
|
||||
"stichworte": ["batterie", "battery", "spannung", "volt", "12v"]},
|
||||
{"key": "REFRESH_BUTTON", "label": "Sofort-Aktualisierung (Knopf)", "gruppe": "uebersicht",
|
||||
"hinweis": "Der von der Integration bereitgestellte Knopf für \"Ziehen zum Aktualisieren\".",
|
||||
"domains": ["button"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
|
||||
"stichworte": ["refresh", "aktualisieren", "update", "abrufen", "poll"]},
|
||||
{"key": "TUER_SENSOREN", "label": "Türen", "gruppe": "sicherheit",
|
||||
"hinweis": "\"aus\"/off = zu. Für \"Sicher abgestellt\" wichtig.",
|
||||
"domains": ["binary_sensor"], "device_classes": ["door"], "units": [], "liste": True, "positionen": POSITIONEN,
|
||||
"pflicht": False, "stichworte": ["tür", "door"]},
|
||||
{"key": "FENSTER_SENSOREN", "label": "Fenster", "gruppe": "sicherheit",
|
||||
"hinweis": "\"aus\"/off = zu.",
|
||||
"domains": ["binary_sensor"], "device_classes": ["window"], "units": [], "liste": True, "positionen": POSITIONEN,
|
||||
"pflicht": False, "stichworte": ["fenster", "window"]},
|
||||
{"key": "HECKKLAPPE_SENSOR", "label": "Heckklappe", "gruppe": "sicherheit",
|
||||
"hinweis": "\"aus\"/off = zu.",
|
||||
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
|
||||
"stichworte": ["heckklappe", "tailgate", "kofferraum", "trunk"]},
|
||||
{"key": "HECKKLAPPENSCHLOSS_SENSOR", "label": "Heckklappenschloss", "gruppe": "sicherheit",
|
||||
"hinweis": "\"on\" = entriegelt, \"aus\"/off = verriegelt.",
|
||||
"domains": ["binary_sensor"], "device_classes": ["lock"], "units": [], "liste": False, "pflicht": False,
|
||||
"stichworte": ["heckklappe", "tailgate", "schloss", "lock"]},
|
||||
{"key": "HAUBE_SENSOR", "label": "Motorhaube", "gruppe": "sicherheit",
|
||||
"hinweis": "\"aus\"/off = zu.",
|
||||
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
|
||||
"stichworte": ["haube", "hood", "bonnet", "motorhaube"]},
|
||||
{"key": "NAECHSTER_OELWECHSEL_SENSOR", "label": "Nächster Ölwechsel (Datum)", "gruppe": "uebersicht",
|
||||
"hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit, ergänzt die App-eigene Servicebuch-Prognose.",
|
||||
"domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False,
|
||||
"stichworte": ["ölwechsel", "oil", "service", "change"]},
|
||||
{"key": "OELWECHSEL_STRECKE_SENSOR", "label": "Nächster Ölwechsel (Restkilometer)", "gruppe": "uebersicht",
|
||||
"hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.",
|
||||
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
|
||||
"stichworte": ["ölwechsel", "oil", "distance", "strecke"]},
|
||||
{"key": "NAECHSTE_INSPEKTION_SENSOR", "label": "Nächste Inspektion (Datum)", "gruppe": "uebersicht",
|
||||
"hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit.",
|
||||
"domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False,
|
||||
"stichworte": ["inspektion", "inspection", "service"]},
|
||||
{"key": "INSPEKTION_STRECKE_SENSOR", "label": "Nächste Inspektion (Restkilometer)", "gruppe": "uebersicht",
|
||||
"hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.",
|
||||
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
|
||||
"stichworte": ["inspektion", "inspection", "distance", "strecke"]},
|
||||
{"key": "STANDORT_LAT_SENSOR", "label": "GPS-Standort: Breitengrad", "gruppe": "standort",
|
||||
"hinweis": "Breitengrad als eigener Sensor (z. B. flespi).",
|
||||
"domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
|
||||
"stichworte": ["breitengrad", "latitude", "lat", "standort", "gps", "position", "ortung"]},
|
||||
{"key": "STANDORT_LON_SENSOR", "label": "GPS-Standort: Längengrad", "gruppe": "standort",
|
||||
"hinweis": "Längengrad als eigener Sensor (z. B. flespi).",
|
||||
"domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
|
||||
"stichworte": ["längengrad", "longitude", "lon", "lng", "standort", "gps", "position", "ortung"]},
|
||||
]
|
||||
|
||||
# Mehrere @state_trigger(f"...")-Dekoratoren (fahrterkennung.py,
|
||||
# fahrtabschluss.py, tankerkennung.py, reifenzaehler.py) backen diese drei
|
||||
# Felder beim Modul-Laden ein (siehe Kopfkommentar) - im Setup-Menü als
|
||||
# "wirkt erst nach Neustart"-Hinweis markiert.
|
||||
_TRIGGER_GEBUNDEN = {"ZUENDUNG_SENSOR", "KM_SENSOR", "TANK_SENSOR"}
|
||||
for _feld in FELDER:
|
||||
_feld["trigger_gebunden"] = _feld["key"] in _TRIGGER_GEBUNDEN
|
||||
|
||||
_SCHLUESSEL = {feld["key"] for feld in FELDER}
|
||||
|
||||
# Schnappschuss der eingebauten Standardwerte aus einstellungen.py, genommen
|
||||
# BEVOR overrides_anwenden() jemals lief (Modul-Ebene, läuft beim `import
|
||||
# entitaeten` in frontend_api.py - vor dem @time_trigger("startup"), der
|
||||
# overrides_anwenden() erstmals aufruft). Notwendig fürs Setup-Menü
|
||||
# "Zurücksetzen": setattr() in overrides_anwenden() überschreibt das Attribut
|
||||
# auf dem laufenden einstellungen-Modul dauerhaft, ein einfaches Weglassen
|
||||
# des Overrides beim nächsten Speichern stellt den eingebauten Wert NICHT
|
||||
# wieder her (das Attribut bleibt ja gesetzt) - "Zurücksetzen" muss den
|
||||
# Standardwert stattdessen aktiv zurückschreiben.
|
||||
_STANDARDWERTE = {feld["key"]: getattr(einstellungen, feld["key"], None) for feld in FELDER}
|
||||
|
||||
|
||||
# --------------------------------------------------------------- JSON-I/O ---
|
||||
# Gleiches Muster wie profil.profil_lesen()/profil_schreiben(): io.open nur
|
||||
# über task.executor (pyscript-Einschränkung, siehe Kopfkommentar dort),
|
||||
# atomares Schreiben über eine .tmp-Datei + os.replace.
|
||||
|
||||
def overrides_lesen():
|
||||
"""Liest die gespeicherten Zuordnungen, oder {} wenn die Datei fehlt bzw.
|
||||
beschädigt ist.
|
||||
|
||||
Der try/except ist nicht Vorsicht um ihrer selbst willen: overrides_
|
||||
anwenden() läuft in beim_start() VOR alles_veroeffentlichen(). Ohne die
|
||||
Absicherung reißt eine einzige unlesbare Zeile in dieser Datei den
|
||||
gesamten Startvorgang mit - das Panel bliebe komplett leer, ohne dass
|
||||
irgendetwas auf die Ursache hindeutet."""
|
||||
if not os.path.exists(OVERRIDES_PFAD):
|
||||
return {}
|
||||
f = task.executor(io.open, OVERRIDES_PFAD, "r")
|
||||
inhalt = f.read()
|
||||
f.close()
|
||||
if not inhalt.strip():
|
||||
return {}
|
||||
try:
|
||||
gelesen = json.loads(inhalt)
|
||||
except ValueError as fehler:
|
||||
log.error(
|
||||
f"audi_dashboard: {OVERRIDES_PFAD} ist kein gültiges JSON ({fehler}). "
|
||||
"Die eingebauten Standardwerte gelten weiter; die Zuordnung lässt sich "
|
||||
"im Setup-Menü neu speichern."
|
||||
)
|
||||
return {}
|
||||
if not isinstance(gelesen, dict):
|
||||
log.error(f"audi_dashboard: {OVERRIDES_PFAD} enthält kein Objekt - wird ignoriert.")
|
||||
return {}
|
||||
return gelesen
|
||||
|
||||
|
||||
def overrides_schreiben(mapping):
|
||||
"""Schreibt nur bekannte Schlüssel (siehe FELDER); unbekannte werden
|
||||
stillschweigend verworfen, damit ein späterer Katalog-Umbau keine
|
||||
Datenleichen in entitaeten.json hinterlässt."""
|
||||
bereinigt = {k: v for k, v in mapping.items() if k in _SCHLUESSEL}
|
||||
os.makedirs(BASIS, exist_ok=True)
|
||||
tmp = OVERRIDES_PFAD + ".tmp"
|
||||
text = json.dumps(bereinigt, ensure_ascii=False, indent=2)
|
||||
f = task.executor(io.open, tmp, "w")
|
||||
f.write(text)
|
||||
f.close()
|
||||
os.replace(tmp, OVERRIDES_PFAD)
|
||||
|
||||
|
||||
# ------------------------------------------------------- Anwenden/Lesen ---
|
||||
|
||||
def _ist_leer(wert):
|
||||
"""Leer heißt: keine Zuordnung. Auch eine Liste, die nur leere Einträge
|
||||
enthält - das Setup-Menü schickt für unbelegte Positionen ["","",...],
|
||||
und das ist inhaltlich dasselbe wie gar keine Liste.
|
||||
|
||||
Die Schleife steht hier bewusst statt eines any(...)-Generatorausdrucks:
|
||||
pyscript kennt Generatorausdrücke nicht und wirft zur Laufzeit
|
||||
"not implemented ast ast_generatorexp". Mengen-, Listen- und
|
||||
Dict-Comprehensions funktionieren dagegen (siehe _SCHLUESSEL oben)."""
|
||||
if wert in (None, ""):
|
||||
return True
|
||||
if isinstance(wert, list):
|
||||
for eintrag in wert:
|
||||
if eintrag:
|
||||
return False
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def overrides_anwenden():
|
||||
"""Setzt für JEDES bekannte Feld den wirksamen Wert auf dem laufenden
|
||||
einstellungen-Modul - den Override, wenn einer hinterlegt ist, sonst den
|
||||
eingebauten Standardwert.
|
||||
|
||||
Wichtig ist das "jedes": Eine frühere Fassung übersprang leere Werte und
|
||||
setzte nur die belegten. Weil setattr() das Modul-Attribut aber dauerhaft
|
||||
verändert, blieb ein einmal gesetzter Wert danach für immer stehen -
|
||||
"Zurücksetzen" im Setup-Menü hatte bei allen Feldern mit leerem
|
||||
Standardwert (15 von 17) schlicht keine Wirkung, und die Oberfläche zeigte
|
||||
beim nächsten Zeichnen wieder den alten Wert, als sei das Speichern
|
||||
fehlgeschlagen. Umgekehrt wurde eine Liste aus leeren Einträgen gesetzt
|
||||
statt übersprungen, was den Sicherheitscheck mit zwölf "unbekannt"-Zeilen
|
||||
füllte und "Sicher abgestellt" dauerhaft grau werden ließ.
|
||||
|
||||
Jedes Feld bei jedem Durchlauf zu schreiben ist damit nicht nur korrekt,
|
||||
sondern auch idempotent: der Zustand hängt allein an entitaeten.json,
|
||||
nicht an der Reihenfolge vorheriger Aufrufe."""
|
||||
overrides = overrides_lesen()
|
||||
for key in _SCHLUESSEL:
|
||||
wert = overrides.get(key)
|
||||
setattr(einstellungen, key, _STANDARDWERTE[key] if _ist_leer(wert) else wert)
|
||||
|
||||
|
||||
def aktueller_stand():
|
||||
"""Katalog + aktuell wirksamer Wert je Feld (aus dem einstellungen-Modul
|
||||
selbst gelesen, nicht direkt aus der JSON-Datei) - zeigt damit sowohl
|
||||
bereits angewendete Overrides als auch die eingebauten Standardwerte
|
||||
einheitlich. Grundlage für pyscript.audi_dashboard_entitaeten."""
|
||||
werte = {feld["key"]: getattr(einstellungen, feld["key"], None) for feld in FELDER}
|
||||
return {"katalog": FELDER, "werte": werte, "standardwerte": _STANDARDWERTE}
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Screening-Logik für den zweistufigen Fahrtabschluss (Bauauftrag §7.2).
|
||||
|
||||
Importierbar aus anderen pyscript-Dateien mit `import fahrtabschluss_logik`.
|
||||
|
||||
Der Kilometerstand kommt laut Datenquelle nicht sicher mit Fahrtende, sondern
|
||||
teils erst mit Beginn oder während der nächsten Fahrt (§10 Punkt 4). Statt auf
|
||||
einen festen Zeitpunkt zu warten, wird deshalb die Recorder-Historie des
|
||||
Kilometerstand-Sensors nach dem Wert durchsucht, dessen Zeitstempel am
|
||||
nächsten am Verbindungsabbruch liegt.
|
||||
|
||||
Zugriff auf die Historie läuft über die HA-REST-API (/api/history/period),
|
||||
nicht über einen internen pyscript-Mechanismus - dafür gibt es in der
|
||||
pyscript-Referenz keinen dokumentierten Weg. Das erfordert ein Long-Lived
|
||||
Access Token, siehe README.
|
||||
|
||||
WICHTIGER VORBEHALT: Der GPS-Fallback aus §7.2 (km_quelle="gps") ist hier noch
|
||||
NICHT umgesetzt, da die Erfassung von Start-/Endposition (device_tracker,
|
||||
Adressauflösung) ein eigenes Arbeitspaket ist. Fahrten ohne passenden
|
||||
Kilometerstand bleiben aktuell dauerhaft "offen" statt auf GPS-Distanz
|
||||
auszuweichen. Siehe die Restarbeiten im Abschlussbericht.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import io
|
||||
import json
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
import frontend_veroeffentlichung
|
||||
import profil
|
||||
|
||||
BASIS = "/config/audi_dashboard"
|
||||
TOKEN_PFAD = f"{BASIS}/ha_token.txt"
|
||||
HA_BASIS_URL = "http://localhost:8123"
|
||||
|
||||
# Wie weit vor/zurück in der Historie gesucht wird, ausgehend vom
|
||||
# Verbindungsabbruch. 48h deckt auch länger stehende Fahrzeuge ab, ohne bei
|
||||
# jedem Screening die komplette Historie zu laden.
|
||||
SUCHFENSTER_STUNDEN = 48
|
||||
|
||||
|
||||
# ---------------------------------------------------------- HA-REST-Zugriff
|
||||
|
||||
def _token_lesen():
|
||||
# io.open über task.executor statt bloßem open() - siehe Kopfkommentar
|
||||
# in profil.py.
|
||||
f = task.executor(io.open, TOKEN_PFAD, "r")
|
||||
inhalt = f.read()
|
||||
f.close()
|
||||
return inhalt.strip()
|
||||
|
||||
|
||||
def historie_abfragen(entity_id, start_iso, end_iso):
|
||||
"""Fragt die Verlaufsdaten des Kilometerstand-Sensors über die REST-API ab.
|
||||
|
||||
Beide blockierenden Schritte laufen über task.executor. Ein früherer Stand
|
||||
rief urlopen() direkt auf - das lief jahrelang durch, bis Home Assistant
|
||||
anfing, blockierende Aufrufe im Ereignisschleifen-Thread zu erkennen und
|
||||
abzubrechen (real beobachtet ab 2026.8: "Caught blocking call to
|
||||
putrequest ... inside the event loop"). Seitdem schlug jedes Screening
|
||||
fehl, und manuell angelegte Fahrten blieben ohne Strecke - sichtbar nur
|
||||
als Warnung im Protokoll, weil der Aufrufer den Fehler bewusst abfängt.
|
||||
|
||||
task.executor braucht echte externe Funktionen; urllib.request.urlopen und
|
||||
die read-Methode der Antwort sind genau das (dieselbe Einschränkung wie bei
|
||||
io.open in profil.py)."""
|
||||
token = _token_lesen()
|
||||
start_enc = urllib.parse.quote(start_iso)
|
||||
end_enc = urllib.parse.quote(end_iso)
|
||||
url = (
|
||||
f"{HA_BASIS_URL}/api/history/period/{start_enc}"
|
||||
f"?filter_entity_id={entity_id}&end_time={end_enc}"
|
||||
)
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
|
||||
resp = task.executor(urllib.request.urlopen, req, timeout=10)
|
||||
rohdaten = task.executor(resp.read)
|
||||
resp.close()
|
||||
daten = json.loads(rohdaten.decode("utf-8"))
|
||||
return daten[0] if daten else []
|
||||
|
||||
|
||||
# ------------------------------------------------------------- Kernlogik
|
||||
|
||||
def naechster_wert(zielzeit, punkte):
|
||||
"""Wert aus der Historie, dessen Zeitstempel am nächsten an zielzeit
|
||||
liegt. Gibt (wert, zeitdifferenz_s) zurück, oder (None, None). Öffentlich,
|
||||
weil auch belegverarbeitung.py sie für den Tankfüllstand nutzt."""
|
||||
beste_wert = None
|
||||
beste_diff = None
|
||||
for p in punkte:
|
||||
try:
|
||||
wert = float(p["state"])
|
||||
except (ValueError, TypeError, KeyError):
|
||||
continue
|
||||
try:
|
||||
zeit = datetime.datetime.fromisoformat(
|
||||
p["last_changed"].replace("Z", "+00:00")
|
||||
)
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
diff = abs((zeit - zielzeit).total_seconds())
|
||||
if beste_diff is None or diff < beste_diff:
|
||||
beste_wert = wert
|
||||
beste_diff = diff
|
||||
return beste_wert, beste_diff
|
||||
|
||||
|
||||
def _zeitpunkt_screenen(km_sensor, zielzeit):
|
||||
start = (zielzeit - datetime.timedelta(hours=SUCHFENSTER_STUNDEN)).isoformat()
|
||||
ende = (zielzeit + datetime.timedelta(hours=SUCHFENSTER_STUNDEN)).isoformat()
|
||||
try:
|
||||
punkte = historie_abfragen(km_sensor, start, ende)
|
||||
except Exception as exc:
|
||||
# Fehlt z. B. der Token (INSTALL.md Schritt 5) oder ist die API kurz
|
||||
# nicht erreichbar, bleibt die Fahrt einfach offen statt den ganzen
|
||||
# Screening-Lauf für alle anderen offenen Fahrten abzubrechen.
|
||||
log.warning(f"audi_dashboard: Kilometerstand-Screening fehlgeschlagen: {exc}")
|
||||
return None
|
||||
wert, _ = naechster_wert(zielzeit, punkte)
|
||||
return wert
|
||||
|
||||
|
||||
def screening_durchfuehren(km_sensor):
|
||||
"""Versucht, offene Fahrten mit Start-/Endkilometer zu vervollständigen.
|
||||
Wird sowohl nach Ablauf der Pausenzeit (§7.1) als auch bei jeder Änderung
|
||||
des Kilometerstand-Sensors aufgerufen."""
|
||||
fahrten = profil.fahrten_lesen()
|
||||
offene = [f for f in fahrten if f.get("status") == "offen"]
|
||||
if not offene:
|
||||
return
|
||||
|
||||
offene.sort(key=lambda f: f.get("ts_start", ""))
|
||||
|
||||
for fahrt in offene:
|
||||
geaendert = False
|
||||
|
||||
if fahrt.get("odo_end") is None:
|
||||
ts_end = datetime.datetime.fromisoformat(fahrt["ts_end"])
|
||||
wert = _zeitpunkt_screenen(km_sensor, ts_end)
|
||||
if wert is not None:
|
||||
fahrt["odo_end"] = wert
|
||||
geaendert = True
|
||||
|
||||
if fahrt.get("odo_start") is None:
|
||||
ts_start = datetime.datetime.fromisoformat(fahrt["ts_start"])
|
||||
wert = _zeitpunkt_screenen(km_sensor, ts_start)
|
||||
if wert is not None:
|
||||
fahrt["odo_start"] = wert
|
||||
geaendert = True
|
||||
|
||||
if geaendert:
|
||||
aenderungen = {"odo_start": fahrt.get("odo_start"), "odo_end": fahrt.get("odo_end")}
|
||||
if fahrt.get("odo_start") is not None and fahrt.get("odo_end") is not None:
|
||||
aenderungen["distance_km"] = round(fahrt["odo_end"] - fahrt["odo_start"], 1)
|
||||
aenderungen["km_quelle"] = "odometer"
|
||||
aenderungen["status"] = "vollständig"
|
||||
profil.fahrt_aktualisieren(fahrt["trip_id"], aenderungen)
|
||||
|
||||
_lueckenlos_verketten()
|
||||
frontend_veroeffentlichung.fahrten_veroeffentlichen()
|
||||
|
||||
|
||||
def _lueckenlos_verketten():
|
||||
"""Fahrt-Ende(N) == Fahrt-Start(N+1), solange dazwischen nicht gefahren
|
||||
wurde (§7.2). Zwischen zwei in der Liste direkt aufeinanderfolgenden
|
||||
Fahrten steht das Fahrzeug nur - der Kilometerstand ändert sich dabei
|
||||
nicht, unabhängig davon, wie lange die Standzeit dauert. Ein bestätigter
|
||||
Wert wird deshalb an den direkten Nachbarn weitergereicht, auch wenn
|
||||
dessen eigenes Screening noch keinen Treffer hatte.
|
||||
|
||||
Annahme: die Fahrten-Liste enthält keine Lücken durch verpasste
|
||||
Verbindungsereignisse (z. B. durch einen HA-Neustart mitten in einer
|
||||
Fahrt). Das ist der Normalfall, aber keine Garantie - siehe
|
||||
Abschlussbericht."""
|
||||
fahrten = sorted(profil.fahrten_lesen(), key=lambda f: f.get("ts_start", ""))
|
||||
geaendert_ids = []
|
||||
|
||||
for i in range(len(fahrten) - 1):
|
||||
aktuelle, naechste = fahrten[i], fahrten[i + 1]
|
||||
|
||||
if aktuelle.get("odo_end") is not None and naechste.get("odo_start") is None:
|
||||
naechste["odo_start"] = aktuelle["odo_end"]
|
||||
geaendert_ids.append(naechste["trip_id"])
|
||||
elif naechste.get("odo_start") is not None and aktuelle.get("odo_end") is None:
|
||||
aktuelle["odo_end"] = naechste["odo_start"]
|
||||
geaendert_ids.append(aktuelle["trip_id"])
|
||||
|
||||
for fahrt in fahrten:
|
||||
if fahrt["trip_id"] not in geaendert_ids:
|
||||
continue
|
||||
aenderungen = {"odo_start": fahrt.get("odo_start"), "odo_end": fahrt.get("odo_end")}
|
||||
if fahrt.get("odo_start") is not None and fahrt.get("odo_end") is not None:
|
||||
aenderungen["distance_km"] = round(fahrt["odo_end"] - fahrt["odo_start"], 1)
|
||||
aenderungen["km_quelle"] = "odometer"
|
||||
aenderungen["status"] = "vollständig"
|
||||
profil.fahrt_aktualisieren(fahrt["trip_id"], aenderungen)
|
||||
@@ -0,0 +1,198 @@
|
||||
"""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 entitaeten
|
||||
import profil
|
||||
|
||||
|
||||
def profil_veroeffentlichen():
|
||||
daten = profil.profil_lesen()
|
||||
if daten is None:
|
||||
return
|
||||
state.set("pyscript.audi_dashboard_profil", "aktuell", new_attributes={"daten": daten})
|
||||
|
||||
|
||||
def fahrten_veroeffentlichen():
|
||||
state.set("pyscript.audi_dashboard_fahrten", "aktuell", new_attributes={"daten": profil.fahrten_lesen()})
|
||||
|
||||
|
||||
def tankvorgaenge_veroeffentlichen():
|
||||
state.set("pyscript.audi_dashboard_tankvorgaenge", "aktuell", new_attributes={"daten": profil.tankvorgaenge_lesen()})
|
||||
|
||||
|
||||
def batterieverlauf_veroeffentlichen():
|
||||
state.set("pyscript.audi_dashboard_batterieverlauf", "aktuell", new_attributes={"daten": profil.batterieverlauf_lesen()})
|
||||
|
||||
|
||||
def entitaeten_veroeffentlichen():
|
||||
"""Katalog + aktuell wirksame Zuordnung fürs Setup-Menü (Einstellungen ->
|
||||
Fahrzeug einrichten -> Setup) - siehe entitaeten.py."""
|
||||
state.set("pyscript.audi_dashboard_entitaeten", "aktuell", new_attributes={"daten": entitaeten.aktueller_stand()})
|
||||
|
||||
|
||||
def beleg_ergebnis_veroeffentlichen(daten):
|
||||
"""Ergebnis eines Beleg-Uploads (geparste Werte fürs Ausfüllen eines
|
||||
Entwurfs, oder ein Fehler wie 'Falsches Belegdatum') fürs Frontend, nach
|
||||
demselben Zustands-statt-Rückgabewert-Muster wie die übrigen
|
||||
veroeffentlichen()-Funktionen hier (siehe Kopfkommentar)."""
|
||||
state.set("pyscript.audi_dashboard_beleg_ergebnis", "aktuell", new_attributes={"daten": daten})
|
||||
|
||||
|
||||
def zustand_oder_none(entity_id):
|
||||
"""Sicherer Zustandszugriff: pyscripts state.get() wirft NameError, wenn
|
||||
die Entity (noch) nicht existiert - real an einer Testinstanz beobachtet,
|
||||
nicht nur vermutet. Genau das ist hier der Normalfall, solange manche der
|
||||
in einstellungen.py hinterlegten Entity-IDs noch Platzhalter sind, und
|
||||
bleibt auch danach relevant: fällt eine Datenquelle aus, soll die
|
||||
Oberfläche das zeigen (§9), nicht an einem Fehler hängen bleiben."""
|
||||
if not entity_id:
|
||||
return None
|
||||
try:
|
||||
wert = state.get(entity_id)
|
||||
except NameError:
|
||||
return None
|
||||
if wert in (None, "unknown", "unavailable"):
|
||||
return None
|
||||
return wert
|
||||
|
||||
|
||||
def _zu_zahl(wert):
|
||||
try:
|
||||
return float(wert)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _abs_zahl(wert):
|
||||
zahl = _zu_zahl(wert)
|
||||
return None if zahl is None else abs(zahl)
|
||||
|
||||
|
||||
def _standort():
|
||||
"""Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel) - liest
|
||||
STANDORT_LAT_SENSOR/STANDORT_LON_SENSOR, zwei eigene sensor-Entities für
|
||||
Breiten-/Längengrad (flespi liefert Koordinaten so, nicht als Attribute
|
||||
einer einzelnen device_tracker-Entity). Fehlt eine der beiden Entity-IDs
|
||||
oder ist der Zustand (noch) nicht verfügbar, liefert diese Funktion
|
||||
durchgehend None statt eines geratenen Werts - genau das
|
||||
zustand_oder_none()-Muster von oben, hier als eigene Funktion, weil
|
||||
zusätzlich zum Zustand noch der Aktualisierungszeitpunkt gebraucht wird:
|
||||
state.get() liefert dafür ein StateVal (str-Unterklasse) mit
|
||||
.last_updated als datetime-Attribut - kein eigenständiges
|
||||
state.last_updated(), das gibt es in pyscript nicht."""
|
||||
leer = {"lat": None, "lon": None, "genauigkeit_m": None, "zeit": None}
|
||||
|
||||
lat_id, lon_id = einstellungen.STANDORT_LAT_SENSOR, einstellungen.STANDORT_LON_SENSOR
|
||||
if not lat_id or not lon_id:
|
||||
return leer
|
||||
try:
|
||||
lat_wert = state.get(lat_id)
|
||||
lon_wert = state.get(lon_id)
|
||||
except NameError:
|
||||
return leer
|
||||
lat, lon = _zu_zahl(lat_wert), _zu_zahl(lon_wert)
|
||||
if lat is None or lon is None:
|
||||
return leer
|
||||
zeit = None
|
||||
try:
|
||||
zeit = lat_wert.last_updated.isoformat()
|
||||
except AttributeError:
|
||||
pass
|
||||
return {"lat": lat, "lon": lon, "genauigkeit_m": None, "zeit": zeit}
|
||||
|
||||
|
||||
POSITIONEN = ["vorne links", "vorne rechts", "hinten links", "hinten rechts"]
|
||||
|
||||
|
||||
def _sicherheitscheck():
|
||||
"""Liste der einzeln geprüften Punkte hinter "Sicher abgestellt" (§9),
|
||||
fürs Frontend (Klick auf den Status öffnet diese Liste mit einem grünen/
|
||||
roten/grauen Punkt je Zeile). "ok" ist None, wenn der Sensor fehlt oder
|
||||
nicht verfügbar ist - genau daraus leitet sich auch die zusammengefasste
|
||||
gesichert-Kennzahl unten ab, damit beide nie auseinanderlaufen können."""
|
||||
eintraege = []
|
||||
for pos, sensor in zip(POSITIONEN, einstellungen.TUER_SENSOREN):
|
||||
w = zustand_oder_none(sensor)
|
||||
eintraege.append({"label": f"Tür {pos}", "ok": None if w is None else w == "off"})
|
||||
for pos, sensor in zip(POSITIONEN, einstellungen.FENSTER_SENSOREN):
|
||||
w = zustand_oder_none(sensor)
|
||||
eintraege.append({"label": f"Fenster {pos}", "ok": None if w is None else w == "off"})
|
||||
for label, sensor in [
|
||||
("Heckklappe", einstellungen.HECKKLAPPE_SENSOR),
|
||||
("Heckklappenschloss", einstellungen.HECKKLAPPENSCHLOSS_SENSOR),
|
||||
("Motorhaube", einstellungen.HAUBE_SENSOR),
|
||||
]:
|
||||
w = zustand_oder_none(sensor)
|
||||
eintraege.append({"label": label, "ok": None if w is None else w == "off"})
|
||||
return eintraege
|
||||
|
||||
|
||||
def fahrzeugstatus_veroeffentlichen():
|
||||
"""Bündelt die live aus Home Assistant gelesenen Fahrzeugwerte
|
||||
Frontend-freundlich, damit die Oberfläche keine Entity-IDs kennen muss.
|
||||
"""
|
||||
km = zustand_oder_none(einstellungen.KM_SENSOR)
|
||||
tankprozent = zustand_oder_none(einstellungen.TANK_SENSOR)
|
||||
reichweite = zustand_oder_none(einstellungen.RANGE_SENSOR)
|
||||
batteriespannung = zustand_oder_none(einstellungen.BATTERIE_SENSOR)
|
||||
|
||||
# "Sicher abgestellt" (§9) - erst wenn WIRKLICH jeder einzeln geprüfte
|
||||
# Punkt zu/verriegelt ist, gilt das Fahrzeug als gesichert; fehlt auch
|
||||
# nur eine Meldung, ist der Status unbekannt statt geraten.
|
||||
sicherheitscheck = _sicherheitscheck()
|
||||
if any([e["ok"] is None for e in sicherheitscheck]):
|
||||
gesichert = None
|
||||
else:
|
||||
gesichert = all([e["ok"] for e in sicherheitscheck])
|
||||
|
||||
standort = _standort()
|
||||
|
||||
daten = {
|
||||
"km": _zu_zahl(km),
|
||||
"tankprozent": _zu_zahl(tankprozent),
|
||||
"reichweite_km": _zu_zahl(reichweite),
|
||||
"batteriespannung": _zu_zahl(batteriespannung),
|
||||
"gesichert": gesichert,
|
||||
"sicherheitscheck": sicherheitscheck,
|
||||
"standort_lat": standort["lat"],
|
||||
"standort_lon": standort["lon"],
|
||||
"standort_genauigkeit_m": standort["genauigkeit_m"],
|
||||
"standort_zeit": standort["zeit"],
|
||||
# Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die
|
||||
# App-eigene Servicebuch-Berechnung, siehe fahrzeugMeldung() im
|
||||
# Frontend) - die Streckensensoren liefern negative Restkilometer-
|
||||
# Werte, hier deshalb der Betrag.
|
||||
"oelwechsel_faellig_ts": zustand_oder_none(einstellungen.NAECHSTER_OELWECHSEL_SENSOR),
|
||||
"oelwechsel_faellig_km": _abs_zahl(zustand_oder_none(einstellungen.OELWECHSEL_STRECKE_SENSOR)),
|
||||
"inspektion_faellig_ts": zustand_oder_none(einstellungen.NAECHSTE_INSPEKTION_SENSOR),
|
||||
"inspektion_faellig_km": _abs_zahl(zustand_oder_none(einstellungen.INSPEKTION_STRECKE_SENSOR)),
|
||||
}
|
||||
state.set("pyscript.audi_dashboard_fahrzeugstatus", "aktuell", new_attributes={"daten": daten})
|
||||
|
||||
|
||||
def alles_veroeffentlichen():
|
||||
profil_veroeffentlichen()
|
||||
fahrten_veroeffentlichen()
|
||||
tankvorgaenge_veroeffentlichen()
|
||||
fahrzeugstatus_veroeffentlichen()
|
||||
batterieverlauf_veroeffentlichen()
|
||||
entitaeten_veroeffentlichen()
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Datenzugriff für Fahrzeugprofil, Fahrten und Tankvorgänge (Bauauftrag §6).
|
||||
|
||||
Importierbar aus anderen pyscript-Dateien mit `import profil`.
|
||||
|
||||
Drei getrennte Bestände, wie in §6.1 festgelegt:
|
||||
- Fahrzeugprofil: eine JSON-Datei, alles Fahrzeugspezifische
|
||||
- Fahrten: JSON Lines, eine Zeile je Fahrt
|
||||
- Tankvorgänge: JSON Lines
|
||||
|
||||
Drei Eigenheiten von pyscript, alle an einer echten Testinstanz beobachtet,
|
||||
nicht nur aus der Dokumentation übernommen:
|
||||
|
||||
1. Das eingebaute `open()` existiert in pyscript nicht (NameError) - aus
|
||||
Sicherheitsgründen bewusst nicht freigegeben. Der funktionierende Weg ist
|
||||
`task.executor(io.open, pfad, modus)`: `io.open` ist eine echte externe
|
||||
Funktion aus der Standardbibliothek, kein im pyscript-Ordner selbst
|
||||
definierter Code.
|
||||
2. task.executor() akzeptiert generell nur solche echten externen Funktionen
|
||||
- eigene, im pyscript-Ordner definierte Funktionen weist es mit "pyscript
|
||||
functions can't be called from task.executor" zurück. Deshalb hier nur
|
||||
`io.open` selbst über den Executor, Lesen/Schreiben/Schließen auf dem
|
||||
zurückgegebenen Datei-Objekt direkt (das ist kein bare-name-Aufruf mehr,
|
||||
sondern ein Methodenaufruf auf einem bereits vorhandenen Objekt).
|
||||
3. Variablen, die innerhalb eines `with ... as f:`-Blocks zugewiesen werden,
|
||||
waren danach außerhalb nicht mehr auffindbar (NameError), deshalb kein
|
||||
`with` - offen/lesen/schließen nacheinander.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
|
||||
BASIS = "/config/audi_dashboard"
|
||||
PROFIL_PFAD = f"{BASIS}/fahrzeugprofil.json"
|
||||
FAHRTEN_PFAD = f"{BASIS}/fahrten.jsonl"
|
||||
TANKVORGAENGE_PFAD = f"{BASIS}/tankvorgaenge.jsonl"
|
||||
BATTERIEVERLAUF_PFAD = f"{BASIS}/batteriespannung.jsonl"
|
||||
BELEGE_ORDNER = f"{BASIS}/belege"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Ordner ---
|
||||
|
||||
def ordner_sicherstellen():
|
||||
os.makedirs(BASIS, exist_ok=True)
|
||||
os.makedirs(BELEGE_ORDNER, exist_ok=True)
|
||||
|
||||
|
||||
# ----------------------------------------------------------- Fahrzeugprofil
|
||||
|
||||
def profil_lesen():
|
||||
"""Liest das Fahrzeugprofil, oder None wenn es fehlt bzw. beschädigt ist.
|
||||
|
||||
Ohne diese Prüfung reißt eine fehlende Datei (Installation unvollständig,
|
||||
siehe INSTALL.md Schritt 2) jeden Trigger und jeden Service mit, der das
|
||||
Profil braucht — bei laufenden Zeittriggern also im Minutentakt. Jeder
|
||||
Aufrufer muss den None-Fall abfangen."""
|
||||
if not os.path.exists(PROFIL_PFAD):
|
||||
log.error(
|
||||
f"audi_dashboard: {PROFIL_PFAD} fehlt. Siehe INSTALL.md Schritt 2 — "
|
||||
"bis dahin bleiben alle Funktionen aus, die das Profil brauchen."
|
||||
)
|
||||
return None
|
||||
f = task.executor(io.open, PROFIL_PFAD, "r")
|
||||
inhalt = f.read()
|
||||
f.close()
|
||||
try:
|
||||
return json.loads(inhalt)
|
||||
except ValueError as fehler:
|
||||
log.error(
|
||||
f"audi_dashboard: {PROFIL_PFAD} ist kein gültiges JSON ({fehler}). "
|
||||
"Letztes Backup aus audi_dashboard/backups/ zurückspielen."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def profil_schreiben(profil):
|
||||
tmp = PROFIL_PFAD + ".tmp"
|
||||
text = json.dumps(profil, ensure_ascii=False, indent=2)
|
||||
f = task.executor(io.open, tmp, "w")
|
||||
f.write(text)
|
||||
f.close()
|
||||
os.replace(tmp, PROFIL_PFAD)
|
||||
|
||||
|
||||
# --------------------------------------------------------- JSON-Lines-Basis
|
||||
|
||||
def _zeilen_lesen(pfad):
|
||||
if not os.path.exists(pfad):
|
||||
return []
|
||||
f = task.executor(io.open, pfad, "r")
|
||||
inhalt = f.read()
|
||||
f.close()
|
||||
datensaetze = []
|
||||
for zeile in inhalt.splitlines():
|
||||
zeile = zeile.strip()
|
||||
if zeile:
|
||||
datensaetze.append(json.loads(zeile))
|
||||
return datensaetze
|
||||
|
||||
|
||||
def _zeilen_schreiben(pfad, datensaetze):
|
||||
tmp = pfad + ".tmp"
|
||||
zeilen = [json.dumps(d, ensure_ascii=False) for d in datensaetze]
|
||||
text = "\n".join(zeilen)
|
||||
if zeilen:
|
||||
text += "\n"
|
||||
f = task.executor(io.open, tmp, "w")
|
||||
f.write(text)
|
||||
f.close()
|
||||
os.replace(tmp, pfad)
|
||||
|
||||
|
||||
def _zeile_anhaengen(pfad, datensatz):
|
||||
f = task.executor(io.open, pfad, "a")
|
||||
f.write(json.dumps(datensatz, ensure_ascii=False) + "\n")
|
||||
f.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Fahrten
|
||||
|
||||
def fahrten_lesen():
|
||||
return _zeilen_lesen(FAHRTEN_PFAD)
|
||||
|
||||
|
||||
def fahrten_schreiben(fahrten):
|
||||
_zeilen_schreiben(FAHRTEN_PFAD, fahrten)
|
||||
|
||||
|
||||
def fahrt_anhaengen(fahrt):
|
||||
_zeile_anhaengen(FAHRTEN_PFAD, fahrt)
|
||||
|
||||
|
||||
def offene_fahrten():
|
||||
"""Alle Fahrten mit status == 'offen', ältere zuerst."""
|
||||
fahrten = fahrten_lesen()
|
||||
offen = [f for f in fahrten if f.get("status") == "offen"]
|
||||
return sorted(offen, key=lambda f: f.get("ts_start", ""))
|
||||
|
||||
|
||||
def _datensatz_aktualisieren(zeilen, id_feld, id_wert, aenderungen, schreiben):
|
||||
"""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]}"
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Reifenzähler ohne utility_meter (§7.5, §10 Punkt 6).
|
||||
|
||||
ABWEICHUNG VOM URSPRÜNGLICHEN LASTENHEFT (Frontend-Anpassung, nach dem
|
||||
ursprünglichen Bau nachträglich verlangt): früher war der Zähler = aktueller
|
||||
Kilometerstand - fester Startwert je Satz (reifen.saetze.<satz>.
|
||||
start_odo_km). Das war falsch, sobald einmal zwischen Sommer und Winter
|
||||
umgeschaltet wurde: die Differenz zum Startwert zählt dann alle seither
|
||||
gefahrenen Kilometer mit, auch die auf dem jeweils anderen Satz. Jetzt gilt
|
||||
stattdessen: jeder gefahrene Kilometer wird dem Satz zugerechnet, der zum
|
||||
Zeitpunkt der Fahrt aktiv war. Dafür führt reifen.saetze.<satz>.km selbst den
|
||||
fortlaufenden Gesamtstand (statt eines Startwerts), und reifen.
|
||||
referenz_odo_km hält den zuletzt verarbeiteten Kilometerstand fest, gegen den
|
||||
die nächste Änderung als Delta gemessen wird. start_odo_km/§13 entfällt
|
||||
damit vollständig.
|
||||
|
||||
Ergebnis wird zusätzlich als eigene pyscript-Zustandsvariable bereitgestellt
|
||||
(nur zur Kontrolle in Entwicklerwerkzeuge → Zustände, das Frontend liest die
|
||||
Werte direkt aus dem veröffentlichten Profil):
|
||||
pyscript.reifen_sommer_km, pyscript.reifen_winter_km,
|
||||
pyscript.reifen_aktiver_satz
|
||||
|
||||
Kein Abhängigkeit von total_increasing oder utility_meter-internem Zustand -
|
||||
die Fortschreibung braucht nur den zuletzt bekannten Absolutwert des
|
||||
Kilometerstand-Sensors. Ein zeitweise `unavailable` Quellsensor (§4.1)
|
||||
verliert dadurch nichts: referenz_odo_km bleibt einfach stehen, bis wieder
|
||||
ein gültiger Wert kommt, und das nächste Delta wird dann gegen diesen alten
|
||||
Stand berechnet - auch km, die während eines HA-Neustarts/Ausfalls gefahren
|
||||
wurden, werden so noch dem damals aktiven Satz zugerechnet.
|
||||
|
||||
Entity-IDs stehen zentral in pyscript/modules/einstellungen.py.
|
||||
"""
|
||||
|
||||
import einstellungen
|
||||
import frontend_veroeffentlichung
|
||||
import profil
|
||||
|
||||
|
||||
def _aktueller_km():
|
||||
try:
|
||||
return float(frontend_veroeffentlichung.zustand_oder_none(einstellungen.KM_SENSOR))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _zustaende_veroeffentlichen(p):
|
||||
reifen = p.get("reifen", {})
|
||||
saetze = reifen.get("saetze", {})
|
||||
for satz_name in ("sommer", "winter"):
|
||||
km = saetze.get(satz_name, {}).get("km")
|
||||
if km is None:
|
||||
state.set(f"pyscript.reifen_{satz_name}_km", "unbekannt")
|
||||
else:
|
||||
state.set(f"pyscript.reifen_{satz_name}_km", km, new_attributes={"unit_of_measurement": "km"})
|
||||
state.set("pyscript.reifen_aktiver_satz", reifen.get("aktiv", "unbekannt"))
|
||||
|
||||
|
||||
def _fortschreiben_und_veroeffentlichen(neuer_km, neuer_aktiv=None):
|
||||
"""Rechnet zuerst mit dem bisher aktiven Satz ab (Delta seit
|
||||
referenz_odo_km), trägt danach optional einen Satzwechsel ein - in dieser
|
||||
Reihenfolge, damit beim Wechseln selbst keine km dem neuen Satz
|
||||
zugerechnet werden, die eigentlich noch auf dem alten gefahren wurden."""
|
||||
p = profil.profil_lesen()
|
||||
if p is None:
|
||||
return
|
||||
reifen = p.setdefault("reifen", {})
|
||||
saetze = reifen.setdefault("saetze", {})
|
||||
aktiv = reifen.get("aktiv", "sommer")
|
||||
referenz = reifen.get("referenz_odo_km")
|
||||
|
||||
if referenz is not None and neuer_km is not None:
|
||||
delta = round(neuer_km - referenz, 1)
|
||||
if delta > 0:
|
||||
satz = saetze.setdefault(aktiv, {})
|
||||
satz["km"] = round((satz.get("km") or 0) + delta, 1)
|
||||
if neuer_km is not None:
|
||||
reifen["referenz_odo_km"] = neuer_km
|
||||
if neuer_aktiv is not None:
|
||||
reifen["aktiv"] = neuer_aktiv
|
||||
|
||||
profil.profil_schreiben(p)
|
||||
frontend_veroeffentlichung.profil_veroeffentlichen()
|
||||
_zustaende_veroeffentlichen(p)
|
||||
|
||||
|
||||
@time_trigger("startup")
|
||||
def beim_start():
|
||||
_fortschreiben_und_veroeffentlichen(_aktueller_km())
|
||||
|
||||
|
||||
# Ohne konfigurierten Kilometerstand-Sensor keinen Trigger registrieren -
|
||||
# eine leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes
|
||||
# Verhalten, siehe gleiches Muster in fahrterkennung.py.
|
||||
if einstellungen.KM_SENSOR:
|
||||
@state_trigger(f"{einstellungen.KM_SENSOR}")
|
||||
def kilometerstand_geaendert(value=None):
|
||||
_fortschreiben_und_veroeffentlichen(_aktueller_km())
|
||||
|
||||
|
||||
@service
|
||||
def audi_dashboard_reifen_wechseln(satz=None):
|
||||
"""Umschalten des aktiven Reifensatzes (§5.2). Aufruf als
|
||||
pyscript.audi_dashboard_reifen_wechseln mit satz="sommer"|"winter"."""
|
||||
if satz not in ("sommer", "winter"):
|
||||
log.warning(f"audi_dashboard: ungültiger Reifensatz '{satz}'")
|
||||
return
|
||||
_fortschreiben_und_veroeffentlichen(_aktueller_km(), neuer_aktiv=satz)
|
||||
log.info(f"audi_dashboard: aktiver Reifensatz auf '{satz}' umgeschaltet")
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Automatische Tankerkennung über den Füllstandssensor (Erweiterung zu §7.7/§5.5,
|
||||
nach dem ursprünglichen Bau nachträglich verlangt).
|
||||
|
||||
Beobachtung des Nutzers: der Füllstand (TANK_SENSOR, in Prozent) springt beim
|
||||
Fahren nie nach oben, er sinkt nur - jeder Anstieg ist also ein Tankvorgang.
|
||||
Schwelle nach Nutzervorgabe: ab +5 Liter ODER +9 Prozentpunkten Anstieg gilt
|
||||
als nachgetankt. Die 5-Liter-Vorgabe wird über fahrzeug.tankvolumen_liter aus
|
||||
dem Fahrzeugprofil in Prozentpunkte umgerechnet, damit beide Angaben auf
|
||||
derselben Einheit (Prozent) verglichen werden können; es gilt jeweils die
|
||||
empfindlichere (kleinere) der beiden Schwellen.
|
||||
|
||||
Tiefststand-Tracking statt einfachem Vorher/Nachher-Vergleich: die
|
||||
Integration liefert einen Tankvorgang oft in mehreren kleinen Schritten
|
||||
(z. B. 40% -> 45% -> 60%), von denen keiner allein die Schwelle
|
||||
überschreiten muss. Deshalb wird der zuletzt bekannte Tiefststand
|
||||
gespeichert und der Anstieg dagegen gemessen, nicht gegen den unmittelbar
|
||||
vorherigen Wert. Nach Anlage eines Tankvorgangs wird der Tiefststand auf den
|
||||
aktuellen Wert zurückgesetzt, damit derselbe Vorgang nicht mehrfach neue
|
||||
Datensätze erzeugt, wenn der Sensor währenddessen weiter ansteigt.
|
||||
|
||||
Der so angelegte Tankvorgang ist bewusst ein unvollständiger Platzhalter
|
||||
(status "unvollständig", ohne Liter/Kosten/Station) - siehe §10 Punkt 5 der
|
||||
letzten Rückmeldung: die Erwartung ist ein automatisch erkannter Tankvorgang
|
||||
mit Zeitstempel, Kilometerstand und gefahrener Distanz, den der Nutzer später
|
||||
per Beleg-Nachtrag (audi_dashboard_beleg_hochladen mit tank_id, siehe
|
||||
belegverarbeitung.py) vervollständigt.
|
||||
|
||||
EINSCHRÄNKUNG: der Tiefststand (_tiefststand_pct) lebt nur im Arbeitsspeicher
|
||||
dieses Skripts, analog zur bekannten Einschränkung in fahrterkennung.py
|
||||
(_fahrt_start_ts). Ein HA-Neustart während oder kurz vor einem Tankvorgang
|
||||
verliert den bisherigen Tiefststand - der nächste Anstieg wird dann gegen den
|
||||
ersten nach dem Neustart gemeldeten Wert gemessen. Bewusst nicht gehärtet,
|
||||
aus demselben Grund wie dort.
|
||||
|
||||
Entity-IDs stehen zentral in pyscript/modules/einstellungen.py.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
import einstellungen
|
||||
import frontend_veroeffentlichung
|
||||
import profil
|
||||
|
||||
LITER_SCHWELLE = 5
|
||||
PROZENT_SCHWELLE = 9
|
||||
STANDARD_TANKVOLUMEN_LITER = 58 # Fallback, falls im Profil nicht hinterlegt
|
||||
|
||||
_tiefststand_pct = None
|
||||
|
||||
|
||||
def _als_zahl(wert):
|
||||
try:
|
||||
return float(wert)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _schwelle_prozent():
|
||||
p = profil.profil_lesen()
|
||||
if p is None:
|
||||
tankvolumen = STANDARD_TANKVOLUMEN_LITER
|
||||
else:
|
||||
tankvolumen = p.get("fahrzeug", {}).get("tankvolumen_liter") or STANDARD_TANKVOLUMEN_LITER
|
||||
liter_als_prozent = (LITER_SCHWELLE / tankvolumen) * 100
|
||||
return min(liter_als_prozent, PROZENT_SCHWELLE)
|
||||
|
||||
|
||||
@time_trigger("startup")
|
||||
def beim_start():
|
||||
profil.ordner_sicherstellen()
|
||||
|
||||
|
||||
# Ohne konfigurierten Tankfüllstand-Sensor keinen Trigger registrieren -
|
||||
# eine leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes
|
||||
# Verhalten, siehe gleiches Muster in fahrterkennung.py.
|
||||
if einstellungen.TANK_SENSOR:
|
||||
@state_trigger(f"{einstellungen.TANK_SENSOR}")
|
||||
def tankfuellstand_geaendert(value=None, old_value=None):
|
||||
global _tiefststand_pct
|
||||
|
||||
aktuell = _als_zahl(value)
|
||||
if aktuell is None:
|
||||
return
|
||||
|
||||
if _tiefststand_pct is None:
|
||||
_tiefststand_pct = aktuell
|
||||
return
|
||||
|
||||
if aktuell <= _tiefststand_pct:
|
||||
_tiefststand_pct = aktuell
|
||||
return
|
||||
|
||||
anstieg = aktuell - _tiefststand_pct
|
||||
if anstieg >= _schwelle_prozent():
|
||||
_tankvorgang_automatisch_anlegen(anstieg)
|
||||
_tiefststand_pct = aktuell
|
||||
|
||||
|
||||
def _tankvorgang_automatisch_anlegen(anstieg_pct):
|
||||
odometer_km = frontend_veroeffentlichung.zustand_oder_none(einstellungen.KM_SENSOR)
|
||||
tankvorgang = {
|
||||
"tank_id": profil.neue_id("f"),
|
||||
"receipt_key": None,
|
||||
"ts": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"liters": None,
|
||||
"fuel_total_eur": None,
|
||||
"price_per_l": None,
|
||||
"discount": None,
|
||||
"station_name": None,
|
||||
"odometer_km": odometer_km,
|
||||
"distance_km": profil.distanz_seit_letzter_tankung(odometer_km),
|
||||
"fuel_type": None,
|
||||
"source": "auto",
|
||||
"status": "unvollständig",
|
||||
"receipt_file": None,
|
||||
"edited_fields": [],
|
||||
}
|
||||
profil.tankvorgang_anhaengen(tankvorgang)
|
||||
frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen()
|
||||
log.info(
|
||||
f"audi_dashboard: Tankvorgang {tankvorgang['tank_id']} automatisch erkannt "
|
||||
f"(Füllstandsanstieg {round(anstieg_pct, 1)} Prozentpunkte)"
|
||||
)
|
||||
@@ -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}")
|
||||
@@ -0,0 +1,90 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Spielt Code-Updates (pyscript + Frontend) in eine laufende Home-Assistant-
|
||||
Installation ein, ohne Neustart und ohne Cache-Probleme im Browser.
|
||||
|
||||
.BESCHREIBUNG
|
||||
Kopiert nur Code, nie Daten:
|
||||
- pyscript/* -> <Ziel>/pyscript/ (pyscript lädt das automatisch neu,
|
||||
siehe Kopfkommentar dort - an
|
||||
einer Testinstanz bestätigt: eine
|
||||
geänderte Datei war innerhalb von
|
||||
Millisekunden aktiv, ganz ohne
|
||||
Neustart)
|
||||
- www/*.js, www/*.css, www/badges/*, www/bilder/* -> <Ziel>/www/
|
||||
(audi-dashboard-ios.css nicht vergessen: die App hängt sich dieses
|
||||
Overlay-Stylesheet selbst per JS in den Shadow-Root - ohne es fehlt
|
||||
nur der optische Feinschliff, kein Fehler, aber ein veralteter Stand)
|
||||
- www/audi-dashboard-version.json wird IMMER neu geschrieben, mit der
|
||||
aktuellen Uhrzeit als Versionsnummer - der Lade-Stub
|
||||
(audi-dashboard-panel.js) liest diese Datei bei jedem Seitenaufruf
|
||||
ohne Cache und lädt den eigentlichen Code darüber immer frisch nach
|
||||
(siehe Kopfkommentar in www/audi-dashboard-panel.js).
|
||||
|
||||
Absichtlich NICHT kopiert: die Datenbestände in data/ - das würde ein
|
||||
bereits laufendes Fahrzeugprofil, die Fahrten- und Tankvorgänge-Archive
|
||||
sowie die Sensor-Zuordnung überschreiben. Diese Dateien sind nur die
|
||||
Erstbefüllung für die einmalige Installation (siehe INSTALL.md Schritt 2).
|
||||
|
||||
Eine Ausnahme davon ist shell_beleg_parser.py: die Datei liegt zwar in
|
||||
data/, ist aber Code, kein Datenbestand - belegverarbeitung.py ruft sie
|
||||
als Unterprozess aus /config/audi_dashboard/ auf. Ohne diese Ausnahme
|
||||
käme jede Änderung am Belegleser (dem einzigen getesteten Teil des
|
||||
Projekts) auf keiner Instanz an.
|
||||
|
||||
.PARAMETER Ziel
|
||||
Pfad zum config-Verzeichnis von Home Assistant, z. B. ein per Samba
|
||||
eingebundenes Laufwerk (\\<HA-IP>\config) oder ein lokal gemounteter Pfad.
|
||||
|
||||
.BEISPIEL
|
||||
.\update.ps1 -Ziel "\\homeassistant.local\config"
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Ziel
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$hier = $PSScriptRoot
|
||||
|
||||
if (-not (Test-Path $Ziel)) {
|
||||
throw "Ziel nicht erreichbar: $Ziel — Samba-Share verbunden? Pfad korrekt?"
|
||||
}
|
||||
|
||||
Write-Host "Kopiere pyscript/ ..." -ForegroundColor Cyan
|
||||
robocopy "$hier\pyscript" "$Ziel\pyscript" /MIR /NFL /NDL /NJH /NJS | Out-Null
|
||||
|
||||
Write-Host "Kopiere www/ (ohne bilder/ und badges/, die separat behandelt werden) ..." -ForegroundColor Cyan
|
||||
New-Item -ItemType Directory -Force -Path "$Ziel\www" | Out-Null
|
||||
Copy-Item "$hier\www\audi-dashboard-app.js" "$Ziel\www\audi-dashboard-app.js" -Force
|
||||
Copy-Item "$hier\www\audi-dashboard-panel.js" "$Ziel\www\audi-dashboard-panel.js" -Force
|
||||
Copy-Item "$hier\www\audi-dashboard.css" "$Ziel\www\audi-dashboard.css" -Force
|
||||
Copy-Item "$hier\www\audi-dashboard-ios.css" "$Ziel\www\audi-dashboard-ios.css" -Force
|
||||
|
||||
# Code, der in data/ liegt (siehe Kopfkommentar) - der Ordner selbst wird
|
||||
# bewusst nicht gespiegelt, diese eine Datei aber schon.
|
||||
if (Test-Path "$Ziel\audi_dashboard") {
|
||||
Copy-Item "$hier\data\shell_beleg_parser.py" "$Ziel\audi_dashboard\shell_beleg_parser.py" -Force
|
||||
Write-Host " Belegleser aktualisiert"
|
||||
}
|
||||
|
||||
if (Test-Path "$hier\www\bilder") {
|
||||
Write-Host "Kopiere www/bilder/ ..." -ForegroundColor Cyan
|
||||
New-Item -ItemType Directory -Force -Path "$Ziel\www\bilder" | Out-Null
|
||||
Copy-Item "$hier\www\bilder\*" "$Ziel\www\bilder\" -Force -Recurse
|
||||
}
|
||||
|
||||
if (Test-Path "$hier\www\badges") {
|
||||
Write-Host "Kopiere www/badges/ ..." -ForegroundColor Cyan
|
||||
New-Item -ItemType Directory -Force -Path "$Ziel\www\badges" | Out-Null
|
||||
Copy-Item "$hier\www\badges\*" "$Ziel\www\badges\" -Force -Recurse
|
||||
}
|
||||
|
||||
$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."
|
||||
@@ -0,0 +1,297 @@
|
||||
/* ==================================================================
|
||||
iOS-Auflage über audi-dashboard.css — Audi CI (Rot F50537, Audi Type,
|
||||
dunkle Fläche) in der Formensprache einer modernen iOS-App:
|
||||
Große Titel, gruppierte Listen mit 44-px-Zeilen, transluente Tab-Leiste,
|
||||
iOS-Schalter und -Segmente, gefüllte Aktionsknöpfe.
|
||||
Reine Auflage: audi-dashboard.css und die App-Logik bleiben unberührt.
|
||||
================================================================== */
|
||||
|
||||
:host{
|
||||
display:block;height:100%;container-type:inline-size;
|
||||
font-family:"Audi Type",Helvetica,Arial,sans-serif;
|
||||
--ios-tint:#F50537;
|
||||
--ios-sep:rgba(255,255,255,.13);
|
||||
--ios-grau:#8E8E93;
|
||||
--ios-fill:rgba(120,120,128,.24);
|
||||
--r-tile:16px;
|
||||
/* deckende Kachelfarbe fuer Flaechen, die etwas verdecken muessen
|
||||
(Wischzeile ueber dem Loeschen-Feld, Kartenfeld) */
|
||||
--tile-deckend:#171B21;
|
||||
}
|
||||
:host([data-theme="nacht"]){
|
||||
--canvas:#0C1014;
|
||||
--tile:rgba(255,255,255,.055);
|
||||
--tile-2:rgba(255,255,255,.10);
|
||||
--line:rgba(255,255,255,.13);
|
||||
--line-strong:rgba(255,255,255,.24);
|
||||
--fg:#FFFFFF; --fg2:#BFC4CC; --fg3:#8E8E93;
|
||||
--ok:#30D158; --warn:#FFD60A; --bad:#FF453A;
|
||||
}
|
||||
:host([data-theme="tag"]){
|
||||
--canvas:#F2F2F7;
|
||||
--tile:#FFFFFF;
|
||||
--tile-2:#EFEFF4;
|
||||
--line:rgba(60,60,67,.13);
|
||||
--line-strong:rgba(60,60,67,.24);
|
||||
--fg:#000000; --fg2:#3C3C43; --fg3:#8E8E93;
|
||||
--ok:#34C759; --warn:#FF9F0A; --bad:#FF3B30;
|
||||
--ios-sep:rgba(60,60,67,.16);
|
||||
--ios-fill:rgba(120,120,128,.16);
|
||||
--tile-deckend:#FFFFFF;
|
||||
}
|
||||
|
||||
body{padding:0;background:var(--canvas)}
|
||||
html,body{height:100%}
|
||||
/* Der von _aufbauen() eingefuegte Wrapper zwischen Host und .phone. Die
|
||||
412px-Deckelung aus audi-dashboard.css (fuer die alte Einzelgroesse
|
||||
gedacht) wird hier unbedingt aufgehoben, nicht nur ab 860px: auf
|
||||
schmalen Bildschirmen macht der Viewport selbst die Breite eng genug,
|
||||
:host-Selektoren innerhalb @container werden von diesem Browser aber
|
||||
offenbar verworfen - deshalb unbedingt statt bedingt.
|
||||
Die Hoehe bleibt dagegen viewport-verankert (100dvh statt 100%): das ist
|
||||
der Grund, warum audi-dashboard.css das ueberhaupt so baute - HA gibt der
|
||||
panel_custom-Kette keine verlaessliche Hoehe weiter, height:100% liess den
|
||||
Hauptinhalt hier auf 0px kollabieren (Tabbar direkt unter dem Header,
|
||||
main#view unsichtbar). Erst ab 860px (siehe @container unten) darf die
|
||||
880px-Deckelung fallen, damit die App auf grossen Bildschirmen mehr als
|
||||
eine schwebende Telefonkarte ist. */
|
||||
:host > div{height:min(880px, calc(100dvh - 24px));max-width:none}
|
||||
.phone{width:100%;max-width:none;height:100%;border-radius:0;
|
||||
font-family:"Audi Type",Helvetica,Arial,sans-serif;
|
||||
-webkit-font-smoothing:antialiased;letter-spacing:0}
|
||||
|
||||
/* ---------------- Navigationsleiste ---------------- */
|
||||
/* Sichere Bereiche des Geräterahmens: Statusleiste oben, Home-Indikator unten */
|
||||
.topbar{align-items:center;gap:4px;padding:56px 20px 4px}
|
||||
.head{display:flex;flex-direction:column}
|
||||
.eyebrow{display:none}
|
||||
.title{font-size:22px;font-weight:400;letter-spacing:-.01em;line-height:1.2}
|
||||
.back{width:34px;height:34px;margin-left:-8px;justify-content:center}
|
||||
.back svg{width:22px;height:22px;color:var(--ios-tint)}
|
||||
.sync{margin-top:3px;font-size:12.5px;color:var(--fg3)}
|
||||
.sync svg{width:13px;height:13px}
|
||||
.themebtn{width:36px;height:36px;border:0;background:var(--ios-fill);color:var(--fg2)}
|
||||
.themebtn:hover{background:var(--tile-2);color:var(--fg)}
|
||||
/* Telefonbreite: die Ringe stehen oben rechts und oeffnen die Einstellungen
|
||||
(Stand nach dem Design-Audit). Das Zahnrad entfaellt dort, damit oben rechts
|
||||
nur ein Element sitzt. */
|
||||
.rings{order:9;margin-left:8px;margin-right:-10px;cursor:pointer;
|
||||
min-width:44px;min-height:44px;height:44px;justify-content:flex-end;padding:0 10px}
|
||||
.rings svg{height:17px}
|
||||
.profilbtn{display:none}
|
||||
/* Unterseite: kompakter, zentrierter Titel wie in einer iOS-Navigationsleiste */
|
||||
.phone:has(.back.on) .topbar{padding:54px 16px 6px;gap:8px}
|
||||
.phone:has(.back.on) .head{align-items:center}
|
||||
.phone:has(.back.on) .title{font-size:17px;font-weight:400;letter-spacing:-.01em;text-align:center}
|
||||
.phone:has(.back.on) .sync{margin-top:2px;font-size:11.5px}
|
||||
|
||||
main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smooth}
|
||||
|
||||
/* ---------------- Karten und Listen ---------------- */
|
||||
.tile{padding:16px 18px;margin-bottom:14px;border-radius:var(--r-tile)}
|
||||
.tile.flat{padding:0}
|
||||
.tilebtn:hover{background:var(--tile-2)}
|
||||
/* top/margin-top NICHT von der Basis uebernehmen: alle .go-Kacheln
|
||||
(Fahrzeug/Service/Versicherung/Reifen/Schutzbrief) sind mehrzeilige
|
||||
Kacheln mit .label oben plus einer ganzen dl.rows-Liste darunter - eine
|
||||
vertikale 50%-Zentrierung landet dann mitten in der Zeilenliste statt
|
||||
neben dem Titel und ueberlappt umgebrochenen Zeilentext (Design-Audit
|
||||
2026-08-13). Bleibt bei top:var(--sp-5) aus der Basis, nur rechter
|
||||
Abstand/Farbe werden fuer die iOS-Optik angepasst. */
|
||||
.tilebtn .go{right:18px;stroke:var(--ios-grau);stroke-width:1.6}
|
||||
.label{font-size:13px;letter-spacing:.01em;text-transform:none;color:var(--fg3)}
|
||||
.row{min-height:44px;padding:11px 0;border-bottom:1px solid var(--ios-sep)}
|
||||
.row dt{font-size:16px;color:var(--fg)}
|
||||
.row dd{font-size:16px;color:var(--fg2)}
|
||||
.row dd small{font-size:12.5px;margin-top:2px}
|
||||
.rows-fest dt{flex:0 0 150px}
|
||||
.chev{stroke:var(--ios-grau);stroke-width:1.6}
|
||||
|
||||
.acc-h{padding:13px 0;border-bottom:1px solid var(--ios-sep)}
|
||||
.acc-h .k{font-size:16px}
|
||||
.acc-h .v{font-size:12.5px}
|
||||
.leaf{padding:12px 0 12px 40px;border-bottom:1px solid var(--ios-sep)}
|
||||
.leaf .k{font-size:16px}
|
||||
.leaf .v{font-size:15px}
|
||||
.leaf .k small,.leaf .v small{font-size:12.5px}
|
||||
.mark::before,.mark::after{background:var(--ios-grau)}
|
||||
|
||||
/* ---------------- Bedienelemente ---------------- */
|
||||
.seg{gap:0;padding:2px;background:var(--ios-fill);border-radius:9px;margin-top:14px}
|
||||
.seg button{border:0;border-radius:7px;padding:7px 8px;font-size:13px;font-weight:400;
|
||||
letter-spacing:0;text-transform:none;color:var(--fg2)}
|
||||
.seg button.on{background:var(--tile-2);color:var(--fg);box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
||||
|
||||
/* Rot bleibt Akzent/Primäraktion/destruktiv, nicht Flächenfarbe jeder Aktion
|
||||
(Design-Review 2026-08-13 §B): .aktion allein ist die neutrale
|
||||
Sekundäraktion, .primaer die einzige rot gefüllte Fläche. */
|
||||
.aktion{margin-top:18px;padding:15px;border:0;border-radius:14px;
|
||||
background:var(--ios-fill);color:var(--fg);font-size:16px;font-weight:400;
|
||||
letter-spacing:0;text-transform:none}
|
||||
.aktion:hover{background:var(--tile-2)}
|
||||
.aktion.primaer{background:var(--ios-tint);color:#fff}
|
||||
.aktion.primaer:hover{filter:brightness(1.08)}
|
||||
.aktion.loeschen{background:var(--ios-fill);color:var(--bad);border:0}
|
||||
.aktion.loeschen:hover{background:var(--bad);color:#fff;filter:none}
|
||||
|
||||
.feld{min-height:44px;padding:9px 0;border-bottom:1px solid var(--ios-sep)}
|
||||
.feld label{font-size:16px;color:var(--fg)}
|
||||
.feld input,.feld select{background:transparent;border:0;border-radius:8px;font-size:16px;
|
||||
padding:8px 4px;color:var(--fg2)}
|
||||
/* Design-Audit 2026-08-13/16: die graue Flaeche ist das einheitliche Signal
|
||||
"das hier kann ich aendern". Sie trugen bisher nur die Felder mit Einheit
|
||||
(.mitEinheit input, z.B. "Pause bis [15] Minuten"); alles andere - <select>
|
||||
wie "10.000 km"/"1 Jahr", aber auch FIN oder Kennzeichen - stand ohne Kasten
|
||||
da und war von reinem Anzeigetext nicht zu unterscheiden. Jetzt bekommt
|
||||
jedes editierbare Feld denselben Kasten. Ausgenommen sind Eingaben mit
|
||||
eigener Optik (Schalter, Auswahlkaestchen, Regler, Dateiauswahl) - die
|
||||
behalten die transparente Grundregel darueber.
|
||||
.mitEinheit input braucht deshalb keine eigene Regel mehr; Breite und
|
||||
Rechtsbuendigkeit dort kommen weiterhin aus audi-dashboard.css. */
|
||||
.feld input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]),
|
||||
.feld select{background:var(--ios-fill);padding:8px 10px}
|
||||
.feld input:focus,.feld select:focus{color:var(--fg)}
|
||||
.notiz{background:var(--ios-fill);border:0;border-radius:12px;font-size:16px}
|
||||
|
||||
.switch{width:51px;height:31px}
|
||||
.switch .track{background:var(--ios-fill);border:0}
|
||||
.switch .track::before{left:2px;top:2px;width:27px;height:27px;box-shadow:0 2px 6px rgba(0,0,0,.3)}
|
||||
/* Grün statt Rot: Schalter-Ein-Zustand ist kein destruktiver/Primär-Akzent,
|
||||
sondern ein neutrales "an" - deckt sich mit dem echten iOS-Systemgrün, das
|
||||
--ok hier ohnehin schon trägt (Design-Review §B). */
|
||||
.switch input:checked+.track{background:var(--ok)}
|
||||
.switch input:checked+.track::before{transform:translateX(20px)}
|
||||
|
||||
.pill{font-size:12px;letter-spacing:.01em;text-transform:none;padding:5px 11px;
|
||||
border:0;background:var(--ios-fill);color:var(--fg2)}
|
||||
.pill.work{background:rgba(245,5,55,.16);color:var(--ios-tint)}
|
||||
|
||||
.status{padding:14px 0 2px}
|
||||
.status .t{font-size:16px}
|
||||
.dot{width:10px;height:10px}
|
||||
|
||||
/* ---------------- Zahlen (Audi CI bleibt führend) ---------------- */
|
||||
.fig{letter-spacing:-.03em}
|
||||
.unit{color:var(--fg3)}
|
||||
.balken{height:6px;background:var(--ios-fill)}
|
||||
.balken i{background:var(--ok)}
|
||||
.quad .l{font-size:11px;letter-spacing:.01em;text-transform:none}
|
||||
.legend span{font-size:12.5px}
|
||||
|
||||
/* ---------------- Menüs und Popups ---------------- */
|
||||
.bildmenu,.sdpopup{border:0;border-radius:14px;background:rgba(40,44,52,.92);
|
||||
-webkit-backdrop-filter:saturate(180%) blur(22px);backdrop-filter:saturate(180%) blur(22px);
|
||||
box-shadow:0 12px 40px rgba(0,0,0,.5)}
|
||||
:host([data-theme="tag"]) .bildmenu,:host([data-theme="tag"]) .sdpopup{background:rgba(250,250,252,.92)}
|
||||
.bildmenu button{padding:13px 16px;font-size:16px;border-bottom:1px solid var(--ios-sep)}
|
||||
|
||||
/* ---------------- Tab-Leiste ---------------- */
|
||||
.tabbar{border-top:1px solid var(--ios-sep);padding-bottom:26px;
|
||||
background:rgba(12,16,20,.72);
|
||||
-webkit-backdrop-filter:saturate(180%) blur(24px);backdrop-filter:saturate(180%) blur(24px)}
|
||||
:host([data-theme="tag"]) .tabbar{background:rgba(242,242,247,.78)}
|
||||
.tab{border-top:0;margin-top:0;padding:7px 2px 6px;gap:3px;
|
||||
font-size:10px;letter-spacing:0;text-transform:none;color:var(--ios-grau)}
|
||||
/* Beschriftung folgt weiter dem Schalter in den Einstellungen */
|
||||
.tabbar.ohne .tab span{display:none}
|
||||
.tab svg{width:26px;height:26px;stroke-width:1.5}
|
||||
/* myAudi-Menueleiste: das aktive Symbol sitzt hell in einer gefuellten Pille,
|
||||
die uebrigen bleiben grau - kein farbiger Tint, kein Oberkantenstrich. */
|
||||
.tabpille{width:63px;height:39px;border-radius:20px}
|
||||
.tab.on{color:var(--fg);border-top-color:transparent}
|
||||
.tab.on .tabpille{background:rgba(255,255,255,.14)}
|
||||
:host([data-theme="tag"]) .tab.on .tabpille{background:rgba(0,0,0,.07)}
|
||||
.tabbar.ohne .tab{padding:11px 2px 10px}
|
||||
|
||||
:focus-visible{outline:2px solid var(--ios-tint);outline-offset:2px}
|
||||
|
||||
/* Die Wischzeile muss das darunterliegende Loeschen-Feld verdecken - das
|
||||
halbtransparente --tile der Auflage liess es durchscheinen. */
|
||||
.swipe-wrap .swipe-content{background:var(--tile-deckend)}
|
||||
.mapbox,.mapbox .leaflet-container{background:var(--tile-deckend)}
|
||||
|
||||
/* ==================================================================
|
||||
Große Bildschirme (Tablet, Desktop): dieselbe App, aber die Tab-Leiste
|
||||
wird zur seitlichen Navigation und der Inhalt bekommt eine ruhige,
|
||||
begrenzte Spalte. Container-Abfrage statt @media, weil die App in einem
|
||||
eigenen Rahmen sitzt und nicht die Fensterbreite messen darf. Schwelle 860 px.
|
||||
================================================================== */
|
||||
/* Klon der Ringe in der Menueleiste - nur im Spaltenlayout sichtbar */
|
||||
.navmarke{display:none}
|
||||
|
||||
@container (min-width:860px){
|
||||
/* Ab hier keine schwebende Telefonkarte mehr - die 880px-Deckelung faellt,
|
||||
die App fuellt die tatsaechliche (weiterhin viewport-verankerte) Hoehe. */
|
||||
:host > div{height:calc(100dvh - 24px)}
|
||||
.phone{display:grid;grid-template-columns:264px minmax(0,1fr);
|
||||
grid-template-rows:auto auto minmax(0,1fr)}
|
||||
/* max-width:860px muss zu main#view passen, sonst treibt der Flex-Inhalt
|
||||
(.head{flex:1} schiebt .profilbtn nach rechts) das Zahnrad bis an den
|
||||
Rand der vollen Grid-Spalte statt an den Rand der Inhaltsspalte darunter -
|
||||
auf breiten Bildschirmen klaffte da eine sichtbare Luecke. */
|
||||
.topbar{grid-column:2;grid-row:1;padding:38px 44px 8px;max-width:860px}
|
||||
.phone:has(.back.on) .topbar{grid-column:2;grid-row:1;padding:34px 44px 8px;justify-content:flex-start;max-width:860px}
|
||||
/* Design-Audit 2026-08-13: .back ist auch inaktiv (opacity:0) noch ein
|
||||
44px breites Flex-Element plus Gap - auf Wurzelseiten ohne Zurueck-Pfeil
|
||||
schob das die Kopfzeile (.head/.title) sichtbar weiter nach rechts als
|
||||
main#view darunter beginnt. Auf Detailseiten (.back.on) bleibt der Platz
|
||||
bewusst reserviert, das ist dort ein echter, bedienbarer Knopf. */
|
||||
.back:not(.on){width:0;min-width:0;margin:0;overflow:hidden}
|
||||
.phone:has(.back.on) .head{align-items:flex-start}
|
||||
.phone:has(.back.on) .title{font-size:26px;text-align:left}
|
||||
.title{font-size:34px}
|
||||
.ptr{grid-column:2;grid-row:2;max-width:860px}
|
||||
main#view{--seitenrand:44px;grid-column:2;grid-row:3;padding:8px var(--seitenrand) 48px;max-width:860px}
|
||||
|
||||
.tabbar{grid-column:1;grid-row:1 / 4;display:flex;flex-direction:column;gap:4px;
|
||||
align-content:start;width:264px;padding:38px 14px 20px;
|
||||
border-top:0;border-right:1px solid var(--ios-sep);background:none;
|
||||
-webkit-backdrop-filter:none;backdrop-filter:none}
|
||||
.tab{flex-direction:row;justify-content:flex-start;align-items:center;gap:14px;
|
||||
padding:12px 14px;border-radius:12px;font-size:15px;letter-spacing:0}
|
||||
.tab span{display:block}
|
||||
.tab svg{width:22px;height:22px}
|
||||
.tabpille{width:auto;height:auto;border-radius:0;background:none}
|
||||
.tab.on .tabpille{background:none}
|
||||
.tab:hover{background:var(--tile)}
|
||||
/* Aktive Navigation: Rot bleibt Text-/Icon-Akzent, die Fläche wird neutral
|
||||
statt rosa hinterlegt (Design-Review §B - Rot nicht als Flächenfarbe). */
|
||||
.tab.on{background:var(--ios-fill);color:var(--ios-tint)}
|
||||
.tabbar.ohne .tab span{display:block}
|
||||
.tabbar.ohne .tab{padding:12px 14px;gap:14px}
|
||||
.tabbar.ohne .tab svg{width:22px;height:22px}
|
||||
/* Marke am Kopf der Seitenspalte, dafuer keine Ringe in der Kopfzeile
|
||||
(Design-Audit: die Marke soll nur einmal im Bild stehen). */
|
||||
.navmarke{display:block;margin:2px 0 26px 14px;color:var(--fg)}
|
||||
.navmarke svg{width:54px;height:auto;display:block}
|
||||
/* Grosser Bildschirm: Marke am Kopf der Spalte, Zahnrad wieder oben rechts */
|
||||
.profilbtn{display:inline-flex}
|
||||
/* Die Kopf-Ringe setzt die App per Inline-Stil (display:inline-flex),
|
||||
deshalb hier mit !important. */
|
||||
.rings{display:none !important}
|
||||
|
||||
.duo{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
.carfix,.bildbox.carfix{height:260px}
|
||||
.carfix.klein,.bildbox.carfix.klein{height:220px}
|
||||
.bildgrid{grid-template-columns:repeat(4,1fr)}
|
||||
.quad{gap:14px}
|
||||
|
||||
/* Design-Audit 2026-08-13: .setup-popup/.sdpopup/.sheet/.standortmenu sind
|
||||
position:absolute relativ zu .phone (der vollen Rasterbreite, nicht nur
|
||||
der Inhaltsspalte) mit left/right auf einen festen Abstand - auf breiten
|
||||
Bildschirmen ergab das einen fast bildschirmbreiten Dialog statt einer
|
||||
ruhigen Karte. max-width + margin:auto zentriert sie innerhalb des
|
||||
bestehenden left/right-Abstands, ohne die transform-basierte
|
||||
Einblend-Animation dieser Elemente zu beruehren. */
|
||||
.setup-popup,.sdpopup,.sheet{max-width:560px;margin-left:auto;margin-right:auto}
|
||||
.standortmenu{max-width:640px;margin-left:auto;margin-right:auto}
|
||||
}
|
||||
|
||||
/* Fahrzeugbild randlos wie in einer Fahrzeug-App: der Platzhalter bleibt
|
||||
sichtbar, aber ohne technischen Strichrahmen. */
|
||||
.szene{margin-bottom:20px}
|
||||
.bildbox.bild-fehlt .platzhalter{border:0;background:var(--tile);border-radius:var(--r-tile)}
|
||||
.platzhalter-datei{display:none}
|
||||
|
||||
/* Zeilen mit Wert plus Statuspunkt rechts (Sicherheitscheck) ruhiger setzen */
|
||||
.row dd .dot{width:8px;height:8px}
|
||||
@@ -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": 1786766000}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -0,0 +1,115 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>DataMetric360 — Gerät verbinden</title>
|
||||
<!--
|
||||
Erzeugt den Einrichtungs-QR-Code für die App, vollständig auf dem Gerät.
|
||||
|
||||
Der Punkt dabei: Ein QR-Generator im Internet bekäme den Zugangstoken zu
|
||||
sehen. Diese Seite lädt ihre Bibliothek aus dem eigenen Home Assistant
|
||||
(dm360-qrcode-lib.js daneben) und stellt keine einzige Netzanfrage — der
|
||||
Token verlässt das eigene Netz nicht.
|
||||
|
||||
Ablegen unter /config/www/, aufrufbar als /local/dm360-qr.html.
|
||||
Gegenstück in der App: UMSETZUNGSPLAN.md Phase 10 Stufe 2.
|
||||
-->
|
||||
<script src="./dm360-qrcode-lib.js"></script>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body {
|
||||
margin: 0; min-height: 100vh; display: grid; place-items: center;
|
||||
background: #161b23; color: #fff;
|
||||
font: 400 15px/1.55 Helvetica, Arial, sans-serif;
|
||||
}
|
||||
main { width: min(420px, 92vw); padding: 24px; }
|
||||
h1 { font-size: 22px; font-weight: 300; margin: 0 0 6px; }
|
||||
p { color: #9aa1ad; font-size: 13.5px; margin: 0 0 18px; text-wrap: pretty; }
|
||||
label { display: block; font-size: 10px; letter-spacing: .2em;
|
||||
text-transform: uppercase; color: #8a94a3; margin: 14px 0 6px; }
|
||||
input {
|
||||
width: 100%; box-sizing: border-box; padding: 11px 12px; font-size: 16px;
|
||||
background: #1f2733; color: #fff; border: 1px solid rgba(255,255,255,.1);
|
||||
border-radius: 8px; outline: none;
|
||||
}
|
||||
input:focus-visible { outline: 2px solid #F50537; outline-offset: 2px; }
|
||||
button {
|
||||
margin-top: 18px; width: 100%; padding: 13px; font-size: 15px;
|
||||
background: #fff; color: #000; border: 0; border-radius: 999px; cursor: pointer;
|
||||
}
|
||||
#ausgabe { margin-top: 22px; display: none; text-align: center; }
|
||||
#ausgabe.sichtbar { display: block; }
|
||||
#ausgabe img { width: 100%; max-width: 320px; height: auto;
|
||||
image-rendering: pixelated; background: #fff;
|
||||
padding: 12px; border-radius: 12px; box-sizing: border-box; }
|
||||
.warnung { color: #ffaa00; font-size: 12px; margin-top: 14px; }
|
||||
.fehler { color: #fd2c4e; font-size: 13px; margin-top: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Gerät verbinden</h1>
|
||||
<p>
|
||||
Trag die von außen erreichbare Adresse und einen langlebigen Zugriffstoken ein.
|
||||
Der Code entsteht hier auf dem Gerät; nichts davon wird irgendwohin gesendet.
|
||||
</p>
|
||||
|
||||
<label for="adresse">Server-Adresse</label>
|
||||
<input id="adresse" type="url" placeholder="https://api.datametric360.app" />
|
||||
|
||||
<label for="token">Zugriffstoken</label>
|
||||
<input id="token" type="password" placeholder="in Home Assistant unter Profil → Sicherheit" />
|
||||
|
||||
<button id="erzeugen" type="button">Code anzeigen</button>
|
||||
|
||||
<div id="ausgabe"></div>
|
||||
<p id="meldung" class="fehler" hidden></p>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const ausgabe = document.getElementById("ausgabe")
|
||||
const meldung = document.getElementById("meldung")
|
||||
|
||||
function melde(text) {
|
||||
meldung.textContent = text
|
||||
meldung.hidden = !text
|
||||
}
|
||||
|
||||
document.getElementById("erzeugen").addEventListener("click", () => {
|
||||
melde("")
|
||||
ausgabe.classList.remove("sichtbar")
|
||||
|
||||
const url = document.getElementById("adresse").value.trim().replace(/\/+$/, "")
|
||||
const token = document.getElementById("token").value.trim()
|
||||
if (!url || !token) {
|
||||
melde("Bitte Adresse und Token eintragen.")
|
||||
return
|
||||
}
|
||||
|
||||
const inhalt = JSON.stringify({ url, token })
|
||||
try {
|
||||
// Version 0 heißt: kleinstmögliche Version selbst wählen.
|
||||
// Fehlerkorrektur L, weil der Code direkt vom Bildschirm abgelesen
|
||||
// wird — es gibt keinen zerkratzten Aufkleber zu retten.
|
||||
const qr = qrcode(0, "L")
|
||||
qr.addData(inhalt)
|
||||
qr.make()
|
||||
ausgabe.innerHTML = qr.createImgTag(6, 0, "Einrichtungs-Code")
|
||||
ausgabe.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
'<p class="warnung">Wer diesen Code abfotografiert, hat vollen Zugriff auf die ' +
|
||||
"Schnittstelle. Nach dem Einrichten die Seite schließen.</p>",
|
||||
)
|
||||
ausgabe.classList.add("sichtbar")
|
||||
} catch (fehler) {
|
||||
melde(
|
||||
"Der Code konnte nicht erzeugt werden: " +
|
||||
String((fehler && fehler.message) || fehler) +
|
||||
". Ist der Token vollständig eingefügt?",
|
||||
)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -138,15 +138,19 @@ main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smo
|
||||
.feld label{font-size:16px;color:var(--fg)}
|
||||
.feld input,.feld select{background:transparent;border:0;border-radius:8px;font-size:16px;
|
||||
padding:8px 4px;color:var(--fg2)}
|
||||
/* Design-Audit 2026-08-13: ein <select> (z.B. Oelwechsel-Intervall "10.000 km"/
|
||||
"1 Jahr") sah wie reiner, nicht editierbarer Text aus, seit hier Rahmen/
|
||||
Flaeche entfernt wurden. Der Hinweis "hier gibt es eine Auswahl" laeuft
|
||||
deshalb ueber dieselbe graue Flaeche wie bei .mitEinheit input (Feld
|
||||
"Pause bis [15] Minuten") - ein Kasten liest sich als bedienbar, waehrend
|
||||
eingefaerbter Text auch bloss Hervorhebung sein koennte. */
|
||||
/* Design-Audit 2026-08-13/16: die graue Flaeche ist das einheitliche Signal
|
||||
"das hier kann ich aendern". Sie trugen bisher nur die Felder mit Einheit
|
||||
(.mitEinheit input, z.B. "Pause bis [15] Minuten"); alles andere - <select>
|
||||
wie "10.000 km"/"1 Jahr", aber auch FIN oder Kennzeichen - stand ohne Kasten
|
||||
da und war von reinem Anzeigetext nicht zu unterscheiden. Jetzt bekommt
|
||||
jedes editierbare Feld denselben Kasten. Ausgenommen sind Eingaben mit
|
||||
eigener Optik (Schalter, Auswahlkaestchen, Regler, Dateiauswahl) - die
|
||||
behalten die transparente Grundregel darueber.
|
||||
.mitEinheit input braucht deshalb keine eigene Regel mehr; Breite und
|
||||
Rechtsbuendigkeit dort kommen weiterhin aus audi-dashboard.css. */
|
||||
.feld input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]),
|
||||
.feld select{background:var(--ios-fill);padding:8px 10px}
|
||||
.feld input:focus,.feld select:focus{background:var(--ios-fill);color:var(--fg)}
|
||||
.mitEinheit input{background:var(--ios-fill);padding:8px 10px}
|
||||
.feld input:focus,.feld select:focus{color:var(--fg)}
|
||||
.notiz{background:var(--ios-fill);border:0;border-radius:12px;font-size:16px}
|
||||
|
||||
.switch{width:51px;height:31px}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version": 1786765000}
|
||||
{"version": 1786766000}
|
||||
|
||||