From fb38c297bddc2d4b3383ea96625caea0b835f50f Mon Sep 17 00:00:00 2001 From: Tobi G Date: Sun, 16 Aug 2026 12:52:31 +0200 Subject: [PATCH] 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 --- AGENTS.md | 29 + homeassistant/.gitignore | 10 +- homeassistant/data/shell_beleg_parser.py | 86 +- .../data/tests/test_shell_beleg_parser.py | 13 +- homeassistant/installationspaket/ANLEITUNG.md | 75 + .../installationspaket/ANLEITUNG.pdf | Bin 0 -> 84729 bytes .../configuration_snippet.yaml | 38 + .../data/fahrzeugprofil.example.json | 184 + .../data/shell_beleg_parser.py | 452 ++ .../installationspaket/pyscript/backup.py | 113 + .../pyscript/batterieverlauf.py | 62 + .../pyscript/belegverarbeitung.py | 275 ++ .../pyscript/bilderverwaltung.py | 71 + .../pyscript/fahrtabschluss.py | 31 + .../pyscript/fahrterkennung.py | 171 + .../pyscript/frontend_api.py | 112 + .../pyscript/modules/einstellungen.py | 93 + .../pyscript/modules/entitaeten.py | 245 + .../pyscript/modules/fahrtabschluss_logik.py | 196 + .../modules/frontend_veroeffentlichung.py | 198 + .../pyscript/modules/profil.py | 280 ++ .../pyscript/reifenzaehler.py | 107 + .../pyscript/tankerkennung.py | 123 + .../pyscript/updateverwaltung.py | 184 + homeassistant/installationspaket/update.ps1 | 90 + .../www/audi-dashboard-app.js | 4089 +++++++++++++++++ .../www/audi-dashboard-ios.css | 297 ++ .../www/audi-dashboard-panel.js | 47 + .../www/audi-dashboard-version.json | 1 + .../installationspaket/www/audi-dashboard.css | 1030 +++++ .../www/badges/rs3-negative.svg | 1 + .../www/badges/rs3-positive.svg | 1 + .../www/badges/rs4-negative.svg | 1 + .../www/badges/rs4-positive.svg | 1 + .../www/badges/rs5-negative.svg | 1 + .../www/badges/rs5-positive.svg | 1 + .../www/badges/rs6-negative.svg | 1 + .../www/badges/rs6-positive.svg | 1 + .../www/badges/rs7-negative.svg | 1 + .../www/badges/rs7-positive.svg | 1 + .../www/badges/s3-negative.svg | 1 + .../www/badges/s3-positive.svg | 1 + .../www/badges/sq7-negative.svg | 1 + .../www/badges/sq7-positive.svg | 1 + .../www/bilder/shell-logo.svg | 8 + .../installationspaket/www/dm360-qr.html | 115 + .../www/dm360-qrcode-lib.js | 2297 +++++++++ homeassistant/www/audi-dashboard-ios.css | 20 +- homeassistant/www/audi-dashboard-version.json | 2 +- 49 files changed, 11131 insertions(+), 27 deletions(-) create mode 100644 homeassistant/installationspaket/ANLEITUNG.md create mode 100644 homeassistant/installationspaket/ANLEITUNG.pdf create mode 100644 homeassistant/installationspaket/configuration_snippet.yaml create mode 100644 homeassistant/installationspaket/data/fahrzeugprofil.example.json create mode 100644 homeassistant/installationspaket/data/shell_beleg_parser.py create mode 100644 homeassistant/installationspaket/pyscript/backup.py create mode 100644 homeassistant/installationspaket/pyscript/batterieverlauf.py create mode 100644 homeassistant/installationspaket/pyscript/belegverarbeitung.py create mode 100644 homeassistant/installationspaket/pyscript/bilderverwaltung.py create mode 100644 homeassistant/installationspaket/pyscript/fahrtabschluss.py create mode 100644 homeassistant/installationspaket/pyscript/fahrterkennung.py create mode 100644 homeassistant/installationspaket/pyscript/frontend_api.py create mode 100644 homeassistant/installationspaket/pyscript/modules/einstellungen.py create mode 100644 homeassistant/installationspaket/pyscript/modules/entitaeten.py create mode 100644 homeassistant/installationspaket/pyscript/modules/fahrtabschluss_logik.py create mode 100644 homeassistant/installationspaket/pyscript/modules/frontend_veroeffentlichung.py create mode 100644 homeassistant/installationspaket/pyscript/modules/profil.py create mode 100644 homeassistant/installationspaket/pyscript/reifenzaehler.py create mode 100644 homeassistant/installationspaket/pyscript/tankerkennung.py create mode 100644 homeassistant/installationspaket/pyscript/updateverwaltung.py create mode 100644 homeassistant/installationspaket/update.ps1 create mode 100644 homeassistant/installationspaket/www/audi-dashboard-app.js create mode 100644 homeassistant/installationspaket/www/audi-dashboard-ios.css create mode 100644 homeassistant/installationspaket/www/audi-dashboard-panel.js create mode 100644 homeassistant/installationspaket/www/audi-dashboard-version.json create mode 100644 homeassistant/installationspaket/www/audi-dashboard.css create mode 100644 homeassistant/installationspaket/www/badges/rs3-negative.svg create mode 100644 homeassistant/installationspaket/www/badges/rs3-positive.svg create mode 100644 homeassistant/installationspaket/www/badges/rs4-negative.svg create mode 100644 homeassistant/installationspaket/www/badges/rs4-positive.svg create mode 100644 homeassistant/installationspaket/www/badges/rs5-negative.svg create mode 100644 homeassistant/installationspaket/www/badges/rs5-positive.svg create mode 100644 homeassistant/installationspaket/www/badges/rs6-negative.svg create mode 100644 homeassistant/installationspaket/www/badges/rs6-positive.svg create mode 100644 homeassistant/installationspaket/www/badges/rs7-negative.svg create mode 100644 homeassistant/installationspaket/www/badges/rs7-positive.svg create mode 100644 homeassistant/installationspaket/www/badges/s3-negative.svg create mode 100644 homeassistant/installationspaket/www/badges/s3-positive.svg create mode 100644 homeassistant/installationspaket/www/badges/sq7-negative.svg create mode 100644 homeassistant/installationspaket/www/badges/sq7-positive.svg create mode 100644 homeassistant/installationspaket/www/bilder/shell-logo.svg create mode 100644 homeassistant/installationspaket/www/dm360-qr.html create mode 100644 homeassistant/installationspaket/www/dm360-qrcode-lib.js diff --git a/AGENTS.md b/AGENTS.md index 2c7da24..4ac58c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` diff --git a/homeassistant/.gitignore b/homeassistant/.gitignore index d3c7379..14c4c82 100644 --- a/homeassistant/.gitignore +++ b/homeassistant/.gitignore @@ -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. diff --git a/homeassistant/data/shell_beleg_parser.py b/homeassistant/data/shell_beleg_parser.py index a444dd3..0337554 100644 --- a/homeassistant/data/shell_beleg_parser.py +++ b/homeassistant/data/shell_beleg_parser.py @@ -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 diff --git a/homeassistant/data/tests/test_shell_beleg_parser.py b/homeassistant/data/tests/test_shell_beleg_parser.py index 7f3d0e7..7619aff 100644 --- a/homeassistant/data/tests/test_shell_beleg_parser.py +++ b/homeassistant/data/tests/test_shell_beleg_parser.py @@ -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) diff --git a/homeassistant/installationspaket/ANLEITUNG.md b/homeassistant/installationspaket/ANLEITUNG.md new file mode 100644 index 0000000..0e32208 --- /dev/null +++ b/homeassistant/installationspaket/ANLEITUNG.md @@ -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 (`\\\config`): + +| Aus diesem Ordner | Nach | +|---|---| +| `pyscript\` | `\\\config\pyscript\` | +| `data\` | `\\\config\audi_dashboard\` (**umbenennen** von `data` zu `audi_dashboard`) | +| `www\` | `\\\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 "\\\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`. diff --git a/homeassistant/installationspaket/ANLEITUNG.pdf b/homeassistant/installationspaket/ANLEITUNG.pdf new file mode 100644 index 0000000000000000000000000000000000000000..54d65055f768f87450174ac3c31a76feb3de57ec GIT binary patch literal 84729 zcma&M1CXRa*EQJgp0+v7X|vk4ZQHgzZB5&@ZQGc(ZQJg@=l$M|Z#QCNW25S3)^qcm zo2T-5Dk~%NlE?{((9qK|L6gj#o$Nr<695Qo4J@F!xagG3ove%rxI~0SL;wIL0RVuN z`P&%2jUNDDq5n4FZxj5sZ$AtBx1Z^MF#qwh0ssu(FZyraHwD1--Tu$d%J@IH|M35D z|Jiq}f82jDSibxEhx^C*kN;n6mj7P=x1ayS|F4$+;Qs0Ocm1#ZpZ%B5#PoC z5NMIBn%fxJx;hfbC=t*DXaSrA-wqZgP69U;CM^mAemgrWV^w1VNpmMUW=2+8MwV}i zq?nSl6g7dBxur3IsIj4??Ke=&Sl`IX*wInY%)!>$n2w&Ap7uN1cMppCCi)KM{})-< z$n<~PD4^-&9Bhr84UNB5DO#HA(|zAo0_$(?cgug|n%mk4={p${kPC5q-xn4D8$AO( zGb24c0~G*3_Wl0GN!uFze?xe9ppC!3;`igh_`i>dp*{gUG@XDn0iBGkgSEcZ|AJWm z7bIeC<@7C$PQ>b4gOIVIt&uS_os_YSsgv3FSg`|`cz6gLoxX?F8rm)6!dtzXTHI#S z^CR4gB_~4-69<5)cX?qx`rBAuw; zkPvI#zjs;C>UQgprTJq4-dE+;^R9B?Vr{C~w>?Phu_0*wQEe7IgUVCV8m4lQdw_2Z z|MTW?>uc_j?i(?<5kd_2SiD!f<dh@^Sao^>J$Z z{&=SQb!yxFd4&Jn&mpU>&$EZ4CY)wb3;5-2wvA`uZu866&u}I;$oGEUEuW`m-EVe! zjmCYs@RnHGedp*CUGRf}6Q1rm&Pz?@7wgF@x7FkO+*Sp|p4qf5{nB62RfXWH9qBaN zA>u1i)una})6coD=eK5GEjY4FMcVW&g^S3jZo@3cbM8H=_g{i>RCk3^J5{*Q=69M> zudJQ6PtVX_BXpgXPgR-cE!j<P$&#e3pi-2!*HxgU(#?V$h z{#Lf)>Ggvv?pM@RfAUP$ncbCBYAQALSO_3}ue9z+{!{g1r{6R&d2}5>-Nnw}qS+(NRj@kHr@XS9kLwmq{0DHE#yJP@|MQK5<5Ayl`G4%T-3V z8lKhtCHFG-AjM(YeIoTuL6G|51+4T>kppaD@D=9#11&790uEfEhT*S|9E~|VDxQm~ zgP(h0=INN!x)?vWWxI@6*s}}|nxl`*5D3N*+!1=^b5TNBL#%e5P$u+fGsf5T2=`IA zyrF{{OEZdJ1Sxn#^(yGlzo>U;=)js;k8tXUC$r<|Pt(Q+7h=3T)Bq<$hM~kR z1|YR#pKFffe$IsL3{CUiv`Z2uz$y+4AIVQ+y_+%Cu%0ldDUS}4eUgtGp1-cB%VlDtg&V2#>h4fGFDu--w2ls%12HeC3pKPj1cl% z?eX=*j2Zg$H@e42hme>t`-vM9)_owrlF?2FJri88)t!_2hrkBI)Z-nXuaC!kn}}un-aKGqCvc>A{G6Q ziQ+{GR6QEzqrZ@Jvcf#kw;(^1cy_Py8|M9HJ&^Sh*@Uhc5X)}4M}Y!%;9O4-jaREm*xMl9!PSPU3f9{g_YqU`H9tU;l&!VJa!p?@Bq*b zj)S52WrCUF9RVyiQls$Bch<)epc%?cWDNG46n7=^(K`uL!=-1^}My_j&!8&Mu{ud#`gR;_hDp&+6 z(?4FR7C}>vI}GdXec#ij%2CYMqa9fDIHHGdHSZSN1>UL@6dYm^3%GjkTwj~KNDr7o zM!%NuQ(vF5QttvU5Y>xr=2{ zxgUb!(Y|(*&juRD2tFf=2!p2q75F|K&jIZGs+C0+S#_I40$gY_yM@Vlq8!VVCz4ZBSYvD4X1&bjXgmT^ zNAt-pr$nKV$5hbf#pas|MVF}lO6hZ2Kd#M{FDAK_ryYBtN639g$a%Tz^e{qz`_DwV z_sqRlGqufEV>XcUOgX#BVH8l!2DI>xgs<_ppq^P7DPkIufXG&JZw|*Nci0n{A)dQH@vLB&h!*X1Z1S9s_dzh24fOvs% z6>z`qGOq2w=$b!CvxJbEM0lM$6kzrf1HVK<$B$mma#b74ynH-{t%SWg8fi?9cplZ?(E{-qh9Wy>kmHp zK%8t~z!%ukeRi0zWRG?+=wMV}Ng%TYiv%366g)R+#Oh^}lR0E=~^0?VCnI!l3V z`9ih7JDdM@iG2pOZkVR5^?6Hjk|a(mf~1)9YtIJ~V`JFs>YZ!Zy#5jVf}ikFG7z)> zJRcGAVe4fA4aO(Vust&aghLk12hEi$H&LC$Vv3X*ou#D6X-9 zfR&8h#Mr^DDr?NK>OG;sjdXppSW7z^Xr*rr8m`kp_oPhzK8%J_aofwg=z8srPuxs# z)Dt`2mc-~PVNm2a*hQCJM)T@MjiD86Xng;mM$swk(rN?}I00+zdL;-}J-}pIKlInC zBKhXr#!x0!ksIP2fQ-Sw+?nog^Ra!oN0T!KSGdXh{%fcy!KwGakrnx27FplQ{RrmR zpPawf3KLQ8kDTWeisasXeH|Stx<@x^+T8|gEECpi$W551kM&&7b)6d$Ti53Au6UXn zB=?z;bb!qn3D`a9f`q0Kdz{VbR~IVpc5xmVL*DXx@6rtL!@(44{c{Zwc>IJk5?JJ= zB9ahnjWB&G`%86?xFVE*p(K)iH`sTYN;pYb?;6b$PKVCUO5&HJwMc1PACEnvD_CsBi=PwfvPvwI#$>@+GMwLXrIV#W~=YnNcwhP>GV`*AmtolK#V}2lPT&QlI@!_wBF; z?CVJj_!L+V6&o!>up;ev`O9GQeum>?zs9;4s0iq5xN+N9h@=zZ9kp>o)kjZOzP?uN z6@^E{uqldC>gq)n%X$~ZbVi?&h1Z>k2SLxGjL8e9*PgR7JW>`J05K0)0)wC8da&J7 zkOQ>H>=}FUR|w&gW6Aun46d$t2dnGV#6H{HXJ-XrHB}#PugIk{<7ZIVX7UDV{00va zo_pq;zKcHn<->8!$~>X|tCFIAu-~ZgdL!48!u{CE!dN~hhWVr2=Wfr;d=+h)U=>~5 zNsO5l7~?srJM4LEFH|TZC*0OhDDx_4IZDdcVToLze$RkND3)WLKf__p1hto96P8_l z2p-}2^WQgnqN%*@?zfZ2x3#Mms5;lENb=^pn_l}2wg<)`RT=zjC&nSHeaOwBi9#_N z1XsbVfi1C0oP015%iREPl6J5~ntqQ(?B!nA^TlS33D~nRI|~iQild2Kv@Evhm`GN@ zGS<+AQq4J*3Q9ffGf?S*4U2zs4}hL-FjuS+E;*k%IUhDY;b>!V?$u(YVEVOATpv>} zzVCOkJT!`?2t^K1g#w>SvjlMdI3@UefHJg@h6)9uqefx;;RZGCpO>-vn_@Wa{V7Nj zeGcO*lcUGoyl4yb;6&JmN%TzKWB-KSdQoC3hOunlC~$Vy^)Jen$j->@w|JvE2PGSB zn(Q*0_a%q+)B9*`6^u%F+7}nYnij{~6DfXYZr)@pqQ%sTQx$kBP|gqs(6nBhH7S2* zZpJ25V0g%tN@@)V8OW8ygUPsb_&Ta_bA#MvTqD(Hv0c?$MQ8?lZ~CAe0>KChDM&01 zQw|9k*DG&$CgEP8bvp?PXwotL*Dw(k$Kfp@<-Am2I5BA^$3j1#FcF=HW*lUynTcK% z57IOq21QQ$;GzAX=I;9-4H+7xtO+(xToIzv09eiOh=w?4Z-&KJNQwcGqn(JE5DMAP zS==*6&96E(H%NMlAkxQ6)~XMLDG%@NFFR_=9{%{oH$)L(&?;C0G&qq3P;9`gPcaGUbr#$?0EBBTDEi|+!M#>3CVwSmQ zLiE^B#`cI^Xqu=y>PRp)c#iu!>Xo3#ydkOsE=iZJA5LPIK3RowknmW%ll2WP^27^} zAAFJrW5ltGzyvm7(fH7IS*UR`rAolGk@K<*Oh@2c4XQt9_}r82C%?Sf7e23D%9+4O zztqjcm8^4%D7oIy3_7wrt+{DlT?tu$yq0M4U4s{d7tr4Vh*ye&RRgUhCn_2X_Tvku}fbZj-j= zUI2xR!Wly@%h;T7P^uGL@#5h&MR;y0sI&ELo0_i|3DzDgF30GxpmuW~9uL zWn>XxuLS}l+M+-s1T@RRCQxOT^lmgW3zNZyaOLZe10q0la^@WP7O z47Zj(F^!vY&kJGsev8mdm78izarRabqcP#|g+8V+;Rp|CL}Tpknqcfd<%kBh4<0lc zMELX%QFX};PboBG8fVt}pGGucqN_q+eien69f$sPNo~pLMHSY9%MLz{$xX4Zcv0`8 zzD_QSEJQOkwY8kVkHUGs4Apd{IfBFtc~1eF!7d2uNc}aJ;#3e=i%ZLvH#CAuXJXW3KJ*O^e0&%Lp8+_ux-cj-GP4BG(fx(USOqhOn2k^MLnJI9fzUp+V! zJ|bQ{7L=EnaMN*io&O??^TDj-K0p_DLs0 zGnOP~@rJ`h??r~{NPb)sXRTNeJ*XiTE~E;Tc`lcTV5**q4rmAcC}5alwyP?gXn+@% z#3q|Z@-3zA&M|CD`fPMVVrh&9L6 zDa7huG@mbh*Xv_87t@kgEEWJqT`YA2USXy=fHEpICl@8q%H+r{)wsJBvG6&HtXPUs zwD8!Dysu*Hyb~EBKkLO&pW|-|PTfOR6YCqSs|h=UMvS(!n}I?BSAGF1FsXZbWt$uEyoI|D4sAu=dg_#L-k z&RYf_gBe)&YYntAG-xJ;qX)xLdF@2&_v=8ik4)3TCXj=i!T03hK|Zw+Cy*fpU2kgf zO|NU1PSaD1WA18n0umc1^k)f>fVd^y$kCI@!JGof!TPA>OA<7RswD5ZJ_2cB{2-Ok zL7Gdb!G;9Xp`=Rc5SNvW32Ru!1k@lXVf1@Tr~?~=>JS%mza6!bm{Exzl;&!5Z$A_% zKX=LAMQ>27XpEJ!Ytgdk%lKq1MzydKPYT`_Uwa)Cq&${0(?2I2uLd;?FFs_J+KJe0 zqW>O1K|tEFn)^h}(0X_LK*d=C-d+c_R&Ad|c7MvLGG&mH+6;>PXg;`3n&0^;VCe9E1&b#Uz5a)?(A!sr0P(Aj4+K9b#_weYSci$sZfiJ}2dSCK{ zA+wh%4>_D(khC!)!9NuvSAPb)IxP60nC3)wFyj&m zzOjT(2I^}o9cRRsOP0XS14?{zo(@m9{63sgdP+F(^(-=2sey!%sL=*VD$1fB+i42LbNiEK^Y*pos^dz?Kuu36 zNm*^pUIszkGa+yN7%1>5<6}-`^3Dym4 zQ<@=&_R!pPtawj4nCpy7gx3>2S#9wAr>Lj-QUHi8V}JTKjBm)sTkZ4atkETl+4I2QmQx9>-vM}#nej^?hn zn==CHC?<@DDMNI-8snakxe}z~^h;yP_ZGi@Nz+FnzjO9rB(>z4U6+grI(i5KdUViwh6rPs$18%Q(S{1YiQ4hV~NCXTeGRBGIXfR4#I3K4iFhsbwit51Ui006POS%6$UTy?@iWri8J^I|3fS_Ul_@okv*4iXp;={OZD{5K$r}3^a-vsYO-f*2d zOv()ExPftl4Bb|*%P%zjmxj8;yQDIq1h#CEeQ}M&!IFx;a33wlI54(dac_FZ#`t;5 za|QTxQ+wU1^(-E+n)>ZP{lggVK7Bjb*DqQVV#|VQT{vj3S96(jzcm``|68s>NtzCiHGP z68ZNP&MWU+ct04J-QC)O4Ua9y_*S#(uw5#pO7oQt7f#62_a>bA$>R>|)Jp;ng2X}! zOTcEC%M;F>d=ksJlFKGk@x}dc(d@c~5G`-O;ujPt1sM4EM20j#Kwo>_?T3ZZ_!qS9A-c!Z0|K^t7>=^YmG zDj&_8ru5N2@o4mOUFA+GM8Ll%%O!HhSuq zIS6py!WD*BynV$45(J3TRdNJKIY7t_0%C-ffQ$!fvRv~eBJ{aIVHR+LTHx2Dghy)J zmrW?8g2h*XEL5NyzFoKzHL<@3O-}`}Ew~Oi=H14e11FztKg-7@t$!Un@( z^OA3$+bf+gOktA`?poS|JR)1YgZq_9(bb0N`32l8l1dsr&kgfY}IBYYguy2YYRT_vIwM}8fh zV{m^SE$;aWd?a7Z7DYnYxL6lj;{JK+S^EGDiD2k-YTWW+(ClKzl=h(mX2Re} z^}cNRNP~9HdKroJmYd0kUbX!h2;<#qt!&L}J#+|r2k*Lxx}?3fDhsD%<39SNmKHl% z0y6v9b%3+wVG0F)`wh<#B%`oP}^=cEl%b{0fSEuCvd$RLLqG%@rMj(9%tv#2TvY4-8J)# zFZYf58(DjaE1|6b&Re%F-*txgV385O=F2Oa?iM@I&292gqKX?n0Z2WysEowd0X^gqzE}2n3sl_V(^c>?KnZ+g%?aqEj91O1Ich z?rr`4B5s9pIFj|~p-^iO-R^}cVu?=+xa9$fwB|>4O5keMm-h|77cF@IweTrfTV`*4 zu5Ay@q09GMII*QGY=){%`j6dPXK@&liSyE4DeAp<6tjkRTSma+a0ev4so1+I*TcZq z?k)XIcZ<6c&TTZ7o2y0>n@7U))OPGx?vH8Mj`C_HEw6vq?E}6Ln5tFhb-$Ju?=-6YVi*389KU)${4E4==L|TmSC+{~&>`=+1o*ZbGt)yaiMK-WaV*dakA zpXP0UFV0qBI8x_g-|>*min~Wp-8_Gd$WV3YQ1w!idpIAu%ocT48WP=jq$)Dn_6z0t zA$x9(QD1v-|1IE=yqh4qV{rq21EtGu@i^y9xRDy%)--ElNFtLT@-T!8y-gS@0))ON zyk6e$v)WPk`MB3u#3mf#_Lo^JV6PtMB)1s(DcJ$RCEbpgfl3cQAQTJ-$VeG)rc|uP zpHbhZCmyimaG(O5Bgxn9mC*nvmDS}xpcxr~IDd7DyTC;ZIoDgBH(Y-8smJN)wqNsm z{JrSIscauc>@Q$FqE(35tZ5e$nYFx{Lb!mrqFI5vpdm!y4&eDsPrCr@sLlal0(WXg zWLiqovpsA3+fuHRFR{=g(W=*4Kw7@j-fXoQM^2|oQ=_96mCg?7SbLkb5h{Uh!9fmQ z1V_+ayAnwo#iFrdFyJtmwSxMrt}lLa|H?+02iGD`${{$x1YEd6{487X)<<*vZ1b z?Z(Za#1Z{pVij_{s!=gnE<03D(%&3-!cVk@rulMA=YM>&#Wq~y-#ZxUGQMa0vrg3^ zaXoHB=T1cnoI4e?QP_nVX@{(q$qe924$6gilvm*o z7$a%lYw&DEpL_U4!Mt}c_4BvN1zS{h;e(L+RZ;;9^3qcG*t_asqJ>~KEqqQjYhK?X zFWuf%;>#v=zf|woR*(uD3ybfamp3DNyeRxc>o2H6{=)-C5jsYRd^jq%hzShntEo~) zb%c!{XC;ji-eGiNv&qEPYPTb{P|eYR;x`-91tYxso(O*NGC?+o>KsPnDym`a0)(Ks z$IIhw z+Vp^)F&if?U|*x&!>l)pKxws^fV{&xLQLu|`=K$!LP`Fpfs3$-17AGM+f?hVR(Lx) zMZ5Kmaj4QdxS^w8zRG^l6*;e4R~GWh$m*Nrn^jEsAr+sKc6!x@-CQSEMK+sm@g9&5 z;=5SjrCN*~+>K{cHCFcnaVZ@)TP*46 z&4$VxLqMNi=$uACDs?{mA?zW(o+(!N??GcZkgnc)urcaiXlBh;>bVsu9Rk+1?tU+}l*gSZsEYNNmo3!QHS;;MTjZLCcNukdjcganLu3VJoJDyIZ4ljUTegtJ8=S zu_8CWY@7JYIMW$Y%puIAKHiK^L*yL$?IN)J+wfXjGZM|#D2uq6oFB~{du3W#Tm!Ry zqP^2LsvT{jkN0MGZr|3Xa5-%OF|A zWHI2gf0-!7DBO`XXI~nPu0KyqLdkk>;VYbVSc=!>D04wl4d4@nEr}3-k0$H3Px$pv zj+(}G;|*_pPEJ(8BxHq70=D=BhCWD48!y%cC9vC!(~&&)2gux$JrXL$WdaA65u`wH!r=z& zfpu8P?J7_*wkN@H1}cV#EEx+}f%uT&7hm|L786K8;Hbk2M6{6oW0mJ$$0-7 z9Hw3;#Gg71&?J8!zn4WQKk+50-|703Jx?g?miE-JeMBHy{SN6ue#)(8tc#ZsO!{y~ zyS$0Yp~rnBR~KS|>K?-tNR|*X6ACpZ4SUA>eY>ur`vD}XJsDZ77#+ayzD_g!h zv>{#R@~P>r2dRbjw|1xz)X4}xpZkD|27mGWF|@np}FdMnEK$z+t}8o-T{bYD*HSY4d!7WeR2-|olei(d=i%Xc_^=7qCHtW`!m}>E*uiSD z7hr^&Jmq4J^~@w`UGpv_@&U#m4bQ@=!ZWGrrqov8F_-jy0eJFH;V{wXk`AYC$3X`D`B?M}Z<5u#oas{rPPm)P$lA^L> z6$TK!xH-BDy=37wcCP3H+2t5`r`2wD-lA=CFiaIU8;KWq#FFKb8gqh<*uA6o&u}V! z#oAX$lbII|X%$72h2%Rd>&Cz%(+WCG=wt+`ZsIJqcu|85i=Fnt7edL8TL#&)!WAOkcd|rRbjq8EBQ>qlr(H5`YRj^T#mEMr#B-1 z9uw_*tWW*rv0wPfTE>1+T(>?8$~Lfj=^HQEw#|IEj+NGKmA(rSeU9b3a})V(h`{h!lY)1Op!XcIyQbko-uF7F8s7Zaf0<~4u*PfEdt4aWmHMhE%8gF zM}m@s0>DLB$AK;G=dG)?R)>>jaZX9<+fq0%$p`JwEhT6jzt?}4SXIvYsoqx49!2j6 ziwGv@TMt4Uj!Gux+or5gOOB@A>fsNDA7cl;;SMstUimpO=&ZJvSJ~a^sMhYofCk2! z#pp6*-L!ib1KtMwUr{dS*kN@y1G{w04^g;WTh0FFiWruXt?$WRH>Td2i6B&G&oeDX zVfp<2@Y|o9@AB3kT$M#tZuHj=Ssz86O3>Hlv9zIYf6hVN&a5_lsc3LX%xcrLR*CMO zxiN0$ou>5KrZwRNi~48YxIb zu(kzHGq`zGuRimiMMm>l3-&&~fG4f*P7B%jJPh0}2z&a1XXe=jIkr|~h|mCA zIY+6uUzH8xnKL76kFGI6u~FFz$!TN~CQ^^#i84(+W40)kaR8GZUAw-tJ=s1*yYFV2 z>D93C^nK(U#PEd?ESlf>oPucL8Ylew0(A%!OV(}0o3Sveb(YLh2060E5wni&7v6 zbGRNIX!CB*PD=^~m$L+6;vQIz8^oDS#A^z z0+1pKF2*Z~D9C8xDxM?K^H2hSSo8+(RpezeJoD)pD6y$h85AKQCo)*RH~0$H{RoAY zDP{T}4@lvd^*v{XabeKe-Vx`r7{F_#p%1x>_)eBP{ZmOyIhDZC17pq$z{cT)L4G3T z={*T|VGgwQV1m_-aTTL9CfjWa3DDp$Z%XFTdFeDwEe2JkIfufm1RHar#2r zLn?1x+(l6aK_@G*1Smo0Oz;gN^ZA8h8Wii1CHV^rd{^Zl5qiaXrohl8(SeyE9PQ*O zx%TN|Be)3#P_pRf*~uC1UR4$)F2WpEtFWq6urNLRAgCjOxlTx@vw36G^TN z!|XIAvsT&{h>&&^vmH*R$DAX=Z+2LhX%|K z_yfTu^7Zu3`+vU`xpU>0nZ~WB?)JeZmiezlp|vO8bsuz){cPKvDaBe4_MG@x^lS5kCM4`P|X;lYha8?`&fB@kaxQF5@1SQWxv7f zl82pC`?`r+TYbDf-`pH1;3 zav+U3u3k@8lvkMc*x88uP)M?{FO}-Au<_LWHMTGY+q10@M}TlBhM9aT#3LyfUZw)- zLULGm90o5-~eVrKu5_?Ue0AI6}MUq%Z^r1J@0AA$@U*B9p>4&EI0aT8H~RqCZcl zz~4KmwxaliNQohdi{M-NhBxh3TldX|Bejf~vQKAHtcPMfB-=keGd(CUgu-vY@OUQ& zU*u$7_+~!7A_k}C6e$oZcC$%4_d{jZW6Aa&IxKYAbMph%cYG}9+7b@DcH>>8_S?ck zK7|ePtolLEw+CGC7dW>7-MAV1K z1E;eBBfNarT}V1|ssD@j@g=(Z<)Vxb`#?mVPd&UDf7Ot@p>sWNAgyN!)ttOV5=a`~ zf?hB#$J{}ig1PL*GfpPt zwLFs6pl5W*ENsbO#)>6PD%gmQIS}(zVc4hyf@RWjIOI$%GQU}aP77M#H6VK~cE;ff z8f!X__oC9kt9;LNOCJ_+!p%WN@uirB_eTjYz~X*?Sw&XisZqI zeqx>j8N`G~&O=T^mg@};nV1yT1j7dSVaD+gIitE*QKW##3AzrKgOH#jN`itn)hY?V zdr-7Qdd4zA3_>7?u637>|<98`7*}+rjF;*&f7c)3o^nyBxVL3yrtgthMBn3 z9%4Fbpm*Sb12n^-jlgxojr9D{Nawy3p-3N(f82Dev|3b3l0dPR8N){A(E)IvEIRN2 zCws6C|DhF4pe1wjiOXq2OH#ciwlv;lMf4?+OOfuyQ*vm4+{1Y6ojvTLt5sF(CJNLq zUDb~LmLSYiuvaZw5366VUB2uPPHuWXY8>R965R76(@XoQyZ@)h53jACtEqRX(@(t~ zYZ5JIq)WeYB%@d@34Udf`~L|O-S2jyNI;F?Lf6!x@DKD$mXiP)7u?|Bp*T&qJbL5k zsf92S8x~OPE}Eu8{8>hdSWJvi-ZQM@VfWN`?sxQ)3(sl5(6~(Z_lwFTu97@vTI?NO^2=at5VBFt~c zJIC&pHlZiD;`HGF^>ie6(G7PpGBY<5J;*n!BOh|hdvxBVGu&#%D|x5gQh&ZB^-s+| zOX_J{=#;~|p|Tz-4t5?!wuwKxkdWA@C*W&)227nI;VO(Buzx&PuKuPCjGl4Rdh^uTK86s-+ZXlP! zNjYbiOh75BK4QZM4NxX%lb7KH3RPwg)&%e-@%n0yCHysqCchW~1+o#v1Co<1UKq%c zu(y&61vf4i#|}#a1}_QA*J{?ZFaaD_c?BtuZdVrhAVXU8EI)cKa>>WOj;As2rcsos z7giX%qD?MHhOHul!>KEoO~o`hC7J}HvLP49Icf^?D%y=l3#7v~Lo_X(Jsu=EdU~`T zXw{3>ei0odSsJcM&@$r--z*!_GP@?i=Z_Z_wGJJx3Q3f(*TzKE(~j*BSkHnAlFU09 znna8vTj%oAsYlEi{M@Yc*z*CM@)SMua$+3EX+=9!7v*GuOq8BJ`SA592>U7%$SQ zx9cW6XpC&z^U|!9-e|h|_(Pj8-1oUyp|46bLZeeol9i(2H+tj}rBdTw#kCTDKbj$m zT&R>0)3DP$YM63c8ByDYyD|u^{Da)*vHO@lA}~gN57;W&brq0 zX@K?s7{6;vlOd*gJ*|W3IiQracQXA1N@l5D{>U+syU_ktYc%GP(dsQzrwK<@VXhG_ z0x56eA=2qpuGJ(`)-z@kl;Oi}%Hvis)c56Bh_H4##$W56QFf^itW0H>zA!DeWjYv` zQz}KzRD9Ez$5FN(Wm;$>IbZ;)+*4Qq3$A3l9(up;1t-U|XqDjT%;fhtZON8YMYUb3+MF`<)~EWKeCF^j zfHAe+n#lFiUQV~}-op44?yu8Z#Toq`tQ=rhr6>BpA8xJB0+)VP;3;1_{?<~RJgQp_ z=*C<0ViX6t5sCx546)4J4xpPl}P=S z?kN3!AL*+Rkgbgw{|A}xdO{xS7M*ztt?}fCFya=^P%qq$F3<4J@I?C?(VO4*L06xu zA22b;S}#K1yS?$RrKdG5n^W9;2jydDoofZ8Y89c*07V{|n>I&wb$sWNU^c~AJ;y^I z?Vw~MS4o^nt9f>?>5ba9V*CzP$!q0J@;|%3T{=j59tC~>wABOKt2`Ob|6(A#4o)c} zOTs}k050RxMm^h!$~}hGc2cg|*7&p8lR#ncVO^ieIrqA=hso5{B5jJf}xiskiY(fX)xCgn&RziI$@9^BLDn)o#nD zMoj3V_T#tO@uXS&I@ak??ZY!mC-QtatA3Vg>y!^r!f)` zlQw+>bsTa@VkO8 z&gc5smX^~_-^o5DTtfQ}R5+fwiv34B8?#9iAR;?I)dRQHpq zvpL6cg$-$(ON>ho#k-U(8Q$|xFotHE0d0z+UUu7d5vC4pSI-H~#VLHXa;Hr7%@T{2 zwm1(W*CPhDg({<$kVDVvLr(mLi1QuFwi8;qw4>s@s$gWl%<~LMjjutnXt~~WQM*|e zo+^LQUN@`@oug_ah7CF`8`u6})hB~KvSLSFh>iEMZt=N|tW*2;sk{T{U`4d21<8)N zkOJf}dcCwKG3lY3>kA0^`h2H>c-UhPi3A`r;L~bkG7Bb*gTv$2T zTFA>20UJordBt--6N}ijULd&TxR{@s=7IstUom^@#4@K0PP%bxr9hzT?=rzETEKA2 zTuL^@AoGGRz{RZNA4)*x+lMa>oM?Qfa`uK6UtbQEPk7%sEeNZ4uZyEazEKWw3vz)? z6uP*WYvp~SxntOQ0;1C6Y0_Y+-~g9X=%shlP8fq-@my}jHpH@?Hpc9z7S)#5j!4n~ zIkeR|kBFjL9Xj9#zVO%3QiI<2rp-TcPqS+-XC%;#VuP>(nS+-TD@bPtu+DqwwU`fsjzXx%W?5lnc)_Qc%7+X;I8{?|!orD+%g22Wu_*U(cb)E&i&5i$MfC>7` zFmGiFz{llm{KFw?rtTp#RM(x_8k-PSNGi7;Gjqdk6dhfH?=TiDrIJI&AF}V8fa6VD zJl+AbbedRbu@zhBRGDWvR)ycY9W_S(*5C6gLz^`osB*%dH&R{Q)dhEb%WkmAuZiFM z_5Mo=J1Wr3%At&GiZE(z(L;gn+>DhAid zQ%3p6PdI*=y>|jbMu)O8l1(hk&Ndo!4vqYb^s-Y@s$~78Rd4!*Dt?%(gT-wK?ye*& z!N$;%AQJ21>3$b0NGO^f)w)dR20enIy@p(H_(20GjldhT=^Wu-|A)Ho0H-p18&{$t zWD9X@g~PE9Wn^#4%HFa!C9{&16|z?)Bzt5edq=iZwv=Scp8wZ5r#i~V&+ogg@4Ei3 z%Q@$LpZC4rdq4O4Joo)PWDugEXK3I8HBuo=I|~1^Dn_rkEJAu?KPAZznvh+T28Q^S z(l1V3FTW+PB-TGkW@UYYi>p%XM!k6F`3)HE9-u<#RF9bRkc2ey-MRp~@N2sS;UipTVQ zqLw3FDBikYm|e-EFEOtrhSlo8-p4bDWxAt3Y#qO_n0|WBHUvCjHh7U}j^}Onpwxu* zoa-0osGI^@8m0T^eY^p*YxJ3EH1nTO3zL{dqzDkc6u#3_h@rAzlo3Jr^1(S(Po{Z) zZxKHl?ZVavCn=fZPikVn`%LqGx!uadaykOdaz57=Vsgn_dTy&38$88 zm+w?iFVCQv`bxOd)KsIURG5vnRov(wVbdB(T%W#5RrNgVbN1Obi9)4ez59{EJ;es( zrc!j+sC^U}_X`9QTTu7 zS|dgnBhj+~M%qSlF)q=cqc@` zR1qGm)Ft??VxiQCp+mBOrDe3SZ;-Ko%=u0{Sn$kd@XN|6nm1(70h*utuekEnywft2WO?-Yxj$DR1`jxZ zOJDivXlV@FtJ%tz=#wPky^<kD7J8qUb z9h7{pPE?VL4|geXlt{{|RyCmY?wj~{&2n+iG*5wOqc#O?d9+n`@>};VP|C5JkiO>e zH8XMHY{qb7YsOFxZ@uH!zGNE4G=D$-?neAfUaZk(H=zwjP+L{}6n|kbmPFGk;RjaxmD|+x={oY7Kt$QDy<8P@r#y6x?d#O(wztLf+p@i9-v^`)B+yz1&utKehh);-*<353 z{dRS>!nM&UWcI#*^^H(X)>-T+`^lbVSKh5$f0^`GcdEH_k;U%gAN@&1Zs&{MS&%0O+ya>rt9^szw@AZGma65|7qy63 zmLwCYf_x<3vq~n8hWIQ+>OSV9p$obet7{rFELFQ=Qnve)^Ic_YW8QKpwCDb%(~<>B z36D$%i+A#!Gypm31#2m6>MDh5$R&=jS*1rzJac@ZvL(rKL*g?tY08Iui7QXF^kzJ` z$8kx@l8DjJ8oo_P>s7}n;!alh{ zs$RcXx~i~uVoWp@0k1^51WnT=CbG|?NWSu$Qnblt706%gWcumqFe+{mGTiPuS?bGs z-(%r@-{eT>=Dj+l(|wJpUij}BKZgz1aSRkau^q#WDY(M35ID+mG2U5xGcrtyFX4Q~ z_!pOO+r+@~I7N#bZ`RQpbbhODTV^pjsxSCe>jq_~c~31L2N zXG&-{e!hI^E@P0J^)Ai}_Fx7!()Aolywx1D^-2mnLH&R>E7Dgr7eAX@gw4_zwnizK zmb-=ApK80St-BJEz3RSw8GqQHVn>%T`r*RN-4cciTP4}sDyRGSWk$BL8G{{PSLB zULpst&f$>}LM} zvys65E@Na53Yn+}-wDkAtjI~J(yi?Pu&q<0=qU@E&&0VCTK4ySuec0&)7&3zY!;Nym4evjnVke__JFR$_0%=X2EH#ngu6c_EWIvDxq{hg;gckNMSwJsyLzpJ!kIn9u7l!M zu&<_X>-JeYoag}lxSeadcWu)sO8bm~m+Ge?VFVO>u}o^~z`NB`8Ddt39<3igTdGXw z-tpzyyscz)`pjf>QgcmUjP8PWl1}+uU)?)(X`I>^3$vFiQf5HYHxzaR?<>f}iiDr8 zE?}O$9Ph92PVs@^__>>FXMUdlVz7AGXLIxnvE_4xaHU0=vq_3K82Mfn=CnA!a1un7 z0SexsIOT<>v8>G=y{Fj8zV0X1ugy@;(Pv5CGU|cuUBv0j>!x!bXO)9Z5V&@ zWA-HaRs7-fFR=^Gy2}s`zvnNU(S>M=UUD4h&b58jVRu8Mv2uLKhn@jX&cN)3`qR>7 z7hF{vwwzOa?In=vmYI3Y3k+P8pRLSvSR4wHU-Ig+7+NGp8_u2`pJC&dN)GFwPG(3+ zr*V94ZJc{G_H{#uae`IQ&(G*Yu6RXM&oUhfh!c!2=4tOv6GOk(j*j^(i?nEGNxfeu zk+UHQDn~b%diQ&^3=s3GzYbU~>Sze-#TZHMU;Ntc=+%bRb z`qi(ii5vLgQ8x1mCJS8{oievYS-Zz5`tjaAe6%_i@n#)r&yn*2JUc~MPSbJMraD0E z%opa}jg_g-)w?Ip@(GpvJ2lPzXKGscuB|bM^}4STM>^ z$=DI330o_QRoU3t5yUEG1zaeM_!dEYOKD@Uia9$xos| z#@cc3oH!?lbMG8*h6}`n_{9z4hF`aLrJS*mnZB@%Gf0ySILXD!3FhVE<>dzPa6-Vm z5MEv$;4vZqG-GQ=2hiT)Q2;C@M|&qj$Lsp`u#;>UtjhWZ4j@(~V7)8YIbiiD_yc~e zOvM8n{#wB7|4+*I(hB~e99UxOD9T}+_=j|dJ)SxNB4Wp2l`%7N0BP>&h9YQ>Pm0=q z7VCoS32)!saMp`i8e0Kk*jwGp8h!>~JqQ95Kk&;01Oo@a2Js>RLs}mUu77_4lLY7i zpdju=h63mY43&Mj?J3cJjvGSxkfB1@kdXUFMgBr=Kcxso+y{*v2^!KWX9toh0$b}0 zAoc*XG$9ZWZQYJ4!|NLxb`EFP1@(pdDolfIMuhO_0fy0kOlRi3H?WHgF(;JV?-v zGIv=i7&^aQg(gf;iXe8F@`BjmD#!k}C9v<^aF}+;?t%T~MF1)hVif{u0v(b9h*j3i z!4X(z5crgLvNABXH?uYYv5J~G7zzj=sWiu)k{Z~`=+8yV=atnomqk*pEcewaG{ z?eSqMrU-&b6ZnGLB%B){j=yb^^H}=Gd5}IL84IlaFkF=-_e`AgfNn&S0|diG5r`Up z8$1vrSpspclA+CB&?{nN>11UMEG-K628c#Qn>$Lj`qm)G-wnX!zZw9TQlcP6YoPB2 z7NQ0I*f}o!@pqiZvLem{tcU~2CSmRWB@cj@dk>fYBOY)f^WZ3_sl4ZgKpY5f2?Q2! zT>!z^fu#MM$I^aISWmX!!*L+_Bv|`l-27#roCldZU^_5$kbM&D!owgci^6miW;c6* z@Glhq*2=wweU0sb2zkr{^s8S{h%h`LtyCa zfIo`CBhPk_`9!rBj`_LnAe z9q0+5AQ(FP_;-{h@0afZU^$TbKJH`LE%yO-%Zb#Bz}gR^_RnjJFb8WtjKD+134{vV@SYM0zvG$f0pjFDa-7`9 z3O2b9f`(-Lu=f9+S}qU_9c1O@KAv$MgdfsdC`|T;;rF*I(S(~TFzmM%uYlm{41y^f za0HV*h#S_0g5bOX!4N(uvN^CH{onTkFt>LoNqSsh2sBFKL9d=mEZo3+WXAdaQ053VXxdABJ%u z`E^+PVJ!HqJ_n$s3AhLtI>_q7bJVNTzA)lIYLUh&d5-05cn)wjTu6Q$*8bm93ryy~ zf=49T@*HI>M^lRw)bSk4HSiqZ8n}^yCRqD_Pc1J9hR#0O?kT}x5Rasm8_9L?9IMaf zIUpeBMzRZ7`(e2K2@!uOoeT(u0#b;`b3EfZz$Unnf;!l94nyhhu_~CSPz1q^76h{- zU@i-$)F7Cq0Y@;QgLq(%2EmyEf}xEpDBfd5-@FHOt$S6yzd~JD`(bn(GU&?s9bNg0^FKZYhAq}V)2wg+oJ48y-WLtdC>gWw(;1Z2|e zy;;LO8i*Ih4?s>Zg9pB0{06?^_dwY86jTv7hTjF3-rh0dJ{W2Hi9J@Q1new#fIyJV z7 z{0!tE)HH#B29DBxsT|Ad4us5u)O*4&I1I_Zl@J0?YyogXXwtt4Ek|Z~{3V!!Oc~jG z!IDxA!|@Q!0bCb6DFs<`V2J~V0o;!uEH44xiSBV9_$32ER-7aA7XAdh z3x9%2c-vuU{Ur+s95G}F$LfY4@U#q=N&F@Zxu-lbzvdst{|2*{*9A}a*vI(e^=t4% zk^T4~TiB7g7k|kSfgLbr@f%NMTYx2`9L9fSIU=lI1|SorzerLzUV;haAS#j7|9B}O z5Tty8-&8`7L&zf&27ddcgD}$sI%)_!g#cNyN9Kn7C5k`J^AD29-8byfhsokEThN5S zbDjWkzyJa`B6>nNKY-(ZIuKau!(q?Zk25@bWxqD=OT9ZX+vX5+0pbmKCdNLd9?x8m zGFtvH6}byOa(M4Af)P-V?FEADA7I%chcWQCCLM&DCeV{W;0XiBM8gIV4+Huak_TxL zvXeMo1`7l!h2;+uks@G<9onshw#LCO&MEqUbh zHwY{N;4r5CMeqUU!;Wl@M`ptO)}(__(*)uNI7<5h#3K_r{zCEq^I=C0ZjMZc`A3uB z`7i)(F#3T+XqYA;3+Bkgg#9@FN>D)dL+r?Y=*X$O-;9K3tLz1Ez;4)kkr3!$7wg5S55IC~?s6Ad#_A3cvAGOF1?Z`xjzqoghgpnQEk#oC$xc6tQ@DI&Ee+z+6 z_UIcW}ZWWK{+ME`lG_>C!Y+86{jFMSw(hZ7A16>yaH+1~L|JRk=W zjWj?2IWh-gKhf~n_5B`}1KF03%=b8gXik9Wf1)T@p2cCD+>hjeL?avXk*N^-;lYy# z{vsMVs)t>07=A|(4U9d&GZc{3`*_(O2TK~+29KP={flUXQX~5a__q-Npm8{_{Ynefc(lz*he&SfBGM1P8=u?O(4R7{F6HYfz6R0rau1x z(a1IkbT&*}mMh8XgLCq4nkh#^0CY7lUYxDPS; zkKiXj^FJniWD3Q8n*WUa{r0u{#IlF?VHEF&22a1(Z_u2`4&ule`oG}8My~&YhwLDZ z%trWaD+i&b34}Wk`1~ZYgE(GN0^}gb$Vox)3l3xAKWYP?(*Xam7Vr9kju5fI|_ z8~&aLLLdd7Uj@NO$N_)|&4rJH0>=o=h7Xnjuk`RKUc^WV@Y0AlMiebT;I9@y#QDoY zU>E{uA+Cd`3;`^F;fc(G-?SR=4SJPd&Yg6jyIgI|K6 z=dWrg_?3uZ3&g0!Uo{$V(0`R`!0$uw0P*^Uczc7tU?PeI5DDstOm`4G97p7BgAgo2 z_?TZwdvL6O-#>FE2(|fV?gSy(f96mSGKS5BAgxlBkqTfpaW_Se^L6CWB~b{oC6GQAyD|G`M)$C{yqa}HliIqrVM~W zJnHwL7(z$?85BcU9-{dZIVaX)-?;MYqy@Qt|t*E=9QcOI}v z1Zxnfl#qY=HpG4Z^l^y${^{!wto^6YLs;S8eIGoi0r-W0htN?(zYm;8!28E}BJ3F9 zME{6rkN$l=6keyUY;O$IB>`24K=}s{OCb_h_NsqjI=0UR?L86!Le|X6%<-R!k$r_p zK)N6RSl
i?^-1cdNb$mJ%Sm;Nolfgqtyo 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 acUL!47q@?%+ky2IxcSD$Ca literal 0 HcmV?d00001 diff --git a/homeassistant/installationspaket/configuration_snippet.yaml b/homeassistant/installationspaket/configuration_snippet.yaml new file mode 100644 index 0000000..4763226 --- /dev/null +++ b/homeassistant/installationspaket/configuration_snippet.yaml @@ -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 diff --git a/homeassistant/installationspaket/data/fahrzeugprofil.example.json b/homeassistant/installationspaket/data/fahrzeugprofil.example.json new file mode 100644 index 0000000..901e04d --- /dev/null +++ b/homeassistant/installationspaket/data/fahrzeugprofil.example.json @@ -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"] } + ] +} diff --git a/homeassistant/installationspaket/data/shell_beleg_parser.py b/homeassistant/installationspaket/data/shell_beleg_parser.py new file mode 100644 index 0000000..0337554 --- /dev/null +++ b/homeassistant/installationspaket/data/shell_beleg_parser.py @@ -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 ` (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 + + + + Obj.-Nr.: Tel. ... + Beleg-Nr. + * EUR #A* + *Zp l EUR/l # * + [ - EUR #A -- optional, nur mit SmartDeal + EUR/Liter -- optional] + Gesamtbetrag EUR + Typ Netto Mwst Brutto + A:% + ... + Mobile Payment + -- Zahlungszeitstempel + ... + (Seite 2) POS:...-...-... TSE-Beleg-Nr: + Start/Ende: / ... +""" + +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.+?)\s*\n\s*(?P.+?)\s*\n\s*(?P.+?)\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[\d/]+)\s+(?P\d{2})\.(?P\d{2})\.(?P\d{2})\s+(?P\d{2}):(?P\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\d+)\s+(?P.+?)\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" + _ZAHL + r")\s*l\s+(?P" + _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" + _ZAHL + r")\s+(?P" + _ZAHL + r")\s+(?P" + _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\d{2})\.(?P\d{2})\.(?P\d{4})\s+(?P\d{2}):(?P\d{2}):(?P\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\d{2})\.(?P\d{2})\.(?P\d{4})\s+(?P\d{2}):(?P\d{2}):(?P\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 ", 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() diff --git a/homeassistant/installationspaket/pyscript/backup.py b/homeassistant/installationspaket/pyscript/backup.py new file mode 100644 index 0000000..afb421a --- /dev/null +++ b/homeassistant/installationspaket/pyscript/backup.py @@ -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") diff --git a/homeassistant/installationspaket/pyscript/batterieverlauf.py b/homeassistant/installationspaket/pyscript/batterieverlauf.py new file mode 100644 index 0000000..24d2be1 --- /dev/null +++ b/homeassistant/installationspaket/pyscript/batterieverlauf.py @@ -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)") diff --git a/homeassistant/installationspaket/pyscript/belegverarbeitung.py b/homeassistant/installationspaket/pyscript/belegverarbeitung.py new file mode 100644 index 0000000..1e3d92d --- /dev/null +++ b/homeassistant/installationspaket/pyscript/belegverarbeitung.py @@ -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 +`, 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") diff --git a/homeassistant/installationspaket/pyscript/bilderverwaltung.py b/homeassistant/installationspaket/pyscript/bilderverwaltung.py new file mode 100644 index 0000000..c3712a8 --- /dev/null +++ b/homeassistant/installationspaket/pyscript/bilderverwaltung.py @@ -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 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})") diff --git a/homeassistant/installationspaket/pyscript/fahrtabschluss.py b/homeassistant/installationspaket/pyscript/fahrtabschluss.py new file mode 100644 index 0000000..07eb09a --- /dev/null +++ b/homeassistant/installationspaket/pyscript/fahrtabschluss.py @@ -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) diff --git a/homeassistant/installationspaket/pyscript/fahrterkennung.py b/homeassistant/installationspaket/pyscript/fahrterkennung.py new file mode 100644 index 0000000..b445a4c --- /dev/null +++ b/homeassistant/installationspaket/pyscript/fahrterkennung.py @@ -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") diff --git a/homeassistant/installationspaket/pyscript/frontend_api.py b/homeassistant/installationspaket/pyscript/frontend_api.py new file mode 100644 index 0000000..e3d6a99 --- /dev/null +++ b/homeassistant/installationspaket/pyscript/frontend_api.py @@ -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() diff --git a/homeassistant/installationspaket/pyscript/modules/einstellungen.py b/homeassistant/installationspaket/pyscript/modules/einstellungen.py new file mode 100644 index 0000000..ba43fc0 --- /dev/null +++ b/homeassistant/installationspaket/pyscript/modules/einstellungen.py @@ -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" diff --git a/homeassistant/installationspaket/pyscript/modules/entitaeten.py b/homeassistant/installationspaket/pyscript/modules/entitaeten.py new file mode 100644 index 0000000..d38f16d --- /dev/null +++ b/homeassistant/installationspaket/pyscript/modules/entitaeten.py @@ -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} diff --git a/homeassistant/installationspaket/pyscript/modules/fahrtabschluss_logik.py b/homeassistant/installationspaket/pyscript/modules/fahrtabschluss_logik.py new file mode 100644 index 0000000..14f0648 --- /dev/null +++ b/homeassistant/installationspaket/pyscript/modules/fahrtabschluss_logik.py @@ -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) diff --git a/homeassistant/installationspaket/pyscript/modules/frontend_veroeffentlichung.py b/homeassistant/installationspaket/pyscript/modules/frontend_veroeffentlichung.py new file mode 100644 index 0000000..dd0cff5 --- /dev/null +++ b/homeassistant/installationspaket/pyscript/modules/frontend_veroeffentlichung.py @@ -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() diff --git a/homeassistant/installationspaket/pyscript/modules/profil.py b/homeassistant/installationspaket/pyscript/modules/profil.py new file mode 100644 index 0000000..4631d86 --- /dev/null +++ b/homeassistant/installationspaket/pyscript/modules/profil.py @@ -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]}" diff --git a/homeassistant/installationspaket/pyscript/reifenzaehler.py b/homeassistant/installationspaket/pyscript/reifenzaehler.py new file mode 100644 index 0000000..1615d5c --- /dev/null +++ b/homeassistant/installationspaket/pyscript/reifenzaehler.py @@ -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.. +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..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") diff --git a/homeassistant/installationspaket/pyscript/tankerkennung.py b/homeassistant/installationspaket/pyscript/tankerkennung.py new file mode 100644 index 0000000..cbd212d --- /dev/null +++ b/homeassistant/installationspaket/pyscript/tankerkennung.py @@ -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)" + ) diff --git a/homeassistant/installationspaket/pyscript/updateverwaltung.py b/homeassistant/installationspaket/pyscript/updateverwaltung.py new file mode 100644 index 0000000..6e8ed31 --- /dev/null +++ b/homeassistant/installationspaket/pyscript/updateverwaltung.py @@ -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// 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}") diff --git a/homeassistant/installationspaket/update.ps1 b/homeassistant/installationspaket/update.ps1 new file mode 100644 index 0000000..d50518e --- /dev/null +++ b/homeassistant/installationspaket/update.ps1 @@ -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/* -> /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/* -> /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 (\\\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." diff --git a/homeassistant/installationspaket/www/audi-dashboard-app.js b/homeassistant/installationspaket/www/audi-dashboard-app.js new file mode 100644 index 0000000..da54ea4 --- /dev/null +++ b/homeassistant/installationspaket/www/audi-dashboard-app.js @@ -0,0 +1,4089 @@ +/* ================================================================ + Audi-Dashboard — panel_custom Custom Element + ================================================================ + Umbau von dashboard-muster-ohne-bilder.html (der verbindliche Prototyp + aus dem Lastenheft) zu einem panel_custom-Element für Home Assistant. + + Strategie: die Render-Logik des Prototyps bleibt praktisch unverändert + (dieselben Funktionen vHome(), vTrips(), render() usw.) - geändert wurde + nur die Datenschicht (CONFIG/CAR/TRIPS/FILLS werden jetzt von den + pyscript-Zustandsentitäten geladen statt fest im Code zu stehen) und die + DOM-Anbindung (Shadow DOM statt document, siehe $ weiter unten). + + Bekannte Lücken in diesem Stand: + - Kein Bild-Ordner vorhanden; Bilder fallen auf die im Prototyp bereits + vorgesehenen Platzhalter-Maße zurück (§7a), erscheinen aber als + gebrochene Bilder, bis bilder/ tatsächlich befüllt ist. + - Leaflet lädt weiterhin per CDN (wie im Prototyp) - funktioniert nur, + wenn das Gerät zusätzlich zur Tailscale-Verbindung Internetzugang hat. +*/ + +/* --------------------------------------------------------------------- + Modul-weiter Zustand. Bewusst außerhalb der Klasse, damit die praktisch + unveränderte Render-Logik des Prototyps (freie Funktionen, kein `this`) + weiterhin funktioniert - siehe Kopfkommentar. Für eine Einzel-Panel- + Anwendung wie diese ist eine Instanz gleichbedeutend mit "die eine + Oberfläche", genau wie im ursprünglichen Prototyp auch nur ein + existierte. + --------------------------------------------------------------------- */ +let HASS = null; +let ROOT = null; // this.shadowRoot der Panel-Instanz +let HOST = null; // die Panel-Instanz selbst (für :host([data-theme])) + +let CONFIG = null; +let CAR = null; +let TRIPS = []; +let FILLS = []; +let BATTERIEVERLAUF = []; +let ENTITAETEN = null; // {katalog, werte} aus pyscript.audi_dashboard_entitaeten (Setup-Menü) +let DATEN_GELADEN = false; +// Zeitpunkt (ms) des letzten echten Fahrzeugstatus-Updates vom Backend - +// null, solange keiner bekannt ist (siehe standAlterText()). +let LETZTES_UPDATE_TS = null; + +/* Cache-Buster fuer audi-dashboard.css: diese Datei selbst wird von HA mit + derselben langen /local/-Cache-Zeit ausgeliefert wie audi-dashboard-panel.js + (siehe dessen Kopfkommentar) - ohne Versions-Query bleibt ein Browser, der + sie einmal geladen hat, auch nach einer CSS-Aenderung auf dem alten Stand + haengen, obwohl app.js selbst per audi-dashboard-version.json korrekt neu + geladen wird. Die eigene Script-URL (?v=N, von audi-dashboard-panel.js + gesetzt) liefert dieselbe Versionsnummer, ohne version.json ein zweites + Mal abfragen zu muessen. + */ +const CSS_VERSION = (() => { + try { + return new URL(import.meta.url).searchParams.get("v") || Date.now(); + } catch (e) { + return Date.now(); + } +})(); + +const $ = (s) => ROOT.querySelector(s); + +/* ---------------------------------------------------------------- Format */ +const de = (n, d = 0) => Number(n ?? 0).toLocaleString("de-DE", { minimumFractionDigits: d, maximumFractionDigits: d }); +const eur = (n) => de(n, 2); +const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); +const sum = (a, f) => a.reduce((s, x) => s + f(x), 0); +/* Durchschnittspreis IMMER mengengewichtet: Summe Kosten / Summe Liter */ +const schnittpreis = (list) => sum(list, (f) => f.fuel_total_eur ?? f.summe ?? 0) / sum(list, (f) => f.liters ?? f.liter ?? 0); + +/* -------------------------------------------------------- Adapter-Schicht + Übersetzt die Fahrzeugprofil-Struktur aus dem Backend (fahrzeug/ + einstellungen/versicherung/steuer/reifen/service/technik/ausstattung) in + die CONFIG/CAR-Form, mit der der Rest dieser Datei arbeitet - direkt aus + dem Prototyp übernommen, um dessen Render-Funktionen unverändert + weiterzuverwenden. */ +function profilZuConfig(p) { + return { + fahrzeugtitel: p.fahrzeug.titel, + fahrzeugzusatz: p.fahrzeug.zusatz, + fahrzeuguntertitel: p.fahrzeug.untertitel, + kennzeichen: p.fahrzeug.kennzeichen, + startbild: p.einstellungen.uebersichtsbild, + version: "1.0", + tankvolumen: p.fahrzeug.tankvolumen_liter, + nachtVon: p.einstellungen.nacht_von, + nachtBis: p.einstellungen.nacht_bis, + pauseMin: p.einstellungen.fahrten_pausenzeit_min, + markenname: p.einstellungen.kraftstoffanbieter, + backupIntervall: p.einstellungen.backup_intervall || "aus", + letztesBackup: p.einstellungen.letztes_backup || null, + ausfuehrung: p.fahrzeug.ausfuehrung || "", + }; +} + +function profilZuCar(p, status) { + // km ist kein abgeleiteter Wert mehr, sondern wird vom Backend + // (reifenzaehler.py) direkt im Profil fortgeschrieben - jeder gefahrene + // Kilometer zählt dort auf den zum Zeitpunkt aktiven Satz. + const reifenSatz = (k) => p.reifen.saetze[k] || {}; + return { + fin: p.fahrzeug.fin, + details: p.fahrzeug.details, + erstzulassung: p.fahrzeug.erstzulassung, + hu: p.fahrzeug.hauptuntersuchung_faellig, + odo: status.km ?? 0, + odoBekannt: status.km != null, + tankPct: status.tankprozent ?? 0, + tankBekannt: status.tankprozent != null, + reichweite: status.reichweite_km, + batteriespannung: status.batteriespannung, + gesichert: status.gesichert, + sicherheitscheck: status.sicherheitscheck || [], + standortLat: status.standort_lat, + standortLon: status.standort_lon, + standortGenauigkeitM: status.standort_genauigkeit_m, + standortZeit: status.standort_zeit, + oelwechselFaelligTs: status.oelwechsel_faellig_ts, + oelwechselFaelligKm: status.oelwechsel_faellig_km, + inspektionFaelligTs: status.inspektion_faellig_ts, + inspektionFaelligKm: status.inspektion_faellig_km, + kmProTag: 18.3, // Fallback, nur solange kein Servicebuch-Eintrag existiert + // "Gültig bis" ist einschließlich - der Rabatt gilt noch am Stichtag + // selbst, erst am Folgetag schaltet der Schalter automatisch ab, ohne + // dass der Nutzer manuell eingreifen muss. + smartdeal: { aktiv: p.einstellungen.smartdeal.aktiv && (!p.einstellungen.smartdeal.laeuft_ab || + p.einstellungen.smartdeal.laeuft_ab >= new Date().toISOString().slice(0, 10)), laeuftAb: p.einstellungen.smartdeal.laeuft_ab }, + oel: { + modus: p.einstellungen.oelwechsel_intervall.modus, + km: p.einstellungen.oelwechsel_intervall.km, + monate: p.einstellungen.oelwechsel_intervall.monate, + herstellerKm: p.einstellungen.oelwechsel_intervall.hersteller_km, + herstellerMonate: p.einstellungen.oelwechsel_intervall.hersteller_monate, + }, + versicherung: p.versicherung, + steuer: { ...p.steuer, faellig: p.steuer.faellig || "" }, + reifen: { + aktiv: p.reifen.aktiv === "winter" ? "Winter" : "Sommer", + anzugsmoment: p.reifen.anzugsmoment_nm, + wechsel: p.reifen.wechsel, + sommer: reifenSatz("sommer"), + winter: reifenSatz("winter"), + }, + service: p.service, + technik: p.technik, + ausstattung: p.ausstattung, + }; +} + +/* Rückrichtung: CONFIG/CAR (im Speicher gehalten, vom Prototyp-Code direkt + mutiert) zurück in die Profil-Form für audi_dashboard_profil_schreiben. */ +function configCarZuProfil(vorherigesProfil) { + const p = JSON.parse(JSON.stringify(vorherigesProfil)); + p.fahrzeug.titel = CONFIG.fahrzeugtitel; + p.fahrzeug.zusatz = CONFIG.fahrzeugzusatz; + p.fahrzeug.untertitel = CONFIG.fahrzeuguntertitel; + p.fahrzeug.kennzeichen = CONFIG.kennzeichen; + p.fahrzeug.hauptuntersuchung_faellig = CAR.hu; + p.fahrzeug.fin = CAR.fin; + p.fahrzeug.erstzulassung = CAR.erstzulassung; + p.fahrzeug.ausfuehrung = CONFIG.ausfuehrung; + p.einstellungen.uebersichtsbild = CONFIG.startbild; + p.einstellungen.fahrten_pausenzeit_min = CONFIG.pauseMin; + p.einstellungen.backup_intervall = CONFIG.backupIntervall; + p.einstellungen.smartdeal = { aktiv: CAR.smartdeal.aktiv, laeuft_ab: CAR.smartdeal.laeuftAb }; + p.einstellungen.oelwechsel_intervall = { + modus: CAR.oel.modus, km: CAR.oel.km, monate: CAR.oel.monate, + hersteller_km: CAR.oel.herstellerKm, hersteller_monate: CAR.oel.herstellerMonate, + }; + p.versicherung = CAR.versicherung; + p.steuer = { ...CAR.steuer, faellig: CAR.steuer.faellig || null }; + p.reifen.aktiv = CAR.reifen.aktiv.toLowerCase(); + p.reifen.anzugsmoment_nm = CAR.reifen.anzugsmoment; + p.reifen.wechsel = CAR.reifen.wechsel; + for (const k of ["sommer", "winter"]) { + // km wird vom Backend fortgeschrieben (reifenzaehler.py, reagiert auf + // den Kilometerstand-Sensor) - hier nie mitschreiben, sonst würde ein im + // Browser gehaltener älterer Stand eine zwischenzeitliche Fortschreibung + // überschreiben. + const { km, ...rest } = CAR.reifen[k]; + p.reifen.saetze[k] = { ...p.reifen.saetze[k], ...rest }; + } + p.service = CAR.service; + return p; +} + +let PROFIL_ROH = null; // letzter vom Backend gelesener Rohstand, für configCarZuProfil() + +async function profilSpeichern() { + const neuesProfil = configCarZuProfil(PROFIL_ROH); + PROFIL_ROH = neuesProfil; + await HASS.callService("pyscript", "audi_dashboard_profil_schreiben", { profil_json: JSON.stringify(neuesProfil) }); +} + +/* ------------------------------------------------------------- Bilder + Feste Dateinamen laut §7a, referenziert unter /local/ (aus www/ serviert). + Der Ordner bilder/ selbst ist noch nicht befüllt (siehe Kopfkommentar) - + die Flächen behalten dennoch ihre Maße, das Layout springt nicht. */ +const BILDER = [ + { name: "Seitenansicht", label: "Auto Seite", datei: "seitenansicht.webp" }, + { name: "Front schräg", label: "Auto Front schräg", datei: "front-schraeg.webp" }, + { name: "Frontansicht", label: "Auto Front", datei: "frontansicht.webp" }, + { name: "Heckansicht", label: "Auto Heck", datei: "heckansicht.webp" }, + { name: "Cockpit", label: "Innenraum Cockpit", datei: "cockpit.webp" }, + { name: "Scheinwerfer", label: "Scheinwerfer", datei: "scheinwerfer.webp" }, + { name: "Sitze", label: "Innenraum Sitze", datei: "sitze.webp" }, +]; +let bild = 0; +const SEITE_WINTER = { label: "Auto Seite mit Winterrädern", datei: "seitenansicht-winter.webp" }; +// Alle einzeln hochladbaren Bild-Plätze (§ Fahrzeugbilder-Upload in +// Einstellungen) - dieselben festen Dateinamen wie BILDER, plus die separat +// geführte Winter-Variante der Seitenansicht. +const BILDER_UPLOAD_SLOTS = [...BILDER, { name: "Seitenansicht (Winter)", label: SEITE_WINTER.label, datei: SEITE_WINTER.datei }]; +// Bei jedem Hochladen/Löschen eines Fahrzeugfotos hochgezählt und als +// Query-Parameter an alle bilder/-URLs gehängt (siehe bildInfo() +// unten) - ohne das würde der Browser nach einem Upload weiter das alte Bild +// oder den zwischenzeitlich gecachten 404 unter derselben URL zeigen. +let bildVersion = Date.now(); + +/* Platzhalter statt kaputtem Bild-Symbol: bei Ladefehler (Bilder aus §7a + noch nicht vorhanden) wird die Bildfläche durch ein generisches Symbol + plus eindeutiger Beschriftung und dem erwarteten Dateinamen ersetzt - + damit klar ist, welche Datei unter welchem Namen nachgereicht werden muss, + ohne dass sich am Layout etwas verschiebt (Maße bleiben über die CSS- + Klassen erhalten, siehe carfix/carfix.klein/radbild in audi-dashboard.css). */ +/* A5: Der Platzhalter nennt das Motiv und die nächste Handlung - der + Dateiname ist Entwicklersprache und steht nur noch in den Einstellungen + (siehe BILDER_UPLOAD_SLOTS). Die Maße bleiben über die CSS-Klassen erhalten, + das Layout springt beim Nachliefern eines Fotos nicht. */ +function bildMitPlatzhalter(src, datei, label, klasse, imgId, dateiZeigen = false) { + const idTeil = imgId ? ` id="${imgId}"` : ""; + return `
+ ${esc(label)} +
+ + ${esc(label)} + ${dateiZeigen ? esc(datei) : "Foto hinzufügen"} +
+
`; +} + +function startIndex() { + const i = BILDER.findIndex((b) => b.name === CONFIG.startbild); + return i < 0 ? 0 : i; +} +function bildInfo(i) { + const b = BILDER[i]; + if (b.name === "Seitenansicht" && CAR.reifen.aktiv === "Winter") { + return { label: SEITE_WINTER.label, datei: SEITE_WINTER.datei, src: `/local/bilder/${SEITE_WINTER.datei}?v=${bildVersion}` }; + } + return { label: b.label, datei: b.datei, src: `/local/bilder/${b.datei}?v=${bildVersion}` }; +} +function bildSrc(i) { return bildInfo(i).src; } +function carImg(modus) { + if (modus === "fest") { + const info = bildInfo(startIndex()); + return bildMitPlatzhalter(info.src, info.datei, info.label, "carfix klein"); + } + const info = bildInfo(bild); + return bildMitPlatzhalter(info.src, info.datei, info.label, "carfix", "carimg") + + `
${BILDER.map((_, i) => ``).join("")}
`; +} +/* Bildmarke des Kraftstoffanbieters (Shell). Anders als bei den Fahrzeug- + fotos ist hier kein generischer Platzhalter-Rahmen sinnvoll, weil das Logo + inline neben Text steht (.marke) - Fehlerzustand blendet stattdessen den + Markennamen als kleine Textmarke ein (.marke .ph in audi-dashboard.css), + exakt wie im Prototyp vorgesehen. Die Bilddatei selbst liefert der Nutzer + nach (offizielles Shell-Logo, hier nicht nachgezeichnet). */ +const MARKENLOGO_DATEI = "shell-logo.svg"; +function markenlogo() { + if (!CAR.smartdeal.aktiv) return ""; + const label = CONFIG.markenname || "Logo"; + return ``; +} +const OHNE_SZENE = ["Cockpit", "Sitze", "Scheinwerfer"]; +function szeneKlasse(i) { + if (!bodenAn) return ""; + if (OHNE_SZENE.includes(BILDER[i].name)) return ""; + return CAR.reifen.aktiv === "Winter" ? "winter" : "strasse"; +} +function bildWeiter() { + bild = (bild + 1) % BILDER.length; + const img = ROOT.getElementById("carimg"); + if (!img) return; + const info = bildInfo(bild); + img.src = info.src; + img.alt = info.label; + const box = img.closest("[data-bildbox]"); + if (box) { + box.classList.remove("bild-fehlt"); // neues Bild kann laden, auch wenn das vorherige fehlte + box.querySelector(".platzhalter-label").textContent = info.label; + box.querySelector(".platzhalter-datei").textContent = info.datei; + } + const sz = ROOT.getElementById("szene"); + if (sz) sz.className = "szene " + szeneKlasse(bild); + [...ROOT.getElementById("dots").children].forEach((d, i) => d.classList.toggle("on", i === bild)); +} + +/* ---------------------------------------------------- Termine/Vorhersage + heute() statt einer eingefrorenen Konstante - der Prototyp nutzte ein + festes Demo-Datum (new Date(2026,7,3)), das für den echten Betrieb durch + das tatsächliche Tagesdatum ersetzt werden muss. */ +function heute() { return new Date(); } +const dTag = 86400000; +/* Ein unlesbares Datum ergibt einen Strich, keine Rechenreste. dat() erwartet + TT.MM.JJJJ; steht im Profil etwas anderes (es wird laut INSTALL.md von Hand + gepflegt, z. B. versehentlich im ISO-Format), lieferte mmjj() sonst + "NaN/N" - ein Wert, der wie eine Angabe aussieht und keine ist. */ +const gueltigesDatum = (d) => d instanceof Date && !Number.isNaN(d.getTime()); +const mmjj = (d) => (gueltigesDatum(d) + ? String(d.getMonth() + 1).padStart(2, "0") + "/" + String(d.getFullYear()).slice(2) + : "–"); +const dedat = (d) => (gueltigesDatum(d) + ? d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }) + : "–"); +function dat(s) { const [d, m, j] = s.split(".").map(Number); return new Date(j, m - 1, d); } + +const MISCHDAUER = 180; +function tagesleistung() { + // §9: fällt die Datenquelle aus, wird das sichtbar gemacht statt mit einem + // Platzhalterwert weiterzurechnen - ohne diese Sperre rechnete die + // Prognose mit CAR.odo=0 weiter und erzeugte Termine weit im Jahr -13000. + if (!CAR.odoBekannt) return { wert: CAR.kmProTag, quelle: "Kilometerstand unbekannt", tage: 0, km: 0 }; + const b = CAR.service.buch; + if (!b.length) return { wert: CAR.kmProTag, quelle: "Vorgabe", tage: 0, km: 0 }; + const alt = b[b.length - 1]; + const tageGesamt = Math.max(1, (heute() - dat(alt.datum)) / dTag); + const langfristig = (CAR.odo - alt.km) / tageGesamt; + const letzter = b[0]; + const tageSeit = (heute() - dat(letzter.datum)) / dTag; + const kmSeit = CAR.odo - letzter.km; + if (tageSeit < 14 || kmSeit <= 0) + return { wert: langfristig, quelle: "gesamter Verlauf", tage: Math.round(tageGesamt), km: CAR.odo - alt.km }; + const aktuell = kmSeit / tageSeit; + const g = Math.min(1, tageSeit / MISCHDAUER); + return { + wert: langfristig * (1 - g) + aktuell * g, + quelle: g >= 1 ? "seit dem letzten Service" : "gemischt, " + Math.round(g * 100) + " % aktueller Abschnitt", + tage: Math.round(tageSeit), km: kmSeit, + }; +} +function kmProTag() { return tagesleistung().wert; } +function prognose(zielKm) { + if (!CAR.odoBekannt) return { tage: null, datum: null }; + const rest = zielKm - CAR.odo; + if (rest <= 0) return { tage: 0, datum: heute() }; + const tage = Math.round(rest / kmProTag()); + return { tage, datum: new Date(heute().getTime() + tage * dTag) }; +} +function ics(titel, datum, ort, notiz) { + const tag = datum.replace(/-/g, ""); + const naechster = new Date(new Date(datum).getTime() + dTag).toISOString().slice(0, 10).replace(/-/g, ""); + const text = [ + "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Audi Dashboard//DE", "BEGIN:VEVENT", + "UID:" + Date.now() + "@audi-dashboard.local", + "DTSTAMP:" + new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z", + "DTSTART;VALUE=DATE:" + tag, "DTEND;VALUE=DATE:" + naechster, + "SUMMARY:" + titel, "LOCATION:" + (ort || ""), "DESCRIPTION:" + (notiz || ""), + "BEGIN:VALARM", "TRIGGER:-P2D", "ACTION:DISPLAY", "DESCRIPTION:" + titel, "END:VALARM", + "END:VEVENT", "END:VCALENDAR", + ].join("\r\n"); + const a = document.createElement("a"); + a.href = "data:text/calendar;charset=utf-8," + encodeURIComponent(text); + a.download = titel.replace(/[^\wäöüÄÖÜß ]/g, "").replace(/\s+/g, "-") + ".ics"; + document.body.appendChild(a); a.click(); a.remove(); +} +function ring(pct) { + const r = 64, c = 2 * Math.PI * r, on = (c * pct) / 100; + return `
+ + + + +
+
${pct}%
+ ${de(CONFIG.tankvolumen * pct / 100, 1)} Liter +
`; +} + +/* ------------------------------------------------------------------ Karte + Leaflet weiterhin per CDN geladen (siehe Kopfkommentar). */ +let MAP = null, TILE = null, LEAFLET_LADEND = null; +/* Bewusst themeunabhaengig hell: Kartendienste (Google Maps & Co.) zeigen die + Standard-Strassenkarte immer im hellen Stil, weil Strassen, Beschriftungen + und Wegfarben darauf lesbar bleiben. Die frueher bei Nacht eingeblendeten + dark_all-Kacheln liessen die Karte im Panel schwarz wirken. */ +const TILE_URL = "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"; +const ATTR = "© OpenStreetMap · CARTO"; +/* Satellitenansicht der Standort-Vollbildkarte (Layer-Knopf) - wie die + CARTO-Straßenkacheln oben ohne eigenen Schlüssel nutzbar. */ +const SAT_TILE_URL = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"; +const SAT_ATTR = "Tiles © Esri"; + +const LEAFLET_CSS_URL = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css"; +const LEAFLET_JS_URL = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"; + +/* Das Leaflet-Stylesheet muss in den Shadow-Root, nicht in document.head: + Dokument-Styles greifen nicht in ein Shadow-DOM hinein. Lag es im Head, + fehlte drinnen u. a. .leaflet-tile{position:absolute} - die Kacheln flossen + dann als gewoehnliche Bilder untereinander (bei 8 Kacheln ueber 1000 px + hoch), von der Karte war nur ein schmaler Streifen zu sehen und der + Fahrzeugmarker landete weit unterhalb des sichtbaren Ausschnitts. Das JS + gehoert dagegen weiterhin in den Head, damit window.L global bleibt. */ +function leafletCssEinhaengen() { + if (!ROOT || ROOT.querySelector("link[data-leaflet-css]")) return Promise.resolve(); + return new Promise((fertig) => { + const css = document.createElement("link"); + css.rel = "stylesheet"; + css.href = LEAFLET_CSS_URL; + css.dataset.leafletCss = "1"; + /* Auch bei Ladefehler weitermachen statt haengen zu bleiben - dann sieht + die Karte zwar wieder falsch aus, aber die Ansicht baut sich auf. */ + css.onload = fertig; + css.onerror = fertig; + ROOT.appendChild(css); + }); +} + +function leafletLaden() { + /* Bewusst bei jedem Aufruf: haengt HA das Panel neu ein, entsteht ein neuer + Shadow-Root ohne das Stylesheet, waehrend window.L laengst gesetzt ist. */ + const css = leafletCssEinhaengen(); + if (window.L) return css; + if (!LEAFLET_LADEND) { + LEAFLET_LADEND = new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = LEAFLET_JS_URL; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); + } + return Promise.all([css, LEAFLET_LADEND]); +} + +function mapBox() { return `
`; } + +/* Kartenmittelpunkt ohne eigene Positionsdaten (z. B. Fahrt ohne GPS-Punkte): + die Werkstatt-Koordinaten aus dem Profil statt einer im Code fest + hinterlegten Region - die liegen ohnehin meist in der Nähe und kommen + schon aus einer echten, dafür vorgesehenen Datenquelle. Ohne Werkstatt- + Koordinaten bleibt nur ein neutraler Mittelpunkt (geografische Mitte + Deutschlands), keine an dieses Fahrzeug gebundene Vermutung. */ +function kartenMittelpunkt() { + const pos = CAR.service.autohaus.pos; + return pos && pos.length === 2 ? pos : [51.1657, 10.4515]; +} + +function fakeTrack(a, b, n = 13) { + const pts = []; let seed = Math.round((a[0] + b[1]) * 1000); + const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff - 0.5; }; + for (let i = 0; i <= n; i++) { + const t = i / n, k = Math.sin(t * Math.PI); + pts.push([a[0] + (b[0] - a[0]) * t + rnd() * 0.05 * k, a[1] + (b[1] - a[1]) * t + rnd() * 0.05 * k]); + } + pts[0] = a; pts[n] = b; return pts; +} +function tiles() { + if (!MAP || !window.L) return; + const url = TILE_URL; + if (TILE) MAP.removeLayer(TILE); + TILE = window.L.tileLayer(url, { maxZoom: 19, attribution: ATTR }).addTo(MAP); +} +async function initMap() { + await leafletLaden(); + const el = ROOT.getElementById("map"); + if (!el || typeof window.L === "undefined") return; + MAP = window.L.map(el, { zoomControl: true, attributionControl: true, scrollWheelZoom: false }); + TILE = null; tiles(); + const rot = getComputedStyle(HOST).getPropertyValue("--red").trim() || "#F50537"; + if (route.name === "trip") { + const t = TRIPS.find((x) => x.trip_id === route.id); + if (t && t.start_lat != null && t.end_lat != null) { + const track = fakeTrack([t.start_lat, t.start_lon], [t.end_lat, t.end_lon]); + const line = window.L.polyline(track, { color: rot, weight: 2.5, opacity: 0.9 }).addTo(MAP); + track.slice(1, -1).forEach((pt) => window.L.circleMarker(pt, { radius: 2.5, color: rot, weight: 0, fillColor: rot, fillOpacity: 0.85 }).addTo(MAP)); + window.L.circleMarker([t.start_lat, t.start_lon], { radius: 6, color: "#fff", weight: 2, fillColor: "#000", fillOpacity: 1 }).addTo(MAP).bindPopup("Start · " + (t.start_address || "")); + window.L.circleMarker([t.end_lat, t.end_lon], { radius: 6, color: rot, weight: 2, fillColor: rot, fillOpacity: 1 }).addTo(MAP).bindPopup("Ziel · " + (t.end_address || "")); + MAP.fitBounds(line.getBounds(), { padding: [26, 26] }); + } else { + MAP.setView(kartenMittelpunkt(), 11); // kein Track ohne GPS-Daten (§7a-Backend-Lücke) + } + } else if (route.name === "fill") { + const f = FILLS.find((x) => x.tank_id === route.id); + if (f && f.station_lat != null) { + MAP.setView([f.station_lat, f.station_lon], 14); + window.L.circleMarker([f.station_lat, f.station_lon], { radius: 7, color: rot, weight: 2, fillColor: rot, fillOpacity: 0.85 }).addTo(MAP).bindPopup((f.station_name || "") + "
" + (f.station_address || "")); + } else { + MAP.setView(kartenMittelpunkt(), 11); + } + } + setTimeout(() => MAP && MAP.invalidateSize(), 60); +} + +/* ---------------------------------------------------------------- Standort + Live-Position des Fahrzeugs (Übersicht-Kachel + Vollbildansicht) und des + Endgeräts (Browser-Geolocation). Bewusst außerhalb des sonst + durchgängigen "ein render() zeichnet alles neu"-Musters: eine Leaflet- + Karte verliert bei jedem Neuaufbau ihres DOM-Containers Kartenausschnitt + und Zoomstufe (genau wie MAP oben schon), und das Fahrzeug-Menü soll sich + beim Öffnen/Schließen/Ziehen nicht wie der Rest der App anfühlen (kein + kompletter Neuaufbau bei jedem Fingertipp). initMap() oben handhabt genau + dasselbe Problem bereits für Fahrt-/Belegkarten - hier nur konsequent für + eine interaktive Karte mit eigenem Bedienelement weitergedacht: Standort- + und Nutzerdaten ändern CAR/USER_POS und lösen ein normales render() aus + (Kartenneuaufbau inklusive, wie bei MAP), aber Menü-Auf/Zu/Ziehen und die + Zentrieren-Knöpfe manipulieren SMAP und das Menü-DOM direkt. */ +let USER_POS = null; // {lat, lon, genauigkeit} oder null +let USER_POS_TS = 0; +let USER_POS_FEHLER = null; +let STANDORT_ADRESSE = null; // zuletzt aufgelöste Adresse (Cache über Koordinaten-Rundung) +let STANDORT_ADRESSE_KEY = null; +let STANDORTMENU_OFFEN = false; +let SMAP = null, SMAP_TILE = null, SMAP_STIL = null; // Vollbild-Karte (eigenständig von MAP/TMAP); SMAP_STIL: null/"strasse" oder "satellit" +let TMAP = null, TMAP_TILE = null; // Vorschau-Karte der Übersicht-Kachel +let FAHRZEUG_MARKER = null, USER_MARKER = null; +const STANDORT_PEEK_PX = 96; // sichtbarer Anteil im geschlossenen Zustand, muss zu --standort-peek in audi-dashboard.css passen + +function standortBekannt() { return CAR.standortLat != null && CAR.standortLon != null; } + +/* Kartenmarkierung: poi-car aus dem CI-Satz, in den beiden gelieferten + Groessenvarianten - poi-car-l (48er-Raster, mehr Detail) ab 34 px, darunter + poi-car-s (24er-Raster). Die Nadelspitze sitzt unten, deshalb ankert Leaflet + am unteren Rand (siehe iconAnchor an den Aufrufstellen: Spitze bei y=45.25 + im 48er- bzw. y=23.3 im 24er-viewBox). */ +function fahrzeugMarkerSVG(px = 34) { + const gross = px >= 34; + const box = gross ? 48 : 24; + return ``; +} + +/* Fahrzustand fürs Menü: "fährt" solange die aktuelle Fahrt noch offen ist + (siehe fahrterkennung.py), sonst "steht". Ohne echtes Bewegungssignal vom + Fahrzeug (siehe AGENTS.md, FMM003/flespi offen) ist das die beste Näherung + aus den bereits vorhandenen Fahrtdaten. + + parkplatz unterscheidet einen endgültigen Stillstand (laenger als die in + "Fahrten zusammenfassen" hinterlegte Pausenzeit) von einem blossen + Zwischenstopp. Das Feld ist vorbereitet, wird aber derzeit von keiner + Ansicht gelesen - eine "Letzter Parkplatz"-Anzeige gibt es noch nicht. */ +function standortZustand() { + const t = TRIPS[0]; + if (t && t.status === "offen") return { faehrt: true, seitTs: null, parkplatz: false }; + const seitTs = t && t.ts_end ? new Date(t.ts_end).getTime() : null; + const minuten = seitTs != null ? (Date.now() - seitTs) / 60000 : null; + return { faehrt: false, seitTs, parkplatz: minuten != null && minuten >= (CONFIG.pauseMin ?? 15) }; +} +function dauerSeitText(ts) { + if (ts == null) return "unbekannt"; + const sekunden = Math.max(0, Math.floor((Date.now() - ts) / 1000)); + const tage = Math.floor(sekunden / 86400); + const stunden = Math.floor((sekunden % 86400) / 3600); + const minuten = Math.floor((sekunden % 3600) / 60); + if (tage > 0) return `${tage} Tg. ${stunden} Std. ${minuten} Min.`; + if (stunden > 0) return `${stunden} Std. ${minuten} Min.`; + return `${minuten} Min.`; +} + +/* Haversine-Näherung für die Distanzangabe oben links im Menü ("15 m"). */ +function distanzMeter(a, b) { + if (!a || !b) return null; + const R = 6371000, rad = Math.PI / 180; + const dLat = (b.lat - a.lat) * rad, dLon = (b.lon - a.lon) * rad; + const s = Math.sin(dLat / 2) ** 2 + Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s)); +} +function distanzText(m) { return m == null ? "" : m < 1000 ? Math.round(m) + " m" : de(m / 1000, 1) + " km"; } + +/* Zeile unter dem Fahrzeugnamen: Abstand Nutzer -> Fahrzeug, sonst der Grund, + warum er noch fehlt. */ +function standortAbstandText() { + if (!standortBekannt()) return ""; + if (USER_POS) return distanzText(distanzMeter(USER_POS, { lat: CAR.standortLat, lon: CAR.standortLon })) + " entfernt"; + return USER_POS_FEHLER || "Abstand wird ermittelt …"; +} +function standortErfassen() { + if (!navigator.geolocation) { USER_POS_FEHLER = "Kein Zugriff auf den Standort dieses Geräts"; standortAnsichtAktualisieren(); return; } + navigator.geolocation.getCurrentPosition( + (pos) => { + USER_POS = { lat: pos.coords.latitude, lon: pos.coords.longitude, genauigkeit: pos.coords.accuracy }; + USER_POS_TS = Date.now(); USER_POS_FEHLER = null; + standortAnsichtAktualisieren(); + }, + (err) => { + USER_POS_FEHLER = err.code === err.PERMISSION_DENIED ? "Standortzugriff verweigert" : "Standort nicht verfügbar"; + // Auch der Fehlschlag zaehlt als Versuch. Ohne diese Zeile greift die + // Drossel in standortErfassenFallsNoetig() nie, solange der Nutzer die + // Freigabe verweigert - und jedes Backend-Update loeste eine neue + // Hochgenauigkeits-Abfrage aus. + USER_POS_TS = Date.now(); + standortAnsichtAktualisieren(); + }, + { enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 } + ); +} +function standortErfassenFallsNoetig() { + // Auf USER_POS_TS statt auf USER_POS pruefen: sonst waere ein fehlgeschlagener + // Versuch kein Versuch, siehe Fehlerpfad oben. + if (USER_POS_TS && Date.now() - USER_POS_TS < 30000) return; + standortErfassen(); +} + +/* Reverse-Geocoding über die öffentliche Nominatim-API (OpenStreetMap) - + wie die Kartenkacheln oben (TILE_URL) ohne eigenen Schlüssel; bei diesem + Aufrufvolumen (ein Fahrzeug, nur bei geöffneter Standortansicht) im Rahmen + der Nominatim-Nutzungsbedingungen unproblematisch, genau wie das bereits + dokumentierte Leaflet-über-CDN-Vorgehen (siehe AGENTS.md). Cache-Schlüssel + auf 3 Nachkommastellen (~110 m) gerundet, damit ein erneutes Öffnen an + derselben Stelle keinen zweiten Abruf auslöst. */ +async function standortAdresseAufloesen(lat, lon) { + const key = lat.toFixed(3) + "," + lon.toFixed(3); + if (STANDORT_ADRESSE_KEY === key) return STANDORT_ADRESSE; + try { + const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lon}&zoom=18&addressdetails=1`, { headers: { "Accept-Language": "de" } }); + const daten = await res.json(); + const a = daten.address || {}; + const strasse = [a.road, a.house_number].filter(Boolean).join(" "); + STANDORT_ADRESSE = [strasse, [a.postcode, a.city || a.town || a.village].filter(Boolean).join(" ")].filter(Boolean).join(", ") || daten.display_name || ""; + } catch (e) { + // Den Schluessel bewusst NICHT setzen: sonst gilt ein einzelner + // fehlgeschlagener Abruf (Rate-Limit, kurzer Netzausfall) fuer diese Zelle + // die ganze Sitzung lang als "schon versucht", und es blieben dauerhaft nur + // die Koordinaten stehen. + STANDORT_ADRESSE = null; + return null; + } + STANDORT_ADRESSE_KEY = key; + return STANDORT_ADRESSE; +} +function standortAdresseLaden() { + if (!standortBekannt()) return; + standortAdresseAufloesen(CAR.standortLat, CAR.standortLon).then((adresse) => { + const el = $("#standortAdresse"); + if (el && adresse) el.textContent = adresse; + }); +} + +function standortKartenlink() { + return standortBekannt() ? `https://www.google.com/maps/dir/?api=1&destination=${CAR.standortLat},${CAR.standortLon}` : null; +} +async function standortTeilen() { + const url = standortKartenlink(); + if (!url) return; + const titel = `${CONFIG.fahrzeugtitel} · Standort`; + if (navigator.share) { + try { await navigator.share({ title: titel, url }); } catch (e) { /* vom Nutzer abgebrochen */ } + return; + } + try { await navigator.clipboard.writeText(url); hinweis("Link kopiert", "Der Standort-Link wurde in die Zwischenablage kopiert."); } + catch (e) { hinweis("Teilen nicht möglich", url); } +} + +/* Direkte DOM-Updates ohne render() (siehe Kopfkommentar) - hält SMAP und + ein offenes Menü am Leben, während neue Positionsdaten eintreffen. */ +function standortAnsichtAktualisieren() { + if (route.name !== "standort") return; + standortMarkerZeichnen(); + const dist = $("#standortDistanz"); + if (dist) dist.textContent = standortAbstandText(); +} + +function standortTiles() { + if (!SMAP || !window.L) return; + if (SMAP_TILE) SMAP.removeLayer(SMAP_TILE); + SMAP_TILE = SMAP_STIL === "satellit" + ? window.L.tileLayer(SAT_TILE_URL, { maxZoom: 19, attribution: SAT_ATTR }).addTo(SMAP) + : window.L.tileLayer(TILE_URL, { maxZoom: 19, attribution: ATTR }).addTo(SMAP); +} +function standortMarkerZeichnen() { + if (!SMAP || !window.L) return; + if (FAHRZEUG_MARKER) { SMAP.removeLayer(FAHRZEUG_MARKER); FAHRZEUG_MARKER = null; } + if (USER_MARKER) { SMAP.removeLayer(USER_MARKER); USER_MARKER = null; } + if (standortBekannt()) { + const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `
${fahrzeugMarkerSVG(38)}
`, iconSize: [38, 38], iconAnchor: [19, 36] }); + FAHRZEUG_MARKER = window.L.marker([CAR.standortLat, CAR.standortLon], { icon }).addTo(SMAP); + FAHRZEUG_MARKER.on("click", () => standortMenuOeffnen()); + } + if (USER_POS) { + const icon = window.L.divIcon({ className: "user-pin-wrap", html: `
`, iconSize: [16, 16], iconAnchor: [8, 8] }); + USER_MARKER = window.L.marker([USER_POS.lat, USER_POS.lon], { icon, zIndexOffset: -100 }).addTo(SMAP); + } +} +function standortAufFahrzeug() { if (SMAP && standortBekannt()) SMAP.flyTo([CAR.standortLat, CAR.standortLon], Math.max(SMAP.getZoom(), 15)); } +function standortAufUser() { + if (!USER_POS) { standortErfassen(); return; } + if (SMAP) SMAP.flyTo([USER_POS.lat, USER_POS.lon], Math.max(SMAP.getZoom(), 15)); +} +function standortBeideZeigen() { + if (!SMAP) return; + if (standortBekannt() && USER_POS) SMAP.fitBounds([[CAR.standortLat, CAR.standortLon], [USER_POS.lat, USER_POS.lon]], { padding: [64, 64], maxZoom: 16 }); + else if (standortBekannt()) SMAP.flyTo([CAR.standortLat, CAR.standortLon], 15); + else if (USER_POS) SMAP.flyTo([USER_POS.lat, USER_POS.lon], 15); +} + +async function initStandortMap() { + STANDORTMENU_OFFEN = false; + await leafletLaden(); + const el = ROOT.getElementById("smap"); + if (!el || typeof window.L === "undefined") return; + SMAP = window.L.map(el, { zoomControl: false, attributionControl: false }); + SMAP_TILE = null; standortTiles(); + /* Erst Ansicht, dann Marker: Leaflet legt Marker-Symbole nur auf einer Karte + mit gesetztem Mittelpunkt an - vorher angehaengte Marker blieben unsichtbar. */ + if (standortBekannt()) SMAP.setView([CAR.standortLat, CAR.standortLon], 15); + else if (USER_POS) SMAP.setView([USER_POS.lat, USER_POS.lon], 15); + else SMAP.setView(kartenMittelpunkt(), 11); + standortMarkerZeichnen(); + standortBeideZeigen(); + standortAdresseLaden(); + standortErfassenFallsNoetig(); + setTimeout(() => SMAP && SMAP.invalidateSize(), 60); +} +async function initVorschauMap() { + await leafletLaden(); + const el = ROOT.getElementById("tmap"); + if (!el || typeof window.L === "undefined") return; + TMAP = window.L.map(el, { + zoomControl: false, attributionControl: false, dragging: false, + scrollWheelZoom: false, doubleClickZoom: false, boxZoom: false, keyboard: false, touchZoom: false, + }); + TMAP_TILE = window.L.tileLayer(TILE_URL, { maxZoom: 19, attribution: ATTR }).addTo(TMAP); + if (standortBekannt()) { + TMAP.setView([CAR.standortLat, CAR.standortLon], 14); + const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `
${fahrzeugMarkerSVG(32)}
`, iconSize: [32, 32], iconAnchor: [15, 31] }); + window.L.marker([CAR.standortLat, CAR.standortLon], { icon }).addTo(TMAP); + } else { + TMAP.setView(kartenMittelpunkt(), 11); + } + standortErfassenFallsNoetig(); + setTimeout(() => TMAP && TMAP.invalidateSize(), 60); +} + +function standortMenuElement() { return ROOT.getElementById("standortmenu"); } +function standortMenuOeffnen() { const el = standortMenuElement(); if (el) el.classList.add("offen"); STANDORTMENU_OFFEN = true; } +function standortMenuSchliessen() { const el = standortMenuElement(); if (el) el.classList.remove("offen"); STANDORTMENU_OFFEN = false; } +function standortMenuUmschalten() { STANDORTMENU_OFFEN ? standortMenuSchliessen() : standortMenuOeffnen(); } + +/* Ziehen am Griff/Kopfbereich des Menüs - eigene Pointer-Events-Kette, + bewusst getrennt von der Wisch-zum-Löschen-Logik unten (andere Achse, + anderer Auslöser), manipuliert das Menü-DOM direkt (siehe Kopfkommentar + Standort-Abschnitt). */ +let smY0 = null, smOffenBeimStart = false; +/* Zugweg: alles ausser dem sichtbaren Anteil. Die Blatthoehe haengt am Inhalt, + deshalb wird sie beim Ziehen gemessen statt fest verdrahtet. */ +function standortMenuHub() { + const el = standortMenuElement(); + return el ? Math.max(0, el.offsetHeight - STANDORT_PEEK_PX) : 0; +} +function standortMenuVerdrahten() { + ROOT.addEventListener("pointerdown", (e) => { + if (route.name !== "standort" || !e.target.closest(".standortmenu-griffzone")) return; + smY0 = e.clientY; smOffenBeimStart = STANDORTMENU_OFFEN; + const el = standortMenuElement(); if (el) el.style.transition = "none"; + }); + ROOT.addEventListener("pointermove", (e) => { + if (smY0 === null) return; + e.preventDefault(); + const el = standortMenuElement(); if (!el) return; + const dy = e.clientY - smY0; + const hub = standortMenuHub(); + const basis = smOffenBeimStart ? 0 : hub; + const versatz = Math.max(0, Math.min(hub, basis + dy)); + el.style.transform = `translateY(${versatz}px)`; + }, { passive: false }); + ROOT.addEventListener("pointerup", (e) => { + if (smY0 === null) return; + const dy = e.clientY - smY0; + standortMenuGesteBeenden(); + if (Math.abs(dy) > 40) { dy < 0 ? standortMenuOeffnen() : standortMenuSchliessen(); } + else standortMenuUmschalten(); + }); + /* Ohne pointercancel bleibt smY0 gesetzt, sobald das System die Beruehrung + uebernimmt (iOS-Randgeste, Multitouch, eingehender Anruf). Der + pointermove-Handler oben ruft dann preventDefault() fuer jede weitere + Bewegung - Scrollen waere in der ganzen App blockiert, bis irgendwo ein + pointerup kommt, der dann ein dy aus einem veralteten Startpunkt + auswertet. Die drei anderen Zeigergesten der Datei machen das laengst + richtig. */ + ROOT.addEventListener("pointercancel", () => { + if (smY0 === null) return; + standortMenuGesteBeenden(); + }); +} + +/* Setzt den Gestenzustand zurueck und gibt das Blatt wieder an die + CSS-Uebergaenge ab - gemeinsam von pointerup und pointercancel genutzt. */ +function standortMenuGesteBeenden() { + const el = standortMenuElement(); + if (el) { el.style.transition = ""; el.style.transform = ""; } + smY0 = null; +} + +function standortKachel() { + return `
+ +
`; +} + +/* Symbole aus dem Audi-CI-Satz (assets/icons/) - hier eingebettet, weil die + Panel-Datei ohne zusaetzliche Abrufe auskommen soll. */ +const CI = { + mapLayer: '', + carSearch: '', + gps: '', + poiCar: '', + /* poi-car-l - grosse Variante desselben CI-Symbols (48er-Raster), fuer die + Fahrzeugnadel auf der Vollbildkarte. Siehe fahrzeugMarkerSVG(). */ + poiCarL: '', + selectAll: '', + planRoute: '', + upload: '', + fuelRange: '', + parking: '', +}; +function ciSVG(pfad, px = 16) { + return ``; +} + +function vStandort() { + const zustand = standortZustand(), bekannt = standortBekannt(); + const distanz = standortAbstandText(); + const adresseKey = bekannt ? CAR.standortLat.toFixed(3) + "," + CAR.standortLon.toFixed(3) : null; + /* Solange die Adresse nicht aufgeloest ist, stehen die Koordinaten da - + kein Platzhaltertext, der haengen bleiben kann. */ + const koordinaten = bekannt ? CAR.standortLat.toFixed(4) + ", " + CAR.standortLon.toFixed(4) : ""; + const adresseText = !bekannt ? "Kein GPS-Signal vom Fahrzeug" + : STANDORT_ADRESSE_KEY === adresseKey ? (STANDORT_ADRESSE || koordinaten) : koordinaten; + const link = standortKartenlink(); + return ` +
+
+
+ + + + +
+
+
+ + + +
+
${esc(CONFIG.fahrzeugtitel)}
+
${distanz}
+
+
+
${esc(adresseText)}
+
+ ${zustand.faehrt ? `Fahrzeug fährt` + : `${ciSVG(CI.parking)}${zustand.seitTs != null ? "Geparkt seit " + dauerSeitText(zustand.seitTs) : "Fahrzeug steht"}`} +
+
+ ${ciSVG(CI.fuelRange)} + ${de(CAR.tankPct)} % + ${CAR.reichweite != null ? de(CAR.reichweite) + " km" : "Reichweite unbekannt"} +
+
+ + ${ciSVG(CI.planRoute, 15)}Route + + +
+
+
`; +} + +/* -------------------------------------------------------------- Ansichten + Ab hier praktisch unverändert aus dem Prototyp übernommen. */ +function badge() { + // Offizielle Audi-Markenassets aus www/badges/ (positive = für hellen, + // negative = für dunklen Hintergrund) - welche Variante sichtbar ist, + // entscheidet reines CSS über :host([data-theme=...]) (siehe .badge img in + // audi-dashboard.css), genau wie beim Farbwechsel des restigen Interfaces + // beim Tag/Nacht-Umschalten. Kein Re-Render beim Theme-Wechsel nötig. + // + // Der Zusatz neben dem Badge (z. B. "Avant competition") setzt sich aus + // dem Modellnamen selbst zusammen - alles nach dem erkannten Badge-Präfix + // ("Avant"/"Sportback"/...) - plus der separat gepflegten Ausführung, statt + // aus dem alten, nicht mehr an die Modell-Auswahl gekoppelten + // CONFIG.fahrzeugzusatz (siehe Kopfkommentar bei FAHRZEUG_MODELLE). + const praefix = badgePraefix(); + if (!praefix) return ""; + const datei = BADGE_DATEIEN[praefix]; + const modellZusatz = (CONFIG.fahrzeugtitel || "").slice(praefix.length).trim(); + /* Die Ausfuehrung steht oft schon im Modellnamen ("RS 4 Avant competition" + plus Ausfuehrung "competition") - dann nicht ein zweites Mal anhaengen, + sonst steht dort "Avant competition competition". */ + const ausfuehrung = (CONFIG.ausfuehrung || "").trim(); + const doppelt = ausfuehrung && modellZusatz.toLowerCase().includes(ausfuehrung.toLowerCase()); + const zusatz = [modellZusatz, doppelt ? "" : ausfuehrung].filter(Boolean).join(" "); + const alt = esc(CONFIG.fahrzeugtitel) + " Badge"; + return `
+ ${alt} + ${alt} + ${zusatz ? `${esc(zusatz)}` : ""}
`; +} + +function teaser() { + if (!TRIPS.length && !FILLS.length) return ""; + const t = TRIPS[0], f = FILLS[0]; + return `
Zuletzt + ${t ? `` : ""} + ${f ? `` : ""}
`; +} + +function naechsterTermin() { return termine().slice().sort((a, b) => a.ziel - b.ziel)[0]; } + +function intervalle(t) { + const oel = CAR.oel, b = t.basis; + if (!b) return null; + const bauen = (km, monate) => ({ km: km ? b.km + km : null, datum: monatePlus(b.datum, monate) }); + if (t.art === "Hauptuntersuchung") { + const eig = bauen(null, 24); + return { hersteller: null, eigen: eig, prognose: eig.datum }; + } + const eKm = t.art === "Ölwechsel" ? (oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm)) : 30000; + const eM = t.art === "Ölwechsel" ? (oel.modus === "hersteller" ? oel.herstellerMonate : Math.min(oel.monate, oel.herstellerMonate)) : 24; + const eig = bauen(eKm, eM); + // "Herstellervorgabe" gilt bei Ölwechsel nur, wenn der Kunde in "Einrichten" + // tatsächlich die Herstellervorgabe gewählt hat - bei eigenem Intervall ist + // "eigen" maßgeblich, sonst würde anzeige = iv.hersteller || iv.eigen unten + // immer die Herstellerwerte zeigen und das Kundenintervall ignorieren. + const her = t.art === "Ölwechsel" ? (oel.modus === "hersteller" ? bauen(oel.herstellerKm, oel.herstellerMonate) : null) : bauen(30000, 24); + const perKm = prognose(eig.km).datum; + return { hersteller: her, eigen: eig, prognose: perKm && perKm < eig.datum ? perKm : eig.datum }; +} +/* Vom Fahrzeug selbst gemeldete Fälligkeit (VW Group EU Data Act, siehe + einstellungen.py) - ergänzt die App-eigene, aus dem Servicebuch berechnete + Prognose (intervalle()/terminText() oben), ersetzt sie aber bewusst nicht: + die Werkscomputer-Logik dahinter ist nicht bekannt und könnte von den + selbst eingestellten Intervallen abweichen. null, wenn das Fahrzeug dazu + nichts meldet (Sensor fehlt/nicht verfügbar). */ +function fahrzeugMeldungRoh(art) { + if (art === "Ölwechsel") return { ts: CAR.oelwechselFaelligTs, km: CAR.oelwechselFaelligKm }; + if (art === "Inspektion") return { ts: CAR.inspektionFaelligTs, km: CAR.inspektionFaelligKm }; + return { ts: null, km: null }; +} + +/* App-eigene Ölwechsel-Prognose, eigenständig statt über die allgemeine + tagesleistung()/prognose()-Mischrechnung (die für Ölwechsel zu träge ist, + weil sie den langfristigen Durchschnitt seit dem allerersten Servicebuch- + eintrag mit einfließen lässt): die Berechnung beginnt bewusst komplett neu + ab dem Datum des letzten tatsächlich durchgeführten Ölwechsels. + + Logik (wie vorgegeben): km und Tage seit dem letzten Ölwechsel ergeben die + aktuelle Fahrleistung pro Tag; hochgerechnet auf die restlichen km bis zum + eingestellten Intervall (Kundeneigene Einstellung, siehe CAR.oel) ergibt + das ein Datum - gedeckelt auf spätestens das Intervall-Zeitlimit (z. B. 1 + Jahr) nach dem letzten Ölwechsel, falls seither wenig gefahren wurde. */ +function oelwechselPrognose() { + if (!CAR.odoBekannt) return null; + const letzter = letzterEintrag("öl"); + if (!letzter) return null; + const oel = CAR.oel; + const intervallKm = oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm); + const intervallMonate = oel.modus === "hersteller" ? oel.herstellerMonate : Math.min(oel.monate, oel.herstellerMonate); + const zielKm = letzter.km + intervallKm; + const restKm = Math.max(0, zielKm - CAR.odo); + const kappDatum = monatePlus(letzter.datum, intervallMonate); + let datum; + if (restKm <= 0) { + datum = heute(); + } else { + const tageSeit = (heute() - dat(letzter.datum)) / dTag; + const kmSeit = CAR.odo - letzter.km; + if (tageSeit <= 0 || kmSeit <= 0) { + // Noch keine belastbare Fahrleistung seit dem Ölwechsel bekannt (z. B. + // direkt danach) - ohne Rate keine seriöse Km-Prognose, also das + // Zeitlimit als einzig belastbare Grenze zeigen. + datum = kappDatum; + } else { + const kmProTagSeitOel = kmSeit / tageSeit; + const tageBisZiel = restKm / kmProTagSeitOel; + const berechnet = new Date(heute().getTime() + tageBisZiel * dTag); + datum = berechnet < kappDatum ? berechnet : kappDatum; + } + } + return { km: zielKm, restKm, datum }; +} + +function terminText(t) { + const restKm = t.km !== null && CAR.odoBekannt ? t.km - CAR.odo : null; + const restKmAbs = restKm !== null ? Math.abs(restKm) : null; + const haupt = t.zuerst === "km" && restKm !== null ? "in " + de(restKmAbs) + " km" : dedat(t.datum); + const neben = t.zuerst === "km" ? "voraussichtlich " + dedat(t.kmDatum) + " · " + dedat(t.datum) + : (t.km !== null ? "in " + de(restKmAbs) + " km" : (t.art === "Hauptuntersuchung" ? "" : "aus dem Servicebuch fortgeschrieben")); + return { haupt, neben, restKm }; +} + +/* Zählt seit LETZTES_UPDATE_TS (echter Zeitpunkt des letzten Fahrzeugstatus- + Updates vom Backend, siehe datenLaden()) selbständig hoch und schaltet + dabei die Einheit um - Sek. bis 60, dann Min. bis 60, dann Std. bis 24, + dann Tage. */ +function standAlterText() { + if (LETZTES_UPDATE_TS == null) return "unbekannt"; + const sekunden = Math.max(0, Math.floor((Date.now() - LETZTES_UPDATE_TS) / 1000)); + if (sekunden < 60) return `vor ${sekunden} Sek.`; + const minuten = Math.floor(sekunden / 60); + if (minuten < 60) return `vor ${minuten} Min.`; + const stunden = Math.floor(minuten / 60); + if (stunden < 24) return `vor ${stunden} Std.`; + return `vor ${Math.floor(stunden / 24)} Tage`; +} +function standAlterTicken() { + if (!ROOT || !CAR) return; + const sy = $("#sync span"); if (sy) sy.textContent = standAlterText(); +} + +function vHome() { + const g = CAR.gesichert; + const statusText = g === null ? "Status unbekannt" : g ? "Sicher abgestellt" : "Nicht sicher abgestellt"; + const statusDot = g === null ? "warn" : g ? "" : "bad"; + const pct = CAR.tankPct; + const balkenFarbe = pct <= 10 ? "var(--bad)" : pct <= 25 ? "var(--warn)" : "var(--ok)"; + const liter = CONFIG.tankvolumen ? Math.round((CONFIG.tankvolumen * pct) / 100) : null; + const t = naechsterTermin(); + /* C2/C5/D1: Reihenfolge der myAudi-App - randloses Fahrzeugbild, Name und + Status, große Reichweite mit farbigem Balken, dann Aktionskacheln. Die + Kacheln führen zu Ansichten statt zu Fernsteuerung, die das Panel bewusst + nicht kann. */ + return ` +
${carImg("fest")}
+
+ ${badge()} + ${esc(CONFIG.kennzeichen)} + +
+
+
+
${CAR.reichweite != null ? de(CAR.reichweite) : "–"}km
+ ${CAR.tankBekannt ? pct + " %" : "–"} +
+
+ Reichweite${CAR.tankBekannt && liter != null ? ` · noch etwa ${de(liter)} l im Tank` : ""} +
+
+
Kilometerstand +
${CAR.odoBekannt ? de(CAR.odo) : "–"}km
+ ${(() => { if (!t) return `
Service
kein Eintrag
`; + const artLabel = t.art === "Hauptuntersuchung" ? "Haupt-
untersuchung" : esc(t.art); + if (t.art === "Ölwechsel") { + const restKm = t.km !== null && CAR.odoBekannt ? Math.abs(t.km - CAR.odo) : null; + const datum = t.kmDatum || t.datum; + const teile = []; + if (restKm !== null) teile.push(de(restKm / 1000, 1) + " tkm"); + if (datum) teile.push(mmjj(datum)); + return `
${artLabel} +
${teile.join("/ ")}
`; + } + const teileHU = []; + if (t.zuerst === "km") { + teileHU.push(de(t.km - CAR.odo) + " km"); + if (t.kmDatum) teileHU.push(mmjj(t.kmDatum)); + } else { + teileHU.push(mmjj(t.datum)); + if (t.km !== null && CAR.odoBekannt) teileHU.push(de(Math.abs(t.km - CAR.odo)) + " km"); + } + return `
${artLabel} +
${teileHU.join("/ ")}
`; })()} +
+ ${standortKachel()} + ${teaser()}`; +} + +function vSicherheit() { + const eintraege = CAR.sicherheitscheck; + const g = CAR.gesichert; + const offen = eintraege.filter((e) => e.ok === false).length; + const unbekannt = eintraege.filter((e) => e.ok === null).length; + const sammelText = g === null + ? `${unbekannt} von ${eintraege.length} Punkten liefern derzeit keinen Wert` + : g ? "Fahrzeug ist sicher abgestellt" : `${offen} Punkt${offen === 1 ? "" : "e"} prüfen`; + /* C6: Draufsicht oben, grüne Sammelmeldung, dann die Prüfliste - dieselbe + Reihenfolge wie der Fahrzeugstatus in der myAudi-App. */ + return ` +
${bildMitPlatzhalter(`/local/bilder/draufsicht.webp?v=${bildVersion}`, "draufsicht.webp", "Draufsicht", "carfix klein")}
+
+ + ${esc(sammelText)} +
+
Geprüfte Punkte +
+ ${eintraege.length ? eintraege.map((e, i, arr) => `
+
${esc(e.label)}
+
+ + ${e.ok === null ? "unbekannt" : e.ok ? "in Ordnung" : "prüfen"} +
+
`).join("") : `
Keine Daten verfügbar
`} +
+
`; +} + +function vAudi() { + const r = CAR.reifen, a = r[r.aktiv.toLowerCase()]; + return ` +
${carImg("galerie")}
+
+ ${badge()} + ${esc(CONFIG.kennzeichen)} +
+ +
Zustand
+
Kilometerstand
${CAR.odoBekannt ? de(CAR.odo) + " km" : "unbekannt"}
+
Tankfüllung
${de(CONFIG.tankvolumen * CAR.tankPct / 100, 1)} l${CAR.tankPct} % von ${CONFIG.tankvolumen} l
+
Batteriespannung
+
${CAR.batteriespannung != null ? de(CAR.batteriespannung, 1) + " V" : "unbekannt"} +
+ + + `; +} + +/* ------------------------------------------------- Batteriespannungs-Verlauf + Handgebautes Diagramm ohne externe Bibliothek (kein Bundler im Projekt, + siehe INSTALL.md) - Zoom per Zwei-Finger-Pinch/Mausrad, Verschieben per + Ziehen mit einem Finger, Antippen wählt den nächstgelegenen Messpunkt. + bvZeichnen() aktualisiert nur die SVG/Tooltip-Inhalte (kein render()), damit + Zoom/Pan flüssig bleiben. */ +let bvVollDomain = null, bvDomain = null, bvPunkte = [], bvAusgewaehlt = null; +const bvZeiger = new Map(); // pointerId -> {x,y}, für Pinch (2 Finger) vs. Pan (1 Finger) +let bvGestStart = null; // {type:"pan"|"pinch", ...Ausgangswerte der Geste} +let bvTapKandidat = null, bvBewegtSeit = false; +const BV_W = 320, BV_H = 170, BV_ML = 40, BV_MR = 10, BV_MT = 10, BV_MB = 22; +const kurzdat = (d) => d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "2-digit" }); + +/* Referenzwerte für AGM-Starterbatterien (12V), recherchiert u. a. bei + aboutdarwin.com/agm-battery-voltage-charts und brsbattery.com - eine volle + AGM-Batterie liegt in Ruhe (min. 2h ohne Last/Ladung) bei 12,8-13,0 V, + 50 % Ladezustand bei 12,2-12,3 V, unter 11,8 V gilt sie als kritisch + entladen. Der Ladealternator liefert im Betrieb üblicherweise 13,5-14,8 V - + deutlich über dem Ruhespannungs-Maximum. + + Das Backend (batterieverlauf.py) zeichnet pro Tag min UND max roh auf, + ohne Filterung - der Sensor meldet künftig mehrfach pro Stunde (aktive + Fahrt). AGM_RUHE_MAX_V wird deshalb erst hier beim Anzeigen angewendet, + und zwar auf den Tagesminimalwert: der fällt typischerweise auf den + Moment kurz nach dem Start, bevor der Alternator hochfährt, und ist damit + die für den Ladezustand aussagekräftige Größe. Tage, an denen selbst das + Minimum über der Grenze liegt (Fahrzeug nie im Ruhezustand beobachtet), + tauchen entsprechend nicht in der Ladezustands-Statistik auf. */ +const AGM_RUHE_MAX_V = 13.2; +const BV_SOC_TABELLE = [ + [10.5, 0], [11.8, 10], [11.9, 20], [12.0, 30], [12.1, 40], + [12.2, 50], [12.4, 60], [12.5, 70], [12.6, 80], [12.7, 90], [12.8, 100], +]; +function bvSocProzent(v) { + const t = BV_SOC_TABELLE; + if (v <= t[0][0]) return 0; + if (v >= t[t.length - 1][0]) return 100; + for (let i = 0; i < t.length - 1; i++) { + const [v0, s0] = t[i], [v1, s1] = t[i + 1]; + if (v >= v0 && v <= v1) return Math.round(s0 + ((s1 - s0) * (v - v0)) / (v1 - v0)); + } + return 100; +} +function bvSocLabel(soc) { + return soc >= 90 ? "Sehr gut" : soc >= 70 ? "Gut" : soc >= 50 ? "Mittel" : soc >= 30 ? "Schwach" : "Kritisch"; +} +// Rot (leer/kritisch) -> Gelb (50 %) -> Grün (voll) entlang derselben drei +// Eckwerte wie die SOC-Tabelle oben (11,6 V/0 %, 12,2 V/50 %, 12,8 V/100 %) - +// dieselben Statusfarben wie beim Sicherheitscheck (--bad/--warn/--ok), +// damit die Skala zum Rest der App passt statt eigene Farben zu erfinden. +function bvHexZuRgb(hex) { + const h = hex.trim().replace("#", ""); + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; +} +function bvMischen(a, b, t) { return a.map((x, i) => Math.round(x + (b[i] - x) * t)); } +function bvFarbstufen() { + const stil = getComputedStyle(HOST); + return { + bad: bvHexZuRgb(stil.getPropertyValue("--bad")), + warn: bvHexZuRgb(stil.getPropertyValue("--warn")), + ok: bvHexZuRgb(stil.getPropertyValue("--ok")), + }; +} +function bvFarbe(v, stufen) { + let rgb; + if (v <= 11.6) rgb = stufen.bad; + else if (v <= 12.2) rgb = bvMischen(stufen.bad, stufen.warn, (v - 11.6) / (12.2 - 11.6)); + else if (v <= 12.8) rgb = bvMischen(stufen.warn, stufen.ok, (v - 12.2) / (12.8 - 12.2)); + else rgb = stufen.ok; + return `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; +} + +/* Gesundheitszustand (SOH) ist mit reiner Ruhespannungs-Beobachtung nur eine + Näherung, keine Kapazitätsmessung (ein Lasttest wäre dafür nötig - siehe + Recherche, u. a. battlebornbatteries.com/blogs/articles/battery-state-of- + health-soh) - deshalb hier bewusst als Trend über die Zeit ausgedrückt, + nicht als exakter Prozentwert: Durchschnitt der ersten vs. letzten Phase + der Aufzeichnung, in einem für alternde AGM-Batterien üblichen Rahmen + eingeordnet (übliche Lebensdauer 4-6 Jahre). */ +function bvSohBewertung() { + const TAG_MS = 86400000; + if (bvPunkte.length < 6) return { status: null, text: "Noch nicht genug Messwerte für eine Trendbewertung." }; + const spanneTage = (bvPunkte[bvPunkte.length - 1].t - bvPunkte[0].t) / TAG_MS; + if (spanneTage < 60) return { status: null, text: "Noch zu kurzer Zeitraum für eine Trendbewertung (mind. ~2 Monate nötig)." }; + const fensterMs = Math.min(90, spanneTage / 3) * TAG_MS; + const alt = bvPunkte.filter((p) => p.t <= bvPunkte[0].t + fensterMs); + const neu = bvPunkte.filter((p) => p.t >= bvPunkte[bvPunkte.length - 1].t - fensterMs); + const altD = sum(alt, (p) => p.v) / alt.length, neuD = sum(neu, (p) => p.v) / neu.length; + const delta = neuD - altD; + const status = delta >= -0.05 ? "ok" : delta >= -0.15 ? "warn" : "bad"; + const text = delta >= -0.05 + ? "Stabil - kein auffälliger Rückgang der Ruhespannung." + : delta >= -0.15 + ? "Leicht sinkend - im für alternde AGM-Batterien üblichen Rahmen." + : "Deutlicher Rückgang - kann auf eine alternde oder geschwächte Batterie hindeuten."; + return { status, text, delta }; +} + +function vBatterieverlauf() { + // Der Diagrammpunkt und die Kurve bestehen ausschließlich aus dem + // Tagesminimalwert (siehe AGM_RUHE_MAX_V-Kommentar oben) - min_ts ist der + // echte Zeitstempel genau dieser Einzelmessung, nicht ein fiktiver + // Tagesmittelpunkt, damit Antippen Datum UND Uhrzeit zeigen kann. + bvPunkte = BATTERIEVERLAUF.filter((e) => e.min != null && e.min <= AGM_RUHE_MAX_V) + .map((e) => ({ t: new Date(e.min_ts).getTime(), v: e.min, vMin: e.min, vMax: e.max, minTs: e.min_ts, datum: e.datum })) + .sort((a, b) => a.t - b.t); + bvAusgewaehlt = null; bvZeiger.clear(); bvGestStart = null; + + const genug = bvPunkte.length >= 2; + bvVollDomain = genug ? [bvPunkte[0].t, bvPunkte[bvPunkte.length - 1].t] : null; + bvDomain = genug ? [...bvVollDomain] : null; + + const stufen = bvFarbstufen(); + const letzter = bvPunkte.length ? bvPunkte[bvPunkte.length - 1] : null; + const soc = letzter ? bvSocProzent(letzter.v) : null; + const soh = bvSohBewertung(); + const punktFarbe = (status) => (status === "ok" ? "var(--ok)" : status === "warn" ? "var(--warn)" : status === "bad" ? "var(--bad)" : "var(--fg3)"); + const leerText = BATTERIEVERLAUF.length === 0 + ? "Noch keine Messwerte aufgezeichnet - ein Eintrag entsteht automatisch, sobald eine Spannungsquelle eingerichtet ist." + : "Noch zu wenige Messwerte für einen Verlauf."; + + return ` +
+ Verlauf + ${genug ? ` +
+ +
+
+ + +
+ + Ziehen zum Verschieben, mit zwei Fingern oder dem Mausrad + zoomen, auf einen Punkt tippen für Datum, Uhrzeit sowie Min-/Max-Spannung + ` : ` + ${leerText} + `} + Farbskala Ladezustand (AGM, Ruhespannung) +
+
+ 11,6 V · leer + 12,2 V · 50 % + 12,8 V · voll +
+
+
+ Bewertung +
+
Ladezustand (SOC)
+
+ ${letzter + ? `${de(letzter.v, 1)} V · ${soc} % · ${bvSocLabel(soc)}` + : `unbekannt`}
+
Gesundheitszustand (SOH-Trend)
+
+ ${soh.status + ? `${(soh.delta >= 0 ? "+" : "") + de(soh.delta, 2)} V` + : `-`}
+
+ ${soh.text} + Näherung auf Basis der täglich niedrigsten gemessenen Spannung (aussagekräftig für die + Entladung), keine Kapazitätsmessung - Tageswerte über ${de(AGM_RUHE_MAX_V, 1)} V (Ladevorgang während der Fahrt) fließen nicht ein. + Für eine verlässliche Aussage ist ein Lasttest an der Batterie nötig. +
`; +} + +function bvSkalaX(t) { return BV_ML + ((t - bvDomain[0]) / (bvDomain[1] - bvDomain[0])) * (BV_W - BV_ML - BV_MR); } +function bvSkalaY(v, yMin, yMax) { return BV_H - BV_MB - ((v - yMin) / (yMax - yMin)) * (BV_H - BV_MT - BV_MB); } +function bvDomainKlemmen(domain) { + let [a, b] = domain; + const volle = bvVollDomain[1] - bvVollDomain[0]; + let breite = Math.min(b - a, volle); + breite = Math.max(breite, volle * 0.02); + if (a < bvVollDomain[0]) { a = bvVollDomain[0]; b = a + breite; } + if (b > bvVollDomain[1]) { b = bvVollDomain[1]; a = b - breite; } + return [a, b]; +} +function bvPlotRahmen(rect) { + return { linksPx: rect.left + rect.width * (BV_ML / BV_W), breitePx: rect.width * ((BV_W - BV_ML - BV_MR) / BV_W) }; +} + +function bvZeichnen() { + const svg = $("#bvSvg"); + if (!svg || !bvDomain) return; + const sichtbar = bvPunkte.filter((p) => p.t >= bvDomain[0] && p.t <= bvDomain[1]); + const basis = sichtbar.length >= 2 ? sichtbar : bvPunkte; + let yMin = Math.min(...basis.map((p) => p.v)), yMax = Math.max(...basis.map((p) => p.v)); + if (yMax - yMin < 0.4) { const mitte = (yMax + yMin) / 2; yMin = mitte - 0.2; yMax = mitte + 0.2; } + const pad = (yMax - yMin) * 0.15; yMin -= pad; yMax += pad; + + // etwas über den sichtbaren Bereich hinaus zeichnen, damit die Linie am + // Rand nicht mitten in der Luft abbricht, sondern zum nächsten (Rand-)Punkt + // weiterläuft + const spanne = bvDomain[1] - bvDomain[0]; + const rahmen = bvPunkte.filter((p) => p.t >= bvDomain[0] - spanne * 0.05 && p.t <= bvDomain[1] + spanne * 0.05); + const linie = rahmen.map((p) => `${bvSkalaX(p.t).toFixed(1)},${bvSkalaY(p.v, yMin, yMax).toFixed(1)}`).join(" "); + + const gitter = [0, 1, 2, 3].map((i) => { + const v = yMin + ((yMax - yMin) * i) / 3, y = bvSkalaY(v, yMin, yMax); + return ` + ${de(v, 1)}`; + }).join(""); + const xLabels = [0, 0.5, 1].map((f) => { + const x = BV_ML + f * (BV_W - BV_ML - BV_MR); + const t = bvDomain[0] + f * spanne; + return `${kurzdat(new Date(t))}`; + }).join(""); + const stufen = bvFarbstufen(); + const kreise = rahmen.map((p) => { + const idx = bvPunkte.indexOf(p); + const gewaehlt = idx === bvAusgewaehlt; + return ``; + }).join(""); + + svg.innerHTML = `${gitter}${kreise}${xLabels}`; + + const rangeEl = $("#bvRange"); + if (rangeEl) rangeEl.textContent = `${kurzdat(new Date(bvDomain[0]))} – ${kurzdat(new Date(bvDomain[1]))}`; + const tip = $("#bvTooltip"); + if (tip) { + if (bvAusgewaehlt != null) { + const p = bvPunkte[bvAusgewaehlt]; + const zeit = new Date(p.minTs); + tip.style.display = "block"; + tip.textContent = `${dedat(zeit)} · ${zeit.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr · Min ${de(p.vMin, 1)} V · Max ${de(p.vMax, 1)} V`; + } else { + tip.style.display = "none"; + } + } +} + +function bvNaechsterPunkt(clientX) { + const svg = $("#bvSvg"); if (!svg) return null; + const rect = svg.getBoundingClientRect(); + const zeit = bvDomain[0] + ((clientX - bvPlotRahmen(rect).linksPx) / bvPlotRahmen(rect).breitePx) * (bvDomain[1] - bvDomain[0]); + let beste = null, besterAbstand = Infinity; + bvPunkte.forEach((p, i) => { const a = Math.abs(p.t - zeit); if (a < besterAbstand) { besterAbstand = a; beste = i; } }); + return beste; +} + +let svAuswahl = 0; +function vService() { + const sv = CAR.service; + return ` +
Anstehende Termine +
+ ${termine().map((t, i, arr) => { + const letzte = i === arr.length - 1 ? 'style="border-bottom:0"' : ""; + const iv = intervalle(t); + if (!iv) return `
+
${t.art}
kein Eintrag im Servicebuch
`; + const anzeige = iv.hersteller || iv.eigen; + const fm = fahrzeugMeldungRoh(t.art); + const fmDatum = fm.ts ? dedat(new Date(fm.ts)) : null; + // Forecast-Zeile nur bei Ölwechsel (Kundeneigenes Intervall aus + // "Einrichten", eigenständige Reset-nach-Service-Logik, siehe + // oelwechselPrognose()) - bei Inspektion und Hauptuntersuchung + // bewusst keine Forecast-Zeile mehr. + const eigenOel = t.art === "Ölwechsel" ? oelwechselPrognose() : null; + return `
+
${t.art} + ${t.vereinbart ? `
Werkstatt am ${dedat(new Date(t.vereinbart))}` : ""} +
+
+ ${iv.hersteller ? `Herstellervorgabe` : ""} + ${fm.km != null || fmDatum ? ` + ${fm.km != null ? de(fm.km) + " km" : ""}${fm.km != null && fmDatum ? " / " : ""}${fmDatum || ""} + ${eigenOel ? `${de(eigenOel.restKm)} km, voraussichtlich am ${dedat(eigenOel.datum)}` : ""} + ` : ` + ${eigenOel ? de(eigenOel.restKm) + " km · " + dedat(eigenOel.datum) : (anzeige.km !== null ? de(anzeige.km) + " km · " : "") + dedat(anzeige.datum)} + `} +
+
`; }).join("")} +
+
+
Termin vereinbaren + ${(() => { const ts = termine(), t = ts[Math.min(svAuswahl, ts.length - 1)]; + const vorschlag = CAR.service.vereinbart[t.art] || (t.ziel ? t.ziel.toISOString().slice(0, 10) : ""); + return ` +
+
+
+
+ + ${CAR.service.vereinbart[t.art] ? `` : ""}`; })()} +
+
+ Autohaus + +
${esc(sv.autohaus.name)}
+ ${esc(sv.autohaus.adresse)} + ${esc(sv.autohaus.tel)} + ${sv.autohaus.mail ? `E-Mail schreiben` : ""} + In Karten öffnen +
+
Servicebuch + ${sv.buch.length ? sv.buch.map((e, i) => swipeZeile(`sbuch:${i}`, ``)).join("") : `
+ Noch keine Einträge +

Aus dem Servicebuch berechnet die App die Fälligkeit von Inspektion, Ölwechsel und Hauptuntersuchung. Der erste Eintrag ist die Grundlage für jede Prognose.

+
`} + +
`; +} + +function vIdent() { + return ` +
Fahrzeug +
+
Modell
${esc(CONFIG.fahrzeugtitel)}
+
Ausführung
${CONFIG.ausfuehrung ? esc(CONFIG.ausfuehrung) : "noch offen"}
+
Fahrgestellnummer
${esc(CAR.fin)}
+
Motor und Leistung
${esc(CAR.details)}
+
Erstzulassung
${esc(CAR.erstzulassung)}
+
Kennzeichen
${esc(CONFIG.kennzeichen)}
+
Zulassung
Deutschland
+
+ ${CAR.technik.map((g) => `
${esc(g.gruppe)} +
+ ${g.posten.map((pp, i) => `
+
${esc(pp[0])}
+
${pp[1] ? esc(pp[1]) : "noch offen"}
`).join("")} +
`).join("")} + ${CAR.ausstattung.map((g) => `
${esc(g.gruppe)} +
+ ${g.sonder.map((x, i) => `
+
${esc(x)}
`).join("")} +
`).join("")} + `; +} + +function monatePlus(datum, monate) { const d = dat(datum); d.setMonth(d.getMonth() + monate); return d; } +function letzterEintrag(stichwort) { return CAR.service.buch.find((e) => e.art.toLowerCase().includes(stichwort)) || null; } +function termine() { + const oel = CAR.oel; + const oelKm = oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm); + const oelM = oel.modus === "hersteller" ? oel.herstellerMonate : Math.min(oel.monate, oel.herstellerMonate); + const bauen = (art, basis, kmIntervall, monate, ersatzDatum) => { + const kmZiel = basis && kmIntervall ? basis.km + kmIntervall : null; + const datZiel = basis ? monatePlus(basis.datum, monate) : (ersatzDatum ? monatePlus(ersatzDatum, monate) : null); + const kmDatum = kmZiel !== null ? prognose(kmZiel).datum : null; + const zuerst = (kmDatum && datZiel) ? (kmDatum < datZiel ? "km" : "zeit") : (kmDatum ? "km" : "zeit"); + return { art, km: kmZiel, datum: datZiel, kmDatum, zuerst, basis, + ziel: zuerst === "km" ? kmDatum : datZiel, vereinbart: CAR.service.vereinbart[art] || null }; + }; + const oelTermin = bauen("Ölwechsel", letzterEintrag("öl"), oelKm, oelM); + // Genauere, eigenständige Ölwechsel-Prognose (siehe oelwechselPrognose()) + // ersetzt hier den km-Anteil aus dem generischen bauen() oben, damit + // Übersicht, Service-Kachel und Anstehende Termine überall dieselbe Zahl + // für "wann ist der nächste Ölwechsel fällig" zeigen statt drei leicht + // unterschiedlicher Werte. + const oelProg = oelTermin.basis ? oelwechselPrognose() : null; + if (oelProg) { + oelTermin.km = oelProg.km; + oelTermin.kmDatum = oelProg.datum; + oelTermin.zuerst = oelTermin.datum && oelProg.datum >= oelTermin.datum ? "zeit" : "km"; + oelTermin.ziel = oelTermin.zuerst === "km" ? oelProg.datum : oelTermin.datum; + } + return [ + oelTermin, + bauen("Inspektion", letzterEintrag("inspektion"), 30000, 24), + bauen("Hauptuntersuchung", letzterEintrag("hauptuntersuchung"), null, 24, + CAR.erstzulassung && CAR.erstzulassung.includes("/") ? `01.${CAR.erstzulassung.replace("/", ".")}` : null), + ]; +} + +function vWerkstatt() { + const a = CAR.service.autohaus; + return ` +
Angaben zum Autohaus +
+
+
+
+
+
`; +} + +function vSbuch() { + const i = parseInt(route.id, 10), e = CAR.service.buch[i]; + if (!e) return `
Eintrag nicht gefunden
`; + const istEntwurf = entwurfIndex === i; + const artBekannt = SERVICE_ARTEN.includes(e.art); + return ` +
${esc(e.art)} +
${de(e.km)}km
+ ${e.datum} · ${esc(e.werkstatt)} +
+
Eintrag +
+
+ km
+
+
+
+
+
+ Kosten fließen in die Jahresübersicht ein +
+
Durchgeführte Arbeiten + + Beim Verkauf ist diese Liste mehr wert als die Rechnung — + sie zeigt, dass gepflegt wurde +
+ ${istEntwurf ? `` + : ``}`; +} + +function vVers() { + const v = CAR.versicherung; + return ` +
Versicherung - Schaden Notruf 24/7 + + + Inland · ${esc(v.service)} + + Ausland · ${esc(v.notfall)} +
+
Jahresbeitrag ab ${dedat(new Date(v.gueltigAb))} + +
${eur(v.beitrag)}
+ ${esc(v.zahlweise)} · enthält ${eur(v.steueranteil)} € Versicherungsteuer (${esc(v.steuersatz)}) +
+ ${v.teile.map((t, i) => `
+
${esc(t.bez)}${t.sf ? `
${esc(t.sf)} · Beitragssatz ${esc(t.satz)}
` : ""} +
${eur(t.betrag)} €${v.beitrag ? de(t.betrag / v.beitrag * 100, 0) : 0} %
`).join("")} +
+
+ ${v.teile.map((t, i) => ``).join("")} +
+
+
Vertrag +
+
Gesellschaft
${esc(v.gesellschaft)}
+
Umfang
${esc(v.vertrag)}
+
Vertragsnummer
${esc(v.nummer)}
+ ${v.selbst.map(([b, w]) => `
Selbstbeteiligung ${esc(b)}
${esc(w)}
`).join("")} +
Schadenfreiheitsklasse
+
${v.teile.map((t) => t.sf).filter(Boolean).join(" / ")}zuvor ${v.teile.map((t) => t.sfAlt).filter(Boolean).join(" / ")}
+
+ +
+ ${vSteuer()}`; +} + +function vNotrufBearbeiten() { + const v = CAR.versicherung; + return ` +
Rufnummern ändern +
+
+
+
+
`; +} + +function vBeitrag() { + const v = CAR.versicherung; + return ` +
Beitrag anpassen +
+
+
+
+
+
+
+
+
+
+
+
+ Der Jahresbeitrag oben ist die Summe dieser drei Posten und + rechnet sich beim Speichern automatisch neu — "Gültig ab" steuert den Titel der Jahresbeitrag-Kachel +
`; +} + +function vVertragsdetails() { + const v = CAR.versicherung; + return ` + + ${v.leistungen.map((g) => `
${esc(g.gruppe)} +
+ ${g.posten.map((x, i) => `
+
${esc(x)}
`).join("")} +
`).join("")}`; +} + +function vSchutz() { + const v = CAR.versicherung, sb = v.schutzbrief; + return ` +
Die Entscheidung am Schadenort +
+ Diese drei Leistungen schließen einander aus. Wer eine nimmt, verliert den Anspruch auf die + anderen beiden.
+
+ ${sb.entweder.map((x, i) => `
+
${esc(x.bez)}
+
${esc(x.grenze)}${x.zusatz ? `${esc(x.zusatz)}` : ""}
`).join("")} +
+ Mobilität wird binnen 60 Minuten organisiert +
+
Wann der Schutzbrief greift +
+ ${sb.ausloeser.map((a, i) => `
+
${esc(a)}
`).join("")} +
+ Gilt in ${esc(sb.gilt)} + Versichert: ${esc(sb.versichert)} +
+ ${sb.gruppen.map((g) => `
${esc(g.titel)} +
+ ${g.posten.map((pp, i) => `
+
${esc(pp[0])}${pp[2] ? `
${esc(pp[2])}` : ""}
+
${esc(pp[1])}
`).join("")} +
`).join("")} +
Nicht versichert +
+ ${sb.nicht.map((n, i) => `
+
${esc(n[0])}
+ ${esc(n[1])}
`).join("")} +
+
Deine Pflichten +
+ ${sb.pflichten.map((x, i) => `
+
${esc(x)}
`).join("")} +
+
${esc(sb.rang)}
+ ${esc(sb.stand)} +
`; +} + +function vSteuer() { + const t = CAR.steuer; + return ` +
Kfz-Steuer + +
+
Betrag
${eur(t.betrag)} €${esc(t.zeitraum)}
+
Nächste Fälligkeit
+
${t.faellig ? dedat(new Date(t.faellig)) : "unbekannt"}
+
+
`; +} + +function vSteuerBearbeiten() { + const t = CAR.steuer; + return ` +
Kfz-Steuer +
+
+
+
+
+
+
+
+
`; +} + +/* B5: Die Wahl gilt dauerhaft, nicht nur bis zum Reload - sie wird beim Aufbau + der Menüleiste wieder angewendet. */ +const SCHRIFT_KEY = "audi-dashboard-tableiste-schrift"; +let beschriftung = (() => { + try { return localStorage.getItem(SCHRIFT_KEY) !== "aus"; } catch (e) { return true; } +})(); +let bodenAn = true; +function csvDatei(name, kopf, zeilen) { + const txt = [kopf.join(";")].concat(zeilen.map((r) => r.join(";"))).join("\r\n"); + const a = document.createElement("a"); + a.href = "data:text/csv;charset=utf-8," + encodeURIComponent("" + txt); + a.download = name; document.body.appendChild(a); a.click(); a.remove(); +} + +function vEinst() { + return ` +
Fahrzeug einrichten + + Fahrgestellnummer, Kennzeichen, Erstzulassung, Ausführung und Ölwechsel-Intervall — bei der + Ersteinrichtung oder wenn sich diese Angaben ändern. + ${einrichtenOffen ? ` +
+
+
+
+
+
+
+
+
+
+ Ölwechsel-Intervall +
+ + +
+ ${CAR.oel.modus === "hersteller" + ? `
+
Laufleistung
${de(CAR.oel.herstellerKm)} km
+
Zeit
${CAR.oel.herstellerMonate} Monate
+
+ Aus dem Fahrzeug gelesen` + : `
+
+
+
`} + + ` : ""} + +
+
Darstellung +
+ + +
+
+ +
+ Aus zeigt nur die Symbole und gibt etwas Höhe frei +
+ +
+ Angedeutete Fahrbahn mit Schatten, nur bei Außenansichten +
+
Bild der Übersicht +
+
+
${(() => { const info = bildInfo(startIndex()); return bildMitPlatzhalter(info.src, info.datei, info.label, "carfix"); })()}
+
+
Fahrzeugbilder + + Auf ein Bild tippen, um ein eigenes Foto hochzuladen oder zu ersetzen. +
+ ${BILDER_UPLOAD_SLOTS.map((s) => ` +
+
+ ${bildMitPlatzhalter(`/local/bilder/${s.datei}?v=${bildVersion}`, s.datei, s.label, "carfix mini", null, true)} +
+ + ${esc(s.name)} + ${bildMenuOffen === s.datei ? ` +
+
+ + +
` : ""} +
`).join("")} +
+
+
Kraftstoff-Rabatt +
+
+ ${CAR.smartdeal.aktiv ? ` +
+ ${dedat(new Date(CAR.smartdeal.laeuftAb))}
` : + `Ersparnis und Bildmarke werden ausgeblendet`} + ${smartdealPopupOffen ? ` +
+
+
+
+
+ + +
+
` : ""} +
+
Fahrten zusammenfassen +
+ Minuten
+ Steigst du kürzer als diese Zeit aus — etwa zum Tanken —, + bleibt es eine zusammenhängende Fahrt statt zweier +
+
Fahrzeugprofil + + Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten, + Ausstattung, Reifen, Versicherung, Steuer und Werkstatt. Für ein anderes Fahrzeug wird nur + diese Datei getauscht — die App selbst bleibt unverändert. + + +
+
Backup + + Sichert Fahrzeugprofil, Fahrten und Tankvorgänge gemeinsam. "Jetzt erstellen" legt eine + Kopie auf dem Server ab, "Exportieren"/"Importieren" übertragen den aktuellen + Gesamtstand als einzelne Datei. + ${CONFIG.letztesBackup ? `Letztes Backup: + ${dedat(new Date(CONFIG.letztesBackup))} · ${new Date(CONFIG.letztesBackup).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr` : ""} + +
+
+ + +
+
Daten ausgeben + + Semikolon als Trennzeichen und deutsches Zahlenformat, damit die Dateien ohne Umweg in + Tabellenprogrammen aufgehen. + + + +
+
Version +
+
Dashboard
${esc(CONFIG.version)}
+
Fahrzeugdaten
FMM003Zündung · Batterie
+
Position
FMM003GPS-Tracker
+
+ ${(() => { + const s = UPDATE_STATUS; + if (!s || s.stand === "keine_quelle") return ` + ${s ? "Keine Update-Quelle eingerichtet" : "Prüft, ob eine neuere Version verfügbar ist"} + `; + if (s.stand === "fehler") return ` + ${esc(s.fehler || "Update-Prüfung fehlgeschlagen")} + `; + if (s.stand === "geprueft" && s.verfuegbar) return ` + Update verfügbar — Version ${esc(s.neue_version)} + `; + return ` + Aktuell${s.geprueft_um ? " — zuletzt geprüft " + dedat(new Date(s.geprueft_um)) : ""} + `; + })()} +
`; +} + +let nmOffen = false; +let einrichtenOffen = false; +let bildMenuOffen = null; +let smartdealPopupOffen = false; + +/* --------------------------------------------------------- Setup-Popup + Sensor-zu-Entität-Zuordnung (Einstellungen -> Fahrzeug einrichten -> + Setup). setupZuordnung ist eine Arbeitskopie, die erst bei "Speichern" + an den Service geht - Abbrechen verwirft sie einfach ohne Nebeneffekt. */ +let setupOffen = false; +let setupZuordnung = null; // {KEY: "entity.id"} bzw. {KEY: [4 IDs]} für Listenfelder +let setupZuordnungStart = null; // Schnappschuss von setupZuordnung beim Öffnen - für den Neustart-Hinweis (welche trigger-gebundenen Felder haben sich geändert) +let setupNurPassend = true; // globaler Schalter: Kandidaten auf passende Domain/Einheit einschränken +let setupSucheOffen = null; // Feld-Id ("KEY" oder "KEY:idx") des offenen Dropdowns, sonst null +let setupSuchtext = ""; +let setupSpeichertLaeuft = false; +let ALLE_ENTITAETEN = []; + +function setupKatalog() { return (ENTITAETEN && ENTITAETEN.katalog) || []; } + +function alleEntitaeten() { + if (!HASS || !HASS.states) return []; + return Object.keys(HASS.states).map((id) => { + const attrs = (HASS.states[id] && HASS.states[id].attributes) || {}; + return { id, name: attrs.friendly_name || id, domain: id.split(".")[0], unit: attrs.unit_of_measurement || "", deviceClass: attrs.device_class || "" }; + }); +} + +function entitaetName(id) { + if (!id) return ""; + const treffer = ALLE_ENTITAETEN.find((e) => e.id === id); + return treffer ? treffer.name : id; +} + +/* Höher = besser passend: Stichwort- und Freitext-Treffer zählen am meisten, + Domain/Einheit/Device-Class sind Zusatzpunkte fürs Sortieren, wenn der + Schalter "Nur passende Sensoren" aus ist (dann filtern sie nicht hart, + siehe entitaetKandidaten()). */ +function entitaetScore(feld, e, suchtext) { + let score = 0; + const hay = (e.id + " " + e.name).toLowerCase(); + for (const wort of feld.stichworte || []) if (hay.includes(wort)) score += 5; + if (feld.domains && feld.domains.includes(e.domain)) score += 3; + if (feld.device_classes && feld.device_classes.length && feld.device_classes.includes(e.deviceClass)) score += 3; + if (feld.units && feld.units.length && feld.units.includes(e.unit)) score += 2; + if (suchtext && hay.includes(suchtext.trim().toLowerCase())) score += 10; + return score; +} + +function entitaetKandidaten(feldKey, suchtext, aktuellerWert) { + const feld = setupKatalog().find((f) => f.key === feldKey); + if (!feld) return []; + const text = (suchtext || "").trim().toLowerCase(); + const liste = ALLE_ENTITAETEN.filter((e) => { + if (e.id === aktuellerWert) return true; + if (setupNurPassend) { + if (feld.domains && feld.domains.length && !feld.domains.includes(e.domain)) return false; + if (feld.units && feld.units.length && !feld.units.includes(e.unit)) return false; + } + if (text && !(e.id.toLowerCase().includes(text) || e.name.toLowerCase().includes(text))) return false; + return true; + }); + liste.sort((a, b) => entitaetScore(feld, b, suchtext) - entitaetScore(feld, a, suchtext)); + return liste.slice(0, 60); +} + +/* Ab diesem Wert gilt ein Vorschlag als belastbar. entitaetScore() vergibt für + einen reinen Domain-Treffer bereits 3 Punkte - eine Schwelle von 3 wäre + deshalb immer erfüllt und hätte jedes leere Feld mit dem erstbesten Sensor + der passenden Domain belegt (Reihenfolge aus Object.keys(HASS.states), also + willkürlich). 5 verlangt mindestens einen Stichworttreffer. */ +const SETUP_VORSCHLAG_SCHWELLE = 5; + +/* Bester Vorschlag für ein Feld, der noch nicht anderweitig vergeben ist. + Das "noch nicht vergeben" ist der Punkt: ohne diese Prüfung bekamen alle + vier Positionen einer Liste (Türen, Fenster, Schlösser) denselben Sensor, + weil der Vorschlag nicht vom Index abhängt. Der Sicherheitscheck hätte + danach viermal dieselbe Tür geprüft und "Sicher abgestellt" gemeldet, + obwohl drei Türen nie geprüft wurden. */ +function setupVorschlag(feld, schonVergeben) { + for (const kandidat of entitaetKandidaten(feld.key, "", "")) { + if (schonVergeben.has(kandidat.id)) continue; + if (entitaetScore(feld, kandidat, "") < SETUP_VORSCHLAG_SCHWELLE) return ""; + return kandidat.id; + } + return ""; +} + +function setupOeffnen() { + if (!setupBereit()) return; + ALLE_ENTITAETEN = alleEntitaeten(); + setupNurPassend = true; + const werte = (ENTITAETEN && ENTITAETEN.werte) || {}; + const zuordnung = {}; + /* Bereits gesetzte und bereits vorgeschlagene IDs, damit kein Sensor + zweimal landet. Vorbelegte Werte kommen zuerst hinein. */ + const vergeben = new Set(); + for (const feld of setupKatalog()) { + const vorhandeneWerte = feld.liste ? (werte[feld.key] || []) : [werte[feld.key]]; + for (const id of vorhandeneWerte) if (id) vergeben.add(id); + } + for (const feld of setupKatalog()) { + if (feld.liste) { + zuordnung[feld.key] = feld.positionen.map((_, i) => { + const vorhanden = (werte[feld.key] || [])[i]; + if (vorhanden) return vorhanden; + const vorschlag = setupVorschlag(feld, vergeben); + if (vorschlag) vergeben.add(vorschlag); + return vorschlag; + }); + } else { + const vorhanden = werte[feld.key]; + if (vorhanden) { zuordnung[feld.key] = vorhanden; continue; } + const vorschlag = setupVorschlag(feld, vergeben); + if (vorschlag) vergeben.add(vorschlag); + zuordnung[feld.key] = vorschlag; + } + } + setupZuordnung = zuordnung; + setupZuordnungStart = JSON.parse(JSON.stringify(zuordnung)); + setupSucheOffen = null; setupSuchtext = ""; setupSpeichertLaeuft = false; + setupOffen = true; render(); +} + +/* Das Setup lässt sich erst öffnen, wenn der Katalog wirklich da ist. + Ohne diese Prüfung öffnete sich das Fenster leer (setupKatalog() liefert + dann []), und ein Klick auf "Speichern" schrieb {} - was entitaeten.json + vollständig ersetzt und damit ALLE Zuordnungen löscht. Weil die Werte im + laufenden Prozess erhalten bleiben, wäre das erst beim nächsten Neustart + aufgefallen. */ +function setupBereit() { + if (setupKatalog().length) return true; + hinweis("Setup noch nicht bereit", + "Die Liste der Sensor-Rollen wurde noch nicht vom Server geladen. Nach einem Neustart dauert das einen Moment - bitte gleich noch einmal versuchen."); + return false; +} +function setupSchliessen() { + setupOffen = false; setupSucheOffen = null; setupZuordnung = null; setupZuordnungStart = null; render(); +} + +/* Standardwert (aus einstellungen.py, vor jedem Override) für ein Feld bzw. + eine Listenposition - Grundlage für den Zurücksetzen-Button. */ +function setupStandardwert(key, idx) { + const standard = (ENTITAETEN && ENTITAETEN.standardwerte) || {}; + const wert = standard[key]; + if (idx == null) return wert || ""; + return (wert || [])[idx] || ""; +} + +/* Entity-IDs, die im Arbeitsstand mehr als einer Rolle zugeordnet sind. */ +function setupDuplikate() { + const zaehler = {}; + for (const feld of setupKatalog()) { + const werte = feld.liste ? (setupZuordnung[feld.key] || []) : [setupZuordnung[feld.key]]; + for (const id of werte) { if (id) zaehler[id] = (zaehler[id] || 0) + 1; } + } + return Object.keys(zaehler).filter((id) => zaehler[id] > 1); +} + +/* Labels der trigger-gebundenen Felder (ZUENDUNG/KM/TANK), deren Wert sich + seit dem Öffnen des Popups geändert hat - für den Neustart-Hinweis nach + dem Speichern. */ +function setupGeaenderteTriggerFelder() { + if (!setupZuordnungStart) return []; + return setupKatalog() + .filter((f) => f.trigger_gebunden && JSON.stringify(setupZuordnung[f.key]) !== JSON.stringify(setupZuordnungStart[f.key])) + .map((f) => f.label); +} + +function setupSpeichernAusfuehren() { + // Zweiter Riegel gegen den Datenverlust-Pfad: selbst wenn das Fenster auf + // irgendeinem Weg leer aufgeht, wird nichts geschrieben. overrides_schreiben() + // ersetzt die Datei vollstaendig - ein leeres Objekt loescht alles. + if (!setupZuordnung || !Object.keys(setupZuordnung).length) { + hinweis("Nichts zu speichern", "Es ist keine Zuordnung geladen. Bitte das Setup schliessen und neu oeffnen."); + return; + } + setupSpeichertLaeuft = true; render(); + (async () => { + try { + await HASS.callService("pyscript", "audi_dashboard_entitaeten_schreiben", { zuordnung_json: JSON.stringify(setupZuordnung) }); + } catch (err) { + setupSpeichertLaeuft = false; hinweis("Speichern fehlgeschlagen", err.message); return; + } + const geaendert = setupGeaenderteTriggerFelder(); + setupOffen = false; setupSucheOffen = null; setupZuordnung = null; setupZuordnungStart = null; setupSpeichertLaeuft = false; render(); + if (geaendert.length) { + bestaetigen("Neustart nötig", `Wirkt für ${geaendert.join(", ")} erst nach einem Neustart von Home Assistant.`, "Jetzt neu starten", setupNeustartAusloesen, false); + } + })(); +} + +function setupNeustartAusloesen() { + (async () => { + try { + await HASS.callService("pyscript", "audi_dashboard_neustart", {}); + hinweis("Neustart ausgelöst", "Home Assistant startet neu - die App lädt sich danach automatisch neu."); + } catch (err) { + hinweis("Neustart fehlgeschlagen", err.message); + } + })(); +} + +/* Warnhinweis, falls der aktuell gewählte Sensor keinen brauchbaren Wert + liefert - sichtbar, bevor gespeichert wird, statt es erst hinterher in + der App zu bemerken. */ +function entitaetStatusWarnung(wert) { + if (!wert) return ""; + const zustand = HASS.states[wert] ? HASS.states[wert].state : null; + if (zustand === null) return `
Entität nicht gefunden.
`; + if (zustand === "unavailable" || zustand === "unknown") return `
Aktuell „${esc(zustand)}" - kein Wert vom Sensor.
`; + return ""; +} +function vReifen() { + const r = CAR.reifen; + const feld = (k, schluessel, bez, typ) => { + const t = r[k]; + return `
+
`; + }; + const satz = (k) => { + const t = r[k], aktiv = r.aktiv.toLowerCase() === k; + const radDatei = `rad-${k}.webp`; + const radLabel = k === "sommer" ? "Sommerrad" : "Winterrad"; + return `
+
+ ${bildMitPlatzhalter(`/local/bilder/${radDatei}?v=${bildVersion}`, radDatei, radLabel, "radbild")} +
+ + ${bildMenuOffen === radDatei ? ` +
+
+ + +
` : ""} +
+ ${k === "sommer" ? "Sommer" : "Winter"} +
${t.km != null ? de(t.km) : "–"}km
+
+
+ +
+
+ ${feld(k, "marke", "Hersteller")} + ${feld(k, "modell", "Modell")} + ${feld(k, "mass", "Größe")} + ${feld(k, "dot", "DOT")} +
+ + + / + + bar + +
+ ${feld(k, "kommentar", "Kommentar")} +
+
`; + }; + return ` +
Anzugsmoment der Radschrauben + +
${de(r.anzugsmoment)}Nm
+ ${nmOffen ? ` +
+
+ Nm
+ + +
` : ""} +
+
Wechseltermin +
+
+
+
+ +
+ ${satz("sommer")} + ${satz("winter")}`; +} + +function vTripsFormular() { + // eigene Funktion, damit sowohl der volle als auch der Leer-Zustand + // (§11 Abnahmekriterium 1: eine Fahrt muss erfasst werden können) dasselbe + // Formular zeigen - vorher fehlte es im Leer-Zustand komplett, der Knopf + // wechselte zu "Abbrechen", aber es erschienen keine Felder. + return `${fahrtOffen ? `
Neue Fahrt +
+
+
+
` : ""} + `; +} +function vTrips() { + const jahre = [...new Set(TRIPS.map((t) => new Date(t.ts_start).getFullYear()))].sort((a, b) => b - a); + /* D5: Leerzustand nennt die Ursache - die Fahrtenerkennung greift erst, wenn + die Zündungs-Entität (Setup -> Für die Fahrterkennung nötig) erkennt, + dass der Motor läuft. */ + if (!jahre.length) return `
+ Noch keine Fahrten +

Fahrten werden erkannt, sobald der Motor gestartet wird. Bis dahin lässt sich jede Fahrt von Hand erfassen.

+
+ ${vTripsFormular()}`; + let h = `
`; + jahre.forEach((j, ji) => { + const tj = TRIPS.filter((t) => new Date(t.ts_start).getFullYear() === j); + h += ` +
`; + const monate = [...new Set(tj.map((t) => new Date(t.ts_start).toLocaleDateString("de-DE", { month: "long" })))]; + monate.forEach((m, mi) => { + const tm = tj.filter((t) => new Date(t.ts_start).toLocaleDateString("de-DE", { month: "long" }) === m); + h += `
+
`; + tm.forEach((t) => { h += swipeZeile(`trip:${t.trip_id}`, ``); }); + h += `
`; + }); + h += `
`; + }); + return h + `
+ ${vTripsFormular()}`; +} + +function vTrip(id) { + const t = TRIPS.find((x) => x.trip_id === id); + if (!t) return `
Fahrt nicht gefunden
`; + return ` + ${mapBox()} +
+ Distanz +
${t.distance_km != null ? de(t.distance_km) : "–"}km
+
+
+ tippen zum Umschalten +
+
+
Startort
${esc(t.start_address || "unbekannt")}${dedat(new Date(t.ts_start))}
+
Zielort
${esc(t.end_address || "unbekannt")}
+
Startkilometer
${t.odo_start != null ? de(t.odo_start) + " km" : "noch offen"}
+
Endkilometer
${t.odo_end != null ? de(t.odo_end) + " km" : "noch offen"}
+
Dauer
${Math.round(t.duration_s / 60)} min
+
Verbrauch
${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "liegt nicht vor"}vom Fahrzeug gemeldet
+
Status
${t.status}Quelle: ${t.km_quelle || "–"}
+
+ `; +} + +function quad(t, v) { return `${t} +
${v.map((x) => `
${x[0]}
${x[1]}
`).join("")}
`; } +function statTile(id, label, fig, unit, extra, body) { + return `
${body}
`; +} +/* Echte Auswertung aus TRIPS/FILLS statt Beispielwerten. Nur abgeschlossene + Fahrten/Tankvorgänge zählen (distance_km bzw. liters gesetzt) - offene/ + unvollständige Einträge haben noch keinen belastbaren Wert. + Der Verbrauch ist eine Näherung (getankte Menge im Zeitraum ÷ gefahrene + km im selben Zeitraum), da einzelne Fahrten keinen eigenen Verbrauchswert + tragen - erst über einen Zeitraum gemittelt ergibt das einen sinnvollen + l/100km-Wert. */ +function periodStart(art) { + const jetzt = new Date(); + if (art === "jahr") return new Date(jetzt.getFullYear(), 0, 1); + if (art === "monat") return new Date(jetzt.getFullYear(), jetzt.getMonth(), 1); + if (art === "woche") { + const d = new Date(jetzt); d.setHours(0, 0, 0, 0); + d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); + return d; + } + const d = new Date(jetzt); d.setHours(0, 0, 0, 0); return d; +} +const fahrtenSeit = (art) => TRIPS.filter((t) => t.distance_km != null && new Date(t.ts_start) >= periodStart(art)); +const tankSeit = (art) => FILLS.filter((f) => f.liters != null && new Date(f.ts) >= periodStart(art)); +function istNachtZeit(ts) { + const z = new Date(ts).toTimeString().slice(0, 5), von = CONFIG.nachtVon, bis = CONFIG.nachtBis; + return von > bis ? (z >= von || z < bis) : (z >= von && z < bis); +} +/* D3: Die Strecke führt die Zeile, das Datum steht darunter. Umbruch nur + zwischen den Orten, nie am Pfeil. */ +function strecke(t) { + const a = t.start_address, b = t.end_address; + if (!a && !b) return "Fahrt ohne Ortsangabe"; + if (!b) return esc(a); + if (!a) return esc(b); + return `${esc(a)} → ${esc(b)}`; +} + +/* A1.1: Der Verbrauch einer einzelnen Fahrt kommt vom Fahrzeug (OBD) und wird + vom Backend mitgeliefert - er wird hier nie selbst gerechnet. */ +const fahrtVerbrauch = (t) => (t && t.verbrauch_l_100km != null ? Number(t.verbrauch_l_100km) : null); +/* Mittelwert der gemeldeten Fahrtverbräuche, nach Distanz gewichtet. */ +function schnittFahrtVerbrauch(list) { + const mit = list.filter((t) => fahrtVerbrauch(t) != null && t.distance_km > 0); + const km = sum(mit, (t) => t.distance_km); + return km > 0 ? de(sum(mit, (t) => fahrtVerbrauch(t) * t.distance_km) / km, 1) : "–"; +} +/* A1.2: Langzeitverbrauch = alle je getankten Liter geteilt durch alle bis zum + letzten Tankstop gefahrenen Kilometer. Die frühere Rechnung (Liter der + Periode ÷ km der Periode) ergab unmögliche Werte, sobald eine Fahrt fehlte + oder ein Tankvorgang noch unvollständig war. */ +function langzeitverbrauch() { + const tanks = FILLS.filter((f) => f.liters != null).sort((a, b) => new Date(a.ts) - new Date(b.ts)); + if (!tanks.length) return { wert: "–", km: 0, liter: 0, bis: null, basis: "–" }; + const letzter = tanks[tanks.length - 1]; + const bis = new Date(letzter.ts); + const liter = sum(tanks, (f) => f.liters); + /* Kilometerbasis: bevorzugt der Kilometerstand an den Tankvorgängen selbst - + er zählt auch Strecke mit, für die keine Fahrt aufgezeichnet wurde. Fehlt + er, bleibt die Summe der erfassten Fahrten bis zum letzten Tankstop. */ + const mitOdo = tanks.filter((f) => f.odometer_km != null); + let km = 0, basis = "erfasste Fahrten"; + if (mitOdo.length >= 2) { + km = mitOdo[mitOdo.length - 1].odometer_km - mitOdo[0].odometer_km; + basis = "Kilometerstand an den Tankvorgängen"; + } else { + km = sum(TRIPS.filter((t) => t.distance_km != null && new Date(t.ts_start) <= bis), (t) => t.distance_km); + } + const roh = km > 0 ? (liter / km) * 100 : null; + /* Plausibilitätsfenster: alles außerhalb gilt als unbekannt, statt einen + physikalisch unmöglichen Wert als Tatsache anzuzeigen. */ + const gueltig = roh != null && roh >= 3 && roh <= 30; + return { wert: gueltig ? de(roh, 1) : "–", km, liter, bis, basis, unplausibel: roh != null && !gueltig }; +} +function vStat() { + const jahr = new Date().getFullYear(); + const fJahr = fahrtenSeit("jahr"), fMonat = fahrtenSeit("monat"), fWoche = fahrtenSeit("woche"), fTag = fahrtenSeit("tag"); + const kmJahr = sum(fJahr, (t) => t.distance_km), hJahr = sum(fJahr, (t) => t.duration_s) / 3600; + const tJahr = tankSeit("jahr"); + const lJahr = sum(tJahr, (f) => f.liters), eJahr = sum(tJahr, (f) => f.fuel_total_eur || 0); + const tagKm = sum(fJahr.filter((t) => !istNachtZeit(t.ts_start)), (t) => t.distance_km); + const nachtKm = kmJahr - tagKm; + const arbeitFahrten = fJahr.filter((t) => t.art === "arbeitsweg"), privatFahrten = fJahr.filter((t) => t.art !== "arbeitsweg"); + const arbeitKm = sum(arbeitFahrten, (t) => t.distance_km), privatKm = kmJahr - arbeitKm; + const lv = langzeitverbrauch(); + return ` + ${statTile("s1", `Distanz und Fahrzeit ${jahr}`, de(kmJahr), "km", de(hJahr) + " h", + quad("Gefahrene Distanz", [[de(kmJahr), "Jahr"], [de(sum(fMonat, (t) => t.distance_km)), "Monat"], [de(sum(fWoche, (t) => t.distance_km)), "Woche"], [de(sum(fTag, (t) => t.distance_km)), "Tag"]]) + + quad("Fahrzeit", [[de(hJahr), "Jahr h"], [de(sum(fMonat, (t) => t.duration_s) / 3600), "Monat h"], [de(sum(fWoche, (t) => t.duration_s) / 3600, 1), "Woche h"], [de(sum(fTag, (t) => t.duration_s) / 3600, 1), "Tag h"]]))} + ${statTile("s2", `Getankte Menge und Kosten ${jahr}`, de(lJahr), "l", de(eJahr) + " €", + quad("Getankte Liter", [[de(lJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.liters)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.liters)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.liters)), "Tag"]]) + + quad("Kraftstoffkosten", [[de(eJahr), "Jahr €"], [de(sum(tankSeit("monat"), (f) => f.fuel_total_eur || 0)), "Monat €"], [de(sum(tankSeit("woche"), (f) => f.fuel_total_eur || 0)), "Woche €"], [de(sum(tankSeit("tag"), (f) => f.fuel_total_eur || 0)), "Tag €"]]))} + ${statTile("s3", "Langzeitverbrauch", lv.wert, "l/100 km", lv.bis ? "bis " + dedat(lv.bis) : "–", + `
+
Getankt insgesamt
${de(lv.liter, 1)} l
+
Strecke bis zum letzten Tankstop
${de(lv.km)} km${lv.basis}
+
Letzter Tankstop
${lv.bis ? dedat(lv.bis) : "–"}
+
+ ${lv.unplausibel ? `Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und Kilometerstände vollständig vorliegen, erscheint er hier.` : ""} + ${quad("Vom Fahrzeug gemeldet, je Zeitraum", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"]])} + Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten Tankstop. Die Werte je Zeitraum stammen aus den Verbrauchsmeldungen der einzelnen Fahrten.`)} + ${statTile("s4", "Tag und Nacht", kmJahr > 0 ? de(Math.round((tagKm / kmJahr) * 100)) : "–", "% bei Tag", de(tagKm) + " km", + `
+
Tag · ${de(tagKm)} km + Nacht · ${de(nachtKm)} km
+ Nacht = ${CONFIG.nachtVon} bis ${CONFIG.nachtBis} Uhr`)} + ${statTile("s5", "Art der Fahrten", kmJahr > 0 ? de(Math.round((arbeitKm / kmJahr) * 100)) : "–", "% Arbeitsweg", de(arbeitKm) + " km", + `
+
Arbeitsweg · ${de(arbeitKm)} km + Privat · ${de(privatKm)} km
+
+
Fahrten Arbeitsweg
${de(arbeitFahrten.length)}
+
Fahrten privat
${de(privatFahrten.length)}
`)}`; +} + +let neuOffen = false; +let fahrtOffen = false; +let tankBearbeitenOffen = false; +// Ergebnis eines geparsten, noch nicht gespeicherten Belegs: { praefix, daten } +// - überlagert in tankFelder() die Beleg-Felder des jeweils offenen Formulars. +let belegEntwurf = null; +// Fehlermeldung aus einem Beleg-Upload (z. B. "Falsches Belegdatum"): +// { context, meldung } - context ist "neuTank"/"bearbTank" (Entwurf) oder +// eine tank_id (Einzelbeleg-Ansicht), je nachdem welcher Beleg-Button es war. +let belegFehler = null; +// Merkt sich beim Auslösen eines nur-parsenden Uploads (neu/bearb), welcher +// Entwurf auf das Ergebnis wartet - die Antwort selbst trägt dafür keine +// praefix-Information, nur tank_id (bei einem Parse-Ergebnis immer null). +let belegZielPraefix = null; + +/* Wischen nach links zum Löschen (Fahrten/Tankvorgänge/Servicebuch), analog + zu iOS-Listen. swipeZeile() umschließt eine bestehende Listenzeile mit + einem roten "Löschen"-Hintergrund; die eigentliche Wischgeste wird über + Pointer Events in ereignisseVerdrahten() ausgewertet (nicht Touch Events), + damit dieselbe Logik auch mit der Maus funktioniert. id folgt demselben + "art:id"-Schema wie data-go, siehe eintragLoeschen(). */ +let swipeAktivWrap = null, swipeStartX = 0, swipeStartY = 0, swipeGeste = null, swipeDX = 0; +let swipeWarGezogen = false; +const SWIPE_BREITE = 84; + +/* Ziehen zum Aktualisieren (nur Übersicht) - ptrY0 ist die Y-Position des + Fingers/der Maus beim Start, null wenn gerade keine Geste läuft. Eigene + Variablen statt der swipe*-Variablen oben, da beide Gesten unabhängig + voneinander gleichzeitig "angefasst" werden können (Pointerdown auf einer + Listenzeile UND ganz oben gescrollt). */ +let ptrY0 = null, ptrDrag = 0, ptrLaedt = false; +async function datenAktualisieren() { + try { + await HASS.callService("pyscript", "audi_dashboard_jetzt_aktualisieren", {}); + const states = await HASS.callWS({ type: "get_states" }); + const frisch = states.find((s) => s.entity_id === "pyscript.audi_dashboard_fahrzeugstatus"); + if (frisch) NACHGELADEN.status = frisch; + } catch (e) { + // bleibt ohne sichtbaren Effekt - der naechste periodische 20s-Takt + // holt den Stand ohnehin nach + } + datenLaden(true); +} +function schliesseAlleSwipes() { + ROOT.querySelectorAll(".swipe-wrap.swiped").forEach((w) => w.classList.remove("swiped")); +} +function swipeZeile(id, inhaltHtml) { + return `
+
+
${inhaltHtml}
+
`; +} +const LOESCH_TEXT = { + trip: "Fahrt löschen?", + fill: "Tankvorgang löschen?", + sbuch: "Servicebuch-Eintrag löschen?", + bild: "Foto löschen?", +}; +const LOESCH_HINWEIS = { + trip: "Die Fahrt wird aus dem Fahrtenbuch entfernt. Das lässt sich nicht widerrufen.", + fill: "Der Tankvorgang und der zugehörige Beleg werden entfernt. Das lässt sich nicht widerrufen.", + sbuch: "Der Eintrag wird aus dem Servicebuch entfernt. Das lässt sich nicht widerrufen.", + bild: "Das Foto wird vom Server gelöscht. Der Platz bleibt bestehen und kann neu belegt werden.", +}; +function eintragLoeschen(art, id) { + if (art === "trip") { + serviceRufen("audi_dashboard_fahrt_loeschen", { trip_id: id }); + TRIPS = TRIPS.filter((t) => t.trip_id !== id); + } else if (art === "fill") { + serviceRufen("audi_dashboard_tankvorgang_loeschen", { tank_id: id }); + FILLS = FILLS.filter((f) => f.tank_id !== id); + } else if (art === "sbuch") { + CAR.service.buch.splice(parseInt(id, 10), 1); + profilSpeichern(); + } +} + +/* Index eines gerade per "Eintrag hinzufügen" angelegten, aber noch nicht + bestätigten Servicebuch-Eintrags. Nur solange gesetzt, wie die Seite + offen ist und nicht gespeichert wurde - siehe data-sbneu/data-sbspeichern + und den Verwerfen-Schritt in go(). */ +let entwurfIndex = null; +const SERVICE_ARTEN = ["Ölwechsel", "Inspektion", "Hauptuntersuchung", "Weitere"]; + +/* Update-Funktion (Einstellungen -> "Update suchen", siehe + updateverwaltung.py). HASS.states wird nach einem Service-Aufruf nicht + zuverlässig sofort nachgeführt (derselbe Effekt wie beim Nachlade- + Fallback oben) - deshalb der frische Stand aktiv per WebSocket geholt statt + passiv auf den nächsten hass-Push zu warten. */ +let UPDATE_STATUS = null; +let UPDATE_STATUS_GEHOLT = false; +let updatePruefend = false; +let updateInstallierend = false; +async function updateStatusAbrufen() { + try { + const states = await HASS.callWS({ type: "get_states" }); + const s = states.find((x) => x.entity_id === "pyscript.audi_dashboard_update_status"); + UPDATE_STATUS = (s && s.attributes && s.attributes.daten) || null; + } catch (e) { + // zeigt dann weiterhin den zuletzt bekannten Stand + } +} + +/* Auswahl für "Modell" unter Einrichten - setzt nur CONFIG.fahrzeugtitel + (Zeile "Modell" in der Identität, Kopfzeilen, Kalender-Einträge, und den + Schriftzug unter dem Bild auf der Übersicht). Zusatz, Untertitel, Details + und die technischen Daten hängen bewusst nicht daran - die bleiben, auch + bei einem anderen Modellnamen, wie zuletzt im Profil eingetragen. + Beschränkt auf die Modelle, zu denen ein echtes Badge vorliegt (siehe + BADGE_DATEIEN/www/badges/) - für alles andere gäbe es sonst ein falsches + oder gar kein Badge auf der Übersicht. */ +const FAHRZEUG_MODELLE = [ + "RS 3 Sportback", "RS 3 Limousine", "RS 4 Avant", + "RS 5 Coupé", "RS 5 Sportback", "RS 6 Avant", "RS 6 Avant performance", + "RS 7 Sportback", "S3 Sportback", "S3 Limousine", "SQ7", +]; + +/* Badge-Zuordnung (www/badges/-positive.svg / -negative.svg, jeweils + das offizielle Audi-Markenasset). Schlüssel sind eindeutige Präfixe der + FAHRZEUG_MODELLE-Einträge - Reihenfolge ist hier egal, da sich keiner der + Präfixe mit einem anderen überschneidet. */ +const BADGE_DATEIEN = { + "RS 3": "rs3", "RS 4": "rs4", "RS 5": "rs5", "RS 6": "rs6", "RS 7": "rs7", + "S3": "s3", "SQ7": "sq7", +}; +function badgePraefix() { + const titel = CONFIG.fahrzeugtitel || ""; + return Object.keys(BADGE_DATEIEN).find((p) => titel.startsWith(p)) || null; +} + +/* Gemeinsames Formular für "Tankvorgang erfassen" und die Bearbeitung eines + bestehenden Belegs (Einzelseite) - dieselben Felder an beiden Stellen, + damit sie nicht auseinanderlaufen können. Preis je Liter ist bewusst kein + Eingabefeld, sondern wird aus Kosten/Liter berechnet (live über den + input-Handler in ereignisseVerdrahten, endgültig beim Speichern). */ +const KRAFTSTOFFSORTEN = ["V-Power Racing", "Super Plus", "Super"]; + +/* Vorschlag für "Gefahrene Distanz" bei einer NEUEN Erfassung: Differenz zum + zeitlich letzten bereits erfassten Tankvorgang in FILLS - dieselbe Regel + wie backendseitig in _distanz_seit_letzter_tankung() (belegverarbeitung.py), + hier nur zur Vorbefüllung, da der neue Datensatz noch nicht existiert. Beim + Bearbeiten eines bestehenden Vorgangs kommt der Wert dagegen direkt aus + f.distance_km (siehe Aufrufer). */ +function distanzVorschlag(aktuellerKm) { + if (aktuellerKm === "" || aktuellerKm == null || !FILLS.length) return ""; + const letzte = FILLS.reduce((a, b) => (new Date(b.ts) > new Date(a.ts) ? b : a)); + if (letzte.odometer_km == null) return ""; + return Math.round((aktuellerKm - letzte.odometer_km) * 10) / 10; +} + +function tankFelder(praefix, f) { + // Ein zuvor hochgeladener, noch nicht gespeicherter Beleg (belegEntwurf) + // überlagert hier nur die Beleg-Felder - Kilometerstand/Distanz kommen + // immer vom Fahrzeug bzw. von f, nie vom Beleg (§7.7 Regel 2). + const b = belegEntwurf && belegEntwurf.praefix === praefix ? belegEntwurf.daten : null; + const q = b ? { ...(f || {}), ts: b.ts || (f && f.ts), liters: b.liters, fuel_total_eur: b.fuel_total_eur, discount: b.discount, station_name: b.station_name, fuel_type: b.fuel_type } : f; + const ts = q && q.ts ? new Date(q.ts) : new Date(); + const datum = ts.toISOString().slice(0, 10); + const zeit = String(ts.getHours()).padStart(2, "0") + ":" + String(ts.getMinutes()).padStart(2, "0"); + const km = f && f.odometer_km != null ? f.odometer_km : (CAR.odoBekannt ? CAR.odo : ""); + const distanz = f ? (f.distance_km != null ? f.distance_km : "") : distanzVorschlag(km); + const liter = q && q.liters != null ? q.liters : ""; + const kosten = q && q.fuel_total_eur != null ? q.fuel_total_eur : ""; + const ersparnis = q && q.discount != null ? q.discount : ""; + const kraftstoff = q && q.fuel_type ? q.fuel_type : KRAFTSTOFFSORTEN[0]; + const station = q && q.station_name ? q.station_name : ""; + const preisText = q && q.liters && q.fuel_total_eur != null && q.liters > 0 + ? `Preis je Liter: ${de(q.fuel_total_eur / q.liters, 3)} €` : "Preis je Liter: wird berechnet"; + return ` +
+
+
+
+ km
+
+
+
+
+
+ ${CAR.smartdeal.aktiv ? `
+
` : ""} +
+ ${preisText}`; +} + +function tankFormularWerte(praefix) { + const liter = parseFloat(($(`#${praefix}Liter`).value || "0").replace(",", ".")); + const kosten = parseFloat(($(`#${praefix}Kosten`).value || "0").replace(",", ".")); + const kmFeld = $(`#${praefix}Km`).value; + const distanzFeld = $(`#${praefix}Distanz`).value; + const ersparnisFeld = CAR.smartdeal.aktiv ? $(`#${praefix}Ersparnis`) : null; + const datum = $(`#${praefix}Datum`).value, zeit = $(`#${praefix}Zeit`).value; + return { + ts: new Date(`${datum}T${zeit}:00`).toISOString(), + liter, kosten, + km: kmFeld === "" ? null : parseFloat(kmFeld), + distanz: distanzFeld === "" ? null : parseFloat(distanzFeld), + ersparnis: ersparnisFeld && ersparnisFeld.value !== "" ? parseFloat(ersparnisFeld.value.replace(",", ".")) : null, + kraftstoff: $(`#${praefix}Kraftstoff`).value, + station: $(`#${praefix}Station`).value, + }; +} + +function vFuelFormular() { + // Gleicher Grund wie bei vTripsFormular(): das Formular muss auch im + // Leer-Zustand erscheinen, nicht nur wenn schon Tankvorgänge vorhanden sind. + return `${neuOffen ? `
Neuer Tankvorgang + ${tankFelder("neuTank", null)} + ${belegFehler && belegFehler.context === "neuTank" ? `${esc(belegFehler.meldung)}` : ""} + + +
` : ""} + `; +} +function vFuel() { + const jahre = [...new Set(FILLS.map((f) => new Date(f.ts).getFullYear()))].sort((a, b) => b - a); + /* D5 */ + if (!jahre.length) return `
+ Noch keine Tankvorgänge +

Ein Tankvorgang wird automatisch angelegt, sobald der Tankstand des Fahrzeugs steigt. Den Beleg kann man danach hochladen – oder den Vorgang gleich hier von Hand erfassen.

+
+ ${vFuelFormular()}`; + let h = `
`; + jahre.forEach((j, ji) => { + const fj = FILLS.filter((f) => new Date(f.ts).getFullYear() === j); + const ersparnisJahr = sum(fj, (f) => f.discount || 0); + /* D2: Auf der Jahreszeile stehen nur die Kosten, groß. Preis und Menge + folgen als Zweitzeile, die Ersparnis als neutrale Pille - vorher + konkurrierten drei gleich große rechtsbündige Werte miteinander. */ + h += ` +
`; + const monate = [...new Set(fj.map((f) => new Date(f.ts).toLocaleDateString("de-DE", { month: "long" })))]; + monate.forEach((m, mi) => { + const fm = fj.filter((f) => new Date(f.ts).toLocaleDateString("de-DE", { month: "long" }) === m); + h += `
+
`; + fm.forEach((f) => { const preis = f.liters ? f.fuel_total_eur / f.liters : null; h += swipeZeile(`fill:${f.tank_id}`, ``); }); + h += `
`; + }); + h += `
`; + }); + return h + `
+ ${vFuelFormular()}`; +} + +function vFill(id) { + const f = FILLS.find((x) => x.tank_id === id); + if (!f) return `
Tankvorgang nicht gefunden
`; + const berechneterPreis = f.liters ? f.fuel_total_eur / f.liters : null; + + if (tankBearbeitenOffen) { + return ` +
Tankvorgang bearbeiten + ${tankFelder("bearbTank", f)} + ${belegFehler && belegFehler.context === "bearbTank" ? `${esc(belegFehler.meldung)}` : ""} + + + +
`; + } + + return ` + ${mapBox()} +
+ Getankt +
${f.liters != null ? de(f.liters, 2) : "–"}l
+
+
+
Zeitpunkt
${dedat(new Date(f.ts))}${new Date(f.ts).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr
+
Kilometerstand
${f.odometer_km != null ? de(f.odometer_km) + " km" : "unbekannt"}
+
Gefahrene Distanz
${f.distance_km != null ? de(f.distance_km) + " km" : "unbekannt"}seit der letzten Tankung
+
Kraftstoff
${esc(f.fuel_type || KRAFTSTOFFSORTEN[0])}
+
Getankte Liter
${f.liters != null ? de(f.liters, 2) + " l" : "–"}
+
Kosten
${f.fuel_total_eur != null ? eur(f.fuel_total_eur) + " €" : "–"}real, nach Rabatt
+
Preis je Liter
${berechneterPreis != null ? de(berechneterPreis, 3) + " €" : "–"}Kosten ÷ Liter
+ ${CAR.smartdeal.aktiv && f.discount != null ? `
${markenlogo()} SmartDeal-Ersparnis
${eur(f.discount)} €
` : ""} +
Tankstelle
${esc(f.station_name || "unbekannt")}${esc(f.station_address || "")}
+
+ ${belegFehler && belegFehler.context === f.tank_id ? `${esc(belegFehler.meldung)}` : ""} + + + +
`; +} + +/* ---------------------------------------------------------- Steuerung */ +const ICONS = { + home: '', + /* Wagen von vorn - dasselbe Symbol wie in der myAudi-App (Audi-Symbolsatz, + assets/icons/car.svg). Flaeche statt Kontur, deshalb die Klasse "voll". */ + audi: '', + trips: '', + stat: '', + fuel: '', +}; +const TABS = [ + { id: "home", label: "Übersicht", eyebrow: "", title: "Übersicht" }, + { id: "audi", label: "Mein Audi", eyebrow: "", title: "" }, + { id: "trips", label: "Fahrten", eyebrow: "", title: "Fahrten" }, + { id: "stat", label: "Statistik", eyebrow: "", title: "Statistik" }, + { id: "fuel", label: "Tanken", eyebrow: "", title: "Tanken" }, +]; +/* Symbole, die als Flaeche gezeichnet sind statt als Kontur */ +const ICONS_VOLL = new Set(["audi"]); +let route = { name: "home" }; +const ZURUECK = { + trip: "trips", fill: "fuel", service: "audi", reifen: "audi", ident: "audi", + vers: "audi", beitrag: "vers", vertragsdetails: "vers", schutz: "vertragsdetails", + steuer: "vers", notruf: "vers", werkstatt: "service", sbuch: "service", einst: "home", + battverlauf: "audi", + /* Fehlte: die Sicherheitsansicht war eine Sackgasse ohne sichtbaren + Zurück-Pfeil. */ + sicherheit: "home", + standort: "home", +}; +const ICON = { + sonne: '', + mond: '', +}; +function themeIcon() { const el = $("#themeicon"); if (el) el.innerHTML = HOST.dataset.theme === "nacht" ? ICON.sonne : ICON.mond; } + +/* A6: gespeicherte Wahl, sonst Systemeinstellung. */ +const THEME_KEY = "audi-dashboard-theme"; +function themeGewaehlt() { + let gespeichert = null; + try { gespeichert = localStorage.getItem(THEME_KEY); } catch (e) { /* Privatmodus */ } + if (gespeichert === "nacht" || gespeichert === "tag") return gespeichert; + return window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches ? "tag" : "nacht"; +} +function themeSetzen(modus) { + HOST.dataset.theme = modus; + try { localStorage.setItem(THEME_KEY, modus); } catch (e) { /* Privatmodus */ } + tiles(); +} + +/* ------------------------------------------------------------ Action Sheet + B4: ersetzt confirm()/alert(). Ein offenes Sheet ist Zustand wie jeder + andere - render() zeichnet es mit, damit es Ansichtswechsel überlebt. */ +let sheet = null; +const sheetSchliessen = () => { sheet = null; render(); }; +function bestaetigen(titel, text, knopf, aktion, destruktiv = true) { + sheet = { titel, text, knopf, aktion, destruktiv }; + render(); +} +function hinweis(titel, text) { + sheet = { titel, text, knopf: null, aktion: null }; + render(); +} +function sheetMarkup() { + if (!sheet) return ""; + const kopf = `
${esc(sheet.titel)}${sheet.text ? `${esc(sheet.text)}` : ""}
`; + const aktion = sheet.knopf + ? `` + : ``; + return `
+ `; +} + +/* ------------------------------------------------------------ Setup-Popup */ +const LUPE_SVG = ``; + +function entitaetZeilenMarkup(feldId, key, suchtext, aktuellerWert) { + const kandidaten = entitaetKandidaten(key, suchtext, aktuellerWert); + if (!kandidaten.length) return `
Keine passende Entität gefunden
`; + return kandidaten.map((e) => { + const zustand = HASS.states[e.id] ? HASS.states[e.id].state : null; + const unbekannt = zustand === null || zustand === "unavailable" || zustand === "unknown"; + const wertAnzeige = unbekannt ? (zustand === null ? "nicht gefunden" : zustand) : `${zustand}${e.unit ? " " + e.unit : ""}`; + return ` + `; + }).join(""); +} + +const RESET_SVG = ``; + +function entitaetCombo(key, idx) { + const feldId = idx == null ? key : `${key}:${idx}`; + const wert = idx == null ? setupZuordnung[key] : (setupZuordnung[key] || [])[idx]; + const offen = setupSucheOffen === feldId; + const anzeige = offen ? setupSuchtext : entitaetName(wert); + const standard = setupStandardwert(key, idx); + const zeigeReset = wert !== standard; + return `
+ ${LUPE_SVG} + + ${zeigeReset ? `` : ""} + ${offen ? `
${entitaetZeilenMarkup(feldId, key, setupSuchtext, wert)}
` : ""} +
`; +} + +function setupFeldzeile(feld) { + const kopf = `
+ ${esc(feld.label)} + ${esc(feld.hinweis)} + ${feld.trigger_gebunden ? `Wirkt nach dem Speichern erst nach einem Neustart von Home Assistant.` : ""} +
`; + if (feld.liste) { + return `
${kopf} +
+ ${feld.positionen.map((pos, i) => ` +
+ ${esc(pos)} + ${entitaetCombo(feld.key, i)} + ${entitaetStatusWarnung((setupZuordnung[feld.key] || [])[i])} +
`).join("")} +
+
`; + } + return `
${kopf}${entitaetCombo(feld.key, null)}${entitaetStatusWarnung(setupZuordnung[feld.key])}
`; +} + +const SETUP_GRUPPEN = [ + ["fahrterkennung", "Für die Fahrterkennung nötig"], + ["uebersicht", "Übersicht & Status"], + ["sicherheit", "Sicherheit"], + ["standort", "Standort"], +]; + +function vSetupPopup() { + if (!setupOffen) return ""; + const katalog = setupKatalog(); + return `
+ `; +} + +let letzteAnsicht = null; +let routeStack = []; +let navRichtung = null; +const istTab = (name) => TABS.some((t) => t.id === name); +/* Zu welchem Tab gehoert eine Unterseite? Erst der tatsaechlich gelaufene Weg + (routeStack), sonst die ZURUECK-Tabelle. */ +function tabVon(name) { + if (routeStack.length && istTab(routeStack[0].name)) return routeStack[0].name; + let n = name, i = 0; + while (n && !istTab(n) && i++ < 8) n = ZURUECK[n]; + return istTab(n) ? n : null; +} +function render() { + if (!DATEN_GELADEN) return; + // Eine laufende Wischgeste bezieht sich auf Knoten, die render() gleich + // durch neue ersetzt - Referenz kappen, bevor sie ins Leere zeigt. + swipeAktivWrap = null; swipeGeste = null; swipeDX = 0; + const v = $("#view"), b = $("#back"); + const merkeScroll = v.scrollTop; + const gleicheAnsicht = letzteAnsicht === route.name + "|" + (route.id || ""); + let head; + if (route.name === "home") { head = ["", "Übersicht"]; v.innerHTML = vHome(); } + else if (route.name === "trip") { head = ["Fahrt", "Einzelfahrt"]; v.innerHTML = vTrip(route.id); } + else if (route.name === "fill") { head = ["Tankvorgang", "Einzelbeleg"]; v.innerHTML = vFill(route.id); } + else if (route.name === "service") { head = ["Wartung", "Service"]; v.innerHTML = vService(); } + else if (route.name === "reifen") { head = ["Fahrzeug", "Reifen"]; v.innerHTML = vReifen(); } + else if (route.name === "einst") { head = ["App", "Einstellungen"]; v.innerHTML = vEinst(); } + else if (route.name === "ident") { head = ["Fahrzeug", "Identität und Technik"]; v.innerHTML = vIdent(); } + else if (route.name === "sicherheit") { head = ["Fahrzeug", "Sicherheit"]; v.innerHTML = vSicherheit(); } + else if (route.name === "vers") { head = ["Fahrzeug", "Versicherung/Steuer"]; v.innerHTML = vVers(); } + else if (route.name === "beitrag") { head = ["Versicherung", "Beitrag anpassen"]; v.innerHTML = vBeitrag(); } + else if (route.name === "vertragsdetails") { head = ["Versicherung", "Vertragsdetails"]; v.innerHTML = vVertragsdetails(); } + else if (route.name === "steuer") { head = ["Versicherung", "Kfz-Steuer"]; v.innerHTML = vSteuerBearbeiten(); } + else if (route.name === "notruf") { head = ["Versicherung", "Rufnummern"]; v.innerHTML = vNotrufBearbeiten(); } + else if (route.name === "schutz") { head = ["Im Ernstfall", "Schutzbrief"]; v.innerHTML = vSchutz(); } + else if (route.name === "werkstatt") { head = ["Service", "Autohaus"]; v.innerHTML = vWerkstatt(); } + else if (route.name === "battverlauf") { head = ["Fahrzeug", "Batteriespannung"]; v.innerHTML = vBatterieverlauf(); bvZeichnen(); } + else if (route.name === "sbuch") { head = ["Servicebuch", "Eintrag"]; v.innerHTML = vSbuch(); } + else if (route.name === "standort") { head = ["Fahrzeug", "Standort"]; v.innerHTML = vStandort(); } + else { const t = TABS.find((x) => x.id === route.name); head = [t.eyebrow, t.id === "audi" ? "Mein Audi" : t.title]; + v.innerHTML = { audi: vAudi, trips: vTrips, stat: vStat, fuel: vFuel }[route.name](); } + $("#eyebrow").textContent = head[0]; $("#title").textContent = head[1]; + /* B3: Push nach rechts hinein, Pop nach links heraus. */ + v.classList.remove("push-rein", "push-raus"); + if (navRichtung && !gleicheAnsicht) { + void v.offsetWidth; + v.classList.add(navRichtung === "raus" ? "push-raus" : "push-rein"); + } + navRichtung = null; + // .setup-koerper ist ein eigener Scroll-Container innerhalb des Overlays - + // die innerHTML-Ersetzung gleich darunter erzeugt ihn neu (scrollTop + // faellt sonst bei jedem render(), z. B. beim Umschalten des Filters, + // auf 0 zurueck). + const setupKoerperScroll = setupOffen ? (ROOT.querySelector(".setup-koerper") || {}).scrollTop : 0; + ROOT.getElementById("overlay").innerHTML = sheetMarkup() + vSetupPopup(); + if (setupOffen) { + const koerper = ROOT.querySelector(".setup-koerper"); + if (koerper) koerper.scrollTop = setupKoerperScroll || 0; + } + // Ein offenes Setup-Suchfeld verliert bei jedem render() sein DOM-Element + // (v.innerHTML-Ersatz) - hier neu fokussieren, Cursor ans Ende, sonst + // müsste man nach jedem Tippen erneut anklicken. + if (setupOffen && setupSucheOffen) { + const feld = ROOT.querySelector(`[data-setup-suche="${setupSucheOffen}"]`); + if (feld) { feld.focus(); const len = feld.value.length; feld.setSelectionRange(len, len); } + } + /* D4: die Ringe sind nur auf den Tab-Ebenen sichtbar - auf Unterseiten steht + dort der Zurück-Pfeil. */ + ROOT.getElementById("marke").style.display = istTab(route.name) ? "inline-flex" : "none"; + const sy = $("#sync"); + sy.style.display = route.name === "home" ? "flex" : "none"; + sy.querySelector("span").textContent = standAlterText(); + b.classList.toggle("on", !istTab(route.name)); + v.scrollTop = gleicheAnsicht ? merkeScroll : 0; + letzteAnsicht = route.name + "|" + (route.id || ""); + /* Die Karten haengen an DOM-Knoten, die render() ueber v.innerHTML gerade + ersetzt hat - eine ueberlebende Leaflet-Instanz zeigte danach auf ein + abgehaengtes Element und bliebe leer. Sie muessen deshalb bei jedem + Durchlauf neu aufgebaut werden; die Haeufigkeit begrenzt stattdessen die + Aenderungspruefung in datenLaden(). */ + if (MAP) { MAP.remove(); MAP = null; TILE = null; } + if (route.name === "trip" || route.name === "fill") initMap().catch(() => {}); // ohne Internet bleibt die Kartenflaeche leer + if (SMAP) { SMAP.remove(); SMAP = null; SMAP_TILE = null; FAHRZEUG_MARKER = null; USER_MARKER = null; } + if (route.name === "standort") initStandortMap().catch(() => {}); + if (TMAP) { TMAP.remove(); TMAP = null; TMAP_TILE = null; } + if (route.name === "home") initVorschauMap().catch(() => {}); + /* Menueleiste wie in der myAudi-App: genau ein Eintrag traegt die gefuellte + Pille, und zwar auch dann, wenn eine Unterseite offen ist - die gehoert + zum Tab, aus dem sie aufgerufen wurde (ZURUECK zeigt darauf). */ + const aktiverTab = istTab(route.name) ? route.name : tabVon(route.name); + ROOT.querySelectorAll(".tab").forEach((el) => { + const an = el.dataset.tab === aktiverTab; + el.classList.toggle("on", an); + el.setAttribute("aria-current", an ? "page" : "false"); + }); +} +const go = (name, id) => { + // Bekanntes Race (siehe Kopfkommentar bei nachladeAnstossen): auf einem + // langsam startenden Backend kann die App auf "Lädt …" hängen bleiben, + // obwohl die Daten inzwischen längst da wären - der bislang einzige + // beobachtete Ausweg war ein Tabwechsel, der zufällig einen frischen + // datenLaden()-Durchlauf auslöste. Hier deshalb bei jedem Tab-/Menüklick + // aktiv nachschauen, statt nur auf den nächsten hass-Push zu warten - + // no-op, falls die Daten längst da sind (datenLaden() rendert dann + // ohnehin nichts doppelt Sichtbares). + if (!DATEN_GELADEN) datenLaden(false); + // Ein unbestätigter Servicebuch-Entwurf (siehe data-sbneu) verlässt die + // Anwendung nie ungespeichert - wird die Eintragsseite ohne Klick auf + // "Speichern" verlassen, verwerfen wir ihn hier statt ihn liegen zu lassen + // und beim nächsten unabhängigen Speichern versehentlich mit zu persistieren. + if (entwurfIndex !== null && !(name === "sbuch" && id === String(entwurfIndex))) { + CAR.service.buch.splice(entwurfIndex, 1); + entwurfIndex = null; + } + sheet = null; + /* B3: echter Verlauf statt statischer ZURUECK-Tabelle. Ein Tabwechsel setzt + den Stapel zurück, weil Tabs Wurzeln sind, keine Ebenen. */ + if (istTab(name)) routeStack = []; + else if (route.name !== name || route.id !== id) routeStack.push(route); + navRichtung = istTab(name) ? null : "rein"; + route = { name, id }; render(); +}; +function zurueck() { + sheet = null; + const ziel = routeStack.pop() || { name: ZURUECK[route.name] || "home" }; + navRichtung = "raus"; + route = ziel; render(); +} + +/* B3: Randwischen zurück - dieselbe Bewegung wie ein Tipp auf den Pfeil. */ +let randX0 = null, randY0 = null; +function randwischenVerdrahten() { + const phone = ROOT.querySelector(".phone"); + phone.addEventListener("pointerdown", (e) => { + randX0 = null; + if (!e.isPrimary || sheet || setupOffen) return; + const links = phone.getBoundingClientRect().left; + if (e.clientX - links <= 26 && !istTab(route.name)) { randX0 = e.clientX; randY0 = e.clientY; } + }); + phone.addEventListener("pointerup", (e) => { + if (randX0 !== null && e.clientX - randX0 > 62 && Math.abs(e.clientY - randY0) < 60) zurueck(); + randX0 = null; + }); + phone.addEventListener("pointercancel", () => { randX0 = null; }); +} + +/* -------------------------------------------------------------- Aktionen + Schreibende Aktionen: lokale Anzeige sofort aktualisieren (render()), + Persistenz über hass.callService im Hintergrund - dieselbe Reihenfolge + wie im Prototyp, nur dass jetzt zusätzlich gespeichert wird. */ +function serviceRufen(dienst, daten) { + HASS.callService("pyscript", dienst, daten).catch((err) => console.error("audi_dashboard:", dienst, err)); +} + +function ereignisseVerdrahten() { + /* Tastaturbedienung fuer die beiden Ueberlagerungen. Bisher gab es in der + ganzen Datei keinen keydown-Handler: Das Setup-Fenster deklariert + role="dialog" aria-modal="true", liess sich aber weder mit Escape + schliessen noch verhinderte es, dass der Tabulator in den verdeckten + Hintergrund wandert. Beides gehoert zum Vertrag dieser Rolle. + Der Handler haengt am Dokument, weil Fokus im Shadow DOM auch ausserhalb + von ROOT liegen kann. */ + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + if (sheet) { sheetSchliessen(); e.preventDefault(); return; } + if (setupSucheOffen) { setupSucheOffen = null; setupSuchtext = ""; render(); e.preventDefault(); return; } + if (setupOffen) { setupSchliessen(); e.preventDefault(); return; } + return; + } + if (e.key !== "Tab" || !setupOffen) return; + /* Fokusfalle: innerhalb des Fensters im Kreis wandern statt dahinter. */ + const fokussierbar = ROOT.querySelectorAll( + '.setup-popup button:not([disabled]), .setup-popup input:not([disabled]), .setup-popup [tabindex]:not([tabindex="-1"])'); + if (!fokussierbar.length) return; + const erstes = fokussierbar[0]; + const letztes = fokussierbar[fokussierbar.length - 1]; + const aktiv = ROOT.activeElement; + if (e.shiftKey && (aktiv === erstes || !aktiv)) { letztes.focus(); e.preventDefault(); } + else if (!e.shiftKey && aktiv === letztes) { erstes.focus(); e.preventDefault(); } + }); + ROOT.getElementById("tabbar").innerHTML = TABS.map((t) => + ``).join(""); + ROOT.getElementById("tabbar").classList.toggle("ohne", !beschriftung); + /* Auf breiten Bildschirmen steht die Menueleiste als Spalte links; die Marke + gehoert dann an deren Kopf statt in die Kopfzeile (Design-Audit). Die Ringe + werden aus der Kopfzeile geklont, die Ids im SVG umbenannt, damit der + clipPath eindeutig bleibt. Sichtbar nur ueber die Regel in + audi-dashboard-ios.css - in Telefonbreite bleibt der Klon verborgen. */ + const tabbar = ROOT.getElementById("tabbar"); + if (!tabbar.querySelector(".navmarke")) { + const marke = document.createElement("div"); + marke.className = "navmarke"; + marke.setAttribute("aria-hidden", "true"); + marke.innerHTML = ROOT.getElementById("marke").innerHTML + .replace(/audiRingsClip/g, "audiRingsClipNav"); + tabbar.prepend(marke); + } + ROOT.getElementById("tabbar").addEventListener("click", (e) => { const b = e.target.closest(".tab"); if (b) go(b.dataset.tab); }); + ROOT.getElementById("back").addEventListener("click", () => zurueck()); + ROOT.getElementById("ringsbtn").addEventListener("click", () => go("einst")); + /* In Telefonbreite sind die Ringe oben rechts der Zugang zu den + Einstellungen (das Zahnrad ist dort ausgeblendet). */ + ROOT.getElementById("marke").addEventListener("click", () => go("einst")); + ROOT.getElementById("theme").addEventListener("click", () => { + themeSetzen(HOST.dataset.theme === "nacht" ? "tag" : "nacht"); + themeIcon(); + /* Neu zeichnen, damit theme-abhaengige, in JS gesetzte Werte (z. B. die + aus --red gelesene Routenfarbe in initMap) mitziehen. Die Kacheln selbst + sind seit TILE_URL themeunabhaengig hell und brauchen das nicht mehr. */ + render(); + }); + // Wischgeste über Pointer Events (nicht Touch Events), damit dieselbe + // Logik auch mit der Maus funktioniert (z. B. beim Testen im Browser). + // touch-action:pan-y auf .swipe-content lässt vertikales Scrollen nativ + // zu; erst wenn die Geste als horizontal erkannt ist, übernimmt hier + // preventDefault() die Kontrolle. + ROOT.addEventListener("pointerdown", (e) => { + // Ziehen zum Aktualisieren (nur Übersicht, nur wenn ganz oben gescrollt) - + // unabhängig von der Wischgeste unten, da hier keine .swipe-wrap nötig ist. + if (e.isPrimary && route.name === "home" && !ptrLaedt) { + const ansicht = $("#view"); + if (ansicht && ansicht.scrollTop <= 0) { ptrY0 = e.clientY; ptrDrag = 0; } + } + const wrap = e.target.closest(".swipe-wrap"); + if (!wrap || !e.isPrimary) return; + swipeAktivWrap = wrap; swipeStartX = e.clientX; swipeStartY = e.clientY; swipeGeste = null; swipeDX = 0; + }); + ROOT.addEventListener("pointermove", (e) => { + if (ptrY0 !== null && e.isPrimary) { + const ansicht = $("#view"), dy = e.clientY - ptrY0; + if (dy > 0 && ansicht && ansicht.scrollTop <= 0) { + e.preventDefault(); + ptrDrag = Math.min(90, dy * 0.5); // Widerstand, wie bei iOS + const ptr = $("#ptr"); + if (ptr) { + ptr.style.transition = "none"; + ptr.style.height = ptrDrag + "px"; + ptr.querySelector("span").textContent = ptrDrag >= 56 ? "Loslassen zum Aktualisieren" : "Ziehen zum Aktualisieren"; + } + } else { + ptrY0 = null; ptrDrag = 0; + const ptr = $("#ptr"); + if (ptr) { ptr.style.transition = ""; ptr.style.height = "0"; } + } + } + if (!swipeAktivWrap || !e.isPrimary) return; + const dx = e.clientX - swipeStartX, dy = e.clientY - swipeStartY; + if (swipeGeste === null) { + if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return; + swipeGeste = Math.abs(dx) > Math.abs(dy) ? "x" : "y"; + if (swipeGeste === "y") { swipeAktivWrap = null; return; } + } + e.preventDefault(); + swipeWarGezogen = true; + // Loeschknopf erst waehrend der Geste einblenden (siehe .swipe-delete in + // audi-dashboard.css) - sonst blitzt er dauerhaft an den Zeilenraendern durch. + swipeAktivWrap.classList.add("wischt"); + const basis = swipeAktivWrap.classList.contains("swiped") ? -SWIPE_BREITE : 0; + swipeDX = Math.max(-SWIPE_BREITE - 20, Math.min(0, basis + dx)); + const inhalt = swipeAktivWrap.querySelector(".swipe-content"); + inhalt.style.transition = "none"; + inhalt.style.transform = `translateX(${swipeDX}px)`; + }, { passive: false }); + function swipeBeenden() { + if (!swipeAktivWrap) return; + const inhalt = swipeAktivWrap.querySelector(".swipe-content"); + inhalt.style.transition = ""; inhalt.style.transform = ""; + swipeAktivWrap.classList.remove("wischt"); + if (swipeGeste === "x") { + if (swipeDX <= -SWIPE_BREITE / 2) { schliesseAlleSwipes(); swipeAktivWrap.classList.add("swiped"); } + else { swipeAktivWrap.classList.remove("swiped"); } + } + swipeAktivWrap = null; swipeGeste = null; swipeDX = 0; + // Der nachlaufende Klick derselben Geste feuert synchron noch in diesem + // Tick (Maus: immer: Touch: falls vom Browser nicht ohnehin unterdrückt) - + // erst danach zurücksetzen, sonst würde ein echter, späterer Tipp (z. B. + // auf den gerade erst aufgedeckten Löschen-Knopf) fälschlich verschluckt. + if (swipeWarGezogen) setTimeout(() => { swipeWarGezogen = false; }, 0); + } + function ptrBeenden() { + if (ptrY0 === null) return; + ptrY0 = null; + const ptr = $("#ptr"); if (!ptr) { ptrDrag = 0; return; } + ptr.style.transition = "height .2s"; + if (ptrDrag >= 56) { + ptrLaedt = true; + ptr.style.height = "56px"; + ptr.classList.add("spin"); + ptr.querySelector("span").textContent = "Aktualisieren …"; + datenAktualisieren().finally(() => { + ptrLaedt = false; + const ptr2 = $("#ptr"); + if (ptr2) { ptr2.style.transition = "height .2s"; ptr2.style.height = "0"; ptr2.classList.remove("spin"); } + }); + } else { + ptr.style.height = "0"; + } + ptrDrag = 0; + } + ROOT.addEventListener("pointerup", (e) => { swipeBeenden(); ptrBeenden(); }); + ROOT.addEventListener("pointercancel", (e) => { swipeBeenden(); ptrBeenden(); }); + + // Batteriespannungs-Verlauf: Pinch-Zoom (2 Finger) / Ziehen zum Verschieben + // (1 Finger) / Mausrad-Zoom, per pointerId in bvZeiger nachverfolgt statt + // über die swipe*-Variablen oben, die für horizontales Wischen in Listen + // gedacht sind und mit einer zweiten aktiven Berührung nichts anfangen + // können. + ROOT.addEventListener("pointerdown", (e) => { + if (!e.target.closest("#bvChart") || !bvDomain) return; + bvZeiger.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (bvZeiger.size === 1) { + bvTapKandidat = { x: e.clientX, y: e.clientY }; + bvBewegtSeit = false; + bvGestStart = { typ: "pan", x: e.clientX, domain: [...bvDomain] }; + } else if (bvZeiger.size === 2) { + bvTapKandidat = null; + const [p1, p2] = [...bvZeiger.values()]; + const svg = $("#bvSvg"), rect = svg.getBoundingClientRect(), rahmen = bvPlotRahmen(rect); + const mitteX = (p1.x + p2.x) / 2; + const ankerFrac = Math.max(0, Math.min(1, (mitteX - rahmen.linksPx) / rahmen.breitePx)); + bvGestStart = { + typ: "pinch", dist: Math.hypot(p1.x - p2.x, p1.y - p2.y), domain: [...bvDomain], + ankerFrac, ankerZeit: bvDomain[0] + ankerFrac * (bvDomain[1] - bvDomain[0]), + }; + } + }); + ROOT.addEventListener("pointermove", (e) => { + if (!bvZeiger.has(e.pointerId) || !bvGestStart) return; + bvZeiger.set(e.pointerId, { x: e.clientX, y: e.clientY }); + e.preventDefault(); + if (bvGestStart.typ === "pan" && bvZeiger.size === 1) { + const dxPx = e.clientX - bvGestStart.x; + if (Math.abs(dxPx) > 4) bvBewegtSeit = true; + const svg = $("#bvSvg"), rect = svg.getBoundingClientRect(); + const breite = bvGestStart.domain[1] - bvGestStart.domain[0]; + const dt = -(dxPx / bvPlotRahmen(rect).breitePx) * breite; + bvDomain = bvDomainKlemmen([bvGestStart.domain[0] + dt, bvGestStart.domain[1] + dt]); + bvZeichnen(); + } else if (bvGestStart.typ === "pinch" && bvZeiger.size === 2) { + const [p1, p2] = [...bvZeiger.values()]; + const dist = Math.hypot(p1.x - p2.x, p1.y - p2.y); + const faktor = bvGestStart.dist / Math.max(dist, 1); + const breite = (bvGestStart.domain[1] - bvGestStart.domain[0]) * faktor; + const start = bvGestStart.ankerZeit - bvGestStart.ankerFrac * breite; + bvDomain = bvDomainKlemmen([start, start + breite]); + bvZeichnen(); + } + }, { passive: false }); + function bvBeenden(e) { + if (!bvZeiger.has(e.pointerId)) return; + bvZeiger.delete(e.pointerId); + if (bvZeiger.size === 0) { + if (bvGestStart && bvGestStart.typ === "pan" && !bvBewegtSeit && bvTapKandidat) { + bvAusgewaehlt = bvNaechsterPunkt(bvTapKandidat.x); + bvZeichnen(); + } + bvGestStart = null; bvTapKandidat = null; + } else if (bvZeiger.size === 1) { + // von Pinch (2 Finger) auf Pan (1 Finger verbleibend) wechseln, statt + // die Geste einfach zu beenden - fühlt sich beim Loslassen eines + // Fingers sonst wie ein Ruckler an + const [rest] = [...bvZeiger.entries()]; + bvGestStart = { typ: "pan", x: rest[1].x, domain: [...bvDomain] }; + bvBewegtSeit = true; + } + } + ROOT.addEventListener("pointerup", bvBeenden); + ROOT.addEventListener("pointercancel", bvBeenden); + ROOT.addEventListener("wheel", (e) => { + if (!e.target.closest("#bvChart") || !bvDomain) return; + e.preventDefault(); + const svg = $("#bvSvg"), rect = svg.getBoundingClientRect(), rahmen = bvPlotRahmen(rect); + const frac = Math.max(0, Math.min(1, (e.clientX - rahmen.linksPx) / rahmen.breitePx)); + const ankerZeit = bvDomain[0] + frac * (bvDomain[1] - bvDomain[0]); + const faktor = e.deltaY > 0 ? 1.2 : 1 / 1.2; + const breite = (bvDomain[1] - bvDomain[0]) * faktor; + const start = ankerZeit - frac * breite; + bvDomain = bvDomainKlemmen([start, start + breite]); + bvZeichnen(); + }, { passive: false }); + + ROOT.addEventListener("click", (e) => { + // B4: Das Action Sheet liegt über allem und fängt daher zuerst ab. + if (e.target.closest("[data-sheet-abbrechen]")) { sheetSchliessen(); return; } + if (e.target.closest("[data-sheet-ok]")) { + const aktion = sheet && sheet.aktion; + sheet = null; render(); + if (aktion) aktion(); + return; + } + if (sheet) return; + if (e.target.closest("[data-setup-oeffnen]")) { setupOeffnen(); return; } + if (setupOffen) { + if (e.target.closest("[data-setup-abbrechen]")) { setupSchliessen(); return; } + const wahl = e.target.closest("[data-setup-wahl]"); + if (wahl) { + const feldId = wahl.dataset.setupWahl, entityId = wahl.dataset.setupEntity; + const teile = feldId.split(":"); + if (teile.length === 2) setupZuordnung[teile[0]][Number(teile[1])] = entityId; + else setupZuordnung[feldId] = entityId; + setupSucheOffen = null; setupSuchtext = ""; render(); + return; + } + const reset = e.target.closest("[data-setup-reset]"); + if (reset) { + const feldId = reset.dataset.setupReset; + const teile = feldId.split(":"); + if (teile.length === 2) setupZuordnung[teile[0]][Number(teile[1])] = setupStandardwert(teile[0], Number(teile[1])); + else setupZuordnung[feldId] = setupStandardwert(feldId, null); + render(); + return; + } + if (e.target.closest("[data-setup-speichern]")) { + if (setupSpeichertLaeuft) return; + const duplikate = setupDuplikate(); + if (duplikate.length) { + const namen = duplikate.map(entitaetName).join(", "); + bestaetigen("Doppelt zugeordnet", `${namen} ${duplikate.length === 1 ? "ist" : "sind"} mehr als einer Rolle zugeordnet. Trotzdem speichern?`, "Trotzdem speichern", setupSpeichernAusfuehren, false); + return; + } + setupSpeichernAusfuehren(); + return; + } + // Klick aufs (noch geschlossene) Suchfeld öffnet dessen Dropdown; ein + // Klick, während es schon offen ist (z. B. Cursor neu setzen), soll + // keinen render() auslösen - würde die laufende Eingabe unterbrechen. + const sucheInput = e.target.closest("[data-setup-suche]"); + if (sucheInput) { + const feldId = sucheInput.dataset.setupSuche; + if (setupSucheOffen !== feldId) { setupSucheOffen = feldId; setupSuchtext = ""; render(); } + return; + } + // Klick irgendwo sonst im Popup (aber nicht auf den Catcher, der schon + // oben behandelt wurde) schließt ein offenes Dropdown, ohne das ganze + // Popup zu verlassen. + /* Den Filterschalter VOR dem Schliessen der Auswahlliste behandeln. + Sonst ersetzt render() unten das Overlay-DOM, bevor das + change-Ereignis des Kontrollkaestchens ausgeliefert wird - der + Schalter sprang dann beim naechsten Zeichnen einfach zurueck. */ + if (e.target.closest("[data-setup-nurpassend]") || e.target.closest(".switch")) { + const kasten = ROOT.querySelector("[data-setup-nurpassend]"); + if (kasten) { setupNurPassend = !setupNurPassend; render(); return; } + } + if (setupSucheOffen && !e.target.closest(".entitaet-combo")) { setupSucheOffen = null; setupSuchtext = ""; render(); return; } + return; + } + const bvReset = e.target.closest("[data-bvreset]"); + if (bvReset) { bvDomain = [...bvVollDomain]; bvAusgewaehlt = null; bvZeichnen(); return; } + // Nachlaufender Klick derselben Geste (Maus feuert ihn trotz Bewegung + // weiterhin) - nicht als Navigation/Aktion werten. + if (swipeWarGezogen) { swipeWarGezogen = false; return; } + const loeschBtn = e.target.closest("[data-swipe-loeschen]"); + const offenerWrap = ROOT.querySelector(".swipe-wrap.swiped"); + if (offenerWrap && !loeschBtn) { + // Erster Tipp bei offener Wischfläche schließt sie nur - wie bei iOS, + // egal ob innerhalb der Zeile (statt zu navigieren) oder außerhalb. + schliesseAlleSwipes(); + if (offenerWrap.contains(e.target)) return; + } + if (loeschBtn) { + const [art, id] = loeschBtn.dataset.swipeLoeschen.split(":"); + bestaetigen(LOESCH_TEXT[art], LOESCH_HINWEIS[art], "Löschen", () => { eintragLoeschen(art, id); schliesseAlleSwipes(); }); + return; + } + const tripDel = e.target.closest("[data-tripdel]"); + if (tripDel) { + const id = tripDel.dataset.tripdel; + bestaetigen(LOESCH_TEXT.trip, LOESCH_HINWEIS.trip, "Löschen", () => { eintragLoeschen("trip", id); go("trips"); }); + return; + } + const fillDel = e.target.closest("[data-filldel]"); + if (fillDel) { + const id = fillDel.dataset.filldel; + bestaetigen(LOESCH_TEXT.fill, LOESCH_HINWEIS.fill, "Löschen", () => { eintragLoeschen("fill", id); go("fuel"); }); + return; + } + const sbDel = e.target.closest("[data-sbdel]"); + if (sbDel) { + const id = sbDel.dataset.sbdel; + bestaetigen(LOESCH_TEXT.sbuch, LOESCH_HINWEIS.sbuch, "Löschen", () => { eintragLoeschen("sbuch", id); go("service"); }); + return; + } + const bildDel = e.target.closest("[data-bildloeschen]"); + if (bildDel) { + const dateiname = bildDel.dataset.bildloeschen; + bestaetigen(LOESCH_TEXT.bild, LOESCH_HINWEIS.bild, "Foto löschen", () => { + (async () => { + try { await HASS.callService("pyscript", "audi_dashboard_bild_loeschen", { dateiname }); } + catch (err) { hinweis("Löschen fehlgeschlagen", err.message); return; } + bildMenuOffen = null; bildVersion = Date.now(); render(); + })(); + }); + return; + } + // Fahrzeugbild (Radbild wie Fahrzeugbilder-Slots): vorhandenes Foto -> Popup + // mit Ersetzen/Löschen, fehlendes Foto (Platzhalter, bild-fehlt) -> Dateiauswahl + // direkt öffnen. + const bildKlick = e.target.closest("[data-bildklick]"); + if (bildKlick) { + const dateiname = bildKlick.dataset.bildklick; + const box = bildKlick.querySelector(".bildbox"); + const container = bildKlick.closest(".tile, .bildslot"); + if (box && box.classList.contains("bild-fehlt")) { + const input = container.querySelector(`[data-bildupload="${dateiname}"]`); + if (input) input.click(); + } else { + bildMenuOffen = dateiname; render(); + } + return; + } + if (e.target.closest("[data-bildmenu-schliessen]")) { bildMenuOffen = null; render(); return; } + const bildErsetzen = e.target.closest("[data-bildersetzen]"); + if (bildErsetzen) { + const container = bildErsetzen.closest(".tile, .bildslot"); + const input = container.querySelector(`[data-bildupload="${bildErsetzen.dataset.bildersetzen}"]`); + if (input) input.click(); + return; + } + if (e.target.closest("[data-sdabbrechen]")) { smartdealPopupOffen = false; render(); return; } + if (e.target.closest("[data-sdbestaetigen]")) { + const datum = $("#sdPopupBis").value; + if (!datum) return; + CAR.smartdeal.aktiv = true; CAR.smartdeal.laeuftAb = datum; + smartdealPopupOffen = false; profilSpeichern(); render(); + return; + } + if (e.target.id === "carimg") { bildWeiter(); return; } + if (e.target.closest("[data-sbneu]")) { + CAR.service.buch.unshift({ datum: dedat(heute()), km: CAR.odoBekannt ? CAR.odo : 0, art: SERVICE_ARTEN[0], werkstatt: CAR.service.autohaus.name, kosten: null, arbeiten: "" }); + entwurfIndex = 0; go("sbuch", "0"); return; + } + if (e.target.closest("[data-sbspeichern]")) { + entwurfIndex = null; profilSpeichern(); go("service"); return; + } + if (e.target.closest("[data-nmauf]")) { nmOffen = !nmOffen; render(); return; } + if (e.target.closest("[data-einrichten-auf]")) { einrichtenOffen = !einrichtenOffen; render(); return; } + if (e.target.closest("[data-nmspeichern]")) { + const wert = parseInt($("#nm").value, 10); + if (!isNaN(wert) && wert > 0) { CAR.reifen.anzugsmoment = wert; profilSpeichern(); } + nmOffen = false; render(); return; + } + if (e.target.closest("[data-neu]")) { + neuOffen = !neuOffen; + if (belegEntwurf && belegEntwurf.praefix === "neuTank") belegEntwurf = null; + if (belegFehler && belegFehler.context === "neuTank") belegFehler = null; + render(); return; + } + if (e.target.closest("[data-fahrt]")) { fahrtOffen = !fahrtOffen; render(); return; } + if (e.target.closest("[data-fahrtspeichern]")) { + const datum = $("#neuFahrtDatum").value, start = $("#neuFahrtStart").value, dauer = parseInt($("#neuFahrtDauer").value, 10) || 0; + const tsStart = new Date(`${datum}T${start}:00`); + const tsEnd = new Date(tsStart.getTime() + dauer * 60000); + serviceRufen("audi_dashboard_fahrt_manuell_anlegen", { ts_start: tsStart.toISOString(), ts_end: tsEnd.toISOString(), art: "privat" }); + fahrtOffen = false; render(); return; + } + if (e.target.closest("[data-tankspeichern]")) { + const w = tankFormularWerte("neuTank"); + if (w.liter > 0 && w.kosten > 0) { + serviceRufen("audi_dashboard_tankvorgang_manuell", { + ts: w.ts, liter: w.liter, kosten: w.kosten, km: w.km, ersparnis: w.ersparnis, station: w.station, + distanz: w.distanz, kraftstoff: w.kraftstoff, + }); + } + neuOffen = false; belegEntwurf = null; belegFehler = null; render(); return; + } + if (e.target.closest("[data-tankspeichernbeleg]")) { + const w = tankFormularWerte("neuTank"); + const beleg = belegEntwurf && belegEntwurf.praefix === "neuTank" ? belegEntwurf.daten : null; + if (w.liter > 0 && w.kosten > 0) { + serviceRufen("audi_dashboard_tankvorgang_manuell", { + ts: w.ts, liter: w.liter, kosten: w.kosten, km: w.km, ersparnis: w.ersparnis, station: w.station, + distanz: w.distanz, kraftstoff: w.kraftstoff, + receipt_key: beleg ? beleg.receipt_key : null, receipt_file: beleg ? beleg.receipt_file : null, + }); + } + neuOffen = false; belegEntwurf = null; belegFehler = null; render(); return; + } + if (e.target.closest("[data-tankbearbeiten]")) { + tankBearbeitenOffen = !tankBearbeitenOffen; + if (belegEntwurf && belegEntwurf.praefix === "bearbTank") belegEntwurf = null; + if (belegFehler && belegFehler.context === "bearbTank") belegFehler = null; + render(); return; + } + const tankAkt = e.target.closest("[data-tankaktualisieren]"); + if (tankAkt) { + const w = tankFormularWerte("bearbTank"); + const beleg = belegEntwurf && belegEntwurf.praefix === "bearbTank" ? belegEntwurf.daten : null; + serviceRufen("audi_dashboard_tankvorgang_aktualisieren", { + tank_id: tankAkt.dataset.tankaktualisieren, ts: w.ts, liter: w.liter, kosten: w.kosten, + km: w.km, ersparnis: w.ersparnis, station: w.station, + distanz: w.distanz, kraftstoff: w.kraftstoff, + receipt_key: beleg ? beleg.receipt_key : null, receipt_file: beleg ? beleg.receipt_file : null, + }); + tankBearbeitenOffen = false; belegEntwurf = null; belegFehler = null; render(); return; + } + const belegBtn = e.target.closest("[data-beleg]"); + if (belegBtn) { + // "neu"/"bearb": nur parsen und den offenen Entwurf ausfüllen (siehe + // belegEntwurf). Eine tank_id dagegen (Button in der Einzelbeleg- + // Ansicht) trägt den Beleg direkt in den bereits gespeicherten + // Tankvorgang nach - siehe Kopfkommentar in audi_dashboard_beleg_hochladen. + const ziel = belegBtn.dataset.beleg; + const nurParsen = ziel === "neu" || ziel === "bearb"; + const praefix = ziel === "neu" ? "neuTank" : ziel === "bearb" ? "bearbTank" : null; + const tankId = nurParsen ? undefined : ziel; + const kontext = nurParsen ? praefix : ziel; + if (belegFehler && belegFehler.context === kontext) belegFehler = null; + belegZielPraefix = praefix; + const eingabe = document.createElement("input"); + eingabe.type = "file"; eingabe.accept = "application/pdf"; + eingabe.onchange = () => { + const datei = eingabe.files[0]; if (!datei) return; + const leser = new FileReader(); + leser.onload = () => { + const base64 = leser.result.split(",")[1]; + // Kein serviceRufen() hier: dessen catch loggt nur in die Browser- + // Konsole (bei allen anderen Aufrufen ok, die reagieren auf + // datenLaden() statt auf den Rückgabewert) - ein rejizierter + // Service-Aufruf (Backend nicht erreichbar, Timeout, ...) blieb + // für den Beleg-Upload sonst komplett unsichtbar in der + // Oberfläche, obwohl der Backend-seitige Parser-Fehlerpfad selbst + // schon einen Fehler veröffentlicht (siehe belegverarbeitung.py). + HASS.callService("pyscript", "audi_dashboard_beleg_hochladen", + { pdf_base64: base64, dateiname: datei.name, tank_id: tankId } + ).catch((err) => { + console.error("audi_dashboard: audi_dashboard_beleg_hochladen", err); + belegFehler = { context: kontext, meldung: "Beleg konnte nicht hochgeladen werden" }; + if (!tankId) belegEntwurf = null; + render(); + }); + }; + leser.readAsDataURL(datei); + }; + eingabe.click(); + return; + } + const del = e.target.closest("[data-loeschen]"); + if (del) { delete CAR.service.vereinbart[del.dataset.loeschen]; profilSpeichern(); render(); return; } + const oel = e.target.closest("[data-oel]"); + if (oel) { CAR.oel.modus = oel.dataset.oel; profilSpeichern(); render(); return; } + const mode = e.target.closest("[data-mode]"); + if (mode) { themeSetzen(mode.dataset.mode); themeIcon(); render(); return; } + const pr = e.target.closest("[data-profil]"); + if (pr && pr.dataset.profil === "export") { + const a = document.createElement("a"); + a.href = "data:application/json;charset=utf-8," + encodeURIComponent(JSON.stringify(PROFIL_ROH, null, 2)); + a.download = "fahrzeugprofil.json"; document.body.appendChild(a); a.click(); a.remove(); + return; + } + const bk = e.target.closest("[data-backup]"); + if (bk && bk.dataset.backup === "jetzt") { serviceRufen("audi_dashboard_backup_jetzt", {}); return; } + if (bk && bk.dataset.backup === "export") { + // Die Sensor-Zuordnung gehört mit ins Backup: sie liegt sonst nur in + // entitaeten.json und wäre nach einer Wiederherstellung verloren. + const paket = { erstellt: new Date().toISOString(), profil: PROFIL_ROH, fahrten: TRIPS, tankvorgaenge: FILLS, + entitaeten: (ENTITAETEN && ENTITAETEN.werte) || null }; + const a = document.createElement("a"); + a.href = "data:application/json;charset=utf-8," + encodeURIComponent(JSON.stringify(paket, null, 2)); + a.download = `backup_${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(a); a.click(); a.remove(); + return; + } + if (e.target.closest("[data-update-pruefen]")) { + if (!updatePruefend) { + updatePruefend = true; render(); + (async () => { + try { await HASS.callService("pyscript", "audi_dashboard_update_pruefen", {}); } + catch (err) { hinweis("Update-Prüfung fehlgeschlagen", err.message); } + await updateStatusAbrufen(); + updatePruefend = false; render(); + })(); + } + return; + } + if (e.target.closest("[data-update-installieren]")) { + if (!updateInstallierend) bestaetigen( + "Update installieren?", + "Fahrzeugprofil, Fahrten, Tankvorgänge und eigene Fotos bleiben unangetastet. Die Seite lädt sich danach neu.", + "Installieren", + () => { updateInstallierend = true; render(); + (async () => { + try { + await HASS.callService("pyscript", "audi_dashboard_update_installieren", {}); + await updateStatusAbrufen(); + location.reload(); + return; + } catch (err) { hinweis("Update-Installation fehlgeschlagen", err.message); } + await updateStatusAbrufen(); + updateInstallierend = false; render(); + })(); + }, false); + return; + } + const cs = e.target.closest("[data-csv]"); + if (cs) { + const w = cs.dataset.csv; + if (w === "fahrten") csvDatei("fahrten.csv", ["Start", "Ende", "km", "Art", "Status"], + TRIPS.map((t) => [t.ts_start, t.ts_end, t.distance_km != null ? de(t.distance_km) : "", t.art, t.status])); + else if (w === "tanken") csvDatei("tankvorgaenge.csv", ["Zeitpunkt", "Station", "Liter", "€/l", "Kosten €"], + FILLS.map((f) => [f.ts, f.station_name || "", f.liters != null ? de(f.liters, 2) : "", f.liters ? de(f.fuel_total_eur / f.liters, 3) : "", f.fuel_total_eur != null ? eur(f.fuel_total_eur) : ""])); + else csvDatei("servicebuch.csv", ["Datum", "km", "Art", "Werkstatt", "Kosten €"], + CAR.service.buch.map((e2) => [e2.datum, de(e2.km), e2.art, e2.werkstatt, e2.kosten != null ? eur(e2.kosten) : ""])); + return; + } + const satz = e.target.closest("[data-satz]"); + if (satz) { serviceRufen("audi_dashboard_reifen_wechseln", { satz: satz.dataset.satz.toLowerCase() }); CAR.reifen.aktiv = satz.dataset.satz; render(); return; } + const k = e.target.closest("[data-ics]"); + if (k) { + if (k.dataset.ics === "service") { + const ts = termine(), t = ts[Math.min(svAuswahl, ts.length - 1)]; + const datum = $("#svDat").value; + CAR.service.vereinbart[t.art] = datum; + profilSpeichern(); + ics(`${CONFIG.fahrzeugtitel}: ${t.art}`, datum, CAR.service.autohaus.name, + t.km !== null ? `Fällig bei ${de(t.km)} km · ${CAR.service.autohaus.adresse}` : CAR.service.autohaus.adresse); + render(); + } else { + const datum = $("#rfDat").value; + CAR.reifen.wechsel.datum = datum; + CAR.reifen.wechsel.vereinbart = $("#rfOk").checked; + profilSpeichern(); + const ziel = CAR.reifen.aktiv === "Sommer" ? "Winter" : "Sommer"; + ics(`${CONFIG.fahrzeugtitel}: Reifenwechsel auf ${ziel}`, datum, CAR.service.autohaus.name, `Umstellung des Zählers auf den ${ziel}satz nicht vergessen`); + } + return; + } + const a = e.target.closest("[data-acc]"); + if (a) { const b = ROOT.getElementById(a.dataset.acc); a.setAttribute("aria-expanded", b.classList.toggle("on")); return; } + const art = e.target.closest("[data-art]"); + if (art) { const tr = TRIPS.find((x) => x.trip_id === art.dataset.art); tr.art = tr.art === "arbeitsweg" ? "privat" : "arbeitsweg"; render(); return; } + // Standort (Vollbild) - Karten-/Menü-Bedienung manipuliert SMAP/das Menü- + // DOM direkt statt über render() (siehe Kopfkommentar Standort-Abschnitt). + if (e.target.closest("[data-standort-umschalten]")) { standortMenuUmschalten(); return; } + if (e.target.closest("[data-standort-layer]")) { SMAP_STIL = SMAP_STIL === "satellit" ? "strasse" : "satellit"; standortTiles(); return; } + if (e.target.closest("[data-standort-fahrzeug]")) { standortAufFahrzeug(); return; } + if (e.target.closest("[data-standort-user]")) { standortAufUser(); return; } + if (e.target.closest("[data-standort-beide]")) { standortBeideZeigen(); return; } + if (e.target.closest("[data-standort-schliessen]")) { standortMenuSchliessen(); return; } + if (e.target.closest("[data-standort-teilen]")) { standortTeilen(); return; } + const g = e.target.closest("[data-go]"); + if (g) { const [n, i] = g.dataset.go.split(":"); go(n, i); } + }); + + ROOT.addEventListener("input", (e) => { + const sucheFeld = e.target.dataset.setupSuche; + if (sucheFeld) { + // Nur die Kandidatenliste patchen, nicht render() - ein voller + // Re-Render würde bei jedem Tastendruck das Eingabefeld neu erzeugen + // und den Fokus verlieren (gleiches Problem/gleiche Lösung wie beim + // bestehenden data-tankpreisfeld-Handler direkt hier drunter). + setupSuchtext = e.target.value; + const teile = sucheFeld.split(":"); + const key = teile[0]; + const wert = teile.length === 2 ? (setupZuordnung[key] || [])[Number(teile[1])] : setupZuordnung[key]; + const liste = ROOT.querySelector(`[data-setup-liste="${sucheFeld}"]`); + if (liste) liste.innerHTML = entitaetZeilenMarkup(sucheFeld, key, setupSuchtext, wert); + return; + } + const praefix = e.target.dataset.tankpreisfeld; + if (!praefix) return; + const liter = parseFloat((ROOT.getElementById(`${praefix}Liter`).value || "0").replace(",", ".")); + const kosten = parseFloat((ROOT.getElementById(`${praefix}Kosten`).value || "0").replace(",", ".")); + const anzeige = ROOT.getElementById(`${praefix}PreisAnzeige`); + if (anzeige) anzeige.textContent = liter > 0 ? `Preis je Liter: ${de(kosten / liter, 3)} €` : "Preis je Liter: wird berechnet"; + }); + + ROOT.addEventListener("change", (e) => { + if (e.target.dataset.profil === "import") { + const datei = e.target.files[0]; if (!datei) return; + const leser = new FileReader(); + leser.onload = () => { + try { + const p = JSON.parse(leser.result); + PROFIL_ROH = p; CONFIG = profilZuConfig(p); CAR = profilZuCar(p, { km: CAR.odo, tankprozent: CAR.tankPct, reichweite_km: CAR.reichweite, gesichert: CAR.gesichert }); + serviceRufen("audi_dashboard_profil_schreiben", { profil_json: JSON.stringify(p) }); + bild = 0; render(); + } catch (err) { hinweis("Die Datei ließ sich nicht lesen", err.message); } + }; + leser.readAsText(datei); + return; + } + if (e.target.dataset.backup === "import") { + const datei = e.target.files[0]; if (!datei) return; + const leser = new FileReader(); + leser.onload = () => { + try { + const paket = JSON.parse(leser.result); + if (!paket.profil) throw new Error("Keine gültige Backup-Datei (Profil fehlt)"); + PROFIL_ROH = paket.profil; + CONFIG = profilZuConfig(paket.profil); + CAR = profilZuCar(paket.profil, { km: CAR.odo, tankprozent: CAR.tankPct, reichweite_km: CAR.reichweite, gesichert: CAR.gesichert }); + TRIPS = paket.fahrten || []; + FILLS = paket.tankvorgaenge || []; + serviceRufen("audi_dashboard_backup_wiederherstellen", { + profil_json: JSON.stringify(paket.profil), + fahrten_json: JSON.stringify(TRIPS), + tankvorgaenge_json: JSON.stringify(FILLS), + // Fehlt der Abschnitt (Sicherung von vor dem Setup-Menü), bleibt die + // aktuelle Zuordnung stehen, statt geleert zu werden. + ...(paket.entitaeten ? { entitaeten_json: JSON.stringify(paket.entitaeten) } : {}), + }); + bild = 0; render(); + } catch (err) { hinweis("Die Datei ließ sich nicht lesen", err.message); } + }; + leser.readAsText(datei); + return; + } + if (e.target.dataset.bildupload !== undefined) { + const datei = e.target.files[0]; if (!datei) return; + const dateiname = e.target.dataset.bildupload; + const leser = new FileReader(); + leser.onload = async () => { + const daten_base64 = String(leser.result).split(",")[1] || ""; + try { + await HASS.callService("pyscript", "audi_dashboard_bild_hochladen", { dateiname, daten_base64 }); + } catch (err) { hinweis("Hochladen fehlgeschlagen", err.message); } + bildMenuOffen = null; bildVersion = Date.now(); render(); + }; + leser.readAsDataURL(datei); + e.target.value = ""; + return; + } + if (e.target.dataset.backupintervall !== undefined) { + CONFIG.backupIntervall = e.target.value; profilSpeichern(); return; + } + const ein = e.target.dataset.einrichten; + if (ein) { + if (ein === "kennzeichen") CONFIG.kennzeichen = e.target.value; + else if (ein === "ausfuehrung") CONFIG.ausfuehrung = e.target.value; + else if (ein === "modell") CONFIG.fahrzeugtitel = e.target.value; + else CAR[ein] = e.target.value; + profilSpeichern(); return; + } + const vb = e.target.dataset.vereinbart; + if (vb) { CAR.service.vereinbart[vb] = e.target.value; profilSpeichern(); render(); return; } + const ah = e.target.dataset.ah; + if (ah) { + CAR.service.autohaus[ah] = e.target.value; + profilSpeichern(); render(); return; + } + const sb = e.target.dataset.sb; + if (sb) { + const [i, feld] = sb.split("|"); + const eintrag = CAR.service.buch[+i]; + eintrag[feld] = feld === "km" ? (parseInt(e.target.value, 10) || 0) : feld === "kosten" ? (e.target.value === "" ? null : parseFloat(e.target.value)) : e.target.value; + // Ein per "Eintrag hinzufügen" angelegter, noch nicht bestätigter Entwurf + // wird erst beim Klick auf "Speichern" (data-sbspeichern) persistiert - + // sonst würde schon das testweise Ausfüllen einzelner Felder auto-speichern. + if (entwurfIndex !== +i) profilSpeichern(); + render(); return; + } + const rf = e.target.dataset.rf; + if (rf) { + const [k, schluessel] = rf.split("|"); + const wert = e.target.value; + CAR.reifen[k][schluessel] = wert; + profilSpeichern(); render(); return; + } + if (e.target.dataset.schrift !== undefined) { + beschriftung = e.target.checked; + try { localStorage.setItem(SCHRIFT_KEY, beschriftung ? "an" : "aus"); } catch (err) { /* Privatmodus */ } + ROOT.getElementById("tabbar").classList.toggle("ohne", !beschriftung); + return; + } + if (e.target.dataset.steuer !== undefined) { CAR.steuer.betrag = parseFloat(e.target.value) || 0; profilSpeichern(); render(); return; } + if (e.target.dataset.steuerzeit !== undefined) { CAR.steuer.zeitraum = e.target.value; profilSpeichern(); render(); return; } + if (e.target.dataset.steuerfaellig !== undefined) { CAR.steuer.faellig = e.target.value; profilSpeichern(); render(); return; } + if (e.target.dataset.steuerlast !== undefined) { CAR.steuer.lastschrift = e.target.checked; profilSpeichern(); render(); return; } + const vteil = e.target.dataset.vteil; + if (vteil) { + const [i, feld] = vteil.split("|"); + CAR.versicherung.teile[+i][feld] = feld === "betrag" ? (parseFloat(e.target.value) || 0) : e.target.value; + CAR.versicherung.beitrag = sum(CAR.versicherung.teile, (t) => t.betrag || 0); + profilSpeichern(); render(); return; + } + if (e.target.dataset.vgueltigab !== undefined) { CAR.versicherung.gueltigAb = e.target.value; profilSpeichern(); render(); return; } + const vnotruf = e.target.dataset.vnotruf; + if (vnotruf) { CAR.versicherung[vnotruf] = e.target.value; profilSpeichern(); render(); return; } + if (e.target.dataset.pause !== undefined) { CONFIG.pauseMin = parseInt(e.target.value, 10) || 0; profilSpeichern(); return; } + if (e.target.dataset.sd !== undefined) { + if (e.target.checked) { smartdealPopupOffen = true; render(); } + else { CAR.smartdeal.aktiv = false; profilSpeichern(); render(); } + return; + } + if (e.target.dataset.oelkm !== undefined) { CAR.oel.km = parseInt(e.target.value, 10); profilSpeichern(); return; } + if (e.target.dataset.oelm !== undefined) { CAR.oel.monate = parseInt(e.target.value, 10); profilSpeichern(); return; } + if (e.target.dataset.boden !== undefined) { bodenAn = e.target.checked; render(); return; } + if (e.target.dataset.setupNurpassend !== undefined) { setupNurPassend = e.target.checked; render(); return; } + if (e.target.dataset.startbild !== undefined) { CONFIG.startbild = e.target.value; profilSpeichern(); render(); return; } + if (e.target.dataset.svsel !== undefined) { svAuswahl = parseInt(e.target.value, 10); render(); return; } + }); +} + +/* ------------------------------------------------------------ Datenladen */ +function zustandsDaten(entityId) { + const st = HASS && HASS.states[entityId]; + return st && st.attributes ? st.attributes.daten : undefined; +} + +let letzterProfilState = null, letzterFahrtenState = null, letzterTankState = null, letzterStatusState = null, letzterBelegState = null, letzterBattState = null; + +/* Ergebnis eines Beleg-Uploads verarbeiten (siehe belegEntwurf/belegFehler + weiter oben) - kommt über dieselbe Zustand-statt-Rückgabewert-Route wie + Profil/Fahrten/Tankvorgänge, siehe Kopfkommentar in + frontend_veroeffentlichung.py. */ +function belegErgebnisVerarbeiten(daten) { + if (!daten) return; + if (daten.fehler) { + const kontext = daten.tank_id || belegZielPraefix; + belegFehler = { context: kontext, meldung: daten.fehler }; + if (!daten.tank_id) belegEntwurf = null; + } else if (daten.tank_id) { + // Direkt übernommener Beleg (Beleg nachtragen/ersetzen in der + // Einzelbeleg-Ansicht) - die neuen Werte kommen bereits über + // tankvorgaenge_veroeffentlichen(), hier nur eine evtl. noch offene + // Fehleranzeige für diesen Tankvorgang löschen. + if (belegFehler && belegFehler.context === daten.tank_id) belegFehler = null; + } else if (belegZielPraefix) { + belegEntwurf = { praefix: belegZielPraefix, daten }; + belegFehler = null; + } + render(); +} + +/* Nachlade-Fallback: panel_custom schiebt einem bereits gemounteten Element + frische hass-Objekte nicht zuverlässig nach - an der Testinstanz auch + Minuten nach dem Start und nach SPA-interner Navigation weg und zurück + beobachtet, dass HASS auf dem allerersten, noch unvollständigen Stand + hängen blieb (der übliche Fall: das Backend hat unsere vier Zustände beim + allerersten hass-Push noch nicht veröffentlicht, z. B. kurz nach einem + HA-Neustart). Deshalb hier aktiv per WebSocket nachfragen, statt passiv + auf den nächsten hass-Push zu warten. Ergebnisse landen in einem eigenen + Zwischenspeicher statt in HASS.states selbst - das gehört Home Assistant, + nicht uns. + + Ein früherer Stand brach nach 15 Versuchen (rund 30s) endgültig ab - real + zu kurz beobachtet: startet der Container komplett neu (HA + HACS + die + VAG-Integration mit eigenen Netzwerkaufrufen), kann es deutlich länger + dauern, bis pyscript überhaupt geladen ist und sein @time_trigger("startup") + feuert. Sichtbares Symptom war genau das gemeldete Verhalten: die App + blieb dauerhaft auf "Lädt …" hängen (und die Tableiste zeigte dadurch auch + kein aktives Symbol, weil render() den harten Rücksprung bei + !DATEN_GELADEN nie verlässt) - erst ein Panelwechsel weg und zurück half, + weil das einen frischen connectedCallback()/datenLaden(false) auslöst, zu + dem Zeitpunkt oft schon nach Ablauf der wirklichen Backend-Startzeit. + Deshalb jetzt ohne festes Abbruchlimit: nach der anfänglichen schnellen + Serie (siehe NACHLADE_INTERVALL_MS) läuft es mit einem größeren, aber + endlosen Intervall weiter, bis Daten da sind oder das Element abgehängt + wird (disconnectedCallback stoppt hier nichts explizit, aber ein + getimeter Aufruf ohne HASS/callWS bricht oben ohnehin ab). */ +const NACHGELADEN = {}; +let nachladeTimer = null; +let nachladeVersuche = 0; +const NACHLADE_INTERVALL_MS = 2000; +const NACHLADE_LANGSAM_MS = 10000; +const NACHLADE_SCHNELLE_VERSUCHE = 15; + +function nachladeAnstossen() { + if (nachladeTimer || !HASS || !HASS.callWS) return; + nachladeVersuche++; + // Erster Versuch fast sofort statt erst nach vollem Intervall - der + // Wettlauf mit pyscripts beim_start() (siehe Kopfkommentar) ist meist + // schon nach wenigen hundert Millisekunden entschieden, nicht erst nach + // den vollen zwei Sekunden. Nach den ersten Versuchen (Backend vermutlich + // noch nicht bereit) auf ein ruhigeres Intervall wechseln, statt für immer + // im schnellen Takt weiterzufragen. + const verzoegerung = nachladeVersuche === 1 ? 200 + : nachladeVersuche <= NACHLADE_SCHNELLE_VERSUCHE ? NACHLADE_INTERVALL_MS + : NACHLADE_LANGSAM_MS; + nachladeTimer = setTimeout(async () => { + nachladeTimer = null; + try { + const states = await HASS.callWS({ type: "get_states" }); + const finden = (id) => states.find((s) => s.entity_id === id); + NACHGELADEN.profil = finden("pyscript.audi_dashboard_profil") || NACHGELADEN.profil; + NACHGELADEN.fahrten = finden("pyscript.audi_dashboard_fahrten") || NACHGELADEN.fahrten; + NACHGELADEN.tank = finden("pyscript.audi_dashboard_tankvorgaenge") || NACHGELADEN.tank; + NACHGELADEN.status = finden("pyscript.audi_dashboard_fahrzeugstatus") || NACHGELADEN.status; + NACHGELADEN.batt = finden("pyscript.audi_dashboard_batterieverlauf") || NACHGELADEN.batt; + // Ohne diese Zeile bleibt der Setup-Katalog nach einem HA-Neustart leer, + // bis zufaellig ein Push kommt - siehe setupBereit(). + NACHGELADEN.entitaeten = finden("pyscript.audi_dashboard_entitaeten") || NACHGELADEN.entitaeten; + } catch (e) { + // wird beim naechsten Versuch erneut probiert + } + datenLaden(true); + }, verzoegerung); +} + +async function datenLaden(erzwingen) { + if (!HASS) return; + const profilState = HASS.states["pyscript.audi_dashboard_profil"] || NACHGELADEN.profil; + const fahrtenState = HASS.states["pyscript.audi_dashboard_fahrten"] || NACHGELADEN.fahrten; + const tankState = HASS.states["pyscript.audi_dashboard_tankvorgaenge"] || NACHGELADEN.tank; + const statusState = HASS.states["pyscript.audi_dashboard_fahrzeugstatus"] || NACHGELADEN.status; + const battState = HASS.states["pyscript.audi_dashboard_batterieverlauf"] || NACHGELADEN.batt; + const belegState = HASS.states["pyscript.audi_dashboard_beleg_ergebnis"]; + // Unabhängig vom geaendert-Block unten gelesen (der trackt nur profil/ + // fahrten/tank/status/batt) - ändert sich erst nach einem Setup-Speichern, + // nicht Teil des normalen Update-Rhythmus. + const entitaetenState = HASS.states["pyscript.audi_dashboard_entitaeten"] || NACHGELADEN.entitaeten; + if (entitaetenState && entitaetenState.attributes) ENTITAETEN = entitaetenState.attributes.daten; + + if (belegState && belegState !== letzterBelegState) { + letzterBelegState = belegState; + belegErgebnisVerarbeiten(belegState.attributes && belegState.attributes.daten); + } + + const geaendert = erzwingen + || profilState !== letzterProfilState || fahrtenState !== letzterFahrtenState + || tankState !== letzterTankState || statusState !== letzterStatusState || battState !== letzterBattState; + if (geaendert) { + letzterProfilState = profilState; letzterFahrtenState = fahrtenState; + letzterTankState = tankState; letzterStatusState = statusState; letzterBattState = battState; + + const profil = profilState && profilState.attributes ? profilState.attributes.daten : null; + if (profil) { + const status = (statusState && statusState.attributes && statusState.attributes.daten) || {}; + LETZTES_UPDATE_TS = (status.km != null && statusState && statusState.last_updated) + ? new Date(statusState.last_updated).getTime() : null; + PROFIL_ROH = profil; + CONFIG = profilZuConfig(profil); + CAR = profilZuCar(profil, status); + TRIPS = (fahrtenState && fahrtenState.attributes && fahrtenState.attributes.daten) || []; + FILLS = (tankState && tankState.attributes && tankState.attributes.daten) || []; + BATTERIEVERLAUF = (battState && battState.attributes && battState.attributes.daten) || []; + DATEN_GELADEN = true; + nachladeVersuche = 0; + if (!UPDATE_STATUS_GEHOLT) { UPDATE_STATUS_GEHOLT = true; updateStatusAbrufen().then(render); } + render(); + return; + } + } + + if (!DATEN_GELADEN) nachladeAnstossen(); // Backend noch nicht bereit (erster Tick nach Neustart) +} + +/* -------------------------------------------------------------- Element */ +class AudiDashboardPanel extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + + /* ha-panel-custom erzeugt das Element teils schon, bevor unser eigenes + Modul (dieses hier) mit customElements.define() fertig registriert + ist - beobachtet als Zusammenspiel mit dessen fehlendem Cache für + module_url-Panels (siehe Kopfkommentar in audi-dashboard-panel.js): + _createPanel() kann mehrfach anlaufen, und beim ersten, noch zu + frühen document.createElement(tag) ist "audi-dashboard-panel" für + den Browser noch ein unbekanntes Element. Die anschließende + Zuweisung el.hass = ... trifft dann noch keinen Setter auf dem + Prototyp und legt stattdessen eine gewöhnliche eigene Eigenschaft + auf der Instanz an. Sobald customElements.define() greift und der + Browser das Element "aufwertet" (führt genau diesen Konstruktor + nach), holt die Plattform das NICHT automatisch nach - das gilt nur + für über observedAttributes beobachtete HTML-Attribute, nicht für + beliebige JS-Eigenschaften. Ohne dieses Nachholen bleibt set hass() + dauerhaft unerreichbar: _aufbauen() lief zwar (über + connectedCallback(), einen echten Lifecycle-Hook), aber + datenLaden() nie - sichtbares Symptom war ein dauerhaft + hängendes "Lädt …" trotz vorhandener Backend-Daten. */ + for (const eigenschaft of ["hass", "narrow", "panel"]) { + if (Object.prototype.hasOwnProperty.call(this, eigenschaft)) { + const wert = this[eigenschaft]; + delete this[eigenschaft]; + this[eigenschaft] = wert; + } + } + } + + set hass(hass) { + const erster = !HASS; + HASS = hass; + if (erster) { + this._aufbauen(); + } + datenLaden(false); + } + get hass() { return HASS; } + + set narrow(v) { this._narrow = v; } + set panel(v) { this._panel = v; } + + connectedCallback() { + HOST = this; + if (!this.shadowRoot.querySelector(".phone")) this._aufbauen(); + // Absicherung fuer den Fall, dass panel_custom das Element neu verbindet + // (z. B. weg- und zurueckgeklickt), ohne set hass() dabei erneut + // auszuloesen - ohne das hier bliebe ein noch nicht geladener Stand + // haengen, bis irgendwann zufaellig ein globaler hass-Push durchkommt. + if (!DATEN_GELADEN) datenLaden(false); + } + + disconnectedCallback() { + // Alle drei Karten, nicht nur MAP: sonst bleibt beim Abhaengen des Panels + // (Standort- oder Uebersichtsseite offen) eine Leaflet-Instanz samt ihrem + // window-Resize-Horcher und laufenden Kachelabrufen am Leben. + if (MAP) { MAP.remove(); MAP = null; TILE = null; } + if (SMAP) { SMAP.remove(); SMAP = null; SMAP_TILE = null; FAHRZEUG_MARKER = null; USER_MARKER = null; } + if (TMAP) { TMAP.remove(); TMAP = null; TMAP_TILE = null; } + } + + _aufbauen() { + // Idempotenz-Sperre: sowohl connectedCallback() als auch set hass() + // können _aufbauen() auslösen, je nachdem in welcher Reihenfolge + // panel_custom das Element einfügt und mit hass versorgt. Ohne diese + // Sperre lief der Aufbau zweimal, verdrahtete jeden Knopf doppelt und + // ein Klick auf Tag/Nacht schaltete zweimal um - macht sich gegenseitig + // zunichte, sichtbar erst beim tatsächlichen Testen, nicht im Code. + if (this.shadowRoot.querySelector(".phone")) return; + ROOT = this.shadowRoot; + HOST = this; + /* A6: Standard folgt der Systemeinstellung, eine manuelle Wahl überlebt den + Reload. Vorher stand die App nach jedem Neuladen wieder auf Nacht. */ + HOST.dataset.theme = HOST.dataset.theme || themeGewaehlt(); + + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = `/local/audi-dashboard.css?v=${CSS_VERSION}`; + ROOT.appendChild(link); + + /* iOS-native Optik + responsive Grossbildschirm-Layout (@container ab + 860px), rein additive Auflage aus dem Claude-Design-Entwurf 2026-08-11. */ + const linkIos = document.createElement("link"); + linkIos.rel = "stylesheet"; + linkIos.href = `/local/audi-dashboard-ios.css?v=${CSS_VERSION}`; + ROOT.appendChild(linkIos); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = ` +
+
+ +
Übersicht
+
+ + +
+
Ziehen zum Aktualisieren
+
Lädt …
+
+ +
`; + ROOT.appendChild(wrapper); + + ereignisseVerdrahten(); + randwischenVerdrahten(); + standortMenuVerdrahten(); + themeIcon(); + setInterval(standAlterTicken, 1000); + } +} + +if (!customElements.get("audi-dashboard-panel")) { + customElements.define("audi-dashboard-panel", AudiDashboardPanel); +} + diff --git a/homeassistant/installationspaket/www/audi-dashboard-ios.css b/homeassistant/installationspaket/www/audi-dashboard-ios.css new file mode 100644 index 0000000..aa1114e --- /dev/null +++ b/homeassistant/installationspaket/www/audi-dashboard-ios.css @@ -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 - + + + + + + +
+ + + + + + diff --git a/homeassistant/installationspaket/www/dm360-qrcode-lib.js b/homeassistant/installationspaket/www/dm360-qrcode-lib.js new file mode 100644 index 0000000..df13f82 --- /dev/null +++ b/homeassistant/installationspaket/www/dm360-qrcode-lib.js @@ -0,0 +1,2297 @@ +//--------------------------------------------------------------------- +// +// QR Code Generator for JavaScript +// +// Copyright (c) 2009 Kazuhiko Arase +// +// URL: http://www.d-project.com/ +// +// Licensed under the MIT license: +// http://www.opensource.org/licenses/mit-license.php +// +// The word 'QR Code' is registered trademark of +// DENSO WAVE INCORPORATED +// http://www.denso-wave.com/qrcode/faqpatent-e.html +// +//--------------------------------------------------------------------- + +var qrcode = function() { + + //--------------------------------------------------------------------- + // qrcode + //--------------------------------------------------------------------- + + /** + * qrcode + * @param typeNumber 1 to 40 + * @param errorCorrectionLevel 'L','M','Q','H' + */ + var qrcode = function(typeNumber, errorCorrectionLevel) { + + var PAD0 = 0xEC; + var PAD1 = 0x11; + + var _typeNumber = typeNumber; + var _errorCorrectionLevel = QRErrorCorrectionLevel[errorCorrectionLevel]; + var _modules = null; + var _moduleCount = 0; + var _dataCache = null; + var _dataList = []; + + var _this = {}; + + var makeImpl = function(test, maskPattern) { + + _moduleCount = _typeNumber * 4 + 17; + _modules = function(moduleCount) { + var modules = new Array(moduleCount); + for (var row = 0; row < moduleCount; row += 1) { + modules[row] = new Array(moduleCount); + for (var col = 0; col < moduleCount; col += 1) { + modules[row][col] = null; + } + } + return modules; + }(_moduleCount); + + setupPositionProbePattern(0, 0); + setupPositionProbePattern(_moduleCount - 7, 0); + setupPositionProbePattern(0, _moduleCount - 7); + setupPositionAdjustPattern(); + setupTimingPattern(); + setupTypeInfo(test, maskPattern); + + if (_typeNumber >= 7) { + setupTypeNumber(test); + } + + if (_dataCache == null) { + _dataCache = createData(_typeNumber, _errorCorrectionLevel, _dataList); + } + + mapData(_dataCache, maskPattern); + }; + + var setupPositionProbePattern = function(row, col) { + + for (var r = -1; r <= 7; r += 1) { + + if (row + r <= -1 || _moduleCount <= row + r) continue; + + for (var c = -1; c <= 7; c += 1) { + + if (col + c <= -1 || _moduleCount <= col + c) continue; + + if ( (0 <= r && r <= 6 && (c == 0 || c == 6) ) + || (0 <= c && c <= 6 && (r == 0 || r == 6) ) + || (2 <= r && r <= 4 && 2 <= c && c <= 4) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + }; + + var getBestMaskPattern = function() { + + var minLostPoint = 0; + var pattern = 0; + + for (var i = 0; i < 8; i += 1) { + + makeImpl(true, i); + + var lostPoint = QRUtil.getLostPoint(_this); + + if (i == 0 || minLostPoint > lostPoint) { + minLostPoint = lostPoint; + pattern = i; + } + } + + return pattern; + }; + + var setupTimingPattern = function() { + + for (var r = 8; r < _moduleCount - 8; r += 1) { + if (_modules[r][6] != null) { + continue; + } + _modules[r][6] = (r % 2 == 0); + } + + for (var c = 8; c < _moduleCount - 8; c += 1) { + if (_modules[6][c] != null) { + continue; + } + _modules[6][c] = (c % 2 == 0); + } + }; + + var setupPositionAdjustPattern = function() { + + var pos = QRUtil.getPatternPosition(_typeNumber); + + for (var i = 0; i < pos.length; i += 1) { + + for (var j = 0; j < pos.length; j += 1) { + + var row = pos[i]; + var col = pos[j]; + + if (_modules[row][col] != null) { + continue; + } + + for (var r = -2; r <= 2; r += 1) { + + for (var c = -2; c <= 2; c += 1) { + + if (r == -2 || r == 2 || c == -2 || c == 2 + || (r == 0 && c == 0) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + } + } + }; + + var setupTypeNumber = function(test) { + + var bits = QRUtil.getBCHTypeNumber(_typeNumber); + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[Math.floor(i / 3)][i % 3 + _moduleCount - 8 - 3] = mod; + } + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[i % 3 + _moduleCount - 8 - 3][Math.floor(i / 3)] = mod; + } + }; + + var setupTypeInfo = function(test, maskPattern) { + + var data = (_errorCorrectionLevel << 3) | maskPattern; + var bits = QRUtil.getBCHTypeInfo(data); + + // vertical + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 6) { + _modules[i][8] = mod; + } else if (i < 8) { + _modules[i + 1][8] = mod; + } else { + _modules[_moduleCount - 15 + i][8] = mod; + } + } + + // horizontal + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 8) { + _modules[8][_moduleCount - i - 1] = mod; + } else if (i < 9) { + _modules[8][15 - i - 1 + 1] = mod; + } else { + _modules[8][15 - i - 1] = mod; + } + } + + // fixed module + _modules[_moduleCount - 8][8] = (!test); + }; + + var mapData = function(data, maskPattern) { + + var inc = -1; + var row = _moduleCount - 1; + var bitIndex = 7; + var byteIndex = 0; + var maskFunc = QRUtil.getMaskFunction(maskPattern); + + for (var col = _moduleCount - 1; col > 0; col -= 2) { + + if (col == 6) col -= 1; + + while (true) { + + for (var c = 0; c < 2; c += 1) { + + if (_modules[row][col - c] == null) { + + var dark = false; + + if (byteIndex < data.length) { + dark = ( ( (data[byteIndex] >>> bitIndex) & 1) == 1); + } + + var mask = maskFunc(row, col - c); + + if (mask) { + dark = !dark; + } + + _modules[row][col - c] = dark; + bitIndex -= 1; + + if (bitIndex == -1) { + byteIndex += 1; + bitIndex = 7; + } + } + } + + row += inc; + + if (row < 0 || _moduleCount <= row) { + row -= inc; + inc = -inc; + break; + } + } + } + }; + + var createBytes = function(buffer, rsBlocks) { + + var offset = 0; + + var maxDcCount = 0; + var maxEcCount = 0; + + var dcdata = new Array(rsBlocks.length); + var ecdata = new Array(rsBlocks.length); + + for (var r = 0; r < rsBlocks.length; r += 1) { + + var dcCount = rsBlocks[r].dataCount; + var ecCount = rsBlocks[r].totalCount - dcCount; + + maxDcCount = Math.max(maxDcCount, dcCount); + maxEcCount = Math.max(maxEcCount, ecCount); + + dcdata[r] = new Array(dcCount); + + for (var i = 0; i < dcdata[r].length; i += 1) { + dcdata[r][i] = 0xff & buffer.getBuffer()[i + offset]; + } + offset += dcCount; + + var rsPoly = QRUtil.getErrorCorrectPolynomial(ecCount); + var rawPoly = qrPolynomial(dcdata[r], rsPoly.getLength() - 1); + + var modPoly = rawPoly.mod(rsPoly); + ecdata[r] = new Array(rsPoly.getLength() - 1); + for (var i = 0; i < ecdata[r].length; i += 1) { + var modIndex = i + modPoly.getLength() - ecdata[r].length; + ecdata[r][i] = (modIndex >= 0)? modPoly.getAt(modIndex) : 0; + } + } + + var totalCodeCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalCodeCount += rsBlocks[i].totalCount; + } + + var data = new Array(totalCodeCount); + var index = 0; + + for (var i = 0; i < maxDcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < dcdata[r].length) { + data[index] = dcdata[r][i]; + index += 1; + } + } + } + + for (var i = 0; i < maxEcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < ecdata[r].length) { + data[index] = ecdata[r][i]; + index += 1; + } + } + } + + return data; + }; + + var createData = function(typeNumber, errorCorrectionLevel, dataList) { + + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, errorCorrectionLevel); + + var buffer = qrBitBuffer(); + + for (var i = 0; i < dataList.length; i += 1) { + var data = dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + // calc num max data. + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() > totalDataCount * 8) { + throw 'code length overflow. (' + + buffer.getLengthInBits() + + '>' + + totalDataCount * 8 + + ')'; + } + + // end code + if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) { + buffer.put(0, 4); + } + + // padding + while (buffer.getLengthInBits() % 8 != 0) { + buffer.putBit(false); + } + + // padding + while (true) { + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD0, 8); + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD1, 8); + } + + return createBytes(buffer, rsBlocks); + }; + + _this.addData = function(data, mode) { + + mode = mode || 'Byte'; + + var newData = null; + + switch(mode) { + case 'Numeric' : + newData = qrNumber(data); + break; + case 'Alphanumeric' : + newData = qrAlphaNum(data); + break; + case 'Byte' : + newData = qr8BitByte(data); + break; + case 'Kanji' : + newData = qrKanji(data); + break; + default : + throw 'mode:' + mode; + } + + _dataList.push(newData); + _dataCache = null; + }; + + _this.isDark = function(row, col) { + if (row < 0 || _moduleCount <= row || col < 0 || _moduleCount <= col) { + throw row + ',' + col; + } + return _modules[row][col]; + }; + + _this.getModuleCount = function() { + return _moduleCount; + }; + + _this.make = function() { + if (_typeNumber < 1) { + var typeNumber = 1; + + for (; typeNumber < 40; typeNumber++) { + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, _errorCorrectionLevel); + var buffer = qrBitBuffer(); + + for (var i = 0; i < _dataList.length; i++) { + var data = _dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i++) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() <= totalDataCount * 8) { + break; + } + } + + _typeNumber = typeNumber; + } + + makeImpl(false, getBestMaskPattern() ); + }; + + _this.createTableTag = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var qrHtml = ''; + + qrHtml += ''; + qrHtml += ''; + + for (var r = 0; r < _this.getModuleCount(); r += 1) { + + qrHtml += ''; + + for (var c = 0; c < _this.getModuleCount(); c += 1) { + qrHtml += ''; + } + + qrHtml += ''; + qrHtml += '
'; + } + + qrHtml += '
'; + + return qrHtml; + }; + + _this.createSvgTag = function(cellSize, margin, alt, title) { + + var opts = {}; + if (typeof arguments[0] == 'object') { + // Called by options. + opts = arguments[0]; + // overwrite cellSize and margin. + cellSize = opts.cellSize; + margin = opts.margin; + alt = opts.alt; + title = opts.title; + } + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + // Compose alt property surrogate + alt = (typeof alt === 'string') ? {text: alt} : alt || {}; + alt.text = alt.text || null; + alt.id = (alt.text) ? alt.id || 'qrcode-description' : null; + + // Compose title property surrogate + title = (typeof title === 'string') ? {text: title} : title || {}; + title.text = title.text || null; + title.id = (title.text) ? title.id || 'qrcode-title' : null; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var c, mc, r, mr, qrSvg='', rect; + + rect = 'l' + cellSize + ',0 0,' + cellSize + + ' -' + cellSize + ',0 0,-' + cellSize + 'z '; + + qrSvg += '' + + escapeXml(title.text) + '' : ''; + qrSvg += (alt.text) ? '' + + escapeXml(alt.text) + '' : ''; + qrSvg += ''; + qrSvg += ''; + qrSvg += ''; + + return qrSvg; + }; + + _this.createDataURL = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + return createDataURL(size, size, function(x, y) { + if (min <= x && x < max && min <= y && y < max) { + var c = Math.floor( (x - min) / cellSize); + var r = Math.floor( (y - min) / cellSize); + return _this.isDark(r, c)? 0 : 1; + } else { + return 1; + } + } ); + }; + + _this.createImgTag = function(cellSize, margin, alt) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + + var img = ''; + img += '': escaped += '>'; break; + case '&': escaped += '&'; break; + case '"': escaped += '"'; break; + default : escaped += c; break; + } + } + return escaped; + }; + + var _createHalfASCII = function(margin) { + var cellSize = 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r1, r2, p; + + var blocks = { + '██': '█', + '█ ': '▀', + ' █': '▄', + ' ': ' ' + }; + + var blocksLastLineNoMargin = { + '██': '▀', + '█ ': '▀', + ' █': ' ', + ' ': ' ' + }; + + var ascii = ''; + for (y = 0; y < size; y += 2) { + r1 = Math.floor((y - min) / cellSize); + r2 = Math.floor((y + 1 - min) / cellSize); + for (x = 0; x < size; x += 1) { + p = '█'; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r1, Math.floor((x - min) / cellSize))) { + p = ' '; + } + + if (min <= x && x < max && min <= y+1 && y+1 < max && _this.isDark(r2, Math.floor((x - min) / cellSize))) { + p += ' '; + } + else { + p += '█'; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + ascii += (margin < 1 && y+1 >= max) ? blocksLastLineNoMargin[p] : blocks[p]; + } + + ascii += '\n'; + } + + if (size % 2 && margin > 0) { + return ascii.substring(0, ascii.length - size - 1) + Array(size+1).join('▀'); + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.createASCII = function(cellSize, margin) { + cellSize = cellSize || 1; + + if (cellSize < 2) { + return _createHalfASCII(margin); + } + + cellSize -= 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r, p; + + var white = Array(cellSize+1).join('██'); + var black = Array(cellSize+1).join(' '); + + var ascii = ''; + var line = ''; + for (y = 0; y < size; y += 1) { + r = Math.floor( (y - min) / cellSize); + line = ''; + for (x = 0; x < size; x += 1) { + p = 1; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r, Math.floor((x - min) / cellSize))) { + p = 0; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + line += p ? white : black; + } + + for (r = 0; r < cellSize; r += 1) { + ascii += line + '\n'; + } + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.renderTo2dContext = function(context, cellSize) { + cellSize = cellSize || 2; + var length = _this.getModuleCount(); + for (var row = 0; row < length; row++) { + for (var col = 0; col < length; col++) { + context.fillStyle = _this.isDark(row, col) ? 'black' : 'white'; + context.fillRect(col * cellSize, row * cellSize, cellSize, cellSize); + } + } + } + + return _this; + }; + + //--------------------------------------------------------------------- + // qrcode.stringToBytes + //--------------------------------------------------------------------- + + qrcode.stringToBytesFuncs = { + 'default' : function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + bytes.push(c & 0xff); + } + return bytes; + } + }; + + qrcode.stringToBytes = qrcode.stringToBytesFuncs['default']; + + //--------------------------------------------------------------------- + // qrcode.createStringToBytes + //--------------------------------------------------------------------- + + /** + * @param unicodeData base64 string of byte array. + * [16bit Unicode],[16bit Bytes], ... + * @param numChars + */ + qrcode.createStringToBytes = function(unicodeData, numChars) { + + // create conversion map. + + var unicodeMap = function() { + + var bin = base64DecodeInputStream(unicodeData); + var read = function() { + var b = bin.read(); + if (b == -1) throw 'eof'; + return b; + }; + + var count = 0; + var unicodeMap = {}; + while (true) { + var b0 = bin.read(); + if (b0 == -1) break; + var b1 = read(); + var b2 = read(); + var b3 = read(); + var k = String.fromCharCode( (b0 << 8) | b1); + var v = (b2 << 8) | b3; + unicodeMap[k] = v; + count += 1; + } + if (count != numChars) { + throw count + ' != ' + numChars; + } + + return unicodeMap; + }(); + + var unknownChar = '?'.charCodeAt(0); + + return function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + if (c < 128) { + bytes.push(c); + } else { + var b = unicodeMap[s.charAt(i)]; + if (typeof b == 'number') { + if ( (b & 0xff) == b) { + // 1byte + bytes.push(b); + } else { + // 2bytes + bytes.push(b >>> 8); + bytes.push(b & 0xff); + } + } else { + bytes.push(unknownChar); + } + } + } + return bytes; + }; + }; + + //--------------------------------------------------------------------- + // QRMode + //--------------------------------------------------------------------- + + var QRMode = { + MODE_NUMBER : 1 << 0, + MODE_ALPHA_NUM : 1 << 1, + MODE_8BIT_BYTE : 1 << 2, + MODE_KANJI : 1 << 3 + }; + + //--------------------------------------------------------------------- + // QRErrorCorrectionLevel + //--------------------------------------------------------------------- + + var QRErrorCorrectionLevel = { + L : 1, + M : 0, + Q : 3, + H : 2 + }; + + //--------------------------------------------------------------------- + // QRMaskPattern + //--------------------------------------------------------------------- + + var QRMaskPattern = { + PATTERN000 : 0, + PATTERN001 : 1, + PATTERN010 : 2, + PATTERN011 : 3, + PATTERN100 : 4, + PATTERN101 : 5, + PATTERN110 : 6, + PATTERN111 : 7 + }; + + //--------------------------------------------------------------------- + // QRUtil + //--------------------------------------------------------------------- + + var QRUtil = function() { + + var PATTERN_POSITION_TABLE = [ + [], + [6, 18], + [6, 22], + [6, 26], + [6, 30], + [6, 34], + [6, 22, 38], + [6, 24, 42], + [6, 26, 46], + [6, 28, 50], + [6, 30, 54], + [6, 32, 58], + [6, 34, 62], + [6, 26, 46, 66], + [6, 26, 48, 70], + [6, 26, 50, 74], + [6, 30, 54, 78], + [6, 30, 56, 82], + [6, 30, 58, 86], + [6, 34, 62, 90], + [6, 28, 50, 72, 94], + [6, 26, 50, 74, 98], + [6, 30, 54, 78, 102], + [6, 28, 54, 80, 106], + [6, 32, 58, 84, 110], + [6, 30, 58, 86, 114], + [6, 34, 62, 90, 118], + [6, 26, 50, 74, 98, 122], + [6, 30, 54, 78, 102, 126], + [6, 26, 52, 78, 104, 130], + [6, 30, 56, 82, 108, 134], + [6, 34, 60, 86, 112, 138], + [6, 30, 58, 86, 114, 142], + [6, 34, 62, 90, 118, 146], + [6, 30, 54, 78, 102, 126, 150], + [6, 24, 50, 76, 102, 128, 154], + [6, 28, 54, 80, 106, 132, 158], + [6, 32, 58, 84, 110, 136, 162], + [6, 26, 54, 82, 110, 138, 166], + [6, 30, 58, 86, 114, 142, 170] + ]; + var G15 = (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0); + var G18 = (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0); + var G15_MASK = (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1); + + var _this = {}; + + var getBCHDigit = function(data) { + var digit = 0; + while (data != 0) { + digit += 1; + data >>>= 1; + } + return digit; + }; + + _this.getBCHTypeInfo = function(data) { + var d = data << 10; + while (getBCHDigit(d) - getBCHDigit(G15) >= 0) { + d ^= (G15 << (getBCHDigit(d) - getBCHDigit(G15) ) ); + } + return ( (data << 10) | d) ^ G15_MASK; + }; + + _this.getBCHTypeNumber = function(data) { + var d = data << 12; + while (getBCHDigit(d) - getBCHDigit(G18) >= 0) { + d ^= (G18 << (getBCHDigit(d) - getBCHDigit(G18) ) ); + } + return (data << 12) | d; + }; + + _this.getPatternPosition = function(typeNumber) { + return PATTERN_POSITION_TABLE[typeNumber - 1]; + }; + + _this.getMaskFunction = function(maskPattern) { + + switch (maskPattern) { + + case QRMaskPattern.PATTERN000 : + return function(i, j) { return (i + j) % 2 == 0; }; + case QRMaskPattern.PATTERN001 : + return function(i, j) { return i % 2 == 0; }; + case QRMaskPattern.PATTERN010 : + return function(i, j) { return j % 3 == 0; }; + case QRMaskPattern.PATTERN011 : + return function(i, j) { return (i + j) % 3 == 0; }; + case QRMaskPattern.PATTERN100 : + return function(i, j) { return (Math.floor(i / 2) + Math.floor(j / 3) ) % 2 == 0; }; + case QRMaskPattern.PATTERN101 : + return function(i, j) { return (i * j) % 2 + (i * j) % 3 == 0; }; + case QRMaskPattern.PATTERN110 : + return function(i, j) { return ( (i * j) % 2 + (i * j) % 3) % 2 == 0; }; + case QRMaskPattern.PATTERN111 : + return function(i, j) { return ( (i * j) % 3 + (i + j) % 2) % 2 == 0; }; + + default : + throw 'bad maskPattern:' + maskPattern; + } + }; + + _this.getErrorCorrectPolynomial = function(errorCorrectLength) { + var a = qrPolynomial([1], 0); + for (var i = 0; i < errorCorrectLength; i += 1) { + a = a.multiply(qrPolynomial([1, QRMath.gexp(i)], 0) ); + } + return a; + }; + + _this.getLengthInBits = function(mode, type) { + + if (1 <= type && type < 10) { + + // 1 - 9 + + switch(mode) { + case QRMode.MODE_NUMBER : return 10; + case QRMode.MODE_ALPHA_NUM : return 9; + case QRMode.MODE_8BIT_BYTE : return 8; + case QRMode.MODE_KANJI : return 8; + default : + throw 'mode:' + mode; + } + + } else if (type < 27) { + + // 10 - 26 + + switch(mode) { + case QRMode.MODE_NUMBER : return 12; + case QRMode.MODE_ALPHA_NUM : return 11; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 10; + default : + throw 'mode:' + mode; + } + + } else if (type < 41) { + + // 27 - 40 + + switch(mode) { + case QRMode.MODE_NUMBER : return 14; + case QRMode.MODE_ALPHA_NUM : return 13; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 12; + default : + throw 'mode:' + mode; + } + + } else { + throw 'type:' + type; + } + }; + + _this.getLostPoint = function(qrcode) { + + var moduleCount = qrcode.getModuleCount(); + + var lostPoint = 0; + + // LEVEL1 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount; col += 1) { + + var sameCount = 0; + var dark = qrcode.isDark(row, col); + + for (var r = -1; r <= 1; r += 1) { + + if (row + r < 0 || moduleCount <= row + r) { + continue; + } + + for (var c = -1; c <= 1; c += 1) { + + if (col + c < 0 || moduleCount <= col + c) { + continue; + } + + if (r == 0 && c == 0) { + continue; + } + + if (dark == qrcode.isDark(row + r, col + c) ) { + sameCount += 1; + } + } + } + + if (sameCount > 5) { + lostPoint += (3 + sameCount - 5); + } + } + }; + + // LEVEL2 + + for (var row = 0; row < moduleCount - 1; row += 1) { + for (var col = 0; col < moduleCount - 1; col += 1) { + var count = 0; + if (qrcode.isDark(row, col) ) count += 1; + if (qrcode.isDark(row + 1, col) ) count += 1; + if (qrcode.isDark(row, col + 1) ) count += 1; + if (qrcode.isDark(row + 1, col + 1) ) count += 1; + if (count == 0 || count == 4) { + lostPoint += 3; + } + } + } + + // LEVEL3 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount - 6; col += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row, col + 1) + && qrcode.isDark(row, col + 2) + && qrcode.isDark(row, col + 3) + && qrcode.isDark(row, col + 4) + && !qrcode.isDark(row, col + 5) + && qrcode.isDark(row, col + 6) ) { + lostPoint += 40; + } + } + } + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount - 6; row += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row + 1, col) + && qrcode.isDark(row + 2, col) + && qrcode.isDark(row + 3, col) + && qrcode.isDark(row + 4, col) + && !qrcode.isDark(row + 5, col) + && qrcode.isDark(row + 6, col) ) { + lostPoint += 40; + } + } + } + + // LEVEL4 + + var darkCount = 0; + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount; row += 1) { + if (qrcode.isDark(row, col) ) { + darkCount += 1; + } + } + } + + var ratio = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5; + lostPoint += ratio * 10; + + return lostPoint; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // QRMath + //--------------------------------------------------------------------- + + var QRMath = function() { + + var EXP_TABLE = new Array(256); + var LOG_TABLE = new Array(256); + + // initialize tables + for (var i = 0; i < 8; i += 1) { + EXP_TABLE[i] = 1 << i; + } + for (var i = 8; i < 256; i += 1) { + EXP_TABLE[i] = EXP_TABLE[i - 4] + ^ EXP_TABLE[i - 5] + ^ EXP_TABLE[i - 6] + ^ EXP_TABLE[i - 8]; + } + for (var i = 0; i < 255; i += 1) { + LOG_TABLE[EXP_TABLE[i] ] = i; + } + + var _this = {}; + + _this.glog = function(n) { + + if (n < 1) { + throw 'glog(' + n + ')'; + } + + return LOG_TABLE[n]; + }; + + _this.gexp = function(n) { + + while (n < 0) { + n += 255; + } + + while (n >= 256) { + n -= 255; + } + + return EXP_TABLE[n]; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrPolynomial + //--------------------------------------------------------------------- + + function qrPolynomial(num, shift) { + + if (typeof num.length == 'undefined') { + throw num.length + '/' + shift; + } + + var _num = function() { + var offset = 0; + while (offset < num.length && num[offset] == 0) { + offset += 1; + } + var _num = new Array(num.length - offset + shift); + for (var i = 0; i < num.length - offset; i += 1) { + _num[i] = num[i + offset]; + } + return _num; + }(); + + var _this = {}; + + _this.getAt = function(index) { + return _num[index]; + }; + + _this.getLength = function() { + return _num.length; + }; + + _this.multiply = function(e) { + + var num = new Array(_this.getLength() + e.getLength() - 1); + + for (var i = 0; i < _this.getLength(); i += 1) { + for (var j = 0; j < e.getLength(); j += 1) { + num[i + j] ^= QRMath.gexp(QRMath.glog(_this.getAt(i) ) + QRMath.glog(e.getAt(j) ) ); + } + } + + return qrPolynomial(num, 0); + }; + + _this.mod = function(e) { + + if (_this.getLength() - e.getLength() < 0) { + return _this; + } + + var ratio = QRMath.glog(_this.getAt(0) ) - QRMath.glog(e.getAt(0) ); + + var num = new Array(_this.getLength() ); + for (var i = 0; i < _this.getLength(); i += 1) { + num[i] = _this.getAt(i); + } + + for (var i = 0; i < e.getLength(); i += 1) { + num[i] ^= QRMath.gexp(QRMath.glog(e.getAt(i) ) + ratio); + } + + // recursive call + return qrPolynomial(num, 0).mod(e); + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // QRRSBlock + //--------------------------------------------------------------------- + + var QRRSBlock = function() { + + var RS_BLOCK_TABLE = [ + + // L + // M + // Q + // H + + // 1 + [1, 26, 19], + [1, 26, 16], + [1, 26, 13], + [1, 26, 9], + + // 2 + [1, 44, 34], + [1, 44, 28], + [1, 44, 22], + [1, 44, 16], + + // 3 + [1, 70, 55], + [1, 70, 44], + [2, 35, 17], + [2, 35, 13], + + // 4 + [1, 100, 80], + [2, 50, 32], + [2, 50, 24], + [4, 25, 9], + + // 5 + [1, 134, 108], + [2, 67, 43], + [2, 33, 15, 2, 34, 16], + [2, 33, 11, 2, 34, 12], + + // 6 + [2, 86, 68], + [4, 43, 27], + [4, 43, 19], + [4, 43, 15], + + // 7 + [2, 98, 78], + [4, 49, 31], + [2, 32, 14, 4, 33, 15], + [4, 39, 13, 1, 40, 14], + + // 8 + [2, 121, 97], + [2, 60, 38, 2, 61, 39], + [4, 40, 18, 2, 41, 19], + [4, 40, 14, 2, 41, 15], + + // 9 + [2, 146, 116], + [3, 58, 36, 2, 59, 37], + [4, 36, 16, 4, 37, 17], + [4, 36, 12, 4, 37, 13], + + // 10 + [2, 86, 68, 2, 87, 69], + [4, 69, 43, 1, 70, 44], + [6, 43, 19, 2, 44, 20], + [6, 43, 15, 2, 44, 16], + + // 11 + [4, 101, 81], + [1, 80, 50, 4, 81, 51], + [4, 50, 22, 4, 51, 23], + [3, 36, 12, 8, 37, 13], + + // 12 + [2, 116, 92, 2, 117, 93], + [6, 58, 36, 2, 59, 37], + [4, 46, 20, 6, 47, 21], + [7, 42, 14, 4, 43, 15], + + // 13 + [4, 133, 107], + [8, 59, 37, 1, 60, 38], + [8, 44, 20, 4, 45, 21], + [12, 33, 11, 4, 34, 12], + + // 14 + [3, 145, 115, 1, 146, 116], + [4, 64, 40, 5, 65, 41], + [11, 36, 16, 5, 37, 17], + [11, 36, 12, 5, 37, 13], + + // 15 + [5, 109, 87, 1, 110, 88], + [5, 65, 41, 5, 66, 42], + [5, 54, 24, 7, 55, 25], + [11, 36, 12, 7, 37, 13], + + // 16 + [5, 122, 98, 1, 123, 99], + [7, 73, 45, 3, 74, 46], + [15, 43, 19, 2, 44, 20], + [3, 45, 15, 13, 46, 16], + + // 17 + [1, 135, 107, 5, 136, 108], + [10, 74, 46, 1, 75, 47], + [1, 50, 22, 15, 51, 23], + [2, 42, 14, 17, 43, 15], + + // 18 + [5, 150, 120, 1, 151, 121], + [9, 69, 43, 4, 70, 44], + [17, 50, 22, 1, 51, 23], + [2, 42, 14, 19, 43, 15], + + // 19 + [3, 141, 113, 4, 142, 114], + [3, 70, 44, 11, 71, 45], + [17, 47, 21, 4, 48, 22], + [9, 39, 13, 16, 40, 14], + + // 20 + [3, 135, 107, 5, 136, 108], + [3, 67, 41, 13, 68, 42], + [15, 54, 24, 5, 55, 25], + [15, 43, 15, 10, 44, 16], + + // 21 + [4, 144, 116, 4, 145, 117], + [17, 68, 42], + [17, 50, 22, 6, 51, 23], + [19, 46, 16, 6, 47, 17], + + // 22 + [2, 139, 111, 7, 140, 112], + [17, 74, 46], + [7, 54, 24, 16, 55, 25], + [34, 37, 13], + + // 23 + [4, 151, 121, 5, 152, 122], + [4, 75, 47, 14, 76, 48], + [11, 54, 24, 14, 55, 25], + [16, 45, 15, 14, 46, 16], + + // 24 + [6, 147, 117, 4, 148, 118], + [6, 73, 45, 14, 74, 46], + [11, 54, 24, 16, 55, 25], + [30, 46, 16, 2, 47, 17], + + // 25 + [8, 132, 106, 4, 133, 107], + [8, 75, 47, 13, 76, 48], + [7, 54, 24, 22, 55, 25], + [22, 45, 15, 13, 46, 16], + + // 26 + [10, 142, 114, 2, 143, 115], + [19, 74, 46, 4, 75, 47], + [28, 50, 22, 6, 51, 23], + [33, 46, 16, 4, 47, 17], + + // 27 + [8, 152, 122, 4, 153, 123], + [22, 73, 45, 3, 74, 46], + [8, 53, 23, 26, 54, 24], + [12, 45, 15, 28, 46, 16], + + // 28 + [3, 147, 117, 10, 148, 118], + [3, 73, 45, 23, 74, 46], + [4, 54, 24, 31, 55, 25], + [11, 45, 15, 31, 46, 16], + + // 29 + [7, 146, 116, 7, 147, 117], + [21, 73, 45, 7, 74, 46], + [1, 53, 23, 37, 54, 24], + [19, 45, 15, 26, 46, 16], + + // 30 + [5, 145, 115, 10, 146, 116], + [19, 75, 47, 10, 76, 48], + [15, 54, 24, 25, 55, 25], + [23, 45, 15, 25, 46, 16], + + // 31 + [13, 145, 115, 3, 146, 116], + [2, 74, 46, 29, 75, 47], + [42, 54, 24, 1, 55, 25], + [23, 45, 15, 28, 46, 16], + + // 32 + [17, 145, 115], + [10, 74, 46, 23, 75, 47], + [10, 54, 24, 35, 55, 25], + [19, 45, 15, 35, 46, 16], + + // 33 + [17, 145, 115, 1, 146, 116], + [14, 74, 46, 21, 75, 47], + [29, 54, 24, 19, 55, 25], + [11, 45, 15, 46, 46, 16], + + // 34 + [13, 145, 115, 6, 146, 116], + [14, 74, 46, 23, 75, 47], + [44, 54, 24, 7, 55, 25], + [59, 46, 16, 1, 47, 17], + + // 35 + [12, 151, 121, 7, 152, 122], + [12, 75, 47, 26, 76, 48], + [39, 54, 24, 14, 55, 25], + [22, 45, 15, 41, 46, 16], + + // 36 + [6, 151, 121, 14, 152, 122], + [6, 75, 47, 34, 76, 48], + [46, 54, 24, 10, 55, 25], + [2, 45, 15, 64, 46, 16], + + // 37 + [17, 152, 122, 4, 153, 123], + [29, 74, 46, 14, 75, 47], + [49, 54, 24, 10, 55, 25], + [24, 45, 15, 46, 46, 16], + + // 38 + [4, 152, 122, 18, 153, 123], + [13, 74, 46, 32, 75, 47], + [48, 54, 24, 14, 55, 25], + [42, 45, 15, 32, 46, 16], + + // 39 + [20, 147, 117, 4, 148, 118], + [40, 75, 47, 7, 76, 48], + [43, 54, 24, 22, 55, 25], + [10, 45, 15, 67, 46, 16], + + // 40 + [19, 148, 118, 6, 149, 119], + [18, 75, 47, 31, 76, 48], + [34, 54, 24, 34, 55, 25], + [20, 45, 15, 61, 46, 16] + ]; + + var qrRSBlock = function(totalCount, dataCount) { + var _this = {}; + _this.totalCount = totalCount; + _this.dataCount = dataCount; + return _this; + }; + + var _this = {}; + + var getRsBlockTable = function(typeNumber, errorCorrectionLevel) { + + switch(errorCorrectionLevel) { + case QRErrorCorrectionLevel.L : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 0]; + case QRErrorCorrectionLevel.M : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 1]; + case QRErrorCorrectionLevel.Q : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 2]; + case QRErrorCorrectionLevel.H : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 3]; + default : + return undefined; + } + }; + + _this.getRSBlocks = function(typeNumber, errorCorrectionLevel) { + + var rsBlock = getRsBlockTable(typeNumber, errorCorrectionLevel); + + if (typeof rsBlock == 'undefined') { + throw 'bad rs block @ typeNumber:' + typeNumber + + '/errorCorrectionLevel:' + errorCorrectionLevel; + } + + var length = rsBlock.length / 3; + + var list = []; + + for (var i = 0; i < length; i += 1) { + + var count = rsBlock[i * 3 + 0]; + var totalCount = rsBlock[i * 3 + 1]; + var dataCount = rsBlock[i * 3 + 2]; + + for (var j = 0; j < count; j += 1) { + list.push(qrRSBlock(totalCount, dataCount) ); + } + } + + return list; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrBitBuffer + //--------------------------------------------------------------------- + + var qrBitBuffer = function() { + + var _buffer = []; + var _length = 0; + + var _this = {}; + + _this.getBuffer = function() { + return _buffer; + }; + + _this.getAt = function(index) { + var bufIndex = Math.floor(index / 8); + return ( (_buffer[bufIndex] >>> (7 - index % 8) ) & 1) == 1; + }; + + _this.put = function(num, length) { + for (var i = 0; i < length; i += 1) { + _this.putBit( ( (num >>> (length - i - 1) ) & 1) == 1); + } + }; + + _this.getLengthInBits = function() { + return _length; + }; + + _this.putBit = function(bit) { + + var bufIndex = Math.floor(_length / 8); + if (_buffer.length <= bufIndex) { + _buffer.push(0); + } + + if (bit) { + _buffer[bufIndex] |= (0x80 >>> (_length % 8) ); + } + + _length += 1; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrNumber + //--------------------------------------------------------------------- + + var qrNumber = function(data) { + + var _mode = QRMode.MODE_NUMBER; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var data = _data; + + var i = 0; + + while (i + 2 < data.length) { + buffer.put(strToNum(data.substring(i, i + 3) ), 10); + i += 3; + } + + if (i < data.length) { + if (data.length - i == 1) { + buffer.put(strToNum(data.substring(i, i + 1) ), 4); + } else if (data.length - i == 2) { + buffer.put(strToNum(data.substring(i, i + 2) ), 7); + } + } + }; + + var strToNum = function(s) { + var num = 0; + for (var i = 0; i < s.length; i += 1) { + num = num * 10 + chatToNum(s.charAt(i) ); + } + return num; + }; + + var chatToNum = function(c) { + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } + throw 'illegal char :' + c; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrAlphaNum + //--------------------------------------------------------------------- + + var qrAlphaNum = function(data) { + + var _mode = QRMode.MODE_ALPHA_NUM; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var s = _data; + + var i = 0; + + while (i + 1 < s.length) { + buffer.put( + getCode(s.charAt(i) ) * 45 + + getCode(s.charAt(i + 1) ), 11); + i += 2; + } + + if (i < s.length) { + buffer.put(getCode(s.charAt(i) ), 6); + } + }; + + var getCode = function(c) { + + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } else if ('A' <= c && c <= 'Z') { + return c.charCodeAt(0) - 'A'.charCodeAt(0) + 10; + } else { + switch (c) { + case ' ' : return 36; + case '$' : return 37; + case '%' : return 38; + case '*' : return 39; + case '+' : return 40; + case '-' : return 41; + case '.' : return 42; + case '/' : return 43; + case ':' : return 44; + default : + throw 'illegal char :' + c; + } + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qr8BitByte + //--------------------------------------------------------------------- + + var qr8BitByte = function(data) { + + var _mode = QRMode.MODE_8BIT_BYTE; + var _data = data; + var _bytes = qrcode.stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _bytes.length; + }; + + _this.write = function(buffer) { + for (var i = 0; i < _bytes.length; i += 1) { + buffer.put(_bytes[i], 8); + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrKanji + //--------------------------------------------------------------------- + + var qrKanji = function(data) { + + var _mode = QRMode.MODE_KANJI; + var _data = data; + + var stringToBytes = qrcode.stringToBytesFuncs['SJIS']; + if (!stringToBytes) { + throw 'sjis not supported.'; + } + !function(c, code) { + // self test for sjis support. + var test = stringToBytes(c); + if (test.length != 2 || ( (test[0] << 8) | test[1]) != code) { + throw 'sjis not supported.'; + } + }('\u53cb', 0x9746); + + var _bytes = stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return ~~(_bytes.length / 2); + }; + + _this.write = function(buffer) { + + var data = _bytes; + + var i = 0; + + while (i + 1 < data.length) { + + var c = ( (0xff & data[i]) << 8) | (0xff & data[i + 1]); + + if (0x8140 <= c && c <= 0x9FFC) { + c -= 0x8140; + } else if (0xE040 <= c && c <= 0xEBBF) { + c -= 0xC140; + } else { + throw 'illegal char at ' + (i + 1) + '/' + c; + } + + c = ( (c >>> 8) & 0xff) * 0xC0 + (c & 0xff); + + buffer.put(c, 13); + + i += 2; + } + + if (i < data.length) { + throw 'illegal char at ' + (i + 1); + } + }; + + return _this; + }; + + //===================================================================== + // GIF Support etc. + // + + //--------------------------------------------------------------------- + // byteArrayOutputStream + //--------------------------------------------------------------------- + + var byteArrayOutputStream = function() { + + var _bytes = []; + + var _this = {}; + + _this.writeByte = function(b) { + _bytes.push(b & 0xff); + }; + + _this.writeShort = function(i) { + _this.writeByte(i); + _this.writeByte(i >>> 8); + }; + + _this.writeBytes = function(b, off, len) { + off = off || 0; + len = len || b.length; + for (var i = 0; i < len; i += 1) { + _this.writeByte(b[i + off]); + } + }; + + _this.writeString = function(s) { + for (var i = 0; i < s.length; i += 1) { + _this.writeByte(s.charCodeAt(i) ); + } + }; + + _this.toByteArray = function() { + return _bytes; + }; + + _this.toString = function() { + var s = ''; + s += '['; + for (var i = 0; i < _bytes.length; i += 1) { + if (i > 0) { + s += ','; + } + s += _bytes[i]; + } + s += ']'; + return s; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64EncodeOutputStream + //--------------------------------------------------------------------- + + var base64EncodeOutputStream = function() { + + var _buffer = 0; + var _buflen = 0; + var _length = 0; + var _base64 = ''; + + var _this = {}; + + var writeEncoded = function(b) { + _base64 += String.fromCharCode(encode(b & 0x3f) ); + }; + + var encode = function(n) { + if (n < 0) { + // error. + } else if (n < 26) { + return 0x41 + n; + } else if (n < 52) { + return 0x61 + (n - 26); + } else if (n < 62) { + return 0x30 + (n - 52); + } else if (n == 62) { + return 0x2b; + } else if (n == 63) { + return 0x2f; + } + throw 'n:' + n; + }; + + _this.writeByte = function(n) { + + _buffer = (_buffer << 8) | (n & 0xff); + _buflen += 8; + _length += 1; + + while (_buflen >= 6) { + writeEncoded(_buffer >>> (_buflen - 6) ); + _buflen -= 6; + } + }; + + _this.flush = function() { + + if (_buflen > 0) { + writeEncoded(_buffer << (6 - _buflen) ); + _buffer = 0; + _buflen = 0; + } + + if (_length % 3 != 0) { + // padding + var padlen = 3 - _length % 3; + for (var i = 0; i < padlen; i += 1) { + _base64 += '='; + } + } + }; + + _this.toString = function() { + return _base64; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64DecodeInputStream + //--------------------------------------------------------------------- + + var base64DecodeInputStream = function(str) { + + var _str = str; + var _pos = 0; + var _buffer = 0; + var _buflen = 0; + + var _this = {}; + + _this.read = function() { + + while (_buflen < 8) { + + if (_pos >= _str.length) { + if (_buflen == 0) { + return -1; + } + throw 'unexpected end of file./' + _buflen; + } + + var c = _str.charAt(_pos); + _pos += 1; + + if (c == '=') { + _buflen = 0; + return -1; + } else if (c.match(/^\s$/) ) { + // ignore if whitespace. + continue; + } + + _buffer = (_buffer << 6) | decode(c.charCodeAt(0) ); + _buflen += 6; + } + + var n = (_buffer >>> (_buflen - 8) ) & 0xff; + _buflen -= 8; + return n; + }; + + var decode = function(c) { + if (0x41 <= c && c <= 0x5a) { + return c - 0x41; + } else if (0x61 <= c && c <= 0x7a) { + return c - 0x61 + 26; + } else if (0x30 <= c && c <= 0x39) { + return c - 0x30 + 52; + } else if (c == 0x2b) { + return 62; + } else if (c == 0x2f) { + return 63; + } else { + throw 'c:' + c; + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // gifImage (B/W) + //--------------------------------------------------------------------- + + var gifImage = function(width, height) { + + var _width = width; + var _height = height; + var _data = new Array(width * height); + + var _this = {}; + + _this.setPixel = function(x, y, pixel) { + _data[y * _width + x] = pixel; + }; + + _this.write = function(out) { + + //--------------------------------- + // GIF Signature + + out.writeString('GIF87a'); + + //--------------------------------- + // Screen Descriptor + + out.writeShort(_width); + out.writeShort(_height); + + out.writeByte(0x80); // 2bit + out.writeByte(0); + out.writeByte(0); + + //--------------------------------- + // Global Color Map + + // black + out.writeByte(0x00); + out.writeByte(0x00); + out.writeByte(0x00); + + // white + out.writeByte(0xff); + out.writeByte(0xff); + out.writeByte(0xff); + + //--------------------------------- + // Image Descriptor + + out.writeString(','); + out.writeShort(0); + out.writeShort(0); + out.writeShort(_width); + out.writeShort(_height); + out.writeByte(0); + + //--------------------------------- + // Local Color Map + + //--------------------------------- + // Raster Data + + var lzwMinCodeSize = 2; + var raster = getLZWRaster(lzwMinCodeSize); + + out.writeByte(lzwMinCodeSize); + + var offset = 0; + + while (raster.length - offset > 255) { + out.writeByte(255); + out.writeBytes(raster, offset, 255); + offset += 255; + } + + out.writeByte(raster.length - offset); + out.writeBytes(raster, offset, raster.length - offset); + out.writeByte(0x00); + + //--------------------------------- + // GIF Terminator + out.writeString(';'); + }; + + var bitOutputStream = function(out) { + + var _out = out; + var _bitLength = 0; + var _bitBuffer = 0; + + var _this = {}; + + _this.write = function(data, length) { + + if ( (data >>> length) != 0) { + throw 'length over'; + } + + while (_bitLength + length >= 8) { + _out.writeByte(0xff & ( (data << _bitLength) | _bitBuffer) ); + length -= (8 - _bitLength); + data >>>= (8 - _bitLength); + _bitBuffer = 0; + _bitLength = 0; + } + + _bitBuffer = (data << _bitLength) | _bitBuffer; + _bitLength = _bitLength + length; + }; + + _this.flush = function() { + if (_bitLength > 0) { + _out.writeByte(_bitBuffer); + } + }; + + return _this; + }; + + var getLZWRaster = function(lzwMinCodeSize) { + + var clearCode = 1 << lzwMinCodeSize; + var endCode = (1 << lzwMinCodeSize) + 1; + var bitLength = lzwMinCodeSize + 1; + + // Setup LZWTable + var table = lzwTable(); + + for (var i = 0; i < clearCode; i += 1) { + table.add(String.fromCharCode(i) ); + } + table.add(String.fromCharCode(clearCode) ); + table.add(String.fromCharCode(endCode) ); + + var byteOut = byteArrayOutputStream(); + var bitOut = bitOutputStream(byteOut); + + // clear code + bitOut.write(clearCode, bitLength); + + var dataIndex = 0; + + var s = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + while (dataIndex < _data.length) { + + var c = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + if (table.contains(s + c) ) { + + s = s + c; + + } else { + + bitOut.write(table.indexOf(s), bitLength); + + if (table.size() < 0xfff) { + + if (table.size() == (1 << bitLength) ) { + bitLength += 1; + } + + table.add(s + c); + } + + s = c; + } + } + + bitOut.write(table.indexOf(s), bitLength); + + // end code + bitOut.write(endCode, bitLength); + + bitOut.flush(); + + return byteOut.toByteArray(); + }; + + var lzwTable = function() { + + var _map = {}; + var _size = 0; + + var _this = {}; + + _this.add = function(key) { + if (_this.contains(key) ) { + throw 'dup key:' + key; + } + _map[key] = _size; + _size += 1; + }; + + _this.size = function() { + return _size; + }; + + _this.indexOf = function(key) { + return _map[key]; + }; + + _this.contains = function(key) { + return typeof _map[key] != 'undefined'; + }; + + return _this; + }; + + return _this; + }; + + var createDataURL = function(width, height, getPixel) { + var gif = gifImage(width, height); + for (var y = 0; y < height; y += 1) { + for (var x = 0; x < width; x += 1) { + gif.setPixel(x, y, getPixel(x, y) ); + } + } + + var b = byteArrayOutputStream(); + gif.write(b); + + var base64 = base64EncodeOutputStream(); + var bytes = b.toByteArray(); + for (var i = 0; i < bytes.length; i += 1) { + base64.writeByte(bytes[i]); + } + base64.flush(); + + return 'data:image/gif;base64,' + base64; + }; + + //--------------------------------------------------------------------- + // returns qrcode function. + + return qrcode; +}(); + +// multibyte support +!function() { + + qrcode.stringToBytesFuncs['UTF-8'] = function(s) { + // http://stackoverflow.com/questions/18729405/how-to-convert-utf8-string-to-byte-array + function toUTF8Array(str) { + var utf8 = []; + for (var i=0; i < str.length; i++) { + var charcode = str.charCodeAt(i); + if (charcode < 0x80) utf8.push(charcode); + else if (charcode < 0x800) { + utf8.push(0xc0 | (charcode >> 6), + 0x80 | (charcode & 0x3f)); + } + else if (charcode < 0xd800 || charcode >= 0xe000) { + utf8.push(0xe0 | (charcode >> 12), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + // surrogate pair + else { + i++; + // UTF-16 encodes 0x10000-0x10FFFF by + // subtracting 0x10000 and splitting the + // 20 bits of 0x0-0xFFFFF into two halves + charcode = 0x10000 + (((charcode & 0x3ff)<<10) + | (str.charCodeAt(i) & 0x3ff)); + utf8.push(0xf0 | (charcode >>18), + 0x80 | ((charcode>>12) & 0x3f), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + } + return utf8; + } + return toUTF8Array(s); + }; + +}(); + +(function (factory) { + if (typeof define === 'function' && define.amd) { + define([], factory); + } else if (typeof exports === 'object') { + module.exports = factory(); + } +}(function () { + return qrcode; +})); diff --git a/homeassistant/www/audi-dashboard-ios.css b/homeassistant/www/audi-dashboard-ios.css index 2802cfa..aa1114e 100644 --- a/homeassistant/www/audi-dashboard-ios.css +++ b/homeassistant/www/audi-dashboard-ios.css @@ -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 + 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} diff --git a/homeassistant/www/audi-dashboard-version.json b/homeassistant/www/audi-dashboard-version.json index 3093ca1..b271edc 100644 --- a/homeassistant/www/audi-dashboard-version.json +++ b/homeassistant/www/audi-dashboard-version.json @@ -1 +1 @@ -{"version": 1786765000} +{"version": 1786766000}