Compare commits

279 Commits

Author SHA1 Message Date
tobias 056d4b02a4 Arbeitskopie liegt jetzt unter Projekte\audi-app - die alte ist stillgelegt
Vollstaendige Kopie, kein frischer Klon: companion-app/.env.local, die echten
Belege unter tests/belegparser/belege/ und beide node_modules sind mitgekommen,
es ist also nichts nachzuinstallieren. 10.924 Dateien, fsck ohne Beanstandung.

Die alte Kopie unter .claude\sessions\Audi_app_TG bleibt vorerst liegen, darf
aber nicht mehr pushen: ein pre-push-Hook lehnt jeden Push ab und nennt den
neuen Ort. Zwei Arbeitskopien, die beide pushen duerfen, laufen auseinander -
bei DM180 einmal geschehen. Der Hook liegt in .git/hooks und ist bewusst nicht
versioniert; er gehoert zu jenem einen Verzeichnis, nicht zum Projekt.

Nebenwirkung zum Guten: /stand und die uebrigen Projektbefehle greifen jetzt
ohne den unversionierten Zeiger, weil das Repository direkt geoeffnet wird.
stand.md und die Dateikarte in SPECIFICATION.md entsprechend nachgezogen.

AGENTS.md Abschnitt DK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 01:03:42 +02:00
tobias 2f945dccd9 Zweites Push-Ziel: lokaler Spiegel unter Projekte\audi-app.git
origin traegt jetzt zwei Push-Adressen - Gitea und ein bares Repository unter
C:\Users\tobia\Projekte\audi-app.git. Ein `git push` schreibt an beide.

Der Spiegel wurde aus allen Branches auf Gitea befuellt (main,
umsetzung-datametric360) und war danach ref-identisch. Geholt wird weiterhin
ausschliesslich von Gitea: origin/main bleibt damit das, was der Server sagt -
worauf der Waechter in ios-signieren.sh vollstaendig aufbaut.

Gleiches Muster wie bei DM180 (Projekte\Data-Metric-180.git).

AGENTS.md Abschnitt DK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 00:52:41 +02:00
tobias 5b840ea510 Signierskript richtet den Klon selbst aus - kein Handgriff mehr noetig
Der Waechter brach bisher ab und legte dem Menschen einen Befehl vor. Jetzt
bringt er den Klon selbst auf origin/main, solange das nachweislich verlustfrei
ist: reiner Rueckstand wird vorgespult; Commits, die nur lokal liegen, deren
Tree aber dem des gemeinsamen Vorfahren gleicht, tragen inhaltlich nichts bei
und werden verworfen (Reflog haelt sie 90 Tage). Nur Commits, die auch den
Inhalt aendern, fuehren weiterhin zum Abbruch mit beiden Wegen zur Wahl.

Damit entfaellt die Vorbereitung vor einem Signierlauf vollstaendig - wichtig,
weil die bisher dokumentierte Vorbereitung ausgerechnet `git pull` war, also
genau der Befehl, der auf einem Klon von vor dem Rewrite die entfernten Commits
zurueckgeholt haette.

Fuenf Lagen gegen Wegwerf-Repositories geprueft: gleich, zurueck, inhaltslos
voraus, echte ungepushte Arbeit, echte Divergenz.

APPLE_DEV_BACKLOG.md, OFFEN.md und AGENTS.md (Abschnitt AJ) nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:01:32 +02:00
tobias 185a044c5e Signierskript: ein Klon von vor dem Rewrite darf nicht pullen
Am 07.09.2026 sind drei Commits am Ende von main entfernt und der Branch per
Force-Push zurueckgesetzt worden. Ein aelterer Klon - der Mac, auf dem
signiert wird - steht damit VORAUS statt zurueck. Der Waechter sagte dort
"Erst 'git pull' ausfuehren", und genau das ist die Falle: der Merge holt die
entfernten Commits zurueck in die Historie, der naechste Push stellt sie auf
dem Server wieder her, und zwar ohne --force.

Git kann diese Lage nicht von echter ungepushter Arbeit unterscheiden. Das
Skript entscheidet deshalb nicht, sondern listet die nur lokal vorhandenen
Commits auf, nennt beide Moeglichkeiten samt richtigem Befehl und bricht ab.
Nur der schlichte Rueckstand empfiehlt weiterhin 'git pull'.

Beide Zweige gegen Wegwerf-Repos in der jeweiligen Form geprueft, nicht nur
geparst - die erste Fassung stufte die Lage als "auseinandergelaufen" ein und
haette dem Mac zum Pushen geraten.

APPLE_DEV_BACKLOG.md nennt den einmaligen Schritt im Abschnitt "Vorher".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 20:58:05 +02:00
tobias 0e84248d38 Bindend: Host-Port 8123 ist die reale Instanz und wird nie beruehrt
Ansage des Eigentuemers. Die Portzuordnung des Testcontainers liest sich
irrefuehrend - "0.0.0.0:18123->8123/tcp" enthaelt eine 8123, die aber
containerintern ist. Host-seitig gehoert 8123 der realen Instanz: nicht
oeffnen, nicht abrufen, nicht aendern, auch keine Erreichbarkeitspruefung.

Geprueft, dass nichts im Projekt versehentlich dorthin zeigt: alle drei
Vite-Proxy-Ziele stehen auf 18123, in .claude/ steht nichts. Die uebrigen
Fundstellen von ":8123" sind Zeichenkettentests ohne Netzzugriff sowie
Dokumentation des realen Aufbaus (Reverse-Proxy, Architektur) - nichts davon
wird ausgefuehrt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:42:32 +02:00
tobias db3c870910 Testinstanz heisst jetzt Data-Metric-360
Der Docker-Container auf :18123 ist umbenannt (Ansage des Eigentuemers).
Umgestellt sind nur die betriebsrelevanten Verweise:

  testumgebung/aufsetzen.sh    NAME=
  testumgebung/README.md       docker rm -f
  companion-app/vite.config.ts Kommentar am Entwicklungs-Proxy

Die uebrigen ~120 Nennungen in AGENTS.md stehen in Verlaufsabschnitten und
bleiben, wie sie geschrieben wurden - stattdessen eine bindende Notiz oben in
der Datei, dass "audi_ha_test" in allen aelteren Abschnitten denselben
Container meint. Sie haelt auch fest, dass aufsetzen.sh mit "docker rm -f" auf
diesen Namen beginnt: ein Lauf loescht den Container samt allem, was darin
aufgezeichnet ist.

Geprueft: Portzuordnung 18123 unveraendert, Container startet unter dem neuen
Namen sauber (Audi Dashboard 2026.9.6.9 eingerichtet, kein Traceback), Profil
und Daten unberuehrt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:40:32 +02:00
tobias e23a462da9 Teilbare Fassung der Portierungsbeschreibung verlinkt
Die Beschreibung gibt es zusaetzlich als veroeffentlichte Seite mit echt
gerenderten Farbmustern fuer Tag und Nacht. Adresse in
PORTIERUNG_UEBERSCHRITTEN.md und OFFEN.md nachgetragen, mit dem Hinweis, dass
Aenderungen an der Datei dort nachzuziehen sind - sonst laufen die beiden
Fassungen auseinander.

Die Seite ist markenfrei: keine lizenzierten Schriften, Embleme oder
Modellzeichen, nur die Farbwerte und Strukturen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 14:56:19 +02:00
tobias bc04af9c77 Portierungsbeschreibung fuer "ueberschritten", Testprofil zurueckgestellt
PORTIERUNG_UEBERSCHRITTEN.md fasst zusammen, was fuer ein Spin-off dieser App
nachgezogen werden muss: Fachregel, Farbschwellen mit den gemessenen Werten,
CSS und Markup beider Codebasen, Tests, und die Fallen, die hier Zeit gekostet
haben.

Das Erstzulassungsdatum im Testcontainer ist von 01.03.2020 auf 01.03.2025
zurueckgestellt, die Sicherung entfernt - OFFEN.md 3b entsprechend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 14:42:48 +02:00
tobias 33c2ffa5ff Ueberschritten: nur die Zeile hinterlegen, Farbe je Schwelle trennen
Zwei Rueckmeldungen des Eigentuemers vom 07.09.2026.

1. Die Service-Kachel auf "Mein Audi" war als Ganzes gelb hinterlegt. Gelb
   wird jetzt nur das ueberschrittene Feld, analog zur Termingruppe im
   Service-Blatt: .row.ueberschritten bzw. .dm-wertzeile--ueberschritten,
   12 % --warn mit -12px Aussenabstand bis an den Kachelrand. Die dafuer
   gebaute Hilfsfunktion serviceUeberschritten() entfaellt ersatzlos.

2. Auch #C55300 wirkte braeunlich. Das ist die Schwelle, nicht der Geschmack:
   Apples systemOrange #FF9500 kommt auf Weiss nur auf 2,20:1, jedes Orange
   mit 4,5:1 liest sich als Braun. Deshalb traegt jetzt jedes Element seine
   eigene WCAG-Schwelle:

     --warn-fig   32px = Grosstext, 3:1   -> #DD6000 (3,65:1 auf Weiss)
     die Marke    10,5px = Kleintext, 4,5:1 -> gefuellte Pille #C55300 mit
                  weisser Schrift (4,55:1) statt duenner Schrift

   Nachts #FF9F0A (8,41:1) bzw. #FFA056 mit #171B21 (8,57:1).

Im Panel live gemessen (Testcontainer, Tagmodus): Kachelgrund rgb(255,255,255),
nur die HU-Zeile rgba(255,159,10,0.12) und um 2 x 12px breiter als die uebrigen
Zeilen, Marke rgb(255,255,255) auf rgb(197,83,0), Kennzahl rgb(221,96,0).
Damit ist Abschnitt DJ auch im Panel optisch geprueft - OFFEN.md 3b angepasst.

327/327 Tests, tsc sauber, Buendel auf 2026.9.6.9 neu gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 14:37:19 +02:00
tobias 08d8de60db Service-Kennzahl auf der Uebersicht 40px -> 32px; Farbe belegt statt erinnert
GROESSE: vom Eigentuemer als "sehr gross" gemeldet, im Browser bei 375x812
nachgemessen (Kachel 307px): "8.600 km" war bei 40px 133px breit (43% der
Kachel) und damit BREITER als die Reichweite darueber (60px, 107px, 35%) - der
zweitrangige Wert schlug den Hauptwert in der Flaeche. Mit einem Datum stand es
deutlicher: "01.03.2028" misst bei 40px 231px, 75% der Kachel. Auf 32px sind es
108px bzw. 185px, damit liegt der Servicewert auf der Reichweite gleichauf statt
darueber. Schrift und Gewicht waren nie das Problem und bleiben Audi Type Wide
300. Nimmt die Entscheidung vom 18.08.2026 (40 statt 30) zurueck.

FARBE: auf die Frage "HIG oder Audi CI" nachgeschlagen. Die Werte stehen auf
developer.apple.com nur im Alternativtext der Farbfelder, nicht im Text. Meine
Erinnerung war falsch - Apples Yellow "Increased contrast (light)" ist #A16A00,
nicht #A05A00. Uebernommen wird er trotzdem nicht: auf unserem 12% hinterlegten
Grund liefert er 4.19:1, Apples Orange #C55300 4.16:1, beide unter 4.5:1. Apples
Schwelle gilt fuer Schrift auf einfarbigem Grund. Der gemessene Wert #A15800
(4.90:1) bleibt, jetzt mit belegter Begruendung statt ohne. Nebenbefund: die
Seite zeigt inzwischen die neue "unified"-Palette (systemRed dunkel #FF4245),
dieses Projekt fuehrt die aeltere (#FF453A) - ein einzelner Wert aus der neuen
waere ohnehin inkonsistent.

LIVE GEPRUEFT: der Eigentuemer hat auf einen offenen Browser hingewiesen, damit
liess sich der offene Punkt aus DJ schliessen. Mit voruebergehend
zurueckdatierter Erstzulassung (Profil gesichert, hinterher zurueckgestellt und
geprueft) zeigen alle drei Stellen die Markierung, und nur die ueberschrittene
Gruppe ist hinterlegt - Oelwechsel 2028 und Inspektion 2027 blieben unmarkiert.
Das Panel ist weiterhin nur ueber den Paritaetstest belegt, nicht am Bild.

327/327, tsc sauber, Panel als Modul geparst, Manifest und Buendel auf
2026.9.6.7, Container sauber gestartet.
2026-09-07 14:08:07 +02:00
tobias 27196f9e78 Ueberschrittene Servicetermine werden markiert
Wunsch des Eigentuemers: faellige oder ueberschrittene Termine dezent
gelb/orange hinterlegen und mit "ueberschritten" markieren - Service-Seite und
Mein Audi; auf der Uebersicht zusaetzlich die Kennzahl im Warnton.

Erkannt wird am DATUM, nicht an den Restkilometern. Gemessen: die Sensoren
melden -28600 bzw. -22000 bei Faelligkeit 2028 bzw. 2027, negativ heisst hier
also "noch so weit hin". Welches Vorzeichen ein tatsaechlich ueberschrittener
Wert traegt, ist an den vorliegenden Daten nicht beobachtbar - geraten wird es
nicht. Der km-Fall faellt ohnehin unter die Datumsregel: servicePrognose()
setzt datum auf heute, sobald restKm auf 0 laeuft. Der Faelligkeitstag zaehlt
mit ("bereits faellig bzw. ueberschritten").

Dabei eine Luecke im Panel gefunden, vom Test und nicht im Betrieb:
datumAusText() ist verankert und nimmt keine vollen Zeitstempel - die
Fahrzeugmeldung ist aber genau das und bei Oelwechsel und Inspektion die
Hauptquelle. Sie waere still nie als ueberschritten erkannt worden.
zeitstempelAlsDatum() schliesst sie; die App hatte die Luecke nie.

Farbe nach Rolle geteilt wie --bad/--bad-flaeche: --warn ist die Flaeche,
--warn-text die Schrift. Grund: #FF9F0A als Text auf Weiss sind 2.06:1. Auf dem
12% hinterlegten Grund misst --warn-text 4.90:1 (Tag, #A15800) bzw. 9.30:1
(Nacht).

Geprueft: 15 Faelle gegen die echten Panel-Funktionen, 7 in der App, 14
Paritaetsfaelle Panel gegen App, 3 Rendertests ueber alle drei Anzeigestellen.
Gegenprobe: mit ausgehaengter Entscheidung fallen 9 Tests. 327/327, tsc sauber,
Panel als Modul geparst, Manifest und Buendel auf 2026.9.6.6, Container sauber
gestartet.

Offen: optisch nicht gesehen - der Testbestand enthaelt keinen ueberschrittenen
Termin. In OFFEN.md vermerkt.
2026-09-07 13:39:52 +02:00
tobias 4d721b6602 /stand war von der Arbeitsumgebung aus nicht erreichbar
Der Befehl liegt richtig im Repository, wurde aber nicht gefunden: Claude Code
sucht Projektbefehle nur in <Arbeitsverzeichnis>/.claude/commands/, und diese
Sitzungen laufen eine Ebene ueber dem Repo (.claude/sessions, mit Audi_app_TG
und Audi_app_Labor als Unterverzeichnissen). Weder dort noch unter ~/.claude/
gab es ein commands-Verzeichnis.

Behoben ueber einen Zeiger unter .claude/sessions/.claude/commands/stand.md,
der nur auf die Datei im Repo verweist - eine Quelle, zwei Wege. Der Zeiger
liegt ausserhalb jedes Repositories und ist deshalb nicht versionierbar; das
steht jetzt als Hinweis in der versionierten Datei, damit es nach einem Verlust
der Arbeitsumgebung nicht als Raetsel wiederkommt.
2026-09-07 13:18:43 +02:00
tobias 5274df8909 Korrektur: Asset-Caching ist eingeschaltet und bleibt es
Der eben ergaenzte Abschnitt empfahl "aus" und behauptete, das sei entschieden.
Beides war falsch: der Schalter ist eingeschaltet, und es laeuft. Die Empfehlung
beantwortete die Frage "soll er an sein" - gefragt war "soll ich ihn umlegen",
und die ist anders, wenn der laufende Zustand traegt.

Die Messung bleibt gueltig und ist der Grund, ihn NICHT auszuschalten: /local/
liefert HA schon mit 31 Tagen Cache-Control, der Rest der Freigabeliste ist
dynamisch. Auf der Habenseite steht also nichts, und ein laufender Proxy wird
nicht ohne Gewinn umkonfiguriert.

Die Regex-Konkurrenz zwischen einem Asset-Block und dem handgesetzten
/local/bilder/-Block ist damit empirisch widerlegt - sie steht als Merkposten
fuer spaetere Aenderungen an der Freigabeliste weiter drin, zusammen mit den
zwei Symptomen, die diese Einschaetzung kippen wuerden.
2026-09-07 13:15:37 +02:00
tobias 0c7df28b6d Reverse-Proxy: Asset-Caching bleibt aus, mit Begruendung
Die Doku schwieg zur Frage. Gemessen an der laufenden Instanz: /local/bilder/
liefert HA bereits mit 31 Tagen Cache-Control, /audi_dashboard_static/ ganz ohne
Cache-Control (nur ETag und Last-Modified). Die Fotos sind die einzige
nennenswerte statische Last durch den Proxy und werden ohnehin schon behalten -
der Schalter bringt also nichts.

Dagegen steht, dass ein Asset-Block ein Regex-Block auf genau die Endungen ist,
auf die auch der handgesetzte /local/bilder/-Block hoert. Unter Regex-Blöcken
gewinnt bei nginx der erste passende in Konfigurationsreihenfolge; steht der
erzeugte davor, greift er statt seiner. Wie NPM den Block genau erzeugt, ist
nicht nachgesehen - als Grund genuegt die Konkurrenzsituation.

Gefaehrlich waere es nicht: Fotos und OTA-Buendel tragen Cache-Brecher, das
Buendel zusaetzlich eine SHA-256-Pruefung auf dem Geraet.
2026-09-07 13:12:49 +02:00
tobias e6a9ff4ec8 Apple-Backlog traegt jetzt den Ablauf eines Xcode-Laufs
Auf die Frage, welche Datei man am Mac in die Hand nimmt: der Backlog war die
richtige Antwort, ihm fehlte aber genau das - er beschrieb den Zustand und die
Pruefliste, nicht den Ablauf.

Neuer Abschnitt "Wie ein Lauf abläuft": was vorher zu tun ist (git pull; das
Skript bricht selbst ab, wenn der Baum unsauber oder HEAD nicht gleich
origin/main ist), der eine Befehl samt seiner dreizehn Schritte, dass die
Fassung aus manifest.json kommt und nichts von Hand hochzuzaehlen ist, das
einmalige Schluesselbund-"Immer erlauben" samt Symptom wenn man es uebersieht,
und danach Push plus der itms-services-Link in Safari - ausdruecklich ohne ?v=,
mit Verweis auf den Befund aus Abschnitt CK.

Damit ist die Datei als alleinige Uebergabe brauchbar; AGENTS.md braucht man nur
noch fuers Warum. OFFEN.md 1.1 verweist entsprechend darauf.
2026-09-07 12:09:36 +02:00
tobias 2bf1691fb8 Zentrale Stelle fuer den aktuellen Stand: OFFEN.md und /stand
Ansage des Eigentuemers: ueber eine zentrale Datei bzw. einen zentralen Aufruf
muss immer klar sein, was aktuell zu tun ist.

OFFEN.md ist diese Datei. Sie nennt den Zustand, nicht die Geschichte, und
teilt sich die Arbeit mit den vorhandenen: AGENTS.md beantwortet WARUM etwas so
ist, APPLE_DEV_BACKLOG.md was davon Xcode oder das Portal braucht, OFFEN.md WAS
JETZT zu tun ist. Jeder Punkt sagt auch, was konkret zu tun ist - nicht nur,
dass etwas offen ist.

Gegliedert nach Zustaendigkeit statt nach Thema: was auf den Eigentuemer wartet
(Xcode-Lauf, Gitea-Token DM180, steuer.faellig), was eine Entscheidung braucht
(Uebersicht-Box Versicherung), was eine Sitzung bauen kann (PDF-Ablage,
Notizfeld, Dokumentendrift), was gemeldet aber nicht reproduzierbar ist, und
was bewusst so bleibt - letzteres, damit bekannte Grenzen nicht als Fehler
wiederkommen.

/stand (.claude/commands/stand.md) ist der zentrale Aufruf. Er misst den
mechanischen Teil selbst - Repo-Gleichstand, Manifest gegen OTA-Buendel samt
sha256, Fassung der ausgelieferten .ipa, ob die eigenen Plugins darin
registriert sind, und welche nativen Commits sich seit ihr angesammelt haben -
und liest OFFEN.md dazu. Dieser Teil kann deshalb nicht veralten. Probelauf
ausgefuehrt: er meldet korrekt 2026.9.6.5, Buendel passend, .ipa auf
2026.9.4.17 ohne die beiden eigenen Plugins, zwei native Commits wartend.

Verankert in CLAUDE.md (wird beim Sitzungsstart geladen) und als Punkt 0 der
Leseordnung in AGENTS.md.
2026-09-07 12:04:48 +02:00
tobias 979ea92ce7 Apple Dev Backlog (DM360) angelegt
Ansage des Eigentuemers: alles, was Xcode oder das Apple Developer Portal
braucht, wird kuenftig in einer eigenen Liste gefuehrt - Xcode-Laeufe werden
gebuendelt und erst gestartet, wenn eine nennenswerte Menge zusammengekommen
ist.

APPLE_DEV_BACKLOG.md haelt den am Artefakt gemessenen Stand der ausgelieferten
.ipa fest (2026.9.4.17, beide Profile bis 2027-08-29, alle vier
Berechtigungsschluessel da, packageClassList ohne die beiden eigenen Plugins)
und trennt: was auf einen Lauf wartet, was auf das Portal wartet, was nach dem
Lauf zu pruefen ist, und was das Skript ohnehin selbst erledigt.

Offen sind zwei Positionen, beide bereits im Repo korrigiert und nur auf einen
Lauf wartend: die nicht registrierten Plugins (Zwischenablage und Kalender sind
auf dem Telefon tot) und der Installationslink mit dem zweiten Fragezeichen.
Im Portal ist derzeit nichts offen.

Verankert als verbindliche Apple-Regel in AGENTS.md neben Wartungs- und
Paritaetsregel, in der Leseordnung, und als Querverweis aus Abschnitt DH -
dessen Untersuchungsbericht bleibt stehen, gepflegt wird der Zustand im Backlog.
2026-09-07 11:59:37 +02:00
tobias 0ec36b03a7 AGENTS.md DI: kein Werkbank-Schalter, der Eigentuemer kuratiert selbst
Der in DI vorgeschlagene Schalter zum Anhalten der Aufzeichnung beim
Abstoepseln des Dongles ist abgelehnt ("spaeter werden nur die Daten in der App
behalten die ich bestimme"). Die Trennung Werkbank/Fahrzeug wird nicht
automatisiert, sondern kuratiert - ueber das bereits vorhandene Wischen zum
Loeschen in der Messwertliste. Als "nicht erneut vorschlagen" vermerkt, damit
eine spaetere Sitzung die Frage nicht wieder aufmacht.
2026-09-07 00:53:17 +02:00
tobias 7bbd236a17 Batteriehistorie: stehende Werte gelten nicht mehr als frische Messungen
Der Vorschlag aus Abschnitt CB (CAN-Wert innerhalb 300 s verlangen) wurde
gemessen und trifft nicht: er haette zwei eindeutige Werkbank-Tage
durchgelassen (CAN 5 Minuten neben dem Minimum) und echte Fahrzeugtage
verworfen - am stehenden Fahrzeug schweigt CAN, eine CAN-Frist verwirft also
ausgerechnet die Ruhespannung.

Der wirkliche Mechanismus: zustand_oder_none() liefert den zuletzt bekannten
Zustand unabhaengig vom Alter, und pruefen() stempelte ihn mit "jetzt". Ein
schweigender Dongle "misst" damit alle fuenf Minuten weiter seinen letzten
Wert. Am Bestand belegt: der 16.08.2026 trug einen Tageseintrag, obwohl der
Sensor an dem Tag kein einziges Mal gemeldet hatte (naechste echte Meldung 885
Minuten entfernt); sieben von 19 Eintraegen standen mehr als fuenf Minuten von
jeder echten Meldung entfernt.

messzeitpunkt() beantwortet jetzt beide Fragen an einer Stelle: ist das eine
neue Messung, und wann wurde sie gemacht. Gestempelt wird mit der Geraetezeit,
wenn zuordenbar - so wie der Rueckblick es laengst tut, der damit die richtige
Umsetzung war und keine Aenderung brauchte. Der Tag kommt aus der Messung, nicht
aus date.today(). Die Frischepruefung steht vor der Plausibilitaetsgrenze, sonst
warnt ein offline gegangener Dongle alle fuenf Minuten ueber denselben Wert (77
identische Zeilen in 3000 Protokollzeilen gezaehlt).

Die erste Fassung mass das Alter an last_updated und hatte damit ein Loch, das
erst die Live-Pruefung zeigte: Home Assistant setzt beim Hochfahren jede
Entitaet neu, last_updated steht danach auf der Neustartzeit. Gemessen: nach dem
Neustart um 21:12:54 trug der Spannungssensor last_updated 21:12:54, waehrend
die Meldezeit desselben Datensatzes 14:46:47 sagte. Das Alter wird deshalb an
der Messzeit gemessen.

Geprueft: 18 Tests in tests/batterie/, davon fallen 16 gegen HEAD; alle 116
Backend-Tests gruen; live im Testcontainer belegt - der erste Takt nach dem
Neustart meldet "keine neue Messung" statt der bisherigen Warnung.

Offen: Werkbank und Fahrzeug lassen sich aus diesen Daten nicht sicher trennen.
Abgestellt sind die erfundenen Eintraege, nicht die Frage, wo der Dongle steckt.
2026-09-06 23:30:08 +02:00
tobias f53469f197 AGENTS.md DH: die ausgelieferte .ipa registriert zwei eigene Plugins nicht
Am veroeffentlichten Artefakt gemessen, nicht am Projekt: in App.ipa
(2026.9.4.17) stehen in packageClassList nur die sechs Fremd-Plugins.
BelegZwischenablagePlugin und KalenderTerminPlugin sind einkompiliert - je
sechs Treffer im Binaerprogramm - und trotzdem tot, weil Capacitor 8
ausschliesslich diese Liste liest.

Damit sind die beiden Meldungen vom 04.09. weiterhin zutreffend: die
Zwischenablage meldet, die App muesse erst aktualisiert werden, und der
Kalendereintrag faellt auf das Teilen-Blatt zurueck, das den Kalender nie
erreichen kann.

Das Repo ist vollstaendig: der Fix kam in a99ae43, genau einen Commit nach
9652a43, der die .ipa zuletzt erneuert hat. Es fehlt allein der Xcode-Lauf.
Der Abschnitt nennt die Pruefpunkte fuer danach - vier prueft das Skript
selbst, der fuenfte bleibt Handarbeit auf dem Geraet.
2026-09-06 22:03:44 +02:00
tobias 6272200730 Aufraeumen: Phantom-Tankvorgaenge, wirkungsloser Schalter, tote Angabe
- Die zwei Tankvorgaenge vom 04.09. geloescht; sie stammen aus dem
  asyncio-Wettlauf und haben nie stattgefunden (Anweisung des Eigentuemers).
- Der Beschriftungs-Schalter der Menueleiste verschwindet im Spaltenlayout,
  wo die Beschriftungen ohnehin immer stehen und er nichts bewirkte.
- .dm-fussnote trug font-weight: 300, obwohl es "Audi Type" nur als 400
  gibt - eine Angabe ohne Wirkung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 17:32:27 +02:00
tobias bcfd54489b Das Ende einer verschlafenen Fahrt richtig bestimmen
Drei Fehler, die zusammen eine 5-Minuten-Fahrt als 15 Stunden mit 0,1 km/h
im Bestand stehen liessen:

- nach_neustart_fortsetzen() schloss eine laufende Fahrt bei ALLEM ausser
  "on" - auch bei Funkstille. Jetzt nur noch bei ausdruecklichem "off".
- _letztes_lebenszeichen() las nur die Zuendungsentitaet, die nur bei einem
  Wechsel schreibt. Zusaetzliche Quelle ist die Meldezeit-Entitaet, deren
  Werte die Geraetezeit jedes Datensatzes sind.
- Strecke und Dauer decken bei einer Luecke verschiedene Zeitraeume ab: der
  Durchschnitt entfaellt, und die Plausibilitaetspruefung rechnet ohne die
  verschlafenen Kilometer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 17:08:52 +02:00
tobias d6e27666a3 Verschlafene Kilometer am Fahrtanfang zurueckholen
Der Dongle wacht nach dem Motorstart erst nach 28 s bis 19,5 min auf; was in
der Zeit gefahren wird, zeichnet er nicht auf. Die Kilometer holt das
Screening jetzt aus dem Endkilometerstand der Vorgaengerin zurueck und haelt
in luecke_km fest, wie viel davon betroffen ist.

Keine Sperre der GNSS-Verfeinerung, sondern eine Zerlegung: verschlafener
Anfang in ganzen Kilometern, aufgezeichneter Rest metergenau. Sonst waere die
feine Zahl systematisch zu klein und wuerde bei kleinen Luecken die Korrektur
stillschweigend wieder kassieren.

Zwei Schutzgitter, beide am Bestand gelernt: LUECKE_MAX_KM = 50 gegen
Differenzen, die keine Weckverzoegerung mehr sein koennen, und ein Rueckfall,
wenn _vollstaendig() die Werte daraufhin verwerfen wuerde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:56:14 +02:00
tobias 52fbf375d2 Wide Bold nur noch fuer die Reichweite, sonst zurueck auf Light
Der Eigentuemer hat die gestrige Schriftaenderung zurueckgenommen - ausser
fuer die Reichweite ueber dem Tankbalken. Die Ausnahme haengt deshalb an
.tankzeile .fig bzw. .dm-tankzeile .ads-fig und nicht an .fig/.ads-fig,
sonst zoege sie Kilometerstand, Service-Wert und jede Detailseite mit.

Wide Normal (400) ist wieder heraus - nach der Ruecknahme nutzte es niemand
mehr, im Panel 53 kB base64 und in der App eine 39.888-Byte-woff2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:53:17 +02:00
tobias cf06e28e8d Wide Bold fuer die grossen Zahlen, Genauigkeit der Position anzeigen
- Audi Type Wide Bold (700) eingebunden und fuer .fig/.ads-fig gesetzt, in
  beiden Codebasen. Light war zu duenn, Normal ebenfalls (Ansage des
  Eigentuemers). Die alte Notiz "es gibt nur 300 und 400, niemals fetter"
  ist damit ueberholt und entfernt statt ergaenzt - sie stand nur da, weil
  es keine anderen Schnitte gab.
- standort_genauigkeit_m wird jetzt angezeigt ("Position auf +-12 m genau",
  unter der Anschrift im Standortblatt). Der Wert kam seit jeher vom
  Backend, wurde im Panel in eine Variable gelesen und nirgends gezeigt -
  im Audit als totes Schema gefunden. In der App fehlte er ganz und ist
  jetzt durch Typ und Adapter verdrahtet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 17:58:19 +02:00
tobias f72264c4af Ein Wortlaut fuer den Fahrzustand: "Zuendung an"
Die Sicherheitsseite sagte "Zuendung an - Fahrzeug in Betrieb". Der Zusatz
behauptet mehr, als das Signal deckt: die Zuendungsquelle steht am Geraet
auf Motordrehzahl und meldet auch im Stand mit laufendem Motor "an".
Entscheidung des Eigentuemers ("zuendung an reicht") - jetzt in beiden
Oberflaechen und an allen drei Stellen derselbe Text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 17:49:33 +02:00
tobias 1a6c8d4740 Audi Type Wide Normal fuer die grossen Zahlen, Audit-Durchgang
- Audi Type Wide gab es hier nur als Light (300). Der Eigentuemer hat den
  vollen Satz aus Audis Styleguide geliefert; Wide Normal (400) ist jetzt
  eingebunden (App und Panel), Wide Bold liegt vor und bleibt bewusst
  ungenutzt. Lizenz wie bei allen Audi-Assets: nur diese eine private
  Installation, deshalb nichts davon in design-system.
- Dabei gefunden: die Aenderung im Design-System allein war wirkungslos.
  audi-schrift.css bringt fuer .ads-fig eine eigene, spaetere Regel mit und
  gewinnt. Im gebauten Stylesheet standen beide - nur die Reihenfolge
  entschied, und sichtbar wurde es erst am gerenderten getComputedStyle.
- Vollstaendiger Durchgang (AGENTS.md Abschnitt DC): Statik, struktureller
  Paritaetstest ueber Routen, Dienste, Sensorrollen, Wortlaut und
  Statusfelder, dazu ein Funktionstest an beiden laufenden Oberflaechen.
  Ein echter Befund: standort_genauigkeit_m ist totes Schema im Panel.
  Drei Meldungen des Eigentuemers waren keine Fehler - das breite Layout,
  der Beschriftungs-Schalter ohne Reiterleiste, und eine Testfahrt von mir.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 17:47:50 +02:00
tobias e76252505f Fehlerspeicher, Amber statt Gruen, Tankbalken rot ab 90 km
- Neue Sensorrolle FEHLERSPEICHER_SENSOR (number_of_diagnostic_trouble_codes
  vom CAN) und eine fuenfte Zeile auf der Sicherheitsseite: "Keine
  Fehlermeldungen". Bewusst NICHT im _sicherheitscheck(): "gesichert"
  rechnet ein UND ueber diese Liste, und ein Eintrag im Fehlerspeicher sagt
  nichts darueber, ob der Wagen verschlossen dasteht.
- "Zuendung an" ist Amber statt Gruen. Gruen las sich als "alles in Ordnung
  und sicher"; unterwegs ist kein guter Zustand, sondern ein anderer.
- Das Modell-Auswahlfeld bekam dm-eingabe statt dm-auswahl - nur letzteres
  setzt appearance:none, deshalb zeichnete der Browser seinen nativen Pfeil
  neben den des Design-Systems. Die Breitenregel heisst jetzt
  dm-feld--fahrzeug und gilt fuer input und select.
- "aktuell" erscheint erst nach einer Pruefung, und fuer beide Abschnitte
  gemeinsam. Der App-Abschnitt zeigte die Marke schon, sobald ein Buendel
  vorlag - also unmittelbar nach einem Update wieder.
- Der Tankbalken wird rot ab 90 km Restreichweite, in Kilometern und nicht
  in Prozent: die Reichweite ist die Aussage, um die es geht, und das
  Fahrzeug rechnet die Fahrweise darin bereits ein. Farbe --red (Audi-CI,
  #F50537), nicht --bad-flaeche - letzteres ist fuer weisse Schrift darauf
  gewaehlt, und hier steht keine drauf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 17:31:52 +02:00
tobias 255a593b50 Ein Update-Knopf, Modell als Auswahlfeld, abgestellt schliesst fahrend aus
- Die Update-Kachel hat genau einen Knopf am Ende. Er traegt in jedem
  Zustand den naechsten faelligen Schritt (suchen, installieren, Neustart,
  Neuladen); Beschriftung und Fuellung wechseln mit. Die Abschnitte
  darueber behalten ihre Werte und verlieren nur ihre Knoepfe.
- Abstand unter der Trennlinie von 28 auf 16 px: das padding-top verhindert
  das Kollabieren des margin der Werteliste, also zaehlt jetzt nur noch das
  padding.
- Modell ist in der App ein Auswahlfeld statt eines Textfelds, wie im
  Panel. Die Liste liegt als Zweitschrift in daten/fahrzeugmodelle.ts;
  fahrzeugmodelle.test.ts liest die Panel-Quelle ein und vergleicht sie
  Eintrag fuer Eintrag, damit die beiden nicht auseinanderlaufen.
- Faehrt der Wagen, sagen Uebersicht und Sicherheitsseite das, statt
  "sicher abgestellt" zu behaupten. Die Zuendung meldet der Dongle in
  Sekunden, Tuer- und Schlossmeldungen kommen verzoegert - die schnellere
  Quelle hat Vorrang, weil sonst das Wort nicht stimmt.
- tankerkennung: der Tiefststand wird vor dem Anlegen gesetzt. Ein
  gepufferter Schwung Datensaetze legte sonst denselben Tankvorgang
  zweimal an, weil der zweite Zustandswechsel waehrend des Wartens noch
  den alten Tiefststand las (04.09.2026, 29,8 l und 29,7 l).
- flespi: nach dem Leeren des Zwischenspeichers wird nur abgeglichen, wenn
  ein Geraetewert vorliegt. Sonst ueberschrieb der Waechterfehler den
  frisch gelesenen Stand in der Kachel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 17:12:51 +02:00
tobias 1838ab0401 Pruefzeitpunkt gehoert unter den Suchknopf, nicht an die Integration
Der Zeitstempel stand an der gruenen Marke des Integrations-Abschnitts und sah
dort aus, als gaelte er nur fuer sie - obwohl der Suchknopf seit dem Umbau fuer
die ganze Kachel steht. Vom Eigentuemer bemerkt.

Jetzt steht "Zuletzt geprueft <Zeitpunkt>" unter dem Knopf, die gruene Marke
sagt nur noch "aktuell". In beiden Codebasen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 16:44:39 +02:00
tobias 0268d507d4 Update-Kachel aufgeraeumt: ein Suchknopf unten, weniger Text
Auf Vorgabe des Eigentuemers:

* Die Kachel heisst "Update" statt "Version" - sie zeigt beide Update-Wege.
* Der Suchknopf steht ganz unten unter beiden Abschnitten und ist in JEDEM
  Zustand da. Das legt nahe, was er tut: nach Neuem fuer Integration und App
  sehen. Vorher sass er nur im Zweig "ist aktuell", und daneben brauchte es
  ein zweites "Erneut pruefen", damit sich eine womoeglich wochenalte Zahl
  ueberhaupt auffrischen liess, solange ein Update angeboten wurde. Beides
  eruebrigt sich.
* Der Erklaertext "Laedt die neueste Fassung von Gitea ..." ist entfallen.
* Im App-Abschnitt steht statt eines Satzes nur noch "Update <Fassung>
  vorhanden" - wer ohnehin auf Update klickt, braucht nicht mehr.
* Der Pruef-Spinner mitten in der Kachel ist weg; der Knopf sagt selbst
  "Pruefe ...".

In beiden Codebasen. Am laufenden Panel nachgesehen: "Update | Integration
2026.9.5.33 | Auf Update pruefen", ein einziger Knopf. 300 Tests gruen,
0 Tracebacks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 16:34:45 +02:00
tobias 45259ae9ba Buendel und Manifest muessen dieselbe Fassung tragen - Test dagegen
Am iPhone gesehen: der Server meldete 2026.9.5.29, das mitgelieferte Buendel
war noch .28. buendelPasst() verlangt Gleichheit, die App hielt sich deshalb
fuer nicht erneuerbar - und die Meldung schickte den Eigentuemer zu Xcode,
obwohl nur meine Veroeffentlichung unvollstaendig war: ich hatte die
Versionsnummer erhoeht, ohne npm run ota laufen zu lassen. Betraf .29 und .30.

Zwei Aenderungen:

* buendelversion.test.ts vergleicht die Version in bundle.json mit der in
  manifest.json. Gegengeprueft: der Test faellt, wenn sie auseinanderlaufen.
  Damit wird daraus ein Fehlschlag vor der Auslieferung statt einer
  irrefuehrenden Meldung auf dem Telefon.
* Der Hinweis unterscheidet jetzt zwei Faelle. Liegt ein Buendel, das nur
  nicht zur Serverfassung passt, heisst es, die App koenne sich erneuern,
  sobald eine Veroeffentlichung ein passendes mitbringt. Nach Xcode wird nur
  noch verwiesen, wenn gar kein Buendel da ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 16:23:47 +02:00
tobias 2a4337e030 App meldet nicht mehr "aktuell", wenn ein neueres Buendel danebenliegt
Vom Eigentuemer am iPhone bemerkt: nach "Update installiert, Neustart noetig"
zeigte der App-Abschnitt "5.22 aktuell", obwohl auf der Platte bereits das
Buendel 5.29 lag.

Ursache ist kein Fehler, sondern eine bewusste Bedingung: buendelPasst()
verlangt, dass das Buendel der Fassung entspricht, die diese Installation
gerade ausliefert. Zwischen Installation und Neustart meldet der laufende
Server aber noch seine alte Nummer - das Buendel wird deshalb zurecht nicht
angeboten. Nur "aktuell" zu schreiben war dabei falsch.

Jetzt steht dort, dass die Fassung bereitliegt und nach dem Neustart abrufbar
ist. Kein Knopf: zu tun ist erst danach etwas.

Dazu auf Wunsch des Eigentuemers der Knopftext in beiden Oberflaechen:
"Installation abschliessen und neu starten" statt "... - Jetzt neu starten".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 16:20:19 +02:00
tobias 8b213c540e flespi: kein sleep_mode-Schreiben ohne bekannten Modus
Am laufenden System ausgeloest: nach einem "Jetzt lesen" - das leert flespis
Zwischenspeicher - kannte der Schreibweg das mode-Objekt nicht mehr und
schickte einen timeout ohne type. flespi lehnt das mit 400 ab (the following
properties are missing: type).

Der else-Zweig ist ersatzlos weg. Ist das mode-Objekt samt type unbekannt,
wird gar nicht geschrieben und der Grund gemeldet. Der zweite Grund wiegt
schwerer als der 400er: type IST der Schlafmodus (2 = Deep Sleep, 4 = Ultra
Deep Sleep). Ihn zu erraten hiesse, den Schlafmodus des Dongles zu verstellen
- das darf ohne ausdrueckliche Freigabe nie passieren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 16:03:55 +02:00
tobias 356a0bdf76 Nur Versionsnummer - Nachweis, dass die Meldung durch das Neuladen verschwindet
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 15:26:55 +02:00
tobias 462970251e Meldung verschwindet durch das Neuladen, zu dem sie auffordert
Vorgabe des Eigentuemers: klarer Zustand statt einer Meldung, die auch nach
dem Neuladen stehenbleibt.

Panel: nach einem Update ohne Neustart wird der Sidebar-Eintrag mit der neuen
Versionsnummer neu angemeldet (koordinator.panel_neu_anmelden) - ohne das
traegt die Modul-URL weiter die alte Nummer. Das Panel vergleicht jetzt die
Version aus seiner Modul-URL mit der von der Platte gelesenen: sind sie
gleich, laeuft es bereits auf der installierten Fassung und die Meldung
entfaellt. Sie verschwindet damit genau durch das Neuladen, zu dem sie
auffordert.

App: fuer ein Update ohne Neustart erscheint gar nichts mehr. Geaendert wurden
Dateien des Panels; die App hat hier nichts zu tun. Damit gilt durchgehend:
keine Meldung = alles in Ordnung, Neustart-Knopf = handeln.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 15:26:19 +02:00
tobias 0e6e7fa9ba App: kein irrefuehrender Neuladen-Knopf beim Integrations-Update
Der Zweig fuer ein Update ohne Neustart bot in der App 'App neu laden' an.
Das versprach etwas, das es nicht einloesen kann: geaendert wurden Dateien
unter frontend/, und das sind die des PANELS. Die App laedt ihren eigenen Code
aus /local/dmapp bzw. der nativen Huelle, ihr Update laeuft ueber das
OTA-Buendel im Abschnitt darunter. Vom Eigentuemer im Bild bemerkt.

Jetzt steht dort nur noch der Hinweis, dass kein Neustart noetig ist und das
Panel die Dateien beim naechsten Oeffnen laedt. Im Panel bleibt 'Seite neu
laden' - dort stimmt es.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 15:14:49 +02:00
tobias 4bed48d69e Probeaenderung entfernt - reine Oberflaechen-Veroeffentlichung
Aendert nur audi-dashboard-app.js und die Versionsnummer. Damit muss die
Neustart-Erkennung 'kein Neustart noetig' melden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 13:51:41 +02:00
tobias 7c96bc8304 Neustart-Erkennung: Zeilenenden zaehlen nicht als Aenderung
Am echten Update-Weg gemessen: die installierte Fassung lag mit CRLF auf der
Platte, das Gitea-Archiv liefert LF. Drei .py-Dateien galten dadurch als
geaendert, obwohl sie nach Entfernen der CR byteweise identisch waren - die
Erkennung verlangte einen Neustart fuer einen rein kosmetischen Unterschied.

Textdateien werden jetzt vor dem Hashen auf LF normalisiert. Binaerdateien
(Schriften, Bilder, das OTA-Zip) bleiben roh, dort waere 0x0D0A echter Inhalt.
Drei neue Testfaelle: reiner Zeilenenden-Unterschied, echte Aenderung trotz
verschiedener Zeilenenden, Binaerdatei. 26 Tests der Suite gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 13:51:02 +02:00
tobias ecea8a0c22 Neustart-Erkennung dokumentiert (AGENTS.md CZ)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 13:33:28 +02:00
tobias 189dff5255 Probeaenderung: nur Oberflaeche, fuer den Nachweis der Neustart-Erkennung
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 13:30:46 +02:00
tobias 81808dbc10 Update ohne Neustart, wenn nur Oberflaechendateien wechseln
Bisher verlangte jedes Update einen Home-Assistant-Neustart. Noetig ist er
aber nur fuer Python-Code: HA importiert die Module einmal beim Start und
haengt danach mit lebenden Objekten daran. frontend/ dagegen wird direkt von
der Platte ausgeliefert (StaticPathConfig, cache_headers=False) - eine
ersetzte .js ist sofort wirksam, es braucht nur ein Neuladen im Browser.

aktualisierung.neustart_noetig() vergleicht die alte gegen die neue Fassung
ueber sha256 je Datei und meldet "kein Neustart" nur, wenn JEDE Abweichung
unter frontend/ liegt oder die manifest.json ist. Alles andere - .py,
services.yaml, translations/, vorlage/ - gilt als neustartpflichtig, auch wo
es das im Einzelfall nicht waere. Die Schieflage ist Absicht: ein
faelschlich ausgelassener Neustart laesst neuen Python-Code nie anlaufen, und
der Fehler wird woanders gesucht.

manifest.json ist ausgenommen, weil sich seine Versionsnummer bei jeder
Veroeffentlichung aendert - sonst waere die Unterscheidung wertlos. Weil HA
das Manifest fuer die Laufzeit festhaelt (loader.py: hass.data[
DATA_INTEGRATIONS]), liest version_von_platte() die Nummer direkt von der
Platte; koordinator.version_neu_lesen() zieht sie nach einem Update ohne
Neustart nach, damit Panel und App die neue Fassung auch anzeigen.

Panel und App zeigen im Neustart-freien Fall "Seite neu laden" statt
"Installation abschliessen - Jetzt neu starten".

Neun neue Testfaelle fuer die Unterscheidung, 23 Tests in der
Aktualisierungs-Suite gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 13:30:25 +02:00
tobias 4dad3d21a8 App-Abschnitt zeigt gruenes "aktuell" statt eines Satzes
Statt "Diese App ist aktuell." dieselbe gruene Marke wie bei der Integration -
Vorgabe des Eigentuemers. Ohne Zeitpunkt: anders als bei der Integration wird
hier nicht auf Knopfdruck geprueft, das Buendel meldet der Server beim Laden
mit. Der Fall "kein Buendel hinterlegt" bleibt ein Satz.

Nicht im Bild geprueft: der Abschnitt erscheint nur in der nativen Huelle,
otaMoeglich() ist im Browser falsch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 12:48:40 +02:00
tobias 71403efafe Speicherleiste ersatzlos gestrichen, Felder speichern beim Verlassen
Auf Entscheidung des Eigentuemers nach der HIG-Pruefung. Gegen die Leiste
sprach viererlei: iOS kennt kein solches Muster auf einer geschobenen
Einstellungsseite; sie schwebte ueber der Tab-Leiste und deckte einen Knopf
der Kachel dahinter zu (gemessen); sie war die Ursache des Datenverlusts bei
der Erstzulassung; und das Panel hatte sie nie - dort schreibt jede Aenderung
sofort ins Profil.

Jetzt speichern Textfelder beim Verlassen, Auswahlfelder sofort. Die Meldung
"Gespeichert." entfaellt mit. Der Regler "Fahrt beenden" behaelt seinen
eigenen Bestaetigungsschritt - ihn bei jedem Zwischenschritt zu sichern hiesse
ein Dutzend Schreibvorgaenge je Bedienung.

Mit echten Eingabeereignissen am laufenden System geprueft: getippt, ins
naechste Feld geklickt, Wert steht in fahrzeugprofil.json; danach der
urspruengliche Wert wiederhergestellt. 299 Tests gruen, 0 Tracebacks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 12:42:50 +02:00
tobias b22a0eca5b Beschriftung auf "Integration" gekuerzt
"Home-Assistant-Integration" brach auf Telefonbreite in zwei Zeilen um. Auf
Entscheidung des Eigentuemers gekuerzt; nachgemessen einzeilig, 20px hoch.
In beiden Codebasen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 11:40:18 +02:00
tobias 5a65ec9590 Kein Rueckschritt mehr als Update, Version-Kachel schlanker
aktualisierung.py pruefte mit `remote_version != eigene_version` - reine
Ungleichheit ohne Richtung. Lief die Instanz einer Veroeffentlichung voraus,
bot die Integration an, sich auf die aeltere Fassung zu aktualisieren. Vom
Eigentuemer gemeldet. ist_neuer() ist jetzt das Gegenstueck zu
versionOrdnung() in appVersion.ts, wo derselbe Fehler am 04.09. behoben wurde:
fehlende Stellen zaehlen als 0, kein int() mit Vorabschnitt, und was sich
nicht in eine Reihenfolge bringen laesst, gilt als verfuegbar statt
verschwiegen. Sechs neue Testfaelle, 79 Python-Tests gruen.

Dazu auf Vorgabe des Eigentuemers: die Zeile benennt jetzt selbst, was sie
zeigt - "Home-Assistant-Integration | 2026.9.5.19" statt "Installiert" mit dem
Gegenstand klein darunter. Damit entfallen die Abschnitts-Ueberschriften aus
dem vorigen Commit, sie sagten dasselbe doppelt.

Live in beiden Oberflaechen gegengeprueft: Gitea auf .18, Instanz auf .19 -
gemeldet wird gruenes "aktuell" (--ok) mit Pruefzeitpunkt statt eines
Rueckschritts. 0 Tracebacks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 11:33:56 +02:00
tobias f693a41692 Version-Kachel: Klassenkollision behoben, "aktuell" statt Satz
Die zusammengefuehrte Kachel war zerlegt: ich hatte den Wrapper
.dm-abschnitt genannt, den Namen gibt es aber schon - als Flex-Zeile mit
space-between (bausteine/FahrtDetail/TankDetail). Der Kachelinhalt lag dadurch
nebeneinander statt untereinander, der Knopf lief aus der Kachel. Der neue
Wrapper heisst .dm-kachelabschnitt, die bestehende Klasse bleibt unberuehrt.

Gefunden hat es der Eigentuemer, nicht ich: ich hatte nach dem Umbau die
Abstaende der eingesetzten Probe gemessen, aber nie die Kachel selbst
angesehen.

Dazu auf seine Vorgabe: statt "Integration ist aktuell (Zeitpunkt)." steht
jetzt ein gruenes "aktuell" direkt unter dem installierten Stand, der
Zeitpunkt gedaempft daneben. Wurde NIE geprueft, steht dort gar nichts mehr -
gruen "aktuell" waere ohne Pruefung eine Behauptung ohne Grundlage, und der
Knopf "Auf Update pruefen" sagt den Zustand ohnehin. In beiden Codebasen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 11:29:04 +02:00
tobias 80a3eef6ec Update-Kacheln zu einer zusammengefuehrt
Die App hatte "App-Update" (OTA-Buendel) und "Version" (Integration) als zwei
Kacheln nebeneinander - zweimal dieselbe Frage. Jetzt eine Kachel "Version"
mit zwei benannten, durch eine Haarlinie getrennten Abschnitten; ohne das
staenden zwei gleich aussehende "Update installieren"-Knoepfe untereinander,
ohne dass man ihnen ansieht, was sie erneuern.

Die Unterueberschrift "Integration" erscheint nur, wenn wirklich ein zweiter
Abschnitt folgt (otaMoeglich()) - sonst waere es eine Unterteilung ohne
zweiten Teil. Wo es kein OTA gibt, also im Panel und in der App im Browser,
sieht die Kachel damit aus wie zuvor.

Das Panel bekommt den App-Abschnitt nicht: die App traegt ihre Dateien fest
gebuendelt und kann veralten, das Panel laedt sie bei jedem Aufruf neu. Eine
geplante Abweichung.

Nachgemessen: Panel 216px, App im Browser 203px, gleicher Inhalt; die
Gestaltung des zweiten Abschnitts durch Einsetzen gegengeprueft. 299 Tests
gruen, 0 Tracebacks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 11:20:49 +02:00
tobias 52daaf8611 Knopfpaar von der Speicherleiste getrennt, Modell "RS 6 Avant performance" raus
Das QR-Blatt benutzte dm-speicherleiste als reine Gestaltung eines Knopfpaares,
obwohl dort nichts gespeichert wird - vom Eigentuemer richtiggestellt. Der
Name machte eine Pruefung unbrauchbar, die genau danach suchte. Jetzt trennt
.dm-knopfpaar (zwei gleich breite Knoepfe) von .dm-speicherleiste (dasselbe
Paar als klebende Leiste fuer ein Formular mit Speichern-Schritt).

"RS 6 Avant performance" ist aus der Modellauswahl entfernt; "RS 6 Avant"
stand bereits darin, die Ausfuehrung traegt der Nutzer selbst ein. Es war der
einzige Eintrag, der in der auf 180px vereinheitlichten Auswahl abgeschnitten
wurde. Ein Profil mit dem alten Wert behaelt ihn - die Auswahl stellt einen
nicht gelisteten Titel als eigene Option voran.

Nachgemessen am laufenden System: .dm-knopfpaar flex mit 12px Abstand und zwei
gleich breiten Knoepfen, .dm-speicherleiste weiterhin klebend; zehn Modelle,
laengster 115px bei 142px Platz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 11:00:28 +02:00
tobias 063446239c Felder unter "Fahrzeug einrichten" gleich lang
Ohne feste Breite bemisst sich jedes Feld nach seinem eigenen Inhalt, die
rechte Kante steht fest, und die linken Kanten fransen aus: gemessen
216/202/195/206 in der App und 220/209/203/213 im Panel.

Jetzt 180px in beiden. Der Wert ist die engste Grenze beider Oberflaechen -
das laengste Label ("Erstzulassung") laesst in der App nur 183px fuers Feld,
im Panel 196px.

Vorbehalt: von elf Modell-Eintraegen ist einer laenger als der verbleibende
Textplatz ("RS 6 Avant performance", 182px gegen 142px) und wird ausgewaehlt
abgeschnitten. In der aufgeklappten Liste steht er vollstaendig.

Nachgemessen in beiden: vier Felder je 180px, gleiche linke Kante, kein Label
bricht um.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 10:39:40 +02:00
tobias 44dcb3c9d9 Erstzulassung war in der App nicht speicherbar
Einstellungen.tsx fuehrt fuer die Erstzulassung einen eigenen Entwurf neben
`entwurf`. Zwei Stellen kannten ihn nicht: speichern() stieg in der ersten
Zeile aus, und die Speicherleiste haengt allein an `entwurf` - wer nur die
Erstzulassung aenderte, bekam also gar keinen Speichern-Knopf zu sehen und
verlor den Wert beim Verlassen der Seite. Damit blieb auch die
Hauptuntersuchung leer, die daraus abgeleitet wird. Das Panel speichert an
dieser Stelle sofort und war nie betroffen.

Dazu:

* Platzhalter beider Felder war MM/JJJJ, obwohl ein voller Tag gemeint ist -
  jetzt TT.MM.JJJJ. Die Monatsform bleibt lesbar, damit gespeicherte Werte
  wie "08/2026" gueltig bleiben.
* Das Tagesmuster nimmt jetzt Punkt oder Bindestrich, in beiden Codebasen
  wortgleich. "01-03-2025" ergab vorher still nichts. Unmoegliche Daten
  werden weiterhin abgewiesen, die ISO-Form nicht verwechselt.

Geprueft am laufenden System ueber die ganze Kette: eintragen, speichern,
Profildatei, dann App-Service, Panel-Service und Panel-Mein-Audi mit
uebereinstimmend 15.06.2027. 299 Tests gruen, 0 Tracebacks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 10:31:53 +02:00
tobias ddcd9396b0 Kachelueberschriften bekommen Luft, Profil raeumt ausgemusterte Felder auf
Der Abstand zwischen Kachelueberschrift und erster Inhaltszeile war 0, 10 oder
12px, je nachdem ob das folgende Element einen eigenen oberen Rand mitbrachte.
Bei 0 klebte die Ueberschrift an der Zeile und las sich als deren Beischrift.
Jetzt einheitlich 14px, gesetzt am Kopf selbst - benachbarte Raender fallen
zusammen, vorhandene 10/12 werden also angehoben statt addiert, und absolut
gesetzte Nachbarn wie das Zahnrad bleiben unberuehrt. Nur die erste
Beschriftung einer Kachel: .label/.ads-eyebrow tragen auch Inhaltszeilen wie
die Anschrift des Autohauses, die bleiben unveraendert.

Der urspruengliche Auftrag lautete "Panel wie App setzen" und beruhte auf einer
Falschaussage von mir: ich hatte die 10px-Grossschrift aus dem Design-System
gelesen statt die App zu messen. Die App ueberschreibt sie dort absichtlich auf
den Panel-Wert; beide waren laengst identisch.

Dazu: _profil_aufraeumen() entfernt ausgemusterte Profilfelder einmalig beim
Start, Anlass ist fahrzeug.hauptuntersuchung_faellig. Ohne das bliebe der tote
Schluessel fuer immer stehen, weil beide Oberflaechen das Profil vollstaendig
zurueckschreiben. An der Testinstanz geprueft, einschliesslich zweitem Start
ohne Schreibvorgang.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 02:36:16 +02:00
tobias e9c16325cc Musterseite verworfen, Zahnrad-Regression behoben, Fotomenue als Aktionsblatt
Musterseite entfernt (companion-app/muster, vite.muster.config.ts,
/config/www/dmmuster). Ansage des Eigentuemers: sie fuehrt nur zu neuen
Fehlerquellen. Ausloeser war ein Befund, den ich als App-Fehler gemeldet hatte
- Ringe und Typenschild schwarz auf schwarz - und der nur dort bestand.

Behoben:

* Das Zahnrad im Autohaus-Kasten hatte seine Ecke verlassen (58/288 statt
  8/8). Ursache war mein HIG-Durchgang von gestern: .zahnrad kam in die
  Gruppe fuer die 44x44-Trefferflaeche und bekam damit position:relative,
  was das absolute aus dem Basisblatt ueberschrieb. Die Auflage war
  ueberfluessig - .zahnrad ist dort schon 44x44.
* Das Fotomenue hing mit position:absolute an der Kachel statt am
  Bildschirm und lag bei der hohen Raeder-Kachel ausserhalb des Sichtfelds;
  einen Abbrechen-Knopf hatte es nie. Das Panel benutzt jetzt dasselbe
  Aktionsblatt wie die App. .bildmenu ist samt Zustand, Markup, totem
  Handler und CSS entfernt.
* Der Blattkopf las sich als dritte Auswahl (linksbuendig, 15px, --fg).
  Jetzt zentriert, 13px, gedaempft - Apples Muster, in beiden Codebasen.
* "Anstehende Termine" stand 5px ueber "Oelwechsel" und wirkte als dessen
  Beischrift. Entfernt.
* Wartungsplan-Formular: Art nach oben, Datum 124px statt Browser-Vorgabe,
  Werkstatt ueber die volle Zeile (218 -> 306px), Kosten 116px. Zwei
  Erklaertexte entfernt.
* Thema folgt jetzt einer Systemumstellung im laufenden Betrieb. Vorher las
  es prefers-color-scheme genau einmal beim Laden. Drei Tests, die ohne die
  Behebung nachweislich fallen.

Geprueft: 295 App-Tests, Panel node --check, 0 Tracebacks, alle Aenderungen
live an der Testinstanz gemessen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 02:24:08 +02:00
tobias 863284e540 Hauptuntersuchung: Ableitung aus Erstzulassung und Wartungsplan, drittes Feld entfernt
Gemeldet: Wartungsplan leer, Erstzulassung 01.03.2025, erwartet 01.03.2028 -
gezeigt wurden drei verschiedene Antworten (Uebersicht 01.08.2026, Mein Audi
"August 2026", Service "kein Eintrag im Wartungsplan").

Ursache war das Profilfeld hauptuntersuchung_faellig ("08/2026"), das die
Ableitung ueberstimmte, in keiner Oberflaeche ein Eingabefeld hatte und im
Backend kein Schema. Drei Bildschirme verarbeiteten denselben Wert
verschieden. Das Feld ist samt Lese- und Schreibweg entfernt; es gibt jetzt
zwei Quellen: Wartungsplan + 24 Monate, sonst Erstzulassung + 36 Monate.

Dabei mitbehoben:

* Die Service-Kachel verschluckte abgeleitete Termine, sobald kein
  Wartungsplan-Eintrag dahinterstand.
* monatePlus() in der App verlor einen Tag ueber die Zeitumstellung (drei von
  fuenf gemessenen Faellen) - betraf Oelwechsel- und Inspektionsprognose
  genauso. Das Panel rechnete dort seit jeher richtig.
* Die App verlangte fuer jeden Wartungsplan-Eintrag eine Kilometerangabe. Eine
  Hauptuntersuchung ist rein datumsbasiert; ohne km waere der erste HU-Eintrag
  ignoriert worden. Ausserdem nahm das Panel den ersten Treffer der Liste, die
  App den juengsten - beide nehmen jetzt den juengsten.
* Monatsgenauigkeit ("03/2025") bleibt auf beiden Seiten erhalten.

Neu: paritaet_hu.test.ts vergleicht die App gegen die aus der Panel-Quelle
herausgeschnittenen Originalfunktionen, 15 Faelle. Gesamt 292 App-Tests, 73
Python-Tests mit 231 Untertests, 0 Tracebacks. Live an der Testinstanz auf
allen drei gemeldeten Bildschirmen geprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 01:56:11 +02:00
tobias 5621b818cc flespi: echtes Lesen statt Zwischenspeicher, und kein Gleichstand ohne Grundlage
Der Waechter meldete am 04.09.2026 "stimmt ueberein" fuer
trip_scenario.ign_off_timeout: 900 gegen unsere Konstante NACHLAUF_S = 900.
Das Geraet hat dort 0 - das Trip-Szenario ist abgeschaltet. flespis
Zwischenspeicher trug den Stand vom 01.09. 13:15, dreieinhalb Tage alt.
Der Eigentuemer hat es aufgedeckt: "die gelesenen werte sind veraltet".

Zwei Aenderungen:

1. "Jetzt lesen" leert erst den Zwischenspeicher der ueberwachten
   Einstellungen (DELETE /gw/devices/{id}/settings/{name} - die
   API-Entsprechung des Panel-Knopfes "clear cache and synchronize"; loescht
   den gespeicherten Wert, nicht den im Geraet) und holt dann neu.
   Einstellungen mit einem AUSSTEHENDEN Wert werden uebersprungen: dasselbe
   DELETE wuerde ihn verwerfen, und die Aenderung kaeme nie an - wie
   1003/1004, die am 04.09. acht Stunden in der Warteschlange standen.

2. `abweichung` ist dreiwertig: true, false und **null (kein Urteil)**. Null
   steht dort, wo ein Vergleich nichts aussagt - der Wert wurde nie vom
   Geraet bestaetigt, oder es ist gerade eine Aenderung unterwegs. Dazu
   traegt jede Zeile `gemeldet_am` (flespis `updated`), und beide
   Oberflaechen zeigen es an: "bestaetigt 01.09.2026 (4 T. alt)" bzw.
   "nie bestaetigt".

Ein stiller Gleichstand mit einem veralteten Wert ist schlimmer als gar kein
Vergleich: er behauptet Sicherheit, wo keine ist.

tsc sauber, 271 Tests, vite build sauber, Panel als Modul geparst,
py_compile sauber, audi_ha_test auf 2026.9.4.26 ohne Traceback.
2026-09-05 01:11:23 +02:00
tobias 9ac69cdf44 Eine Neuschreibung ist kein Wechsel - Wartezeit startet nicht mehr neu
Home Assistant schreibt denselben Zustandswert auch neu, wenn sich nur ein
Attribut geruehrt hat. Am 04.09.2026 gezaehlt: von 22 Zustandszeilen der
Zuendung waren nur 6 echte Wechsel, die uebrigen 16 Neuschreibungen - und
alle 6 Wechsel lagen ausserhalb der Fahrten. Das Zuendungssignal ist
unterwegs stabil.

Der Beobachter unterschied das nicht. Fiel eine Neuschreibung von "aus" in
die laufende Wartezeit, brach Zweig 2 die wartende Bestaetigung ab und
begann die 15 Minuten von vorn. Am 04.09. ist es nicht passiert - die
Neuschreibungen lagen hinter dem Fenster -, aber das Loch war da.

_signalwechsel() bekommt jetzt mit, ob es ein echter Wechsel war. Eine
Neuschreibung, waehrend fuer diese Fahrt schon eine Bestaetigung wartet,
laesst sie stehen.

Bewusst NICHT als Filter ganz oben: _spaetes_aus() verlaesst sich darauf,
ein Zuendungs-Aus auch als off -> off zu sehen - daran haengt das
Nachtragen vorlaeufiger Enden. Die Pruefung sitzt nur in Zweig 2, und ein
eigener Test belegt, dass der andere Weg weiter offen ist.

21 Tests (von 18), Gegenprobe mit ausgeschalteter Pruefung scheitert wie
erwartet an einem. py_compile sauber, audi_ha_test auf 2026.9.4.25 ohne
Traceback.

AGENTS.md haelt ausserdem fest: die Beschreibung des Beenden-Knopfes war zu
weit gefasst (die Korrektur zieht nur nach hinten, nie nach vorn), und der
flespi-Cache laesst sich ueber DELETE auf die Einstellung zum Neulesen
zwingen - mit dem Haken, dass dabei ausstehende Werte verloren gingen.
2026-09-05 01:02:11 +02:00
tobias 242312441d Pausenregel rechnet in Geraetezeit - zwei Fahrten bleiben zwei
Am 04.09.2026 war der Dongle von 18:25 bis 23:36 ohne Verbindung. Als er
sich meldete, kamen das Zuendungs-Aus der ersten Fahrt und das Ein der
zweiten in DERSELBEN Sekunde bei Home Assistant an - ihre Geraetezeiten
lagen 5 Stunden 12 auseinander (18:23:32 und 23:35:43).

Die Pausenregel mass an der Ankunft: 0 Sekunden, also dieselbe Fahrt.
Ergebnis war ein Eintrag von 18:10 bis 23:42 ueber 5 h 33 ohne Strecke,
und die Fahrt um 23:30 gab es gar nicht.

Zwei Stellen im Code trugen dazu bei:
- zuendung_geaendert() rief bedingungslos warte_ende_ab_abbrechen(); das
  "an" loeschte damit das wartende Ende des "aus" derselben Sekunde.
- Der Fall "an, waehrend eine Fahrt laeuft" fiel wortlos durch alle drei
  Zweige von _signalwechsel(). Dazu steht seit dem 02.09. ein Kommentar im
  Code - damals wurde eine Sperre eingebaut, die die Reihenfolge richtig
  stellt, aber nicht den Ausgang.

Neu entscheidet _zuendung_kehrt_zurueck() in Geraetezeit. Der Koordinator
fuehrt mit, welches Ende gerade seine Pausenzeit absitzt (ende_wartet).
Liegt zwischen dem echten Ende und dem neuen "an" mehr als die Pausenzeit,
wird die alte Fahrt auf ihr berechnetes Ende geschlossen und eine neue
begonnen. Gemessen wird ab dem echten Ende, nicht ab dem Signal. Ohne
wartendes Ende passiert nichts - ein Schnitt auf Verdacht waere schlimmer
als eine zu lange Fahrt.

Das behebt die Verspaetung nicht, es macht sie harmlos.

Die Tests mussten zwei Dinge trennen, die vorher eine Zahl waren: PAUSE_S
(900, das Fenster der Regel) und WARTEN (0,15 s, die Wanduhrzeit des Tests).

18 Tests in test_zuendungspause.py (von 14), py_compile sauber,
audi_ha_test auf 2026.9.4.24 ohne Traceback. Gegenprobe am alten Stand:
2 der 4 neuen Tests scheitern dort, mit der Korrektur laufen alle 18.
2026-09-05 00:40:59 +02:00
tobias b0ec8b4982 Pruefdurchgang ueber alle 26 Bildschirme - fuenf Fehler behoben, HIG nachgezogen
Gemessen statt geschaut: eine Pruefroutine lief auf jedem Bildschirm und
pruefte Ueberlauf, Tippziele, abgeschnittenen Text, Bilder ohne Quelle und
WCAG-Kontrast fuer jeden Textknoten. Dazu die Live-App mit echten Daten, die
Musterseite fuer Leerzustaende und das Panel zum Abgleich.

1. Deutsche Datumsangaben wurden falsch gelesen. new Date("01.03.2025")
   liest amerikanisch: Tage bis 12 wurden still vertauscht, ab Tag 13 fiel
   der Wert ganz weg. Die Erstzulassung stand auf zwei Bildschirmen
   unterschiedlich da. alsZeitpunkt() erkennt jetzt ISO, TT.MM.JJJJ und
   MM/JJJJ, weist unmoegliche Daten ab und fuehrt die Genauigkeit mit -
   aus "08/2026" wird "August 2026", kein erfundener Erster.

2. Die Hauptuntersuchung war in beiden Oberflaechen unerreichbar. Das Panel
   leitete sie aus der Erstzulassung ab, aber nur bei MM/JJJJ, und benutzte
   den eingetragenen Wert gar nicht. Jetzt beide: eingetragene Faelligkeit
   vor Servicebuch vor Erstzulassung + 24 Monate.

3. Die Tankstellenmarke stand zweimal da, wenn der Name kein Komma hat
   ("Shell München Ost"). ohneMarkeVorn() nimmt sie vorn ab.

4. Bilder wurden geholt, obwohl beide wussten, dass es sie nicht gibt -
   fuenf 404 fuer dieselbe Datei in einem Ladevorgang. Jetzt gar keine
   Adresse bei Stand 0; nachgemessen 4 Anfragen, alle 200.

5. Ein Bildelement ohne Quelle zeigte das kaputte Bildsymbol, und onerror
   greift dort nicht: ohne src gibt es keinen Ladeversuch.

Dazu: leere Klammern beim Steuersatz, leere graue Kachel bei leerer
Leistungsgruppe, Umbruch auf dem Einrichtungsbildschirm, drei
Leerdarstellungen in einer Kachel, fehlende Grundschrift.

Apple HIG: Kontrast war an einer Stelle 3,18 statt 4,5, weisse Schrift auf
der Loeschflaeche 3,41/3,55. Rot als Schrift und Rot als Flaeche sind jetzt
getrennte Token (--bad / --bad-flaeche), beide Apples systemRed fuer
erhoehten Kontrast. Tippziele: unsichtbares 44x44-Overlay, wo die sichtbare
Groesse Teil des Bildes ist, echte Mindesthoehe, wo das Element Flaeche ist -
Formulare werden dadurch sichtbar hoeher.

Neu auf Wunsch: Wischen in der Bildergalerie (beide Richtungen) und die
HIG-konforme Loeschgeste - ein voller Wisch loescht ohne zweiten Tipp, ab
55 % der Zeilenbreite, mit wachsender roter Flaeche und erhaltener
Rueckfrage. Dabei fiel auf, dass der Zugwert aus dem React-Zustand gelesen
wurde und ein schneller Wisch dadurch verlorenging; er liegt jetzt in einer
Referenz.

Paritaet: alle 26 Routen und Titel decken sich, Fahrzeugstatus zeilengleich,
zwei ungeplante Abweichungen (Datumsformatierung, Herkunft der
Hauptuntersuchung) geschlossen.

tsc sauber, 271 Tests, vite build sauber, Design-System gebaut, Panel als
Modul geparst, audi_ha_test auf 2026.9.4.23 ohne Traceback. Live nachgemessen:
Datum auf beiden Bildschirmen gleich, keine 404 mehr, Zurueck-Pfeil sichtbar
34x34 und treffbar 44x44, Galerie wischt in beide Richtungen, voller Wisch
loest die Rueckfrage aus, keine Kontrast-Unterschreitung mehr.
2026-09-04 18:11:07 +02:00
tobias ffaed3f544 Arbeitsdatei .tmp-patch.mjs aus dem Baum nehmen und .tmp-* ignorieren
Beim vorigen Commit ist ein Patch-Skript mitgerutscht - git add -A nimmt
alles, was nicht ignoriert ist. Die Zwischendateien heissen durchgehend
.tmp-*, deshalb steht das Muster jetzt in .gitignore.
2026-09-04 17:09:30 +02:00
tobias 8557bfe230 Markenerkennung: Grenzen gemessen, Summenzeile im Kopf ist keine Marke
Auf die Frage, ob Belege einheitlich gestaltet sind: nein. Die Erkennung
haengt an drei Annahmen - Marke in den ersten acht Zeilen, Marke in der
festen Liste, Marke ueberhaupt vorhanden. Alle drei sind jetzt gemessen und
in Tests festgehalten.

Dabei ein echter Defekt gefunden: "Total" ist Kopfmarke UND Summenwort. Ein
Beleg mit Betrag in den ersten acht Zeilen lieferte faelschlich die Marke
"Total" (an einem echten Beleg reproduziert). _betragszeile() filtert
Summenzeilen jetzt aus dem Kopf - verlangt werden beide Kennzeichen zugleich,
Summenwort und Geldangabe, damit "SHELL TANKSTELLE, 6450 SOELDEN" nicht
mitfaellt. Gegenprobe: ein echter "TOTAL TANKSTELLE"-Kopf ergibt weiter Total.

Der Ausfall beschaedigt nichts: mit unbekannter Marke im Kopf bleiben Liter,
Betrag, Rabatt und Anschrift unveraendert richtig, nur die Marke fehlt.

Parser-Suite 17 Tests (von 11), alle zwoelf abgelegten Belege liefern weiter
Shell, audi_ha_test auf 2026.9.4.22 ohne Traceback.
2026-09-04 17:09:12 +02:00
tobias bdbdb4a010 Tankstellenmarke als eigenes Feld, Belegfelder in der App richtig zugeordnet
Die Marke steht auf jedem Beleg, war aber nirgends gespeichert: der Parser
erkennt sie seit dem 16.08.2026, faltete sie aber nur in station_name.
Fuer jeden Beleg, den eine aeltere Fassung eingelesen hat, blieb dort der
Betreibername stehen und die Marke war danach nicht mehr zu holen.

- shell_beleg_parser: station_brand als eigenes Feld aus beiden Parserwegen,
  dazu marke_aus_text()/marke_aus_pdf() - sie lesen nur den Belegkopf und
  haengen nicht am vollstaendigen Einlesen.
- belege.marken_nachtragen(): traegt die Marke einmal beim Start aus der
  abgelegten Belegdatei nach. Nur wo das Feld gar nicht vorkommt und die
  Datei liegt; ein einmal eingetragenes Feld wird nie ueberschrieben.
- Panel und App: die gespeicherte Marke geht vor, geraten wird nur, wo keine
  da ist. Ein Teil, der fuer sich genommen eine Kopfmarke ist, taugt zudem
  nie als Ort.

Dazu Punkt 5 der Meldung vom 04.09.2026: ein eingelesener Beleg fuellte nur
Datum und Uhrzeit. Der Parser las ihn immer vollstaendig - die App fragte
liter/kosten/station/ersparnis/kraftstoff ab, veroeffentlicht werden aber die
Namen des Belegs (liters/fuel_total_eur/station_name/discount/fuel_type).
Uebereingestimmt hat allein ts. Die Zuordnung steht jetzt als
belegFormularwerte() in daten/belegentwurf.ts und wird gegen die echte
Nutzlast dieses Belegs geprueft.

tsc sauber, 258 Tests (die drei neuen Marken-Tests gegen den alten Stand als
scheiternd nachgewiesen), vite build sauber, Panel als Modul geparst,
Parser-Suite 11 Tests gegen elf echte Belege. Panel und App liefern fuer elf
Faelle byteweise dasselbe. Live in audi_ha_test auf 2026.9.4.21 ohne
Traceback: Uebersicht "Shell, Nuernberger Str., Ansbach", Einzelbeleg
zweizeilig mit Marke, und der echte geteilte Beleg fuellt durchs Backend das
ganze Formular.
2026-09-04 17:00:22 +02:00
tobias a99ae432fc Tankstellen-Nadel, doppeltes Tanken-Symbol, Tankstellenname, packageClassList
Fuenf Meldungen des Eigentuemers, vier davon behoben:

1. Tankstellen-Nadel war bei 32 px ein dunkler Fleck (poi zeichnet nur
   einen Ring). Gefuellter Punkt in currentColor zwischen Silhouette und
   Symbol - die Geometrie stand im Pfad. Rot wurde ausdruecklich
   abgelehnt, die Umsetzung aendert keine Farbe.

2. Tanken-Symbol im Standort-Blatt wurde gefuellt UND umrandet, weil das
   CI-Symbol zusaetzlich stroke-Attribute mitbekam. Jetzt ueber ciSVG
   wie das Parkplatz-Symbol daneben; in der App dafuer imRaster an
   SymbolTanken (der 0,84-Faktor gilt nur in der Reiterleiste).

3. Tankstellenname: der Bestand hat zwei Bauformen - aeltere Belege
   tragen den Betreiber in station_name und die Anschrift daneben.
   Neu tankstelleTeile() liest beide Felder, entfernt Firmierungen,
   kuerzt Strassen und trennt die Hausnummer auch ohne Leerzeichen.
   Uebersicht: Marke, Strasse, Ort. Einzelbeleg: zwei Zeilen.
   adresseTeilen() hat keinen Aufrufer mehr und ist entfernt.

4. Zwischenablage und Kalendereintrag hatten denselben Grund: Capacitor 8
   registriert nur, was in packageClassList steht, und cap sync traegt
   dort ausschliesslich npm-Pakete ein. Beide app-eigenen Plugins lagen
   im Programm und wurden nie registriert. ios-teilen-einrichten.mjs
   traegt sie jetzt nach, ios-signieren.sh bricht ab, wenn sie im
   fertigen .ipa fehlen.

5. Geteilter Beleg nur mit Datum und Uhrzeit bleibt offen - der Weg
   nutzt denselben belegLesen()-Pfad wie eine gewaehlte Datei, es fehlt
   der konkrete Beleg oder die Protokollzeile.

tsc sauber, 246 Tests, vite build sauber, Panel als Modul geparst,
audi_ha_test auf 2026.9.4.20 ohne Traceback. Panel und App liefern fuer
die Tankstelle byteweise dasselbe.
2026-09-04 16:34:46 +02:00
tobias cefb235f6d Versionsvergleich: nur eine neuere Fassung zaehlt
Nach dem Xcode-Lauf lief die App auf 2026.9.4.17, die Instanz auf
2026.9.4.5 - und die App bot an, sich auf .5 zu erneuern. Die
Gleichheitspruefung konnte den Rueckschritt nicht sehen.

versionOrdnung() sortiert jetzt Stelle fuer Stelle als Zahl, aber nur was
dem Format JJJJ.M.T.N entspricht; sonst gibt es eine Abweichung ohne
Richtung. Streifen, Startblatt und OTA-Knopf schlagen nur noch an, wenn
die App nachweislich aelter ist. Damit ist die frueher dokumentierte
Entscheidung "nur auf Gleichheit" umgedreht - Begruendung in
VERSIONIERUNG.md und AGENTS.md Abschnitt CL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:10:47 +02:00
tobias 6365136275 Installationslink: kein zweites ? in der Manifest-Adresse
Der Cache-Brecher vom selben Tag stand auch an der Manifest-Adresse INNERHALB
des itms-services-Links. Das zweite ? und = in der Abfrage kann den Wert von
url abschneiden - der Abruf scheitert, und weil iOS die Rueckfrage erst aus dem
geladenen Manifest baut, erscheint gar keine. Auf .ipa und Symbole innerhalb
des Manifests bleibt der Cache-Brecher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:38:40 +02:00
tobias d0a11b82cd Die ausgelieferte .ipa 2026.9.4.17 gegen die Checkliste geprueft
CFBundleVersion steht jetzt in beiden Buendeln - der Installationsblocker
aus Abschnitt CD ist weg. Kamera-Schluessel da, beide Plugins im Programm,
App-Gruppe in beiden Profilen, Cache-Brecher auf allen drei Adressen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:26:47 +02:00
tobias e237bf1e8b Server-Adresse: das Schema richtet sich nach dem Ziel
Beim Neuverbinden stand "localhost:5173" im Feld, die App meldete "nicht
erreichbar". Ursache war basisUrlNormalisieren(): ohne Schema setzte es
blind https:// davor - womit die eigene Vorlage der App unbrauchbar war,
denn im Feld steht als Beispiel 192.168.1.20:8123, und im Heimnetz gibt
es kein Zertifikat.

Jetzt nach dem Ziel: localhost, 127.x, 10.x, 192.168.x, 172.16-31.x, ::1
und .local/.home.arpa/.localhost bekommen http, alles andere weiter https
(datametric360.de laeuft ueber Cloudflare). Steht ein Schema da, wird
nichts geraten.

Vier Regressionstests, darunter der gemeldete Fall, die Vorlage aus dem
Feld und 172.32.0.1 als Gegenprobe zum privaten Bereich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:16:32 +02:00
Paul Nothaft 9652a436e0 iOS-App auf 2026.9.4.17 (Debug-Bereich, QR-Scanner, Bestaetigungsblatt)
An der fertigen .ipa gegengelesen: Version in Bundle und Webbuendel,
Kalender-Plugin registriert, Kamera- und Kalenderberechtigung vorhanden,
Erweiterung mit echtem arm64-Programm, Signatur gueltig.

Vorher: design-system neu gebaut, Typecheck sauber, 225/225 Tests gruen.
2026-09-04 14:15:19 +02:00
tobias 461151cfec Rueckfrage vor loeschenden Aktionen: eigenes Blatt statt window.confirm
Gemeldet als "Verbindung trennen tut nichts". Kein Fehler der App: in
eingebetteten Ansichten sind Dialoge abgeschaltet, window.confirm kehrt
sofort mit false zurueck (gemessen: 1 ms), und abmelden() beginnt damit.

Die Suche danach brachte den eigentlichen Befund: elf Stellen hingen an
window.confirm - acht direkt, drei im Zeilenmenue (Wischgeste in Fahrten,
Tankvorgaengen, Messwerten). Jede davon eine loeschende Aktion, jede in
eingebetteten Ansichten still wirkungslos. Das Panel zeigt an denselben
Stellen seit jeher sein eigenes Blatt.

screens/bestaetigung.tsx: ein kleiner Speicher plus ein ActionSheet, das
einmal in App.tsx haengt. Kein Hook je Aufrufstelle - der haette jede der
elf Stellen gezwungen, das Blatt auch selbst zu rendern, elf
Gelegenheiten es zu vergessen, ohne dass etwas auffaellt. Ohne gehaengtes
Blatt (Testlauf, Fremdeinbettung) gilt weiter window.confirm.

LOESCH_TEXT und LOESCH_HINWEIS wortgleich aus dem Panel, samt der
Trennung in Frage, erklaerenden Satz und Knopfbeschriftung.

225 Tests (vier neue), live an der gemeldeten Stelle geprueft: das Blatt
erscheint, Abbrechen schliesst es, die Sitzung bleibt. Bestaetigt wurde
bewusst nicht - das haette den Token vom Geraet geloescht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:07:49 +02:00
tobias ae276fb518 Leerzustand der Karte deckend: 2,45:1 waren zu wenig
Der Kasten "Kein GPS-Signal vom Fahrzeug" liegt auf Leaflets eigenem
Hellgrau, und das ist in beiden Themen hell. --tile ist im Nachtmodus ein
durchscheinender weisser Schleier - er macht die Flaeche dort noch heller,
waehrend der Text der Nacht-Palette folgt: gemessene 2,45:1, deutlich
unter den 4,5:1 dieses Projekts.

Deckender Kasten plus Zweitfarbe, in beiden Codebasen. Live nachgemessen,
identische Werte: 9,86:1 nachts, 10,94:1 tags.

Kein Gleichlauf-Befund, sondern eine gemeinsame Schwaeche - deshalb auf
Nachfrage vorgelegt und erst nach Freigabe geaendert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:57:09 +02:00
tobias 9e9ca9ca28 Audit: Wisch-Zeile und Auswahlliste der App nachgezogen
Gleichlauf, Funktion und Fehler geprueft. Sauber: 28/28 Dienste ueber
const/Registrierung/services.yaml/Klarnamen, 27/27 Sensorrollen, Routen
deckungsgleich, 47 Panel-Seiten live ohne einen Konsolenfehler, App
ebenso, Fahrzeugstatus textgleich, Bestand ohne Befund ausser den drei
bekannten Altbelegen mit odometer_km als Zeichenkette.

Zwei echte Befunde, beide in der App und beide dort, wo ein juengerer
Zwilling die Korrekturen des aelteren nie bekommen hat:

WischLoeschen (Raeder-Archiv) liess den roten Loeschknopf dauerhaft
sichtbar unter der Zeile stehen - genau der Haarlinien-Fehler, den das
Panel am 16.08.2026 mit visibility:hidden behoben hat - und malte den
Zeileninhalt mit dem durchscheinenden --tile auf eine Kachel, wodurch im
Nachtmodus der Streifen heller wird und das Rot durchscheint (Regel aus
Abschnitt AP). Beides behoben und im Nachtmodus nachgemessen.

.dm-auswahl option stand auf --tile; die aufgeklappte Liste eines <select>
malt der Browser ausserhalb der Seite, ein durchscheinender Wert laesst
dort den hellen Systemgrund durch. Im Panel steht dafuer seit dem
16.08.2026 --canvas.

Zur ausgelieferten .ipa: entpackt und gelesen - Erweiterung mit echtem
Programm, beide Plugins samt Methoden im App-Programm, App-Gruppe in
beiden Profilen, Kalender- und Standortschluessel da. Fehlt nur die
CFBundleVersion der Erweiterung (der Grund fuer die abgelehnte
Installation) und der Kamera-Schluessel - beides im Skript behoben und
wirksam beim naechsten Xcode-Lauf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:49:40 +02:00
tobias 68732aa10f Bilder: der Cache-Brecher haengt am Foto statt an der Startzeit
Beide Oberflaechen haengten Date.now() an jede Bildadresse - richtig
gegen ein veraltetes Foto (31 Tage Cache-Vorgabe unter /local/), aber die
Adresse aendert sich damit bei JEDEM Start, der Zwischenspeicher greift
zwischen zwei Starts also nie. Mit dem Vorladen waeren das 5,3 MB je
Start gewesen.

bilder.staende() liefert jetzt den Zeitstempel je Datei, veroeffentlicht
an sensor.audi_dashboard_app_version; eine Bildadresse aendert sich damit
genau dann, wenn das Foto ein anderes ist. Ein fehlendes Foto bekommt 0
(dann loest sich auch ein gespeicherter 404 von selbst auf), ein Backend
ohne Staende faellt auf das bisherige Verhalten zurueck, und Upload wie
Loeschen veroeffentlichen sofort neu.

Die App merkt sich den letzten Stand im Browserspeicher: sie malt ihren
ersten Bildschirm, bevor die Versionsangabe eintrifft - ohne das
Gedaechtnis trug genau dieser Durchlauf noch die Startzeit und holte das
Uebersichtsfoto doch wieder bei jedem Start.

Live gemessen: Neuladen ohne Aenderung 5.328.771 Bytes Inhalt bei 0 Bytes
uebertragen; ein per touch geaendertes Foto wird neu geholt (513.403
Bytes), die anderen nicht; App-Start ohne eine einzige Adresse mit
Startzeit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:29:45 +02:00
tobias d6f7993504 QR-Code aus gespeichertem Bild, stiller Debug-Knopf, HA-Zugang bei den Zugaengen
Der Scanner liest den QR jetzt auch aus einer gespeicherten Bilddatei -
in der Testumgebung ohne Kamera der einzige Weg, auf dem Telefon der
kuerzere (der Token entsteht am Rechner, das Bildschirmfoto liegt ohnehin
auf dem Geraet). Gleicher Leser, gleiche Deutung, nur eine andere Quelle;
ohne Kamera entfaellt das schwarze Quadrat.

Dabei ein echter Fehler in der ersten Fassung, gefunden an der
Musterseite: img.decode() loest bei einem GIF in Chromium nie auf - das
Blatt haette ohne Meldung ewig gewartet. Jetzt createImageBitmap, als
Rueckfall bewusst das load-Ereignis statt decode().

Debug-Mode ist nur noch ein stiller Textknopf am Seitenende, ohne Kachel
und ohne Erlaeuterung; der Zugang zu Home Assistant steht als Akkordeon in
der Kachel "Zugaenge" darin. Beides auf Vorgabe des Eigentuemers.

Die Musterseite kann jetzt auch die Ersteinrichtung (?seite=einrichtung) -
damit ist der ganze Weg ohne echten Token nachgewiesen: selbst erzeugter
QR mit Token-Text landet zeichengleich im Feld, einer mit http-Adresse im
Adressfeld, ein Bild ohne Code meldet das sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:08:10 +02:00
tobias 6fb8375ad6 Rendertest fuer den Debug-Bereich der App (215/215)
Der gefuellte Zweig der drei Debug-Kacheln war bisher nur strukturell belegt:
die Beispieldaten trugen keine Geraetemeldung, der Alle-Seiten-Rendertest lief
also immer in den Leerzustand.

beispieldaten.ts traegt jetzt eine echte Meldung des FMM003 nach (Testinstanz,
04.09.2026) - mit Geraetezeit VOR der Ankunft, denn genau der Rueckstand
zwischen beiden ist der Zweck dieser Anzeige. Der neue Test prueft, dass die
Kacheln zugeklappt NICHT existieren und nach dem Aufklappen Geraetename,
Werte samt Einheit und den Rueckstand zeigen.
2026-09-04 12:31:28 +02:00
tobias 4ab6b9fca5 Debug-Mode, eine Version-Kachel, Knoepfe nach HIG, Bilder ohne Verzoegerung (2026.9.4.11)
Debug-Mode: Zugaenge und Dongle stehen nicht mehr verstreut oben in den
Einstellungen, sondern zusammen hinter einem Schalter am Seitenende - dazu
neu der letzte Datensatz des Dongles (geraet.py). "Letzter Datensatz" heisst
dabei: alle Entitaeten des Geraets, deren Aktualisierung hoechstens zwei
Sekunden nach der juengsten liegt - die flespi-Integration setzt einen
Datensatz in Millisekunden. Angezeigt mit Ankunft, Geraetezeit und dem
Rueckstand zwischen beiden.

Beim Verifizieren gefunden: app_version_veroeffentlichen() lief nur beim
Start und nach einer Aktion, und beim Start haben die Entitaeten des Geraets
noch keinen Zustand - die Kachel waere dauerhaft leer geblieben. Laeuft
jetzt im Minutentakt mit, ohne Netzaufruf.

Ausserdem:
- "Version" und "Integration-Update" sind eine Kachel
- Speichern/Verwerfen als zwei gleich breite Knoepfe, der bestaetigende
  gefuellt und rechts; "Verwerfen" war in der App blosser Text
- Bilder werden nach dem ersten Zeichnen in der Leerlaufzeit vorgeladen UND
  dekodiert. Die Verzoegerung beim Seitenwechsel kam nicht vom Netz (31 Tage
  Cache), sondern vom asynchronen Dekodieren eines frisch erzeugten <img>.

214/214 Tests, Panel als Modul geparst, live gegen audi_ha_test geprueft.
2026-09-04 12:15:33 +02:00
tobias 63a45d4ee2 Die .ipa war nicht installierbar, plus QR-Scanner (2026.9.4.8)
Der Erweiterung fehlte CFBundleVersion - eine Pflichtangabe, ohne die iOS
die Installation der ganzen App verweigert ("kann nicht installiert werden").
Verursacht von der Versionsangleichung vom Vortag: das Skript reichte den
Platzhalter $(CURRENT_PROJECT_VERSION) statt einer Zahl weiter, und das per
Skript angelegte Ziel der Erweiterung kennt diese Build-Einstellung nicht.
An den entpackten .ipa nachgemessen, 2026.9.3.3 gegen 2026.9.4.5.

ios-signieren.sh reicht nur noch echte Zahlen weiter, das Gegenlesen der
fertigen .ipa bricht ab statt zu warnen, und abgelegt wird erst danach -
eine Datei, die die Pruefung nicht besteht, darf nicht in auslieferung/
landen.

Dazu:
- Kopfmarke statt Firmierung des Betreibers in der Tankstellen-Zeile
- dritter Suchbegriff: der Name ohne fuehrende Marke. Gemessen: Nominatim
  findet "Shell, Pascalstr. 8, Ingolstadt" nicht, "Pascalstr. 8, Ingolstadt"
  schon - daran blieb die Belegkarte der realen Instanz leer
- der Tankstellen-Verweis zeigt die Tankstelle, statt eine Route zu rechnen
- Cache-Brecher auf .ipa, Symbolen und manifest.plist
- QR-Scanner fuer den Zugangs-Token bei der Ersteinrichtung (jsQR statt
  GoogleMLKit, damit der Xcode-Lauf kein pod install riskiert)

214/214 Tests, Panel als Modul geparst, live gegen audi_ha_test geprueft.
2026-09-04 11:51:44 +02:00
Paul Nothaft d819f2e285 iOS-App auf 2026.9.4.5 (Dongle-Regler, Zugaenge, Geraetezeit)
An der fertigen .ipa gegengelesen: Version in Bundle und Webbuendel,
Erweiterung mit echtem arm64-Programm, beide Plugins im Programm, App-Gruppe
auf beiden Seiten, Signatur gueltig.

Vorher: design-system neu gebaut (neue Slider-Komponente), Typecheck sauber,
200/200 Tests gruen.
2026-09-04 10:44:22 +02:00
tobias 43f25e2e0d Tankstelle zweizeilig und antippbar, Belegkarte ohne toten Knopf (2026.9.4.5)
Sieben Meldungen des Eigentuemers, beide Codebasen.

Karte im Einzelbeleg: "Position laedt nicht" und "der Button funktioniert
nicht" waren dieselbe Stelle - ohne aufloesbaren Ort malte das Panel einen
beliebigen Ausschnitt und liess den Zentrieren-Knopf fuer immer disabled.
Beide zeigen jetzt die Auskunft statt einer Karte und unterscheiden dabei
"steht nicht im Beleg" von "findet niemand". Dazu ein echter Paritaetsmangel:
die App schlug nur station_address nach, das Panel faellt seit jeher auf den
Namen zurueck - von 16 Testbelegen tragen vier eine Anschrift.

Der Suchbegriff darf Name und Anschrift NICHT zusammensetzen: gemessen findet
Nominatim "Pascalstr. 8, 85057 Ingolstadt", mit dem Betreibernamen davor
nichts. Jetzt zwei Kandidaten nacheinander (Anschrift, dann Name); fuer den
Kartendienst dagegen beides zusammen, weil Apple und Google Betriebsnamen
kennen.

Tankstelle steht ueber die volle Kachelbreite, zweizeilig (Tankstelle und
Strasse / Ort) und fuehrt beim Tippen in den Standard-Kartendienst - Apple
Karten auf Apple-Geraeten, sonst Google Maps. Dieselbe Wahl jetzt auch fuer
Fahrzeugstandort und Autohaus.

SmartDeal-Ersparnis brach um, weil die dt-Spalte 150,00px breit war und ihr
Inhalt 150,20px - zwei Zehntel Pixel, Zeile dadurch 44,8 statt 26,4px hoch.

Der Regler "Fahrt beenden" schreibt nicht mehr beim Loslassen: derselbe Wert
stellt den Schlaf-Timeout des Dongles, das soll bewusst ausgeloest werden.
Speichern/Verwerfen erscheinen nur bei echter Aenderung.

Share-Erweiterung: an der ausgelieferten .ipa gemessen trug sie 1.0, die App
2026.9.3.3 - iOS registriert eine Erweiterung mit abweichender Versionsnummer
nicht, sie fehlt dann im Teilen-Blatt. ios-signieren.sh schreibt die Version
jetzt in beide Ziele und liest die fertige .ipa gegen. Zwischenablage: dritter
Weg ueber UIPasteboard.itemProviders, und ein fehlender nativer Teil wird als
solcher gemeldet statt als "Kein Zugriff".

Am Geraet gesetzt: 1003 Network Ping Timeout 0 -> 60 s, 1004 ACK Type
TCP/IP -> AVL. Beide liegen bei flespi als pending bereit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:10:55 +02:00
tobias 7f386d19ea Geraetezeit statt Ankunftszeit - die Wurzel hinter der 3-km-Fahrt (2026.9.4.1)
Das Fahrtfenster stand seit dem 01.09. in der Gerätezeit, der Verlauf war nach
Ankunftszeit sortiert. Zwei Uhren, und das Gerät puffert: was verspätet ankam,
fiel aus einem Fenster heraus, in dem es der Sache nach lag. Am 03.09. hat das
eine Fahrt gekostet - 26 von 48 GPS-Punkten und 3,0 statt 7 km.

verlauf_lesen() stempelt jeden Punkt jetzt mit der Gerätezeit seines
Datensatzes. Eine Stelle, an der alle Verbraucher vorbeikommen. Dazu zwei
Riegel aus derselben Untersuchung: ein Rückschritt von 43 Metern ist keine
Zählerrücksetzung mehr (aus 87 Metern wurden vorher 13,7 km), und das
Fahrtende wird auf das letzte Lebenszeichen des Geräts geklemmt - der Nachlauf
ist eine Rechnung, kein Messwert.

Am echten Recorder nachgerechnet: Tacho 3,0 -> 7,0 km, GNSS 2,952 -> 6,877 km,
Route 26 -> 47 Punkte, Ende 23:21:57 -> 23:17:48. Beim ersten Lauf haben zwei
Fahrten eine Strecke bekommen, die vorher gar keine hatten.

Nicht das Gerät war schuld: es hat lückenlos aufgezeichnet. Die zehn Minuten
Funkstille waren eine offene, aber tote TCP-Sitzung (flespi-Log: 703 s, 16
Nachrichten) - kein Funkloch. Begründung und die Empfehlung fürs Gerät stehen
in AGENTS.md, Abschnitt CB.

Dazu die neun Paritätsbefunde, alle Richtung Panel gelöst - darunter ein
unmaskierter Punkt in beiden Codebasen, der aus "vor 3 Tage" ein "vor 3 Tag"
machte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 02:18:12 +02:00
tobias 4a6c48e81c Zwei Textfehler der Dongle-Kachel, an der Sichtpruefung gefunden (2026.9.3.15)
"Gelesen vor 1 Std.." - der Punkt der Abkuerzung und der Satzpunkt standen
doppelt. Und im Panel stand noch der Halbsatz "Lesen und Schreiben gehen nur bei
laufender Zuendung" vor dem neuen Text - ausgerechnet die Behauptung, die am
selben Tag widerlegt wurde; meine Ersetzung hatte nur den hinteren Teil
getroffen.

Gefunden, weil die Kachel bis dahin nur an den Daten belegt war, nie angesehen.
Der erste Blick zeigte dabei eine sechs Fassungen alte Datei aus dem
Tab-Zwischenspeicher - die Lehre aus Abschnitt AQ, jetzt dort mit dem
Einzeiler zum Mitlesen der geladenen Fassung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 21:22:00 +02:00
tobias 6ec31b5275 Audit, zweite Runde: der Knopf schrieb ein erfundenes Ende (2026.9.3.14)
_letztes_lebenszeichen() fiel auf "jetzt" zurueck, sobald verlauf_lesen() eine
leere Liste lieferte - und die bedeutet sowohl "nichts aufgezeichnet" als auch
"nicht lesbar". Am 03.09. trat der zweite Fall ein: der Knopf traf einen
Neustart, der recorder war noch nicht bereit, die Abfrage lief 2:47 und kam leer
zurueck. Die Fahrt bekam 18:43 statt 17:59 als Ende, 74 Minuten statt 30.

Drei Quellen in fester Reihenfolge statt einer: Aufzeichnung, dann hass.states
(last_reported/last_updated - weiss dasselbe ohne Datenbank), dann "jetzt", und
das nur noch mit Warnung. Vier Regressionstests, drei davon gegen den alten
Stand nachweislich rot.

Dazu aus der Bestandspruefung: odometer_km liegt in drei alten Tankvorgaengen
als Zeichenkette vor, ablage.distanz_seit_letzter_tankung() rechnete damit
float - str. Laeuft jetzt durch als_kilometerstand(). Der Eingang war schon
zu; das ist Altbestand.

Und acht Dienste hatten keinen Klarnamen in translations/de.json - jetzt 28
Dienste, 28 Klarnamen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 21:15:42 +02:00
tobias 2736c3d6e0 Audit nach dem Dongle-Umbau: vier Befunde (2026.9.3.13)
1. profil_schreiben awaitete wunsch_uebernehmen und damit bis zu drei
   HTTP-Runden zu flespi - bei 39 Aufrufstellen von profilSpeichern() im Panel
   je Feldaenderung. Laeuft jetzt als eigene Aufgabe.
2. Ein gescheiterter Schreibversuch wurde bei jedem Speichern wiederholt, weil
   der Fehlerstand kein "werte" hat. Er merkt sich jetzt unter "versucht", was
   gescheitert ist. Dazu: kein zweites Schreiben, wenn der Wert schon als
   pending bereitliegt.
3. buendelPasst() versprach im Kommentar, ein aelteres Buendel abzulehnen,
   pruefte aber nur Ungleichheit - deshalb meldete die App "diese Fassung
   aendert auch Natives", obwohl nur das Buendel nach einem Versionssprung
   nicht neu gebaut war. Vergleicht jetzt gegen die Serverfassung, drei
   Regressionstests (der entscheidende gegen den alten Stand rot). Der Text
   behauptet keine Ursache mehr, die die App nicht kennen kann.
4. Das Regler-Minimum ging heute von 0 auf 1, ein bereits gespeicherter Wert
   darunter lief ungeprueft durch. Beide Oberflaechen klemmen jetzt auf 1-60.

182/182 Tests, 28 Backend-Dateien py_compile, beide Frontends als Modul
geparst, Dienst- und Katalog-Konsistenz in beide Richtungen geprueft, Buendel
auf derselben Fassung wie das Manifest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 21:03:17 +02:00
tobias d4ba3bce7a Pending nur zeigen, wenn es sich unterscheidet (2026.9.3.11)
flespi legt beim Schreiben das ganze Objekt als pending ab. In der Kachel stand
deshalb "Sleep Mode 102: 2 -> 2 unterwegs", obwohl sich an type nichts aendert.
vergleichen() zeigt offen jetzt nur noch bei echtem Unterschied.

Gefunden an der ersten Fahrt mit echten Daten. Dieselbe Fahrt hat zwei weitere
Befunde geliefert, in AGENTS.md festgehalten: die Zuendung steht auf "an", weil
das der letzte je gesendete Wert ist (das Aus kam nie an) - der Fall, fuer den
"Fahrt beenden" gebaut ist; und address=connection hat 30 Minuten durchgehende
Verbindung nicht zum Zustellen genutzt, pending steht weiter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 20:44:45 +02:00
tobias 4d4890034a Regler stellt den Schlaf-Timeout des Dongles (2026.9.3.10)
Der Regler "Fahrt beenden" ist jetzt eine Zahl fuer zwei Dinge: unsere eigene
Wartezeit bis zum Fahrtende und der Schlaf-Timeout des FMM003 (103). So lange
bleibt der Dongle wach und wartet darauf, dass es gleich weitergeht; danach
schlaeft er. Die 180 s Ignition OFF Delay laufen davor und bleiben unsichtbar -
sie werden nur von der Fahrtdauer abgezogen. Der Regler geht deshalb von 1 bis
60 in Einerschritten; das Geraet kennt kein Timeout 0.

flespi.py liest und schreibt die Geraete-Konfiguration. Drei Annahmen sind an
der echten API gefallen und stehen als Messung im Modulkopf: der Wert liegt in
current (nicht value), die Einstellung heisst sleep_mode mit dem Wert eine Ebene
tiefer unter mode, und ein PUT braucht {"properties": ..., "address": ...}.

Die eigene Auftrags-Warteschlange ist wieder entfallen. Sie war fuer "das Geraet
schlaeft" gebaut - das gilt aber nur fuer das Geraet, nicht fuer die
Schnittstelle: die vollstaendige Konfiguration kam herein, waehrend das Fahrzeug
seit dem Vortag stand. flespi puffert selbst (pending + address=connection), ein
zweites Auftragsbuch waere eine zweite Warteschlange fuer dieselbe Aufgabe.

Gegengelesen wird nach jedem Schreiben; als Erfolg zaehlt current ODER pending -
letzteres ist bei stehendem Fahrzeug der Normalfall.

Live gegen den echten Account belegt: Token sieht genau ein Geraet, 127
Einstellungen bei parkendem Fahrzeug gelesen, Regler 15 geschrieben und als
pending bestaetigt (Nachbarfelder unveraendert), zweiter Neustart ohne erneute
Anfrage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 20:00:39 +02:00
tobias 8c96d9b78e Zugaenge: die Dienste-Token aus App und Panel bedienbar (2026.9.3.8)
Die Token liegen seit jeher in entry.options, geschrieben vom Options-Flow
unter Einstellungen -> Geraete & Dienste -> Konfigurieren. Der Weg bleibt und
ist der Rueckfall, wenn die App den Server nicht erreicht - genau der Fall, in
dem ein abgelaufener Token auffaellt. Erreichbar ist er aber nur ueber HAs
eigene Weboberflaeche; die iOS-App zeigt keine Konfigurationsdialoge. Die neue
Kachel ist die Bedienflaeche dafuer, kein zweiter Speicherort.

Der Wert kommt nie zurueck: veroeffentlicht werden nur "gesetzt/nicht gesetzt"
und die letzten vier Zeichen (unter zwoelf Zeichen Laenge gar nichts). Auch das
Protokoll bekommt ihn nicht, und das Panel leert das Eingabefeld sofort nach
dem Absenden.

Bewusst NICHT im Fahrzeugprofil: sicherung.py sichert fahrzeugprofil.json, und
"Backup exportieren" laedt es als Datei auf das Geraet - ein Token dort wanderte
in jede Sicherung und jede weitergegebene Datei.

Ein Dienst statt zwei: zugang_setzen(dienst, token), leerer Text loescht. In der
App nicht ueber die Warteschlange - ein Stunden spaeter nachgereichter Token
ueberschriebe womoeglich einen inzwischen eingetragenen neuen.

CONF_FLESPI_TOKEN ist neu und noch ohne Leser; der Flespi-Weg zum Auslesen der
Geraetekonfiguration ist damit vorbereitet, aber nicht gebaut.

Verifiziert: py_compile auf sechs Backend-Dateien, Panel als Modul geparst,
companion-app tsc sauber und 179/179, audi_ha_test auf 2026.9.3.8 sauber
gestartet. Live am laufenden Panel der ganze Kreis, Container hinterher wie
vorgefunden: Wegwerf-Token gesetzt -> "gesetzt . ...0000", der Wert selbst
taucht in den veroeffentlichten Attributen nirgends auf (darauf geprueft),
ueber die Sicherheitsabfrage geloescht -> wieder "nicht gesetzt", der
Gitea-Token unberuehrt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:08:55 +02:00
tobias 5ccbfec92d Fahrt beenden auf Knopfdruck, Schieberegler, Zuendungspause (2026.9.3.5-.7)
Vier Themen aus einer Sitzung.

1. Die Fahrt endet wieder bei uns statt im Dongle (Abschnitt BW). Der
   Ignition-OFF-Timeout des FMM003 (900 s) und sein Schlaf-Timeout (ebenfalls
   900 s) starten beide beim Zuendungs-Aus und fallen in derselben Sekunde -
   am 02.09. zweimal beobachtet, einmal ging das Trip-Ende verloren, einmal kam
   es eine Sekunde vor dem Schlaf an. Ausloeser ist jetzt die Zuendung, die
   Wartezeit laeuft in Home Assistant. ZUENDUNG_NACHLAUF_S = 180 wird in beiden
   Wegen abgezogen (ueber das Trip-Signal 1080 s, weil dessen Ende selbst an der
   verzoegerten Zuendung haengt).

2. Schieberegler, neu in beiden Codebasen. Schrittweite 5 Minuten; der Daumen
   war im Tagmodus weiss auf weiss und traegt jetzt einen Ring aus
   --line-strong - ein Token, das genau dort sichtbar ist, wo es gebraucht wird.

3. Knopf "Fahrt beenden" in der Zuletzt-Kachel (Abschnitt BX). Schliesst auf
   das letzte Lebenszeichen, nicht auf "jetzt", und kennzeichnet das Ende als
   vorlaeufig. Ein spaet eintreffendes Zuendungs-Aus zieht es nach - innerhalb
   von sechs Stunden, nur nach vorn, und nur bei einer vorlaeufigen Fahrt.
   ts_end wandert bewusst NICHT in edited_fields, sonst blockierte der Schutz
   fuer Handeingaben genau diese Korrektur.

4. Das OTA-Buendel kann auf dem Mac gar nicht entstehen (Abschnitt BV):
   npm run ota ist eine Windows-PowerShell-Datei, ios-signieren.sh baut ein
   frisches dist/ und fasst das Buendel nie an. Ausgeliefert war deshalb eine
   Oberflaeche ohne den Versionshinweis unter richtiger Nummer.

Verifiziert: Backend 27/27 (5 Signalwechsel, 14 Zuendungspause, 8 Fahrtende),
companion-app tsc sauber und 179/179, Panel als Modul geparst, audi_ha_test auf
2026.9.3.7 sauber gestartet. Live am laufenden Panel und ohne Rueckstand
belegt: Wartezeit samt Abbruch, die 180-s-Rechnung, der Regler im Tagmodus und
der Knopf von der laufenden Fahrt bis zum verworfenen Kurzvorgang - Fahrten
vorher 16, nachher 16.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:00:38 +02:00
tobias d30bb0e9f0 OTA-Buendel neu gepackt - ausgeliefert war eine Oberflaeche ohne den Versionshinweis (2026.9.3.4)
bundle.json trug 2026.9.3.3, gebaut am 02.09. 23:54 - der Versionshinweis
kam aber erst am 03.09. 09:50 dazu (c254cf5). In allen 16 Dateien der
ausgelieferten Zip kein Treffer fuer den neuen Code: die .ipa hatte das
Feature, das Buendel unter derselben Nummer nicht.

Kein vergessener Schritt. package.json bindet "ota" an eine Windows-
PowerShell-Datei; ios-signieren.sh baut ein frisches dist/, packt es in die
.ipa und fasst das Buendel nie an - npm run ota liesse sich dort auch gar
nicht ausfuehren. Jede Auslieferung vom Mac erzeugt diese Divergenz.

Schlimmer als der in VERSIONIERUNG.md beschriebene Normalfall, weil die
Nummern uebereinstimmen: die Pruefung in install.ps1 vergleicht Versionen,
nicht Inhalte, und schweigt deshalb.

Neue Nummer statt derselben, weil ein Geraet auf dem faulen .3.3 gegen den
Server "gleich" meldet und nie wieder etwas angeboten bekaeme. c254cf5 war
ausserdem eine Oberflaechenaenderung ohne Versionserhoehung - beides holt
2026.9.3.4 nach.

Verifiziert: neue Zip enthaelt dm360.version.gesehen, bundle.json auf .3.4,
sha256 54a1d06c...

Noch offen (Abschnitt BV): ota-paket.ps1 nach Node portieren und in
ios-signieren.sh hinter npm run build aufrufen, damit .ipa und Buendel aus
demselben dist/ kommen; dazu ein Waechter ueber die Git-Historie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:57:43 +02:00
Paul Nothaft 9a1f8a7136 App neu gebaut mit dem Versionshinweis (weiterhin 2026.9.3.3) 2026-09-03 09:54:06 +02:00
Paul Nothaft c254cf5e2d Hinweis beim Oeffnen, wenn eine neuere Fassung bereitliegt
Bisher gab es dafuer nur den schmalen Streifen der Hinweisleiste, der
dauerhaft mitlaeuft und leicht uebersehen wird. Ein neuer Stand ist aber ein
Ereignis und gehoert einmal nach vorn.

Zwei Faelle, zwei Texte: liegt ein passendes OTA-Buendel bereit, traegt das
Blatt den Knopf, der die App direkt erneuert (derselbe Weg wie in den
Einstellungen). Fehlt eines, hat sich Natives geaendert - dann sagt es das
und bietet keinen Knopf an, der nichts bewirken koennte.

Nur nativ: im Browser und im Panel laedt jeder Aufruf den aktuellen Stand.
Einmal je Fassung: die weggetippte Serverfassung wird gemerkt, erst eine
andere bringt das Blatt zurueck.

Die Entscheidung steckt in der reinen Funktion hinweisFaellig(), sechs Faelle
getestet. 179/179 Tests gruen, tsc sauber.
2026-09-03 09:50:44 +02:00
Paul Nothaft 2c466bf5a2 iOS-App auf 2026.9.3.3 (Kalender ueber EventKit, reparierter Teilen-Weg)
An der fertigen .ipa geprueft:
  - beide neuen Plugins im Programm nachweisbar (BelegZwischenablage,
    KalenderTermin)
  - Kalender-Berechtigung in beiden Fassungen in der Info.plist
  - Erweiterung mit echtem arm64-Programm, App-Gruppe auf beiden Seiten
  - Signatur gueltig, Version 2026.9.3.3 einkompiliert

Vorher: design-system neu gebaut, Typecheck sauber, 173/173 Tests gruen.
2026-09-03 09:45:28 +02:00
Paul Nothaft 63496a8e3b Plugin-Schleife stuerzte ab: addSourceFile setzt eine Gruppe "Plugins" voraus
Erster Lauf der neuen Schleife auf einem Mac. Sie brach ab mit
"TypeError: Cannot read properties of null (reading 'path')" in
correctForPluginsPath - addSourceFile() ohne Gruppe geht intern ueber
addPluginFile, und das sucht eine Projektgruppe namens "Plugins". Ein
Capacitor-Projekt hat die nicht, pbxGroupByName liefert null.

Die Gruppe wird jetzt angelegt, falls sie fehlt. Sie ist reine Ablage im
Navigator. Belegt am neu erzeugten Projekt: beide Plugin-Dateien mit Pfad
App/<name>.swift in der Sources-Phase des App-Ziels, neben AppDelegate und
SceneDelegate.
2026-09-03 09:41:54 +02:00
tobias 6016053a92 Kalender ueber EventKit, Orte nach dem Import, Fotos verkleinert, Signalsperre (2026.9.3.1-.3)
- Kalendertermine gehen nativ ueber EventKit statt ueber das Teilen-Blatt:
  Apples Kalender meldet sich beim System gar nicht als Teilen-Ziel an, ein
  anderes Dateiformat haette also nie geholfen (Abschnitt BS).
- Der Rueckblick stoesst jetzt selbst das Screening an, wenn er Fahrten
  angelegt hat - sonst blieb eine importierte Fahrt ohne Ort liegen, solange
  das Fahrzeug steht (Abschnitt BT).
- Fahrzeugfotos werden vor dem Upload im Browser auf 2000 px/WebP gebracht.
  Gemessen: 3.099.022 -> 325.994 Bytes. Damit ist die Nachrichtengrenze der
  WebSocket-Verbindung kein Thema mehr ("connection lost" auf der realen
  Instanz). Formate, die der Browser weder umwandeln noch anzeigen kann,
  werden mit klarer Meldung abgelehnt statt roh gespeichert.
- Zwei Wechsel des Fahrtsignals in derselben Sekunde kosteten eine ganze
  Fahrt: das "on" ueberholte den noch laufenden Beende-Vorgang und fiel durch
  beide Zweige. Neue asyncio-Sperre plus Regressionstest, der ohne sie
  nachweislich rot ist (Abschnitt BU).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 01:59:32 +02:00
tobias 8c95e50554 Teilen-Weg repariert, Zwischenablage nativ, Sprung in den Tankvorgang (2026.9.2.24/.25)
BEFUND. Die Uebergabe der Teilen-Erweiterung an die App konnte nie
funktionieren: sie legte den Beleg im Gruppen-Container ab, die App suchte ihn
ueber @capacitor/preferences - und dessen iOS-Code liest IMMER
UserDefaults.standard der App, configure({group}) setzt nur ein
Schluessel-Praefix. Falscher Behaelter, falscher Schluessel, und weil ein
fehlender Eintrag der Normalfall ist, meldete nichts einen Fehler.

UMBAU. Die Erweiterung schreibt das PDF als Datei in den Gruppen-Container und
gibt den Pfad im Aufruf mit; die App liest ihn ueber @capacitor/filesystem
(volle file://-Pfade) und loescht ihn danach. Abgeholt ueber getLaunchUrl()
und appUrlOpen - iOS benutzt beide. Nebenbei entfallen die
Base64-Aufblaehung und die 4-MB-Grenze der UserDefaults.

ZWEI WEITERE FEHLER AUF DEMSELBEN WEG. Ein Beleg zu einem bestehenden
Tankvorgang lief in die 20-Sekunden-Frist, obwohl der Server laengst fertig
war. Und beim Duplikat verriet das Backend nicht, welcher Vorgang gemeint ist
- jetzt kommt vorhandener_tank_id mit, und beide Oberflaechen springen
dorthin. Dazu: nach dem Speichern oeffnet sich der neue Vorgang, und waehrend
gelesen wird, steht "Tankbeleg wird verarbeitet ..." mit Zapfsaeule und
Kreisel im Formular.

ZWISCHENABLAGE. navigator.clipboard.read() gibt nur Text, HTML und Bilder
heraus - ein kopiertes PDF ist dort nie zu bekommen. UIPasteboard kennt die
Grenze nicht: ein kleines eigenes Plugin holt es, auch aus Mail (Anhang als
Verweis, ueber pasteboard.urls mit Sicherheitsbereich).

Der native Teil braucht einen Mac-Lauf; ios-teilen-einrichten.mjs spielt beide
Swift-Dateien bei jedem Bau ein.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 22:12:34 +02:00
tobias 36a6c21bfd Listenkoepfe vereinheitlicht, Zeilen mit Luft, Kalenderdateien mit Namen (2026.9.2.21-.23)
KOEPFE. Jahr und Monat tragen dasselbe Format (13px, Zweitfarbe, gemischt) -
vorher drei Elemente in drei Formaten. Die Versalien aus .20 waren falsch: das
ist der iOS-Stil vor 13. Vom Eigentuemer aus drei gerenderten Vorschlaegen
gewaehlt.

ZEILEN. padding-left 20px statt 0 - die Spalte des Aufklapp-Pfeils, damit die
Zeilen auf derselben Kante stehen wie der Text der Koepfe. Nachgemessen bei
375px: Beschriftungsspalte 157px, laengster Ortsname 142,7px.

GALERIE. Der gezeigte Platz ueberlebt den Seitenwechsel - er lag in der
Komponente und starb mit ihr. Im Panel ist er seit jeher modulweit.

KALENDER. Jeder Termin hiess "-.ics": in dateiname() fehlte das \w in der
Zeichenklasse, es ueberlebten nur w/ae/oe/ue/ss und der Bindestrich. Zwei
Termine ueberschrieben einander damit. Jetzt mit Umschrift und Rueckfall, und
ueber Share.files statt Share.url.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:15:50 +02:00
tobias d889353cbe Termine ohne Gedaechtnis, Listen nach HIG, zwei zu schmale Felder (2026.9.2.13-.20)
TERMIN. Der Werkstatttermin wanderte bisher ins Fahrzeugprofil, stand als
Merkmal an der Karte und liess sich "verwerfen" - eine zweite Verwaltung neben
dem Kalender. Auf Ansage entfernt: er dient jetzt allein dem Kalendereintrag
"was, wann, bei welcher Werkstatt". Dabei aufgefallen: die App schrieb gar kein
LOCATION in die .ics, obwohl das Panel es seit jeher setzt.

Ohne Speicherung startete das Feld leer ("tt.mm.jjjj", so gemeldet). Es
schlaegt jetzt die Faelligkeit der gewaehlten Art vor, sonst heute - dafuer
liefert service.ts serviceKandidaten(). "Andere" ergaenzt die Auswahl.

BREITEN. Am laufenden Panel gemessen: die vier festen 132px-Datumsfelder
brauchen ab 140px, damit die letzte Ziffer nicht unter dem Kalendersymbol
liegt (jetzt 150px); die Art-Auswahl braucht 178px fuer
"Hauptuntersuchung" (jetzt 190px). In der App war nichts davon kaputt - eine
dort vorsorglich eingebaute Breite ist wieder entfernt.

LISTEN. Fahrten- und Tankliste verloren 58px an tote Breite: 22px Einrueckung
der Monatsebene plus 40px linkes Polster der Zeile. Auf dem Telefon brach
"Eichstaett -> Adelschlag" deshalb um. Apples Richtlinien gruppieren ueber
Abschnittskoepfe statt Einrueckung; die Zeilen stehen jetzt alle auf derselben
Kante (34 statt 96px), der Monat traegt Versalien und Sperrung und liest sich
als Kopf. Beschraenkt auf .liste/.dm-liste - anderswo ist das Polster gewollt.

BILDER. Die Draufsicht ist aus der Galerie genommen (nurStatus), gehoert aber
weiter in die Auswahl der Einstellungen: nur dort laesst sie sich hochladen.
Die Galerie schneidet Fotos nicht mehr an - die Frontansicht verlor unten die
Stossstange.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 16:36:25 +02:00
tobias 38e7731a3d Rollende Zeitraeume, zwei Gesten, CI-Symbole und das Screening faellt nach (2026.9.2.8-.12)
Gemeldet vom Eigentuemer an der realen Instanz, in einem Zug abgearbeitet.

STATISTIK. "Monat kleiner als Woche" war kein Rechenfehler, sondern die
Definition: am 02.09. begann die Kalenderwoche am 31.08., der Monat erst am
01.09. Umgestellt auf rollende Fenster (1/7/30/365 Tage), damit 1 ⊂ 7 ⊂ 30 ⊂
365 an jedem Tag gilt. "Jahr" meint damit nicht mehr das Kalenderjahr - so
entschieden. Zwei neue Tests halten die Verschachtelung fest.

Aus dem Langzeitverbrauch sind Herleitung und Erklaerabsatz aus der ANSICHT
genommen, nicht aus der Rechnung; das Datum des letzten Tankstops ebenfalls.

UEBERSICHT. Unter "Letzter Tankvorgang" steht das Datum statt der Tankstelle.

BILDER. Die Draufsicht war der einzige sichtbare Bildplatz ohne Zugang -
bilder.py kannte den Dateinamen, BILDER/BILDPLAETZE nicht. Jetzt ein Platz wie
jeder andere. Die Vorschau in den Einstellungen schneidet Fotos nicht mehr an,
und der Bildbereich der Fahrzeugseite steht eingerueckt statt randlos.

ZAHNRAD. Es lag mit seiner 44px-Trefferflaeche 21px im Kachelinhalt und fing
die obere Haelfte des ersten Knopfes ab (Versicherung, "Inland"). Die Kopfzeile
bekommt jetzt die Hoehe, die es braucht.

SCREENING. Lief ausschliesslich bei einer Aenderung des Kilometerstands, also
nur waehrend der Fahrt - im Stand blieben Ortsnamen und Verbrauch liegen. Jetzt
zusaetzlich einmal nach dem Start, und danach faellt es selbst nach, solange
ein Lauf sein Ortsbudget aufbraucht UND dabei etwas aufloest. Kein Dauertakt:
die Kette endet, sobald ein Lauf mit uebrigem Budget durchkommt.

GESTEN. Zum Loeschen nach links wischen, jetzt auch im Radarchiv (das Panel
hatte die Mechanik, das Archiv war nie angeschlossen; die App hatte sie gar
nicht). Vom linken Rand nach rechts wischen = zurueck, jetzt auch in der App,
mit denselben Zahlen wie im Panel und in beiden mit sichtbarer Rueckmeldung.

CI-SYMBOLE. Das Kachel-Zahnrad ist settings-s - im Panel stand der Pfad
sechsmal wortgleich im Markup, jetzt einmal als CI.settingsS. Das
handgezeichnete Zahnrad der Seitenleiste ist ersetzt und geloescht. Die
Zapfsaeule des Tanken-Reiters ist fuel-station-s, Faktor 0,84 um die
Rastermitte - live per getBBox() gemessen, damit sie auf dieselbe senkrechte
Spanne kommt wie Statistik.

Zurueckgenommen: die Titel der Unterseiten stehen wieder mittig. Zentriert ist
Apples HIG fuer Navigationsleisten; die linksbuendigen Titel sind die der
Hauptbereiche und folgen der anderen Regel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 15:48:24 +02:00
Paul Nothaft e28abdce25 Abschluss festhalten: Teilen-Erweiterung laeuft, fuenf Befunde dokumentiert 2026-09-02 15:05:27 +02:00
Paul Nothaft c72dbc7963 iOS-App auf 2026.9.2.7 mit funktionsfaehiger Teilen-Erweiterung
Erste .ipa, die die Share-Erweiterung wirklich enthaelt. An der fertigen
Datei geprueft statt am Projekt:
  - DataMetric360Share.appex traegt ein echtes Mach-O arm64 Programm
  - App und Erweiterung tragen beide group.app.datametric360
  - URL-Schema datametric360 eingetragen
  - Ad-hoc-Profil mit beiden Geraeten, gueltig bis 29.08.2027
  - Signatur gueltig, Erweiterung eingeschlossen

Vorher: design-system neu gebaut, Typecheck sauber, 165/165 Tests gruen.
2026-09-02 15:04:45 +02:00
Paul Nothaft 2f7669b15b Erweiterung: gerade Anfuehrungszeichen beendeten den Swift-String vorzeitig
Erster Kompilierlauf der Datei ueberhaupt - vorher hing sie in keiner
Sources-Phase und wurde nie uebersetzt. Die deutschen Anfuehrungszeichen in
der Hinweismeldung waren gemischt: oeffnend typografisch (U+201E), schliessend
aber gerade (U+0022). In einem Swift-String beendet das gerade Zeichen die
Zeichenkette, der Rest der Zeile war Unsinn.

Jetzt beidseitig typografisch. In Zeile 97 stehen nur noch die zwei geraden
Anfuehrungszeichen des Strings selbst.
2026-09-02 15:01:36 +02:00
Paul Nothaft ff8a623e46 Quellpfad der Erweiterung relativ zum Projektordner angeben 2026-09-02 14:58:08 +02:00
Paul Nothaft e04f97c247 Erweiterung wurde ohne Programm gebaut - Quelle hing in keiner Sources-Phase
An der fertigen .ipa gemessen: DataMetric360Share.appex enthielt nur
Info.plist, Signatur und Profil - keine ausfuehrbare Datei, obwohl
CFBundleExecutable eine versprach. Xcode meldet dabei nichts, der Bau gilt
als erfolgreich. Das Teilen-Blatt haette schlicht nicht funktioniert.

Ursache: addSourceFile() legt die Datei nur in die uebergebene Gruppe, wenn
man ihm eine mitgibt - an eine Sources-Phase haengt es sie dann nicht. Der
PBXBuildFile-Eintrag existierte, war aber verwaist, und alle Sources-Phasen
des Ziels blieben leer.

Die Quelle wird jetzt direkt beim Anlegen der Sources-Phase uebergeben.
Belegt am neu erzeugten Projekt: zwei Sources-Phasen, die der App mit
AppDelegate/SceneDelegate, die der Erweiterung mit ShareViewController.
2026-09-02 14:55:04 +02:00
Paul Nothaft 25aa118e4b Zweiter Befund: App ohne App-Gruppe signiert; Portal-Schritt 3 fehlt noch 2026-09-02 14:46:06 +02:00
Paul Nothaft 1f87f6d1c7 App wurde ohne App-Gruppe signiert - Vergleich traf sie nie
An der fertigen .ipa gemessen: die Erweiterung trug
com.apple.security.application-groups, die App nicht. Der Beleg waere also
abgelegt, aber nie gefunden worden - Teilen meldet Erfolg, die Tanken-Seite
bleibt leer. Genau die Sorte stiller Ausfall, gegen die dieses Projekt sonst
ueberall anbaut.

Ursache: die pbxproj fuehrt Werte mal mit, mal ohne Anfuehrungszeichen. Was
addTarget schreibt, ist gequotet ("app.datametric360.teilen"); was Capacitor
fuer die App erzeugt, steht nackt da (app.datametric360). Der Vergleich auf
die gequotete Form lief damit fuer die App immer ins Leere, und
CODE_SIGN_ENTITLEMENTS wurde dort nie gesetzt.

Jetzt wird entquotet verglichen. Belegt am neu erzeugten Projekt: vier
CODE_SIGN_ENTITLEMENTS statt zwei - Debug und Release beider Ziele.
2026-09-02 14:40:56 +02:00
Paul Nothaft 0752b7dc0b Stand festhalten: Share-Erweiterung baut, es fehlen die drei Portal-Schritte 2026-09-02 14:28:29 +02:00
Paul Nothaft e250b6c5e1 Share-Erweiterung wurde doppelt eingebettet - eine Copy-Phase zu viel
Erster Lauf des Einrichtungsskripts auf einem Mac. Xcode brach ab mit
"error: Unexpected duplicate tasks", zweimal ValidateEmbeddedBinary auf
dieselbe DataMetric360Share.appex.

Ursache: addTarget() legt fuer den Typ app_extension selbst schon eine
Copy-Phase nach PlugIns am ersten Ziel an. Das Skript fuegte danach eine
zweite mit demselben Ziel und derselben Datei hinzu. Die explizite Phase
entfaellt jetzt; belegt am erzeugten Projekt: genau eine Phase mit
dstSubfolderSpec 13 statt zwei.

Alles Uebrige des Skripts lief auf Anhieb: Ziel, Entitlements, URL-Schema,
Idempotenz.
2026-09-02 14:25:28 +02:00
tobias 12bc9e4220 Signierskript: Kopf auf das bezahlte Konto, OTA-Buendel auf 2026.9.2.7
Der Kopfkommentar von ios-signieren.sh beschrieb noch das kostenlose
Personal Team - keine Geraeteverwaltung, Ablauf nach 7 Tagen,
"-exportArchive ungeprueft" - und widersprach damit dem eigenen Rumpf
zwanzig Zeilen tiefer, wo die Widerlegung vom 2026-08-29 steht (Team
RMACS9VLS4, Laufzeit ein Jahr). Erprobt ist der Export laengst: die .ipa
vom 29. und 30.08. sind auf genau diesem Weg entstanden und wurden ueber
die Luft installiert.

Neu benannt ist damit auch die eigentliche Voraussetzung: nicht "iPhone
am Kabel", sondern "Geraet im Team eingetragen" - einmalig per Kabel oder
auf developer.apple.com.

Das OTA-Buendel stand auf 2026.9.2.5, die manifest.json auf 2026.9.2.7:
die beiden Symbol-Commits fehlten darin. Neu gebaut, 265.969 Bytes,
sha256 55a0da03.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 12:29:34 +02:00
tobias b73a8fb334 Meldezeit auch nach hinten prüfen - "Geparkt seit 20698 Tg." war die Unix-Epoche
geraetezeit() prüfte nur, ob die Meldezeit in der Zukunft liegt. Ein Sensor,
der 0 meldet, rutschte durch, und fromtimestamp(0) ergibt 1970 - auf der realen
Instanz sichtbar als "Geparkt seit 20698 Tg. 9 Std. 39 Min.".

Neu geraetezeit_plausibel(): nicht aus der Zukunft und nicht älter als
MELDEZEIT_RUECKLAUF (365 Tage). Geprüft wird an drei Stellen - beim Auslesen
der Gerätezeit, beim Setzen des Parkbeginns und beim LADEN aus dem
Laufzeit-Store, denn ein einmal falsch gespeicherter Wert überlebt sonst jedes
Update.

Fängt auch eine im Setup falsch zugeordnete ID-Entität ab: das flespi-Gerät hat
drei ID-Sensoren, deren Namen dem Zeitstempel ähneln, und deren Werte ergeben
als Unix-Zeit ebenfalls 1970.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:58:57 +02:00
tobias 960e07d7d7 Übersicht-Symbol: nochmals 10 % (Faktor 1,21) und auf die Rastermitte gelegt
Die eigene Mitte des Symbols liegt bei x=13, nicht 12. Ohne Verschiebung wäre
Faktor 1,21 rechts wieder angestoßen (24,10 statt 22,89). Jetzt Geometrie
1,11-22,89, mit Strichstärke 0,36-23,64 - im Raster.

Gemessen: 21,8 x 12,5 gegen ursprünglich 18,0 x 10,3, gerenderte Strichstärke
weiterhin 1,5 wie bei den Nachbarn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:53:17 +02:00
tobias 0b497642f4 Übersicht-Symbol: Faktor 1,1 statt 1,33 - der erste Versuch stand über
Faktor 1,33 hatte das Symbol auf volle Rasterbreite gezogen und dabei von
x=3,99 bis x=27,9 geschoben - knapp vier Einheiten über den rechten Rand des
24er-Rasters, also rechts abgeschnitten. Beim Nachmessen waren Breite und Höhe
geprüft, aber nicht die Position.

Jetzt Faktor 1,1 um die Rastermitte (12,12): 19,8 x 11,3 bei x 3,2-23,0 und
y 7,6-18,9, also die vom Eigentümer gewünschten 10 % ohne Überstand. Alle fünf
Tab-Symbole nachgemessen, diesmal mit Rändern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:50:23 +02:00
tobias 2af5c469d6 Ortsnamen aus dem Backend, Verbrauchsfaktor aus dem Tankbeleg, Radzaehler ueber Fahrten
Ortsauflösung serverseitig (geokodierung.py): der Ort steht jetzt in der Fahrt
statt im Zwischenspeicher jedes Geräts. Beide Oberflächen fragen nichts mehr ab.

Verbrauchskorrektur (verbrauchskorrektur.py): Faktor = Beleg-Liter / Summe der
Einzelfahrten, voll zu voll. Statt einer Schranke am Faktor wird der Nenner
geprüft - decken die erkannten Fahrten 90-110 % der Tacho-Spanne ab? Ausgelöst
beim Schreiben eines Tankvorgangs, nicht erst bei der nächsten Fahrt.

Radzähler: summiert die Fahrten des montierten Satzes statt Tacho-Deltas. Der
alte Weg hatte Fahrzeugwechsel als gefahrene Strecke verbucht (375.962 km bei
einem Tacho von 61.823).

Null heißt unbekannt: ein Kilometerstand von 0 oder ein leeres Feld werden zu
None normalisiert, statt an sechs Lesestellen als "Tachostand null" zu gelten.
Momentanwerte der Sensoren gelten nur für einen Tankvorgang von jetzt.

Oberfläche, beide Codebasen: Wertespalte der Fahrtenliste ausgerichtet, Ort ->
Ort in "Zuletzt", "Räder" statt "Reifen", "Termin vereinbart" entfernt,
Markenlogo 20 % größer, Übersicht-Symbol auf volle Rasterbreite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:28:37 +02:00
tobias 78a8017e95 Verbrauch: Tankstand ab Fahrtbeginn statt davor (2026.9.1.24)
DER VERBRAUCH WAR SYSTEMATISCH ZU HOCH

Der Startwert kam aus wert_bei(), also dem letzten Literstand VOR der Fahrt -
und das ist im Stand der Wert vom Ende der VORIGEN Fahrt. An der Fahrt
Eichstaett - Adelschlag nachgemessen: 36,4 l um 13:52 (Ende der vorigen Fahrt),
36,2 l um 16:20:49 (erster Datensatz der neuen). In der Standzeit dazwischen
ist niemand gefahren; diese 0,2 l Setzung wurden der neuen Fahrt zugerechnet.

  alt: 36,4 - 35,6 = 0,8 l auf 6,896 km -> 11,6 l/100 km
  neu: 36,2 - 35,6 = 0,6 l auf 6,896 km ->  8,7 l/100 km

Neu wert_ab() in verlauf.py: der erste Wert AB dem Zeitpunkt. Fuer einen
ZAEHLER ist "zuletzt davor" richtig (der Kilometerstand aendert sich im Stand
nicht), fuer einen gemessenen FUELLSTAND nicht - der driftet. Das Fahrtende
bleibt bei wert_bei(). Livepfad und Rueckblick benutzen beide die neue
Funktion.

Bereits gerechnete Fahrten behalten ihren Wert: _verbrauch_screenen() fuellt
nur fehlende. Die Neuberechnung kommt mit dem Korrekturwert aus den
Tankvorgaengen, den der Eigentuemer vorgeschlagen hat.

ANZEIGE

Hoechstgeschwindigkeit ohne Nachkommastelle. Statuswort raus aus der
Fahrtenliste - "vollstaendig" sagt dem Nutzer nichts, und "offen" verspricht
Daten, die bei einer laengst abgeschlossenen Fahrt nie mehr kommen; fehlt ein
Wert, steht dort ein Strich.

Strecke und Verbrauch stehen jetzt in einer Spalte: die Wertspalte wuchs mit
dem Inhalt, gemessen 86px gegen 37px, und die Zahlen standen versetzt. Feste
Mindestbreite in beiden Oberflaechen, nachgemessen alle Zeilen 92px.

Verifiziert: py_compile, Panel als Modul, tsc --noEmit sauber, 165/165 Tests,
wert_ab() gegen vier Randfaelle, im Browser nachgesehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 21:13:43 +02:00
tobias ce287ccac2 Fahrtenliste zeigt Orte aus dem Zwischenspeicher (2026.9.1.23)
Was die Einzelfahrt einmal aufgeloest hat, zeigt die Liste umsonst mit:
ortAusCache() liest nur den Zwischenspeicher und fragt nie das Netz. Ein Abruf
je Zeile waere genau der Vorratsabruf, um dessen Unterlassung Nominatim bittet.

In der Liste steht nur der ORT, nicht die Anschrift (Wunsch des Eigentuemers):
eine Zeile traegt Datum, Art, Strecke und Verbrauch, zwei volle Anschriften
passen dort nicht. Auf der Einzelfahrt bleibt die vollstaendige Anschrift.

Der Ortsname wird beim Aufloesen separat gemerkt ("ort:"-Schluessel), nicht aus
der Anschrift geschnitten - faellt Nominatim auf display_name zurueck, stuende
hinter dem letzten Komma das LAND.

Der Rueckfall war trotzdem noetig, und das Ausliefern hat es gezeigt: die Liste
stand weiter voller Anschriften, weil der Ortsname nur bei einem frischen Abruf
entsteht und die Adressen laengst im Speicher lagen. stadtAusAnschrift()
schneidet ihn deshalb aus einer gespeicherten Anschrift - aber nur, wenn hinter
dem letzten Komma eine Postleitzahl steht. Damit greift es bei unserem Format
"<Strasse>, <PLZ> <Ort>" und niemals bei display_name.

VERIFIZIERT: 7 Faelle gegen den Schnitt (mit/ohne Strasse, benannter Platz,
ohne PLZ, display_name-Form, null), Panel als Modul, tsc --noEmit und
vite build sauber, 165/165 Tests gruen. Im Browser: "Eichstaett -> Adelschlag"
in der Liste, volle Anschrift auf der Einzelfahrt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 20:36:24 +02:00
tobias 0f680780a4 Einzelfahrt: Orte aus den Koordinaten, "Min." statt "min" (2026.9.1.20)
START UND ZIEL STANDEN AUF "UNBEKANNT"

start_address/end_address stehen in HANDFELDER - das Backend fuellt sie nie von
selbst, sie kommen nur aus einer von Hand angelegten oder bearbeiteten Fahrt.
Die Koordinaten dagegen traegt das Screening ein: 12 von 14 Fahrten im
Testbestand hatten Start- und Zielposition, aber genau eine hatte eine Adresse.

Beide Oberflaechen loesen sie jetzt beim Oeffnen der Einzelfahrt auf, ueber
denselben Cache wie die Standortansicht - ein Abruf je Ort, danach nie wieder.
Bewusst NICHT in der Liste: ein Vorratsabruf fuer alle Fahrten waere genau das,
worum Nominatim in seinen Nutzungsbedingungen bittet, es nicht zu tun.

Bis zur Antwort steht "wird ermittelt ...", ohne Koordinaten oder ohne Treffer
"unbekannt". Das Panel prueft vor dem Eintragen, ob noch dieselbe Fahrt offen
ist - wer waehrend des Abrufs weiterblaettert, soll nicht die Adresse der
vorigen Fahrt in der neuen sehen.

Live: Start "Schottenau, 85072 Eichstaett", Ziel "Am Anger 7a, 85111
Adelschlag".

"MIN." STATT "MIN"

Deutsche Abkuerzung, wie die Oberflaeche sie an anderer Stelle laengst
verwendet ("Geparkt seit 2 Tg. 16 Std. 12 Min."). In dauerText() bzw. dauer(),
also ueberall wo eine Dauer erscheint. Zwei Testerwartungen mitgezogen.

Verifiziert: Panel als Modul, tsc --noEmit und vite build sauber, 165/165
Tests gruen, im Browser nachgesehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:38:09 +02:00
tobias 97a1482bb5 Drei Erlaeuterungen aus der Fahrt-Detailansicht entfernt (2026.9.1.19)
Auf Wunsch des Eigentuemers entfallen die Kleingedruckten unter drei Zeilen:

  "Strecke / Dauer, Standzeiten eingerechnet"   bei der Durchschnittsgeschwindigkeit
  "groesster gemeldeter Wert, alle 10 s ein Datensatz"  bei der Hoechstgeschwindigkeit
  "Naeherung aus Tankfuellstand"                bei Verbrauch

In beiden Oberflaechen (Paritaetsregel). Die Ortsangaben unter Start und Ziel
bleiben, das sind Daten und keine Erlaeuterung.

Die Herleitungen selbst stehen weiterhin im Quelltext: durchschnitt_kmh() in
verlauf.py rechnet unveraendert Strecke durch Dauer, hoechstwert_im_fenster()
erklaert in seinem Docstring, warum der gemeldete Wert nicht die tatsaechliche
Spitze ist, und verbrauch_aus_literstaenden() begruendet die Naeherung samt
MIN_STRECKE_VERBRAUCH_KM. Es verschwindet nur die Anzeige, nicht das Wissen.

Verifiziert: Panel als Modul geparst, tsc --noEmit sauber, 165/165 Tests gruen,
audi_ha_test auf 2026.9.1.19 ohne Fehler gestartet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:31:14 +02:00
tobias 9a4999ccbc Strecken gestaffelt anzeigen (2026.9.1.18)
Die gewonnene Genauigkeit kam nicht an: de() formatiert ohne Nachkommastelle,
aus 6,896 km wurde in beiden Oberflaechen "7 km". Den ganzen Tag auf zehn Meter
genau gemessen und im letzten Schritt weggerundet.

Staffelung, vom Eigentuemer festgelegt: unter 1 km Meter ("400 m"), bis
99,9 km eine Nachkommastelle ("6,9 km"), ab 100 km ganze Kilometer ("104 km").
streckeTeile()/streckeText() in beiden Codebasen, wortgleich.

Gestaffelt wird nach dem GERUNDETEN Wert: 0,9996 km sind gerundet 1000 m und
gehoeren in die km-Stufe, sonst stuende dort "1.000 m". streckeTeile() gibt
Wert und Einheit getrennt zurueck, weil die Detailansicht beide in getrennten
Elementen setzt - ohne das stuende unter "400" weiterhin "km".

Im Panel heisst die Funktion streckeText, nicht strecke: den Namen gibt es dort
schon fuer die Ortsangaben einer Fahrt. node --check hat die Kollision gefunden.

Angewandt auf Einzelfahrt und Jahres-/Monatssummen. Nicht auf Kilometerstaende,
Serviceintervalle oder Reichweite - das sind Zaehlerstaende und Prognosen.

VERIFIZIERT: 12 Faelle gegen die Staffelung samt beider Grenzen, 165/165 Tests
gruen, Panel als Modul, tsc --noEmit und vite build sauber, im Browser
nachgesehen: die Fahrt vom 01.09. steht jetzt mit 6,9 km statt "7 km".

Die angepasste Testerwartung ist selbst ein Beleg: sie verlangte "43 km" fuer
eine Summe von 42,5 km - die alte Anzeige rundete schon dort, wo es etwas zu
zeigen gab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:29:19 +02:00
tobias 9d70192da3 Audit: Livepfad und Rueckblick messen wieder dasselbe (2026.9.1.17)
BEFUND 1 (behoben): dieselbe Fahrt, zwei Strecken

screening.py las den Verlauf ueber verlauf_lesen(), das den Stand zu Beginn
des Fensters mitliefert. historienimport.py schnitt sein Fenster streng heraus
und verlor genau diesen Punkt. An der echten Fahrt nachgemessen: 6,896 km
gegen 6,816 km fuer denselben Zeitraum.

Neu verlauf.zaehlerstrecke(punkte, start, ende) - der Anker ist der letzte
Datensatz am oder vor dem Beginn, dieselbe Ueberlegung wie bei wert_bei().
Beide Wege schneiden jetzt durch dieselbe Funktion. Nachgewiesen: Rueckblick
6,896, Livepfad 6,896.

Dritter Fall dieser Art nach UNPLAUSIBLE_KMH und MINDESTDAUER_S - und ich
hatte ihn am selben Tag selbst eingebaut.

BEFUND 2 (behoben): STANDARDWERTE gingen unnoetig ueber die Leitung.
zuordnung.py schickte sie mit jeder Katalogantwort; gelesen hat sie seit dem
Entfernen des Zuruecksetzen-Knopfes niemand mehr. Die Konstante bleibt, sie
traegt intern die Vorgabewerte und leitet SCHLUESSEL ab.

BEFUND 3 und 4 bleiben offen und gehoeren dem Eigentuemer: die vier Tage alte
Reichweite auf der Uebersicht (RANGE_SENSOR besser leer lassen) und die
Nullfahrt-Regel, die nur der Rueckblick kennt - im Livepfad hiesse sie, einen
bereits gespeicherten Datensatz automatisch zu loeschen.

SAUBER: 24 Backend-Dateien py_compile, Panel als Modul geparst, tsc --noEmit
und vite build sauber, 165/165 Tests, 27 Katalogeintraege gegen 27
Dataclass-Felder ohne Abweichung, jedes Listenfeld mit so vielen Beispielen wie
Positionen, keine verwaisten Verweise, in der Konsole nur das bekannte
ServiceWorker-Rauschen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:09:59 +02:00
tobias 8e73621d85 Versionssprung, damit das rechtsbuendige Beispiel ankommt (2026.9.1.16)
Das CSS aus 2026.9.1.15 war korrekt, kam im Browser aber nicht an: Panel und
Stylesheets werden mit ?v=<Manifest-Version> geladen, und ich hatte nach dem
Versionssprung noch einmal am CSS gefeilt. Die Adresse blieb gleich, der
Browser servierte die alte Datei aus dem Zwischenspeicher.

Nachgewiesen mit getComputedStyle: display block statt flex, font-size 14px
statt 12.5px. Nach erzwungenem Neuladen derselben Datei stimmte beides, und
der Hinweis stand rechtsbuendig auf derselben Kante wie bei den Einzelfeldern.

Regel daraus, in AGENTS.md festgehalten: die Manifest-Version wird als LETZTES
erhoeht, nach der letzten Aenderung an Panel oder Stylesheet.

Verifiziert im Browser: alle acht Tuer- und Fensterfelder gruen, Erwartungswert
je Position rechtsbuendig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:03:57 +02:00
tobias 7e2854fe8d Setup: Erwartungswert je Listenposition, gruen auch dort (2026.9.1.15)
Tuer- und Fenstersensoren haben vier Positionen, aber nur einen Beispielnamen
(front_left_door). Der beschreibt die erste; die anderen drei koennen ihn nie
enthalten. Folge: nur die erste Position konnte gruen werden, und im Kopf
stand ein Erwartungswert, der fuer drei von vier Zeilen nicht galt.

Neu "beispiele" je Position im Feld-Katalog und feldBeispiel(feld, idx) im
Panel. Der Erwartungswert steht jetzt an jeder Position statt im Kopf -
rechtsbuendig, in der kleinen grauen Schrift des Positionslabels. Auch die
Sortierung der Vorschlagsliste nutzt ihn, sonst stuende in allen vier Zeilen
dieselbe Entitaet oben.

Typografie: .setup-feld-hinweis ist fuer die Kopfzeile gebaut (14px, --fg,
rechtsbuendig, max-width 55%). Im 12,5px-Positionslabel wirkt das falsch herum.
Eine Regel im Kontext .setup-unterfeld-label .setup-feld-hinweis laesst Groesse
und Farbe erben; die Rechtsbuendigkeit kommt aus justify-content: space-between
am Label, wie eine Ebene darueber bei .setup-feld-kopf.

Verifiziert: alle acht Positionen tragen ihren eigenen Beispielnamen,
py_compile und Panel als Modul sauber, audi_ha_test ohne Fehler gestartet.
Die Darstellung selbst habe ich nicht im Browser gesehen - das Panel liess
sich in der Testinstanz nicht ansteuern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:00:16 +02:00
tobias 09c7461892 Rueckblick misst auf Meter, Streckenwahl gemeinsam (2026.9.1.14)
Der erste Wurf (2026.9.1.12) verwarf jedes Importfenster mit distanz == 0. Zu
grob: seit der GNSS-Verfeinerung messen wir Meter, und eine Fahrt von 400
Metern steht im CAN-Wert als Null. Der Eigentuemer hat es auf den Punkt
gebracht - "0,0 moechte ich nicht, ab 0,1 schon".

Der Rueckblick liest jetzt ebenfalls den GNSS-Zaehler und entscheidet auf eine
Nachkommastelle.

Dabei stand die Toleranzlogik kurz doppelt im Code - in screening.py und in
historienimport.py. Genau die Doppelung, die dieses Projekt bei
UNPLAUSIBLE_KMH und MINDESTDAUER_S schon einmal teuer bezahlt hat. Sie liegt
jetzt gemeinsam in verlauf.py:

  strecke_waehlen(grob, fein) - die feine Zahl gilt, solange sie in der
  Rundungsunschaerfe des Ankers liegt, oder wenn es keinen Anker gibt.

  ist_gefahren(distanz) - ab 0,1 km ja. Eine unbekannte Strecke gilt als
  gefahren; nur die gemessene Null ist ein Nein.

VERIFIZIERT: 13 Faelle gegen beide Funktionen, darunter die zwei echten
Fahrten, die 400-Meter-Fahrt, die 100-Meter-Untergrenze, 40 Meter, fehlender
Anker, fehlender GNSS-Wert, gedrifteter Zaehler und beide Seiten der Grenze.
Import live: 1 Fenster uebersprungen, null 0-km-Fahrten im Bestand. Livepfad
live: vier alte Fahrten korrekt abgelehnt.

NICHT live gezeigt: ein Fenster mit 0,1-0,9 km, das der Import nun behaelt -
der Recorder enthaelt kein geschlossenes Fenster dieser Groesse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:47:51 +02:00
tobias be95b40d79 Rueckblick legt keine Fahrten ohne gefahrene Strecke mehr an (2026.9.1.12)
Der Eigentuemer hatte auf der realen Instanz alle Nullfahrten geloescht - und
bei JEDEM Import kam eine davon wieder: 31.08.2026, 14:48-15:21, 33 Minuten,
209177 -> 209177 km, vmax 3 km/h. MINDESTDAUER_S liess sie durch, weil sie mit
33 Minuten lang genug war; eine Pruefung auf die Strecke gab es im Rueckblick
nicht.

Steht der Kilometerstand an beiden Enden gleich, hat sich das Fahrzeug nicht
bewegt - Zuendung an, aber nicht gefahren. Solche Fenster werden jetzt
uebersprungen und als eigene Zahl gemeldet, damit der Import nicht
stillschweigend etwas weglaesst.

Nur die GEMESSENE Null wird verworfen, nicht die unbekannte: liegt kein
Kilometerstand vor, bleibt distanz None und die Fahrt wird angelegt. Der Preis,
bewusst getragen: eine echte Fahrt unter einem Kilometer, bei der der Zaehler
nicht umsprang, geht verloren - sie waere aber genau der Nulleintrag, den der
Eigentuemer nicht im Bestand haben will.

Beide Oberflaechen melden die neue Zahl (Paritaetsregel).

VERIFIZIERT im Testcontainer ueber den echten Dienst: 1 Fahrt angelegt,
2 Fenster als "nicht gefahren" uebersprungen, null Fahrten mit 0 km im Bestand.
py_compile, Panel als Modul, tsc --noEmit sauber, 165/165 gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:36:33 +02:00
tobias 02187ee129 Setup: gruene Bestaetigung statt Zuruecksetzen-Knopf (2026.9.1.11)
Passt die zugeordnete Entitaet zum Beispielnamen der Rolle (FELDER-Schluessel
"beispiel"), faerbt sich das Feld leicht gruen. Dieselbe Pruefung, die
entitaetScore() ohnehin mit 8 gewichtet - hier nur fuers Auge, damit man eine
richtig belegte Rolle sieht, ohne den langen Entity-Namen Zeichen fuer Zeichen
mit dem Beispiel daneben zu vergleichen.

Bewusst nur ein Hinweis, keine Bedingung: Installationen mit anderer
Datenquelle haben andere Namen und sind trotzdem richtig zugeordnet. Deshalb
gruen als Bestaetigung, aber kein Rot und keine Warnung, wenn es fehlt. Und
nicht waehrend gesucht wird - dann steht die Sucheingabe im Feld, nicht die
Zuordnung.

Die Farbe kommt per color-mix aus dem vorhandenen --ok-Ton und dem Feldgrund
statt aus einem eigenen Gruen, damit sie in beiden Themen ruhig bleibt.

"Auf Standard zurueckstellen" entfernt (Wunsch des Eigentuemers): Knopf,
Symbol, Zuhoerer, CSS und der nur dafuer vorhandene Helfer setupStandardwert().
Keine verwaisten Verweise mehr im Quelltext.

Panel als Modul geparst, Bundle und Manifest auf 2026.9.1.11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:21:52 +02:00
tobias 24bcc42655 Kein Skalenfehler: GNSS und Raeder stimmen auf 0,3 Prozent ueberein
Nach den beiden Fahrten stand der Verdacht im Raum, der GNSS-Zaehler messe
systematisch 1,5-2 Prozent zu kurz - gemessen gegen Streckenschaetzungen aus
Google Maps. Das war falsch.

Die bessere Messung kam vom Eigentuemer: zwischen zwei Spruengen des
CAN-Kilometerstands ist das Fahrzeug exakt 1,000 km gefahren. Was der
GNSS-Zaehler in derselben Spanne zaehlt, ergibt den Massstab ohne Schaetzung.

Fahrt 2, erster bis letzter Sprung: Raeder 5,000 km, GNSS 5,015 km,
Verhaeltnis 0,9970 - fuenfzehn Meter auf fuenf Kilometer.

Fahrt 1 liefert 1,0879, ist aber unbrauchbar: ihr erster Kilometersprung ist
der Datensatz mit der Ruecksetzung. Einzelne Runden streuen ohnehin von 0,849
bis 1,194 km, weil ein Sprung irgendwo in den zehn Sekunden zwischen zwei
Datensaetzen liegen kann.

Ein Korrekturfaktor ist damit vom Tisch, bevor er gebaut wurde. Die
vermeintlich fehlenden 300 m der Fahrt 2 sind keine Messabweichung: der Tacho
lief von 61817 auf 61823, die echte Strecke liegt zwischen 6 und 7 km, und
6,896 sitzt mitten darin.

Nur Dokumentation, kein Code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:05:56 +02:00
tobias af83805c34 Fahrtstrecke auf 100 m: CAN als Anker, GNSS als Nachkommastelle (2026.9.1.10)
Zwei echte Fahrten am 01.09.2026 haben die Datenform geliefert, auf die
2026.9.1.5 gewartet hat.

strecke_aus_zaehler() summiert die Zuwaechse des geraeteeigenen
Kilometerzaehlers und behandelt einen Ruecksprung als Ruecksetzung. Weder der
rohe Zaehler noch die Teilstrecken taugen allein: der Zaehler verliert bei
einer Ruecksetzung alles Vorherige (Fahrt 1: 0,415 km), die Teilstrecken
verlieren einzelne Datensaetze (Fahrt 2: 4 von 50 null, 0,2 km zu wenig). An
jedem Datensatz sind beide identisch - sie gehen nur verschieden kaputt.

_gnss_verfeinern() ersetzt die grobe Strecke nur, wenn die feine innerhalb der
Rundungsunschaerfe des Ankers liegt (GNSS_TOLERANZ_KM = 1.0; beide Enden des
CAN-Werts sind auf ganze Kilometer gerundet). Sonst behaelt der Fahrzeugwert
recht.

VERIFIZIERT gegen die echten Fahrten:
- Fahrt 1 (mit Ruecksetzung): 6,958 km, Google Maps sagt 7,1
- Fahrt 2 (sauber): 6,816 km
- live: 'Strecke auf 6.896 km verfeinert (Kilometerstand sagte 7.0 km)'
- drei aeltere Fahrten korrekt abgelehnt (209178 km gegen 21 km Anker)
- sechs Randfaelle: zwei Ruecksetzungen, ein Punkt, leer, unlesbare Werte

Nebenbei bestaetigt: NACHLAUF_S = 900 (Zuendung aus 16:33:39, Trip-Signal aus
16:48:44, gespeichertes Ende 16:33:36); der Rueckwaertssprung-Schutz griff live
beim Fahrzeugwechsel (-147361 km verworfen, Fahrt blieb offen); die
RPM-Zuendung prellt nicht; das Bewegungstor meldet wieder Stillstand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:31:33 +02:00
tobias 0124254ea9 Nachlauf-Abzug nur, wenn das Trip-Element ihn erzeugt (2026.9.1.9)
_echtes_ende() zog NACHLAUF_S bedingungslos ab. Richtig, solange die Fahrten
vom Trip-Signal ausgeloest werden - aber verlauf.fahrtsignal() faellt auf die
Zuendung zurueck, wenn TRIP_SENSOR nicht zugeordnet ist, und die hat keinen
Nachlauf. Jede Fahrt haette fuenfzehn Minuten verloren, jede kuerzere waere
ganz verschwunden.

Genau die Konstellation, in die eine bestehende Installation nach dem Update
ohne Zutun laeuft: die neue Rolle ist leer, bis jemand sie zuordnet. Gefunden
beim Durchdenken des Update-Wegs fuer die reale Instanz, nicht durch einen
Fehlerbericht.

Nachgewiesen: TRIP_SENSOR geleert, Zuendung fuenf Minuten an -> Fahrt mit
300 s angelegt statt verworfen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 12:24:50 +02:00
tobias 3cd9e6149b "502" beim Neustart entschaerft, OTA-Buendel nachgezogen (2026.9.1.8)
DIE APP MELDETE EINEN FEHLER, WO KEINER WAR

Nach "Jetzt neu starten" sprang der Knopf zurueck und darunter stand 502,
waehrend Home Assistant ordnungsgemaess hochfuhr. Der Nutzer musste annehmen,
der Neustart sei gescheitert, und drueckte erneut.

updateNeustartAusloesen() behandelte jeden Fehlschlag als Fehler. Ueber einen
Vorschaltserver kommt beim Neustart aber kein Abbruch zurueck, sondern eine
saubere 502/503/504 - der Proxy antwortet, Home Assistant noch nicht. Genau
der Fall, fuer den ApiFehler.istVoruebergehend seit dem 31.08. existiert; er
wurde hier nur nicht gefragt. Der Bildschirm bleibt jetzt auf "Home Assistant
startet neu ...", der bestehende Effekt raeumt ihn ab, sobald die Verbindung
wieder steht.

Im Panel war es kein Fehler (WebSocket bricht ab statt 502 zu liefern), aber
die Luecke war dieselbe, sobald ein Vorschaltserver dazwischen steht -
dieselbe Toleranz deshalb auch dort.

DAS OTA-BUENDEL STAND NOCH AUF 2026.8.31.5

Gemeldet: Kopfzeile "App ist aelter als der Server", Kachel "App ist aktuell
31.5", Integration auf 1.7. Kein Fehler - die Anzeige sagte die Wahrheit.
Sieben Versionen lang wurde nur die Integration ausgeliefert; npm run ota lief
seit dem 31.08. nicht mehr.

Merkposten: eine Aenderung an companion-app/src ist erst dann beim Nutzer,
wenn das Buendel neu gebaut wurde. Der Versionssprung allein beschreibt sonst
eine App, die es nicht gibt.

VERIFIZIERT: Manifest, bundle.json und die ausgelieferte Zip tragen alle
2026.9.1.8 und denselben SHA-256 c2f9192e...; tsc --noEmit sauber, 165/165
Tests gruen, Panel als Modul geparst, audi_ha_test fehlerfrei gestartet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 12:19:04 +02:00
tobias d1e3f7230c Parkbeginn nach einem Neustart aus der Aufzeichnung holen (2026.9.1.7)
Der Zuhoerer bekommt den Zuendungswechsel nur mit, wenn Home Assistant laeuft.
Ging die Zuendung aus, waehrend es unten war - ueber Nacht der Normalfall -,
feuerte nichts, und die Registrierung beim Hochfahren wird bewusst ignoriert
(sonst begaenne die Parkdauer bei jedem Neustart neu). Ergebnis war
"Parkdauer unbekannt", obwohl der Zeitpunkt im recorder steht.

_parkbeginn_nachholen() laeuft einmal in _nach_start und schlaegt ihn nach -
nur, wenn kein Wert gespeichert ist und die Zuendung auf "aus" steht.
Genommen wird der letzte echte Wechsel an -> aus im Fenster von sieben Tagen;
gibt es keinen, bleibt es bei "unbekannt" statt den Rand des Fensters zu
erfinden.

Wieder mit der Meldezeit desselben Datensatzes statt seiner Ankunftszeit -
dieselbe Begruendung wie in verlauf.geraetezeit(), hier rueckblickend.

VERIFIZIERT in audi_ha_test, der Test trennt genau die beiden Zeiten:
Speicher geleert, neu gestartet -> "Parkbeginn aus der Aufzeichnung
nachgeholt: 09:04:57". Das ist die Geraetezeit; angekommen war der Datensatz
um 09:44. Ein zweiter Neustart ruehrt den Wert nicht an, schreibt keine Zeile
und erzeugt keinen Fehler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 11:56:53 +02:00
tobias 553e128066 GNSS-Rollen vorbereitet, "Geparkt seit" von der Zuendung (2026.9.1.5/.6)
GNSS-STRECKENROLLEN, NOCH OHNE VERBRAUCHER

11806 steht seit dem 01.09. auf GNSS. Zwei neue Sensorrollen angelegt, damit
nach der ersten echten Fahrt nur noch gemessen und nicht mehr zugeordnet
werden muss: GNSS_KM_SENSOR (total_calculated_mileage, der robustere fuer eine
Fahrtstrecke - ein verlorener Datensatz verfaelscht die Differenz nicht) und
GNSS_TEILSTRECKE_SENSOR (segment_mileage, zeigt WO Strecke fehlt, taugt als
Gegenprobe). Kein Code liest sie; die Zuordnung aendert nichts.

KM_SENSOR bleibt der Anker - er ist der Tacho des Fahrzeugs und driftet nicht.
Der alte Warnhinweis dort zeigt jetzt auf die neue Rolle statt ins Leere.

"GEPARKT SEIT" KOMMT VON DER ZUENDUNG

Die Anzeige rechnete "jetzt minus ts_end der juengsten Fahrt". Das ist keine
Aussage ueber das Parken, sondern ueber die Fahrterkennung: am 01.09. stand
dort "Geparkt seit 2 Tg. 16 Std.", waehrend am Vorabend 7 km gefahren wurden -
die Fahrt fehlte im Bestand, weil trip_status festhing.

Der Koordinator merkt sich jetzt den Zeitpunkt, zu dem die Zuendung zuletzt
ausging, in der Zeit des Geraets. Er liegt im Laufzeit-Store neben
fahrt_start_ts und ueberlebt einen Neustart. Kein Rueckfall auf die
Fahrtenliste: null heisst unbekannt und wird auch so angezeigt - ausdrueckliche
Ansage des Eigentuemers, eine falsche Zahl ist schlechter als keine.

Beim Ausliefern fiel derselbe Neustart-Fehler auf wie bei der Phantomfahrt:
der Zuhoerer las die Registrierung der Entitaet als Wechsel und stempelte den
Parkbeginn bei jedem Neustart neu. if alt is None: return behebt es. Merkposten
fuer dieses Projekt: jeder neue Zustandszuhoerer braucht diese Pruefung.

VERIFIZIERT in audi_ha_test: Neustart laesst 09:34:15 unveraendert; Zuendung an
-> null; Zuendung aus mit Geraetezeit von vor 40 Minuten -> 09:04:57 statt
jetzt. Companion-App gleichgezogen, tsc --noEmit sauber, 165/165 Tests gruen.

NOTIERT, NICHT GEBAUT: der GNSS-Zaehler driftet im Stand. Die 0,034 km waren
GPS-Rauschen, nicht Aufloesung - der Dongle lag unbewegt. Die GNSS-Zuwaechse
brauchen deshalb ein Bewegungstor, bevor sie summiert werden duerfen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 11:52:05 +02:00
tobias cef11b26f3 Fahrtende ohne den Nachlauf des Geraets (2026.9.1.4)
Das Trip-Signal endet nicht mit der Zuendung, sondern "Ignition OFF Timeout"
spaeter - beim FMM003 900 s. Das Fahrtende lag damit systematisch 15 Minuten
zu spaet. _echtes_ende() zieht NACHLAUF_S = 900 ab.

Die Rechnung stammt vollstaendig aus der Logik des Geraets und ueberlebt
deshalb ein Funkloch: der Datensatz kommt spaeter, traegt aber seine eigene
Zeit (2026.9.1.2/.3).

Die Klemme ist der wichtigere Teil: war das Signal nicht laenger als der
Nachlauf, wurde nicht gefahren - Ende auf den Beginn geklemmt, MINDESTDAUER_S
verwirft den Vorgang. Ohne sie entstuende aus dreissig Sekunden Zuendung eine
Viertelstunde Fahrt.

_nachlauf_gegenpruefen() vergleicht das gerechnete Ende mit dem beobachteten
Zuendungswechsel und warnt ab 120 s Abweichung, ohne zu korrigieren. Wie genau
"Zuendung aus" bekannt ist, haengt am Operanden des Zuendungs-I/O-Elements; er
steht auf 3 (nach der Reihenfolge in der Teltonika-Doku "Monitoring", also
kein eigener Datensatz je Wechsel). Solange das nicht feststeht, waere ein
Umschalten auf den beobachteten Wert eine Verschlechterung.

Keine Einstellung in der Oberflaeche: die Zahl gehoert zum Geraet, nicht zum
Nutzer - dieselbe Linie wie UNPLAUSIBLE_KMH. flespi koennte sie liefern
(GET /gw/devices/{id}/settings/all), aber die Integration liest heute nur
Telemetrie; ein zweiter Zugang samt Token waere unverhaeltnismaessig.

Verifiziert in audi_ha_test: Signal 1200 s -> Fahrt 300 s; Signal 930 s ->
keine Fahrt (Bestand 12 -> 12); Gegenprobe mit 887 s Abweichung ausgeloest.
Testfahrt ueber fahrt_loeschen entfernt, Bestand unveraendert 11 Fahrten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 11:29:17 +02:00
tobias 4cc91c461b Fahrtzeiten vom Geraet, Trip und Zuendung getrennt (2026.9.1.3)
Enthaelt 2026.9.1.2, das nie einzeln ausgeliefert wurde.

FAHRT- UND TANKZEITEN KOMMEN VOM GERAET

Fahrtbeginn war datetime.now() - die Uhrzeit, zu der Home Assistant den
Wechsel verarbeitet. Hat das Geraet gepuffert (Funkloch, Tiefgarage,
Tiefschlaf), liegt die Wahrheit beliebig weit davor. Gemessen: ein Datensatz
mit Fahrtende trug die Geraetezeit 07:29:04 und kam um 07:35:23 an.

Neue Sensorrolle MELDEZEIT_SENSOR (message_timestamp, Unix-Sekunden). Der
Wert ist UTC, keine Ortszeit - nachgemessen lag der Versatz zur UTC-Uhr von
HA bei Sekunden, nicht bei zwei Stunden; eine Zeitzonenumrechnung baute einen
Zwei-Stunden-Fehler ein.

geraetezeit() liegt in verlauf.py, weil Fahrt- und Tankerkennung denselben
Zeitstempel bestimmen muessen. tankerkennung._automatisch_anlegen() stempelte
bisher ebenfalls mit now(); ein aus einem gepufferten Datensatz erkannter
Tankvorgang trug damit die Uhrzeit des Auftauchens.

Die flespi-Integration setzt die Entitaeten eines Datensatzes nacheinander -
gemessen Zuendung 07:35:23.633, zugehoerige Meldezeit 2 ms spaeter. Wer
sofort liest, bekommt den vorigen Datensatz: beim Fahren zehn Sekunden
daneben, im Stand Stunden (On Stop Min Period = 43200 s). geraetezeit()
wartet deshalb bis zu 2 s auf den passenden Wert. Zwei Notbremsen, beide live
ausgeloest: Zeitstempel in der Zukunft und Ende vor dem Beginn.

TRIP UND ZUENDUNG SIND ZWEI ROLLEN

ZUENDUNG_SENSOR zeigte auf das Trip-Signal; im Setup stand "Zuendung/ACC" ueber
einer Trip-Entitaet und die echte Zuendung war nirgends zugeordnet. Neu:
TRIP_SENSOR loest Fahrten aus, ZUENDUNG_SENSOR liefert nur noch die Anzeige
"faehrt/steht". verlauf.fahrtsignal() ist die eine Stelle, die entscheidet -
Trip-Status wenn zugeordnet, sonst Zuendung, damit bestehende Installationen
unveraendert weiterlaufen und Livepfad und Rueckblick nicht auseinanderlaufen.

VERIFIZIERT in audi_ha_test, jeder Weg einzeln:
- Fahrtbeginn 08:30:03 (Geraetezeit) statt 08:38:27 (Ankunft)
- Fahrtende 08:04:40 -> 08:09:22 statt 08:11:04 -> 08:11:24
- Tankvorgang 08:18:45 statt 08:38:51 - 20 Minuten frueher
- Meldezeit in der Zukunft und Ende vor Beginn: Warnung, Ankunft genommen
- Zuendung schalten legt keine Fahrt mehr an, Trip-Signal schon

Der Livetest fing dabei einen NameError, den py_compile nicht sehen konnte:
der Import von geraetezeit in tankerkennung.py fehlte. Kompiliert ist nicht
verifiziert. Alle Testdatensaetze wurden ueber die eigenen Dienste wieder
entfernt; Bestand unveraendert 11 Fahrten, 13 Tankvorgaenge, keine offene Fahrt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 10:40:21 +02:00
tobias ff5a334e95 Keine Phantomfahrt beim Neustart, Tankstand auf wert_bei() (2026.9.1.1)
zuendung_geaendert() prueste den neuen Zustand, aber nicht den alten. Beim
Hochfahren taucht die Zuendungs-Entitaet neu auf; das kommt als Wechsel mit
old_state = None an. Steht das Trip-Signal ohnehin auf "an" - beim FMM003 der
Normalfall -, las der Beobachter das als "Zuendung ging gerade an" und legte
eine Fahrt an, deren Beginn schlicht der Zeitpunkt des Neustarts war. Daher
der Eintrag 30.08. 19:43 bis 31.08. 08:37, 12,9 h, 0 km.

Die Pruefung ist "if alt is None: return" - dieselbe, die
_kilometerstand_geaendert() im Koordinator seit jeher hat. Eine wirklich
laufende Fahrt geht nicht verloren: deren Beginn liegt im Store und wird von
nach_neustart_fortsetzen() aufgenommen.

Nachgewiesen: zwei Neustarts hintereinander bei trip_status = on und leerem
Zwischenstand, null "Fahrt gestartet"-Zeilen und fahrt_start_ts bleibt null.
Vorher entstand unter genau diesen Bedingungen jedes Mal eine.

_verbrauch_screenen() liest die Literstaende jetzt ebenfalls mit wert_bei()
statt naechster_wert() - dieselbe Doppelbewertung gegenueber
historienimport.py wie beim Kilometerstand, eine Ebene tiefer.
naechster_wert() bleibt bei Breiten-/Laengengrad: eine Position ist kein
Zaehler.

Auf Anweisung des Eigentuemers ausserdem die zwoelf Fahrten mit
distance_km == 0 geloescht (ueber den Dienst fahrt_loeschen, nach einem
backup_jetzt) und "Open items" in AGENTS.md aufgeraeumt: vier Karteileichen
des MQTT-Wegs entfernt, Cloudflare-Zugang und iPhone-Signatur als erledigt
gebucht, Befund 07 als Nutzereingabe geschlossen, QR-Scanner auf den halben
Umfang gekuerzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 10:04:55 +02:00
tobias 6f23dfebbf Streckenberechnung: zuletzt davor statt naechstgelegen, keine Rueckwaertsspruenge (2026.8.31.6)
_fahrt_screenen() holte beide Kilometerstaende mit naechster_wert(), also dem
zeitlich naechstgelegenen Wert - gleich ob davor oder danach. Fuer einen
Zaehlerstand ist das falsch, und wert_bei()s eigener Docstring sagt es
woertlich: der Stand bei Fahrtbeginn ist der zuletzt gemeldete, nicht der
naechste, der schon Strecke enthaelt. historienimport.py hat immer wert_bei()
benutzt - dieselbe Fahrt wurde also je nach Weg unterschiedlich bewertet,
genau das, was die gemeinsame Herkunft von UNPLAUSIBLE_KMH und MINDESTDAUER_S
in verlauf.py ausschliesst.

Am echten recorder-Verlauf nachgewiesen: fuer den Fahrtbeginn 16:39:42 UTC
lieferte naechster_wert() 21325 (der Stand von 16:40:53, also 71 s nach dem
Beginn und aus einem anderen Fahrzeug), wert_bei() dagegen 209177.

_vollstaendig() prueft ausserdem nach unten. Bisher galt nur > UNPLAUSIBLE_KMH;
ein Rueckwaertssprung ergab eine negative Strecke und wurde als "vollstaendig"
gespeichert. historienimport.py verlangt odo_end >= odo_start seit jeher. Die
Pruefung sitzt an der Strecke, nicht an der Geschwindigkeit: durchschnitt_kmh()
liefert bei Dauer 0 None, eine Fahrt mit null Sekunden kaeme sonst vorbei.

Dem Fahrzeugwechsel des Dongles waehrend der Testphase steht das nicht im Weg:
verglichen werden Anfang und Ende derselben Fahrt, und jedes Fahrzeug zaehlt
innerhalb seiner eigenen Fahrt aufwaerts. In allen 23 gespeicherten Fahrten
haette die Pruefung nie ausgeloest. Greift sie doch, werden beide Werte
verworfen und die Fahrt bleibt "offen" - der naechste Screening-Lauf versucht
es erneut.

Verifiziert gegen die echte Aufzeichnung in audi_ha_test: sieben Faelle,
darunter drei Regressionen (normale Fahrt, unmoegliches Tempo, echte 0-km-Fahrt
bei stillstehendem Zaehler). py_compile sauber, 2026.8.31.6 sauber gestartet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 23:27:11 +02:00
tobias 9b0e390dfd Audit Panel/iOS: sechs Befunde behoben, Zuendung aufs Trip-Signal (2026.8.31.5)
ZUENDUNG_SENSOR liegt jetzt auf dem Trip-Signal des FMM003 statt auf dem rohen,
prellenden Zuendungseingang. Damit kommt die Pausentoleranz vom Geraet - eine
Fahrt beginnt erst bei Zuendung UND Bewegung UND Start Speed.

MINDESTDAUER_S wandert nach verlauf.py und gilt jetzt auch im Livepfad. Vorher
verwarf nur der Import Zuendphasen unter 60 s; live entstanden daraus am 31.08.
elf Fahrten mit 0 km, zwei davon mit null Sekunden Dauer.

Das Panel erfand einen Literpreis von 0,00 EUR, wenn die Kosten noch nicht
feststanden (null/liter ist 0 in JavaScript) - neue Hilfsfunktion literpreis()
prueft beides, wie die App seit jeher. schnittpreis() teilte ohne Nennerpruefung
und konnte "NaN" oder "unendlich" anzeigen.

Der Setup-Dialog ist wirklich modal: die Tab-Leiste hatte einen eigenen Zuhoerer
vor der Sperre und liess einen Tabwechsel bei offenem Fenster zu.

Verwaister Code entfernt: drei Funktionen und vier Regeln im Panel, 20 Regeln in
der App.

Offen bleibt allein die leere Erstzulassung - eine Angabe, keine Aenderung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:05:59 +02:00
tobias 90efcc5b00 Neustart-Festhaenger, OTA-Pruefsumme, Statistik-Kopfzahl (2026.8.31.4)
Die App blieb nach einem Neustart von Home Assistant auf dem alten Stand
stehen, mit einer 502-Fehlerzeile, die erst ein Beenden der App wegbekam.
Zwei Ursachen: 502/503/504 galten als harter Ladefehler statt als "Server
faehrt gerade hoch", und nach dem Wiederverbinden wurde genau einmal
nachgeladen - schlug das fehl, kam nie wieder ein Versuch. Beides behoben,
vier Regressionstests dazu.

OTA: das Buendel ist von HEAD ueber die Platte bis zu den ausgelieferten
Bytes nachweislich deckungsgleich. Die Fassung haengt jetzt trotzdem als
?v= an der Adresse (der statische Pfad kommt ohne Cache-Control, aber mit
ETag), und ein Pruefsummenfehler wird erklaert statt durchgereicht.

Statistik: der Gesamtwert steht jetzt als grosse Zahl in der Kopfzeile,
darunter bleiben vier Spalten - fuenf passen auf dem Telefon nachweislich
nicht in eine Zeile.

Fahrzeugfoto ohne Beschnitt und ohne Bodenschatten, damit auch ohne den
Schalter dafuer. Setup filtert zusaetzlich nach dem Signalnamen aus dem
Katalog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:35:56 +02:00
tobias 4af38d7e65 Fahrten, Statistik, Trip-Timer (2026.8.31.3)
Der 15-Minuten-Timer war doppelt: der FMM003 wartet laut Konfiguration
(Trip \ Odometer, Ignition OFF Timeout) selbst 900 s, die App noch einmal
15 Minuten. Zusammen eine halbe Stunde. Die Pausenregel ist damit entfallen -
in der Live-Erkennung, im Historienimport und in beiden Oberflaechen. Der
Schwellwert "ab wann ist es ein Parkplatz" hing an derselben Einstellung, hat
damit aber nichts zu tun und steht jetzt als eigene Zahl.

Hoechst- und Durchschnittsgeschwindigkeit je Fahrt. Der Durchschnitt folgt aus
Strecke und Dauer, vmax aus der neuen Rolle GESCHWINDIGKEIT_SENSOR - laut
Konfiguration liest das Geraet die Geschwindigkeit vom OBD/CAN. Es ist der
groesste gemeldete Wert, nicht die tatsaechliche Spitze: alle zehn Sekunden
ein Datensatz. So ist es auch beschriftet.

Der Langzeitverbrauch war um einen ganzen Tankinhalt zu hoch. Gemessen wird
die Strecke ZWISCHEN erstem und letztem Tankstop; verbraucht wurde darauf,
was ab dem ZWEITEN hineinkam - der erste fuellte die Strecke davor. Bei 13
Tankvorgaengen rund 8 Prozent, im Testfall 16,7 statt 8,3 l/100 km. Der Test
hatte die falsche Rechnung festgeschrieben und ist mitkorrigiert. Eine
negative Strecke (falscher Kilometerstand an einem Tankvorgang) gilt jetzt
als unplausibel statt als Null ohne Erklaerung.

Verbrauch je Fahrt bekommt Grenzen: am Sensor nachgemessen loest
can_fuel_volume 0,1 l auf, ein Schritt entspricht also 10/Strecke l/100 km
Fehler. Unter 3 km sagt die Differenz nichts mehr, ueber 60 l/100 km ist es
kein Verbrauch.

Statistik: zusaetzlich "Absolut", und die Einheit steht jetzt in eckigen
Klammern an der Rubrik statt vier Mal am Zeitraum. Fuenf Spalten passen auf
einem Telefon nicht nebeneinander, das Raster bricht um.

Radfoto in Reifen war ein reines <Bild> - Ersetzen und Loeschen von dort aus
gar nicht erreichbar. Jetzt BildMitMenue wie im Panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:03:28 +02:00
tobias 17dc42e645 App-Symbol: Entwurf 3 bei 54 Prozent
DataMetric oben, das CI-Fahrzeug in der Mitte, 360 in Audi-Rot unten, auf
Nacht-Grund. Ausgewaehlt aus zwoelf Varianten, jede zweimal gezeigt: gross
und bei 60 pt, der Groesse auf dem Homescreen - daran entschied es sich.

Vektorquelle unter native/logo/, erzeugt von scripts/logo-bauen.cjs. Das
Fahrzeug liest das Skript aus ICONS.audi im Panel, damit es nur eine Quelle
fuer diese Form gibt; die Schriften stecken als data-URI in der SVG, sonst
haengt das Ergebnis davon ab, welche Fonts auf dem rasternden Rechner
installiert sind.

Drei Ausfuehrungen fuer iOS 18: hell mit eigenem Grund, dunkel und getoent
ohne - dort legt das System seinen eigenen Grund darunter, ein
mitgeliefertes Schwarz bliebe als Kasten sichtbar. Getoent wertet iOS die
Helligkeit aus, deshalb wird die 360 dort ein helleres Grau statt Rot.

ios-signieren.sh legt die Vorlage ueber die von Capacitor erzeugte - ios/
ist gitignored, ein in Xcode eingesetztes Symbol waere beim naechsten
cap add ios wieder weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 14:43:05 +02:00
tobias b524c1d488 Xcode-Lauf ohne Handgriffe: Version, Profile, Luftweg
Drei Schritte, die bisher am Entwickler hingen, laufen jetzt im Bau mit:

- Versionsnummer aus der manifest.json ins Info.plist. Capacitors Vorlage
  setzt 1.0/1 und laesst es dabei - zwei nacheinander aufgespielte .ipa waren
  auf dem Geraet nicht zu unterscheiden.

- Zwischengespeicherte Bereitstellungsprofile dieser App wegraeumen.
  -allowProvisioningUpdates zieht ein neues Profil nur, wenn kein passendes
  herumliegt; ein vorhandenes wird stillschweigend weiterbenutzt, auch wenn
  sich die Berechtigungen geaendert haben. Genau daran ist am 2026-08-29 eine
  Installation gescheitert, und mit der neuen App-Gruppe der Share-Erweiterung
  ist derselbe Fall wieder eingetreten. Entfernt wird nur, was zu dieser App
  gehoert; --profile-behalten ueberspringt es.

- ios-luftweg.sh haengt am Ende des Baus. manifest.plist und Icons landen
  damit ohne zweiten Aufruf in auslieferung/, und der Schluss nennt die
  itms-services-Adresse zum Oeffnen in Safari.

Ausserdem die Reihenfolge im Skript geradegezogen: der Kommentar zur
Standort-Berechtigung stand ueber dem Share-Block statt ueber seinem eigenen
Code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 14:30:27 +02:00
tobias b1411625aa Teilen-Blatt, HIG-Blaetter, geteilte Ansichtseinstellungen (2026.8.31.1)
Share-Erweiterung: ein PDF aus Mail landet ueber Teilen -> DataMetric360 als
Tankbeleg. Alles Native liegt versioniert unter companion-app/native/, weil
ios/ gitignored ist und npx cap add ios es sonst wieder frisst;
scripts/ios-teilen-einrichten.mjs haengt es bei jedem Bau ins Xcode-Projekt
und ist in ios-signieren.sh eingehaengt. Der pbxproj-Teil ist auf keinem Mac
erprobt - er bricht vor dem Schreiben ab, und docs/SHARE_EXTENSION.md
beschreibt dieselben Handgriffe fuer Xcode.

"In den Kalender uebernehmen" ging in der App nie: WKWebView ignoriert das
download-Attribut, der Klick lief ins Leere. Auf dem Geraet jetzt
@capacitor/filesystem plus @capacitor/share.

useTheme/useBoden legten je Aufrufer eigenen Zustand an - fuenf Kopien. Der
Schalter aenderte nur seine eigene, data-theme in App.tsx blieb stehen, die
Tag/Nacht-Umschaltung wirkte erst nach einem Neustart. Jetzt ein Speicher je
Einstellung ueber useSyncExternalStore. Zwei Regressionstests, gegen den
alten Stand als fehlschlagend nachgewiesen.

Alle Popups nach Apple HIG: neue Bausteine Sheet und ActionSheet, blickdicht
und mit abgedunkeltem Schleier, in beiden Codebasen. Popup portalierte nach
document.body - ausserhalb von [data-theme] - und war deshalb im Tagmodus
fast unsichtbar; Portal zielt jetzt auf .ads-root.

ImagePlaceholder merkte sich "fehlgeschlagen" ohne die Adresse: nach einem
leeren Galerieplatz zeigten auch die mit Foto nur noch den Platzhalter.
Vier Regressionstests, ebenfalls gegen den alten Stand geprueft.

Bildadressen tragen jetzt einen Cache-Brecher - HA liefert /local/ mit 31
Tagen Cache-Vorgabe aus, im Panel eingefuegte Radfotos erschienen in der App
deshalb nicht.

uebersichtsbild: Panel speichert den Namen, die App verglich gegen den
Dateinamen - der Vergleich traf nie zu. Kanonisch ist der Name.

Weiter: Navigationspfeil statt gleichschenkligem Dreieck auf der Streckenlinie
(die Kerbe unterscheidet Kopf und Ende), CI-Symbol tour-s als Fahrten-Icon,
Datumsfelder zeigen ihren Wert ohne erstes Antippen, feste Beispielnamen im
Setup, Kopf-Gegengewicht fuer mittige Titel, Standort-Blatt mit festem
Fussabstand, NSLocationWhenInUseUsageDescription, watchPosition fuer die
Live-Ortung, Art-Pille wieder als Knopf, Zwischenablage fuer Tankbelege.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 14:24:52 +02:00
tobias 26f72930c3 Standort-Blatt rastet aufgezogen auf der mittleren Hoehe (2026.8.30.20)
Aufgezogen wurde das Blatt exakt so hoch wie sein Inhalt - die Knoepfe Route
und Teilen schlossen dadurch unten buendig mit der Menueleiste ab.

Apple benennt dafuer das passende Konzept: Blaetter rasten auf "detents" ein,
Hoehen, auf denen ein Blatt natuerlich zur Ruhe kommt; large ist das voll
ausgefahrene Blatt, medium etwa die Haelfte davon (Human Interface
Guidelines, Sheets - nachgelesen, nicht aus dem Gedaechtnis zitiert). Unser
Blatt hatte im aufgezogenen Zustand ueberhaupt keine Rasthoehe, sondern nur
seine Inhaltshoehe.

Jetzt min-height: 50% im aufgezogenen Zustand. Der Ueberschuss liegt unter
den Knoepfen und haelt sie von der Leiste frei. Die eingeklappte Hoehe bleibt
unberuehrt, weil die Verschiebung mit 100% der jeweiligen Blatthoehe minus
dem Guckwert rechnet.

Gemessen: Panel 311px auf 622px Karte, App 298 auf 595 - beide exakt 50%,
Luft unter den Knoepfen 89 bzw. 76px statt zuvor 18.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 00:07:16 +02:00
tobias 3f1d122538 Werkzeugregel: Panel-Bundle als Modul pruefen, nicht als Script
Haelt fest, warum node --check den Syntaxfehler von .14 nicht finden konnte
(Annex-B-HTML-Kommentare sind in Scripts erlaubt, in Modulen nicht) und wie
die Pruefung ab sofort laufen muss. Dazu der zweite Fehler: der Panel-Tab
wurde nach dem Ausliefern nie neu geladen, alle spaeteren Live-Aussagen
liefen gegen das alte Skript.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 00:02:16 +02:00
tobias d622d74434 DRINGEND: Panel war seit 2026.8.30.14 syntaktisch kaputt (2026.8.30.19)
Beim "Zuendung an"-Patch habe ich den Begruendungskommentar in den
${...}-Ausdruck gesetzt statt in den Markup-Text. Dort ist <!-- kein
HTML-Kommentar, sondern JavaScript. Folge: audi-dashboard-app.js liess sich
nicht mehr als Modul parsen, connectedCallback() brach ab, das Panel hatte
keinen Shadow Root - der Bildschirm blieb leer.

Betroffen sind .14 bis .18, also alles, was seit dem auf Gitea lag. Wer in
dieser Zeit aktualisiert und neu gestartet hat, bekam ein totes Panel.

Warum es durchrutschte - und das ist der eigentliche Befund: "node --check"
prueft eine .js-Datei als SCRIPT, und dort sind HTML-artige Kommentare per
Annex B ausdruecklich erlaubt. Der Browser laedt dieselbe Datei als MODUL,
wo sie verboten sind. Die Pruefung, auf die sich dieses Projekt bei jeder
Panel-Aenderung verlaesst, kann diesen Fehler grundsaetzlich nicht finden.

Ab sofort gilt: die Datei vor dem Ausliefern als Modul pruefen, also unter
.mjs kopieren und node --check darauf laufen lassen. Rueckwirkend ueber alle
heutigen Commits geprueft - .14 ist der erste kaputte.

Erschwerend kam dazu, dass ich den Panel-Tab nach .14 nie neu geladen habe.
Alle spaeteren "live geprueft"-Aussagen zur Panel-Seite liefen gegen das noch
im Speicher stehende alte Skript und waren damit wertlos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 00:01:08 +02:00
tobias c300aab223 Update-Kachel: Zeitstempel und "Erneut pruefen" auch im Verfuegbar-Zweig (2026.8.30.17)
Die angezeigte "Verfuegbare Version" ist das gespeicherte Ergebnis der letzten
Pruefung, nicht der Live-Stand - version_pruefen() laeuft ausschliesslich auf
Tastendruck. Im Zweig "Integration ist aktuell" stand der Zeitpunkt der
Pruefung schon dabei, ausgerechnet im Verfuegbar-Zweig nicht. Eine Wochen alte
Zahl sah dort aus wie eine Tatsache.

Schlimmer: der Knopf "Auf Update pruefen" gab es nur im Zweig "ist aktuell".
Sobald einmal ein Update gefunden war, liess sich die Angabe gar nicht mehr
auffrischen - sie blieb bis zur Installation stehen, auch wenn auf Gitea
laengst eine neuere Fassung lag.

Beides in beiden Codebasen ergaenzt: Zeitstempel als Nebenzeile an der
Version, "Erneut pruefen" als zweiter Knopf unter "Update installieren".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 23:51:39 +02:00
tobias 5461382b8f Flaechen-Token: Regel festgehalten und beide Anwendungen durchgeprueft (2026.8.30.16)
--tile gehoert auf den Seitenhintergrund und sonst nirgends. Was auf einer
Kachel oder einem Blatt liegt, nimmt --tile-deckend (deckende Flaeche) oder
--ios-fill (Bedienelement). Grund: --tile ist nachts absichtlich
durchscheinend und tags deckend weiss - auf einer anderen Flaeche versagt es
deshalb in genau einem Theme, tags unsichtbar, nachts als doppelter Schleier.
Genau daran sind heute drei Fehler entstanden, jeder in nur einem Theme
sichtbar.

Geprueft zur Laufzeit statt per Textsuche, weil es auf die aufgeloeste Farbe
gegen die des naechsten gefuellten Vorfahren ankommt - beides steht nicht im
Quelltext. Panel 21 Routen, App 18 Seiten, beide Themes. Einziger
verbleibender Treffer ist die Listenzeile auf der Kachel im Tagmodus, wo
beide deckend weiss sind und die Zeile bewusst mit der Kachel verschmilzt.

Die Pruefung selbst ist gegengetestet: der heute behobene Schliessen-Knopf
wurde von Hand auf den alten Wert zurueckgesetzt und sofort erkannt. Eine
Pruefung, die man nie hat scheitern sehen, belegt nichts.

Dabei gefunden und angeglichen: die Kartenflaechen der Detailseiten trugen in
der App --tile-2, im Panel --tile-deckend - nachts ein zweiter Schleier auf
der ohnehin durchscheinenden Kachel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 23:47:43 +02:00
tobias 852a109c69 Schliessen-Knopf im Standort-Blatt war im Tagmodus unsichtbar (2026.8.30.15)
Der runde Knopf malte var(--tile) auf ein Blatt, das selbst deckend ist - im
Tagmodus beides #FFFFFF, also weiss auf weiss. Nachts faellt es nicht auf,
weil --tile dort ein Schleier ueber dunklem Grund ist.

Derselbe Fehlertyp wie bei den Wischzeilen von heute, nur andersherum: dort
addierten sich zwei Schleier im Nachtmodus, hier verschwindet eine Flaeche im
Tagmodus. Beide Male, weil eine Flaeche auf einer Flaeche mit --tile gemalt
wurde. Jetzt --ios-fill, in diesem Projekt ohnehin das Zeichen fuer "das hier
laesst sich bedienen"; es traegt in beiden Themes. In beiden Codebasen
geaendert - das Panel hatte den Fehler genauso.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 23:41:08 +02:00
tobias d2994ce919 Standort: "Zuendung an" statt "Fahrzeug faehrt" (2026.8.30.14)
Es gibt keinen Bewegungssensor am Fahrzeug. Der einzige Anhaltspunkt ist
ZUENDUNG_SENSOR, und der meldet on auch bei blosser Zuendung oder in
Zubehoerstellung - "Fahrzeug faehrt" behauptete also mehr, als bekannt ist.

Mein zwischenzeitlicher Vorschlag, zusaetzlich eine laufende Fahrt zu
verlangen, war falsch begruendet: fahrt_start_ts in fahrterkennung.py wird aus
genau demselben Signal abgeleitet und haette nur eine zweite Huelle um
dieselbe Aussage gelegt.

Der umgekehrte Fall bleibt unangetastet: Zuendung aus heisst verlaesslich,
dass gerade nicht gefahren wird - "Fahrzeug steht" und "Geparkt seit ..."
stehen weiter. Zwei Typ-Kommentare, die dieselbe Gleichsetzung
weitertrugen, sind mitkorrigiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 23:39:31 +02:00
tobias 534ce46096 Standort-Blatt: doppelter Sicherheitsabstand, deckender Hintergrund (2026.8.30.13)
Das Blatt trug padding-bottom: max(18px, env(safe-area-inset-bottom)). Es
liegt aber nicht am unteren Bildschirmrand, sondern ueber der Tableiste - und
die haelt den Bereich des Home-Indikators bereits frei. Der Einzug wurde also
doppelt gezaehlt: auf dem Geraet war das Blatt rund 16px hoeher als noetig und
schob sich beim Aufziehen entsprechend weiter hoch. Am Rechner faellt das
nicht auf, dort ist der Einzug 0. In beiden Codebasen entfernt.

Ausserdem: der Blatthintergrund der App stand auf --canvas statt auf dem
deckenden Wert, den das Panel dort nimmt - unter dem Blatt liegt die Karte.

Nachgemessen mit frisch gestarteten Panes: Blatt 240px, Unterkante buendig mit
der Karte, in beiden Anwendungen gleich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 23:35:43 +02:00
tobias 67135da4f7 Layer-Symbol: falscher Pfad in der App, zu klein in beiden (2026.8.30.12)
Drei Anlaeufe, die ersten beiden mein Fehler. Gemeldet war "Icon falsch im
Knopf platziert". Ich habe die Zeichenflaeche gemessen, sie fuellt 20,9x15,6
des 24er-Rasters gegen 20x20 bis 24x22 bei den Nachbarn, und daraus auf "zu
schwer und aussermittig" geschlossen - also verkleinert. Zwei Fehler darin:
gemessen habe ich das Panel und geaendert habe ich beide, ohne das Symbol der
App je selbst zu messen; und "fuellt weniger Flaeche als die Nachbarn" spricht
fuer "wirkt kleiner", nicht dagegen.

Nach der Ruecknahme im Panel zeigte die Messung der App den echten Fehler
dort: ihr mapLayer-Pfad begann mit grossem M statt kleinem m. Beim ersten
Befehl ist das gleichbedeutend, aendert aber alles danach - nach M gelten
folgende Zahlenpaare als absolute Linien, nach m als relative. Der zweite
Punkt sprang damit auf (-8.97,-5.66), weit aus dem Raster: das Symbol war
verzerrt, nicht bloss falsch dimensioniert. Pfad wortgleich vom Panel
uebernommen, alle vier Steuerungssymbole vergleichen sich jetzt zeichengleich.

Damit stand die urspruengliche Meldung fuer sich und war schlicht richtig: die
flache Form wirkt bei gleicher Kastengroesse kleiner. Beide zeichnen sie jetzt
mit 26px statt 22px - dieselbe Loesung wie beim Oelwechsel-Icon der
Servicebloecke, das aus demselben Grund 26px neben 20px bekommt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 23:29:07 +02:00
tobias 0fd81df700 Ziehen zum Aktualisieren, Nachtmodus-Zeilen, Parkplatznamen (2026.8.30.10)
Ziehen zum Aktualisieren funktionierte auf keinem Geraet, in keiner der
beiden Anwendungen - und der Grund entwertet zugleich, wie es bisher geprueft
wurde. Beide trieben die Geste ueber pointermove und riefen dort
preventDefault(). Das verhindert laut Spezifikation kein Scrollen; darueber
entscheidet allein touch-action. Auf echtem Touch uebernimmt der Browser die
senkrechte Geste, schickt pointercancel und stellt pointermove ein - der Zug
wird zurueckgesetzt, lange bevor er die Schwelle erreicht. Eine Maus kennt
diese Uebernahme nicht, synthetische PointerEvents ebenso wenig: genau
deshalb lief die Geste 2026-08-17 im Test sauber durch und am Geraet nie.

Jetzt laeuft sie auf Touch-Ereignissen, touchmove zwingend mit passive:false
(sonst ist preventDefault() wirkungslos); der Zeigerweg bleibt fuer Maus und
Stift und ignoriert pointerType "touch". Mit echten TouchEvents geprueft,
inklusive defaultPrevented - dem einzigen Signal, das belegt, dass das native
Ueberscrollen wirklich unterbunden ist.

Der zuletzt nicht nachstellbare Befund war nachtmodus-spezifisch: --tile ist
dort ein durchscheinender Schleier, und die Wischzeilen malten ihn ein
zweites Mal auf die Kachel, auf der sie ohnehin liegen. Am Tag sind beide
deckend weiss, deshalb war nichts zu sehen. Das Panel nutzt an dieser Stelle
--tile-deckend; dieses Token fehlte im Design-System und ist jetzt da.

Ausserdem: steht das Auto auf einem benannten Platz, zeigen beide dessen
Namen statt der naechsten Hausnummer - Nominatim liefert ihn mit, die
Pruefung auf die Art der Flaeche verhindert, dass irgendein benanntes
Gebaeude die Anschrift verdraengt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 23:22:52 +02:00
tobias a08922e9a0 Befundliste des Besitzers: drei echte Fehler, dazu Feinschliff (2026.8.30.9)
Fahrzeug-Pin auf der Standortkarte fehlte: die Marker-Effekte lesen Refs,
die erst im asynchronen Leaflet-Import gesetzt werden. Beim ersten Lauf leer,
Effekt steigt aus - und weil die Koordinaten sich danach nicht mehr aendern,
lief er nie wieder. Ein Bereitschaftsschalter in den Abhaengigkeiten behebt
das fuer Marker, Kachelebene und eigene Position zugleich.

Kachelpfeil auf "Mein Audi" sass auf halber Kachelhoehe statt oben neben der
Ueberschrift; Auswahlfelder hatten nach appearance:none ueberhaupt keinen
Aufklapp-Pfeil mehr.

SmartDeal: das Datumsfeld war fehlerhaft und die Abfrage, bis wann der Rabatt
gilt, fehlte ganz. Das Panel fragt beim Einschalten und zeigt danach nur an -
genau daran haengt, dass der Rabatt von selbst auslaufen kann. Portiert.

Adressen statt Koordinaten: Nominatim drosselt uns inzwischen. Beide Apps
fielen korrekt zurueck, merkten sich Treffer aber nur bis zum Neuladen. Jetzt
dauerhaft im selben Cache wie die Tankstellensuche.

Ziehen zum Aktualisieren auf dem Geraet: dem Scrollbehaelter fehlte
overscroll-behavior, das native Overscroll nahm die Geste weg - derselbe
Befund, den das Panel am 2026-08-17 hatte.

Ausserdem: Platzhalter wieder in voller Groesse, FIN statt Fahrgestellnummer,
Details als Hinweiszeile, Chevron an der Batteriespannung, Reifenzeile
"aktuell montiert:", Zahnrad statt Stift am Anzugsmoment, Radkilometer in der
Zahlenschrift, Layer-Icon zentriert, share-s beim Teilen, Schliessen-Knopf nur
bei aufgezogener Karte.

Nicht nachstellbar und zurueckgemeldet: "Fahrten/Tanken, grauer Hintergrund" -
Panel und App sind dort deckungsgleich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 23:13:01 +02:00
Paul Nothaft f38907991d iOS-App auf 2026.8.30.8 gebracht (Paritaetsrunden 2 und 3)
Neu gebaut und Ad-hoc signiert nach Commit 4a58021. Enthaelt den neuen
TankstellenKarte-Screen und die einkompilierte Version 2026.8.30.8. Profil
weiterhin mit beiden registrierten Geraeten, gueltig bis 29.08.2027.

Vorher geprueft: design-system neu gebaut (dessen Komponenten-CSS hat sich
in dieser Runde stark geaendert), Typecheck sauber, 147/147 Tests gruen.
2026-08-30 18:57:02 +02:00
tobias 4a58021670 Paritaetsrunden 2 und 3, Designpruefung umgesetzt (2026.8.30.8)
Drei zusammenhaengende Runden, alle live bei 375x812 gegen audi_ha_test
geprueft und in beiden Codebasen angewandt.

Paritaetsrunde 2: der gemeinsame Rahmen war das eigentliche Problem -
Seitenrand, Kachelabstaende, Kopfleiste, Tableiste und vier Bausteine des
Design-Systems trugen noch den Stand vor der iOS-Entscheidung. Dazu sieben
Bildschirme neu aufgebaut. Zwei Panel-Fehler dabei mitbehoben: teaser()
zeigte unter "Letzte Fahrt" den aeltesten Datensatz, und "Daten bearbeiten"
war ein toter Knopf.

Paritaetsrunde 3: das Panel hat nie Audi Type gerendert. @font-face in einem
Shadow Root wird ignoriert - Schriftschnitte registriert der Browser pro
Dokument, nie pro Shadow Tree. Die drei Schnitte liegen jetzt in
audi-dashboard-schriften.css und werden ins Dokument gehaengt. Damit erledigt
sich eine ganze Reihe von "die Schrift sieht anders aus"-Eindruecken: die
beiden Anwendungen zeigten tatsaechlich verschiedene Schriften.
Ausserdem "Daten bearbeiten" im Panel gebaut und portiert, das Zeilenmenue
entfernt und die letzten neun Bildschirme angeglichen.

Designpruefung: die fuenf Punkte der Reihenfolge. Beim vierten hat das Messen
den Befund veraendert - gezaehlt waren die deklarierten Groessen, wirksam war
laengst eine saubere Sieben-Schritt-Skala mit 27 Ausreissern; die sind jetzt
auf den naechsten Schritt gezogen, keiner verschiebt sich um mehr als 1px.

Zuletzt: die Standortvorschau zeichnete die falsche Nadel (das Panel wechselt
den Icon-Satz ab 34px, die App nahm immer den grossen), und der Kopfabstand
der App ist auf den sicheren Bereich reduziert - die 56px des Panels liegen
dort unter der Kopfleiste von Home Assistant, in der nativen Huelle steht
darueber nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 18:31:46 +02:00
Paul Nothaft 25c0d073c4 iOS-App auf den Stand des Paritaetsaudits gebracht (2026.8.30.2)
Neu gebaut und Ad-hoc signiert nach dem Audit-Commit e1ebe6d. Enthaelt den
neuen Standort-Screen und die einkompilierte Version 2026.8.30.2. Profil
weiterhin mit beiden registrierten Geraeten, gueltig bis 29.08.2027.

Vorher geprueft: design-system neu gebaut (dessen CSS hat sich mitgeaendert),
Typecheck sauber, 147/147 Tests gruen.
2026-08-30 17:00:09 +02:00
tobias e1ebe6d694 Vollständiger Panel-companion-app-Paritätsaudit (11 Kapitel)
Systematischer Bildvergleich der companion-app gegen das HA-Panel (companion-app/AGENTS.md
Kapitel 1-11): Farbtoken/Radien aus dem falschen Stylesheet gelesen (iOS-Overlay statt Basis-CSS,
betraf fast jede Kachel/Farbe/Radius app-weit), vier app-weite @audi-dash/ui-Bugs (Switch/Seg/Feld
rot statt neutral bzw. falsche Feldbreite), Reifen-Seite strukturell neu gebaut (beide Radsätze
gleichzeitig statt Umschalter, editierbare Felder, Anzugsmoment-/km-Korrektur), Standort-Feature
komplett neu (fehlte bisher ganz), sowie diverse Struktur-/Typografie-/Datenlücken in
MeinAudi/Service/Versicherung/Sicherheit/Fahrten/Tanken/Statistik/Batterie/Einstellungen.

Manifest auf 2026.8.30.2 angehoben, OTA-Bündel neu gebaut und in audi_ha_test verifiziert
(sauberer Neustart, keine Tracebacks).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 12:21:58 +02:00
Paul Nothaft 4fa5d4bc2d Eingabefelder zoomen nicht mehr: 16px setzen sich jetzt auch durch
Beim Antippen eines Feldes auf dem Willkommensbildschirm zoomte iOS die
ganze Seite heran und machte sie verschiebbar. Ursache war nicht eine
fehlende Regel, sondern eine unterlegene: grundlage.css und .dm-eingabe
setzen laengst 16px, aber @audi-dash/ui setzt ".ads-feld input" auf 13.5px -
Klasse plus Typ schlaegt die blosse Klasse. Unterhalb von 16px zoomt iOS
beim Fokus, das ist der ganze Mechanismus.

Behoben mit einem spezifischeren Selektor im App-CSS, gleicher Wert wie
ohnehin beabsichtigt. @audi-dash/ui bleibt unangetastet, wie schon beim
type=date-Fall darunter.

Bewusst nicht genommen: user-scalable=no im Viewport. Das haette das
Symptom in einer Zeile versteckt, nimmt aber allen das Aufziehen - und
dasselbe Buendel wird auch als Web-App aus Home Assistant ausgeliefert.
2026-08-29 13:34:07 +02:00
Paul Nothaft 4e97656a75 Korrektur: auch Ad-hoc-Apps verlangen seit iOS 16 den Entwicklermodus 2026-08-29 13:17:49 +02:00
Paul Nothaft b6a868df5e Ursache des Integritaetsfehlers festhalten: veraltetes Profil im Xcode-Zwischenspeicher 2026-08-29 13:16:02 +02:00
Paul Nothaft 49712f7c7a App neu signiert: Ad-hoc-Profil enthaelt jetzt beide registrierten Geraete 2026-08-29 13:15:26 +02:00
Paul Nothaft e46bec2e32 Luftweg ueber Gitea dokumentieren und den fertigen Safari-Link ausgeben 2026-08-29 13:04:27 +02:00
Paul Nothaft 84b0003070 Luftweg ueber Gitea: Manifest und Symbole in auslieferung/ ablegen 2026-08-29 13:03:41 +02:00
Paul Nothaft 959b98c93a Entscheidung des Besitzers festhalten: die .ipa bleibt im oeffentlichen Repo 2026-08-29 13:02:50 +02:00
Paul Nothaft b452c0a284 Befund festhalten: das Repository ist oeffentlich, die Lizenzregel verlangt privat 2026-08-29 13:00:44 +02:00
Paul Nothaft b4d44b2731 Signierte iOS-App gebaut und Luftweg-Installation vorbereitet
Phase 10 Schritt 7 ist damit erledigt. auslieferung/App.ipa, 2,4 MB, Ad-hoc
signiert mit "Apple Distribution: Paul Nothaft", Profil gueltig bis
29.08.2027, genau ein eingetragenes Geraet.

Zwei Annahmen von heute frueh waren falsch und sind korrigiert: die bezahlte
Mitgliedschaft stuft das bestehende Team hoch, statt ein neues anzulegen (die
Kennung bleibt RMACS9VLS4), und der Export als Ad-hoc funktioniert einwandfrei.

Neue Falle festgehalten: beim ersten Signieren fragt der Schluesselbund per
Dialog um Erlaubnis. Bleibt der unbeantwortet, haengt xcodebuild wortlos und
endet mit errSecInternalComponent. Die Diagnose steht in AGENTS.md, weil das
Symptom von sich aus nirgendwohin zeigt.

Neu: scripts/ios-luftweg.sh erzeugt manifest.plist, Installationsseite und
Symbole fuer die Uebertragung ueber die Luft. Es verweigert eine Basis-Adresse
ohne https, weil iOS sonst erst auf dem Telefon still scheitert.

Offen bleibt der Host, der die Dateien ausliefert.
2026-08-29 12:56:42 +02:00
Paul Nothaft fa7253a64c Team-Kennung nicht mehr vorbelegen: bezahltes Konto bekommt ein neues Team
Der fest eingetragene Wert RMACS9VLS4 gehoert zum kostenlosen Personal Team.
Mit der bezahlten Mitgliedschaft entsteht ein eigenes Team mit anderer
Kennung; der alte Wert wuerde still weiter mit dem kostenlosen Team signieren
und die App nach 7 Tagen sterben lassen. Ohne APPLE_TEAM_ID bricht das Skript
jetzt mit einem Hinweis ab, wo die Kennung zu finden ist.
2026-08-29 11:44:10 +02:00
tobias 5fe19e5006 Merge remote-tracking branch 'origin/main' 2026-08-29 11:29:12 +02:00
tobias f9cd8de8fd AGENTS.md: QR-Code-Scanner fürs Token-Onboarding als offenen Punkt aufgenommen
Owner-Anfrage 2026-08-28 - war 2026-08-11 bewusst weggelassen, jetzt wieder
gewünscht. Ursprünglicher Plan aus UMSETZUNGSPLAN.md Phase 10 als Referenz.
2026-08-29 11:28:36 +02:00
Paul Nothaft 0e16f68b4b Korrektur: das Apple-Konto ist ein kostenloses Personal Team
Der vorige Commit ging von einer bezahlten Mitgliedschaft aus und nannte als
Ausweg, die UDID auf developer.apple.com einzutragen. Das ist falsch.

Xcodes eigener Zwischenspeicher belegt das Gegenteil:
isFreeProvisioningTeam = 1, teamType = "Personal Team". Damit gibt es gar
keine Geräteverwaltung im Portal -- ein Gerät wird ausschließlich dadurch
bekannt, dass es angeschlossen und vertraut ist. Zusätzlich verfallen Profil,
App-ID und Geräteeintrag alle 7 Tage.

Auch die ältere Behauptung weiter oben in AGENTS.md ("Paul has an Apple
Developer Program") ist damit als falsch markiert.

Offen und bewusst als ungeprüft vermerkt: ob -exportArchive mit einem
Personal Team überhaupt eine brauchbare .ipa liefert. Der direkte Weg aufs
angeschlossene Gerät steht als Rückfallebene im Skriptkopf.
2026-08-29 11:22:21 +02:00
Paul Nothaft da85a2c0d6 iOS-Signierung vorbereiten: Skript, Nachweis des Gerätebaus, offener Punkt
Phase 10 Schritt 7 lässt sich nicht abschließen, aber alles bis zur Signatur
ist gebaut und belegt: Simulator- und Gerätebau (arm64, Release) laufen
fehlerfrei durch, 146/146 Tests grün, Typecheck sauber.

Die Signatur scheitert allein daran, dass dem Entwicklerteam kein Gerät
bekannt ist -- Apple erzeugt ein Development-Profil nur für konkrete UDIDs.
Kein iPhone angeschlossen, keins je mit diesem Mac gepaart, kein
App-Store-Connect-Schlüssel zum Nachtragen.

Neu: companion-app/scripts/ios-signieren.sh macht Bauen, Synchronisieren,
Signieren und den .ipa-Export zu einem Befehl. Nötig, weil ios/ absichtlich
gitignored ist und jede in Xcode geklickte Signatureinstellung beim nächsten
npx cap add ios wieder verschwinden würde -- die Team-Kennung braucht eine
versionierte Heimat.

Bewusst nicht getan: eine unsignierte .ipa als Platzhalter einchecken. Sie
wäre nicht installierbar und läge als Binärdatei dauerhaft in der Historie.
2026-08-29 11:17:46 +02:00
tobias 44522db34e Internetzugriff: trusted_proxies-Fix bestätigt, fehlenden Freigabe-Block dokumentiert
Live verifiziert: /api/ liefert jetzt 401 statt der generischen NPM-Fehlerseite - der
Proxy-Vertrauen-Fix (X-Forwarded-For/trusted_proxies, seit Migration über die HA-UI statt
YAML gesetzt) greift. Zusätzlich den include-Datei-Bug beim NPM-Add-on genauer beschrieben
und einen Hinweis ergänzt, dass der abschließende location-/-Block nach Debug-Tests immer
wieder eingefügt werden muss - sonst landet jede Anfrage ungefiltert bei Home Assistant.
2026-08-28 18:13:11 +02:00
tobias 355177a50a INTERNET_ZUGRIFF_EINRICHTEN.md: Sicherheits-Checkliste ergänzt, Cloudflared-Add-on konkretisiert
Neuer Schritt 6 mit den Cloudflare-/NPM-Sicherheitseinstellungen (SSL-Modus,
HSTS, Bot Fight Mode, DNS-Aufräumen, Token pro Gerät, Router-Portcheck) und
dem größten praktischen Stolperstein (Tunnel-Hostname zeigt versehentlich
direkt auf HA statt auf den Reverse Proxy). Nachfolgende Schritte/Verweise
umnummeriert, ein alter Nummerierungsfehler in der Einleitung mitkorrigiert.

Cloudflared-Add-on jetzt namentlich mit Link genannt und geprüft
(homeassistant-apps/app-cloudflared) - die frühere Bezeichnung "offizielles
Add-on" war ungenau (Community-Add-on, kein Nabu-Casa-Produkt). Festgehalten,
dass der dokumentierte Tunnel-Token-Weg keinen Cloudflare-API-Token braucht.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 15:49:24 +02:00
tobias 137d32d28d Domain auf datametric360.de umgestellt (war .app)
Alle Referenzen in Doku und Code aktualisiert (REVERSE_PROXY.md,
INTERNET_ZUGRIFF_EINRICHTEN.md, COMPANION_APP_ARCHITECTURE.md, AGENTS.md,
UMSETZUNGSPLAN.md, companion-app/src/api/umgebung.ts-Kommentar). Dabei den
.app-spezifischen HSTS-Preload-Hinweis in COMPANION_APP_ARCHITECTURE.md §5.4
korrigiert - gilt für .de nicht, Force-SSL in Nginx Proxy Manager deckt das
weiterhin ab. UMSETZUNGSPLAN.md Phase 12 zusätzlich mit einem
Aktualisierungshinweis versehen (zwei-Hostnamen-Plan und pyscript-Namen dort
waren ohnehin schon überholt, jetzt klar auf REVERSE_PROXY.md/
INTERNET_ZUGRIFF_EINRICHTEN.md als maßgeblich verwiesen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 15:06:07 +02:00
tobias 3ad1809af6 Internetzugriff: fehlenden Einrichtungs-Runbook nachgereicht, Pfad-Freigabeliste korrigiert
REVERSE_PROXY.md verwies auf INTERNET_ZUGRIFF_EINRICHTEN.md, das nie
geschrieben wurde - jetzt vorhanden (Cloudflare-Konto, Nameserver-Umstellung,
Cloudflared/NPM-Add-ons, Pfad-Freigabeliste eintragen, Prüfung).

Dabei einen echten Fehler in der Freigabeliste gefunden: sie ging noch von
einem companion-app-Web-Build unter /local/dm360/ aus (Planungsstand
2026-08-11) - das wurde nie gebaut, die App ist nativ/sideload-only. Ersetzt
durch den tatsächlichen Fernbedarf: das OTA-Bündel unter
/audi_dashboard_static/app/* (@capgo/capacitor-updater). Damit genügt auch
ein einziger Hostname statt der ursprünglich erwogenen App-/API-Trennung.
COMPANION_APP_ARCHITECTURE.md §5 und AGENTS.md entsprechend nachgezogen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 14:41:10 +02:00
tobias 391725e663 Setup-Menü: Sensorhinweis auf (z. B. tatsächlicher Sensor) reduziert
Erst zwei Zeilen (festes Beispiel + zugeordneter Sensor) versucht, aber die
erfundenen Beispiele waren für Reichweite/Sofort-Aktualisierung schlicht
falsch. Jetzt nur noch eine Zeile mit dem tatsächlich zugeordneten Sensor,
"(z. B. ...)" in Label-Größe/-Farbe, der Sensorname selbst wie die Einheit
daneben ([on/off], [km]); für Listenfelder (Türen/Fenster) ganz entfernt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 14:40:55 +02:00
tobias bf1f9646e2 REVERSE_PROXY.md auf aktuelle Entitäts-/Dienstnamen korrigiert
Die Pfad-Freigabeliste ging noch von pyscript.audi_dashboard_*/
pyscript.reifen_* und /api/services/pyscript/... aus - seit der Umstellung
auf die native Integration (2026-08-23) heißen Entitäten
sensor.audi_dashboard_* und Dienste audi_dashboard.<name>. Ergänzt um zwei
seither neu hinzugekommene Bedarfe, die in der alten Fassung fehlten:
homeassistant.restart (Update-Neustart-Knopf) und /local/dm360/* (die
ausgelieferte App selbst - ohne diesen Pfad hätte sie über den Tunnel gar
nicht geladen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 12:01:19 +02:00
tobias 6b35de458d Fahrgestellnummer: expliziter "automatisch"-Schalter statt Nur-wenn-leer-Heuristik
- Neues Profilfeld fahrzeug.fin_automatisch (Standard: an).
- identitaet.py: bei "aus" läuft die Ableitung (samt Mehrfach-Sensor-Abgleich)
  gar nicht erst; bei "an" überschreibt der abgeleitete Wert auch einen
  vorher von Hand eingetragenen - der Schalter selbst entscheidet jetzt, statt
  aus einem leeren Feld zu raten.
- dienste.py: profil_schreiben löst die Ableitung jetzt auch aus, damit das
  Umschalten des Schalters selbst sofort wirkt.
- Panel: Schalter "automatisch" zwischen Label und Textfeld in der
  Fahrgestellnummer-Zeile, Feld wird bei "an" deaktiviert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 12:01:10 +02:00
tobias c0acc836f0 Fahrgestellnummer (FIN) automatisch aus den zugeordneten Sensoren ableiten
Neues identitaet.py: durchsucht das Geräte-Register der aktuell im Setup
zugeordneten Sensoren (cupra_eu_data_act, FMM003, ...) nach einem
VIN-förmigen Geräte-Identifier/Seriennummer, statt ein eigenes Setup-Feld zu
verlangen. Mehrere übereinstimmende Quellen werden akzeptiert, abweichende nur
geloggt statt geraten; eine bereits von Hand eingetragene FIN wird nie
überschrieben. Läuft nach jedem Start und nach jeder Setup-Änderung
(koordinator.py/dienste.py).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 11:47:08 +02:00
tobias 8f88ccf581 Zuletzt-Kachel: Werte jetzt wirklich spaltenbündig
- .teaser-Klasse auf die Kachel gesetzt (teaser()).
- .teaser .leaf .v: min-width:72px (gleiche Spaltenbreite je Zeile).
- .teaser .leaf .k: flex:1 1 auto, damit unterschiedlich lange Labels nicht
  mehr über justify-content:space-between den ganzen Rest der Zeile
  verschieben - vorher blieben .v-Boxen zwar gleich breit, saßen aber an
  unterschiedlichen X-Positionen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 11:31:07 +02:00
tobias dd8d62e3b3 Tanken: Distanz-Vorschlag bevorzugt TANK_DISTANZ_SENSOR; Zuletzt-Kachel: Zeilenabstand ergänzt
- distanzVorschlag(): liest jetzt zuerst den Live-Wert von TANK_DISTANZ_SENSOR
  (wie backendseitig schon in distanz_seit_tankung()), fällt ohne Zuordnung
  auf die bisherige FILLS-Berechnung zurück (jetzt distanzVorschlagBerechnet()).
- .leaf .v: gap:3px ergänzt (fehlte im Gegensatz zu .leaf .k) - die zweizeiligen
  Werte in der "Zuletzt"-Kachel (Übersicht) berührten sich sonst.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 11:26:23 +02:00
tobias ff9aaeb338 Setup-Menü: runde Klammern aus Feld-Labels entfernt, Popup verbreitert
- FELDER-Katalog: "(Datum)"/"(Restkilometer)"/"(Liter)"/"(Knopf)"/"(Fahrertür)"
  aus sieben Labels entfernt - die Unterscheidung übernimmt bereits das
  [Einheit]-Label neben der Überschrift (feldEinheitAnzeige()).
- .setup-popup auf max-width:760px verbreitert (eigener Wert, getrennt von
  .sdpopup/.sheet/.beleg-popup), damit lange Rohsensornamen nicht mehr
  umbrechen müssen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 11:19:28 +02:00
tobias d1cc742a59 Setup-Menü: Sensorkennung per Mehrheitsentscheid statt nächstem Nachbarn; Ellipsis entfernt; Filter/Tanken-Fixes
- entitaetIdKurz(): Präfix per Stimmenmehrheit über alle zugeordneten Sensoren statt
  des einzelnen nächsten Nachbarn - verhindert Über-Kürzung durch zufällig geteilte
  Namensteile zwischen verwandten Feldern (can_fuel_volume->fuel_volume,
  oil_change_due->due waren falsch).
- .setup-feld-hinweis: Ellipsis-Kürzung entfernt, voller Sensorname sichtbar.
- entitaetKandidaten(): "Nur passende Sensoren anzeigen" filtert jetzt auch nach
  device_class für Datum/Timestamp-Felder, nicht nur nach Domain/Einheit.
- tankFelder(): Kilometerstand/Getankte Liter in .mitEinheit gewrappt (wie alle
  anderen km/l-Felder im Rest der App).
- distanzVorschlag(): negative Vorschläge (fehlerhafte km-Basis eines vorherigen
  Tankvorgangs) werden nicht mehr angezeigt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 11:15:12 +02:00
tobias 4f85a179cf Setup-Menue: Sensorkennung per naechstem Verwandten statt globalem Praefix
Zwei weitere Korrekturrunden, beide vom Nutzer live erkannt:

- .6: paarweise Abstimmung statt fortlaufender Verengung - robuster gegen
  einen einzelnen Ausreisser, aber grundsaetzlich noch falsch.
- .7: die eigentliche Ursache war, dass diese Installation zwei etwa
  gleich grosse Sensorquellen gleichzeitig hat (9 FMM003-Rollen, 18 Rollen
  einer zweiten Integration mit "audi_rs_4_avant_"-Praefix) - ein
  EINZELNES globales "Sieger"-Praefix kann immer nur eine der beiden
  Quellen richtig kuerzen. entitaetIdKurz() sucht jetzt pro Feld den
  naechsten Verwandten unter allen zugeordneten Sensoren und kuerzt nur
  dessen gemeinsames Praefix - unabhaengig von Anzahl und Groesse der
  Quellen.

Diesmal vor dem Deploy anhand der ECHTEN sensor.audi_dashboard_entitaeten-
Zuordnung durchgerechnet statt mit erfundenen Test-IDs (das hatte den
.6-Fehler faelschlich als behoben erscheinen lassen). Version 2026.8.28.7,
live verifiziert: alle 20 Einzelwert-Felder zeigen die richtige, kurze
Kennung, keine installationsspezifischen Reste aus beiden Quellen mehr.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 10:54:16 +02:00
tobias af64fc39e3 Setup-Menue: Sensorkennung ohne installationsspezifisches Praefix, Einheit statt Live-Wert
Korrektur zur letzten Aenderung, vom Nutzer live erkannt:
- "fmm003_" war hart einprogrammiert und liess trotzdem noch
  installationsspezifisches Rauschen stehen (Geraete-Slug, Instanzname der
  Integration). Jetzt wird das laengste gemeinsame Praefix ueber alle
  aktuell zugeordneten Sensoren berechnet (setupGemeinsamesPraefix()) -
  nichts mehr hartkodiert. Faellt bei einer einzelnen Entitaet aus
  anderer Quelle korrekt auf die volle ID zurueck, statt falsch zu kuerzen
  (live bestaetigt: TANK_DISTANZ_SENSOR zeigte den vollen Namen, weil er
  auf eine andere Integration zeigte).
- Der Live-Wert des Sensors gehoert nicht mehr neben die ID - stattdessen
  zeigt das Label selbst jetzt die ERWARTETE Art des Werts an
  ("Tankfuellstand [%]", "12V-Batteriespannung [V]"), statisch aus dem
  FELDER-Katalog abgeleitet, unabhaengig von einer aktuellen Zuordnung.

Version 2026.8.28.5, live im Testcontainer verifiziert (acht echte
Setup-Zeilen gegen die Nutzerbeispiele geprueft).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 10:45:45 +02:00
tobias b1cd03be51 Wartungsplan-Schema companion-app<->Panel angeglichen; Setup-Feldkopf zeigt zugeordneten Sensor
companion-app nannte Servicebuch-Feldnamen betrieb/notiz, waehrend Panel und
Backend (profil["service"]["buch"]) werkstatt/kosten/arbeiten verwenden -
ohne Uebersetzung dazwischen zeigten in einer Oberflaeche angelegte
Eintraege in der anderen leere Werkstatt-/Kosten-Werte. werkstatt/kosten/
arbeiten als kanonisch uebernommen (aeltere, bereits etablierte Panel-
Konvention); ServicebuchEintrag, das Bearbeitungsformular (inkl. neuem
Kosten-Feld) und die Ansicht in Service.tsx angepasst, den Workaround-Cast
im CSV-Export (DatensatzPopup.tsx) sowie die Testfixture entsprechend
bereinigt.

Setup-Popup (Panel, Item 11 aus der letzten Sammel-Rueckmeldung): die
statische Beschreibung unter jedem Feldnamen ist jetzt die Kennung und der
aktuelle Wert des tatsaechlich zugeordneten Sensors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 10:38:27 +02:00
tobias 2be6b99667 companion-app: Datensatz sichern/laden portiert (Item 9 Paritaet)
- "Daten ausgeben" zu "Fahrzeugprofil" umgebaut, gleiche zwei Knoepfe wie
  im Panel: Datensatz sichern/laden, popup mit vier Aktionen
- CSV-Spaltennamen jetzt byte-identisch zum Panel-Export (gemeinsamer
  Backend-Parser, csv_import.py) - vorher eigene, abweichende Kopfzeilen
  ohne Reimport-Moeglichkeit
- neue api.csvImportieren(), Fahrzeugprofil-Import ueber das bestehende
  api.profilSchreiben() (volles Profil, nicht die teilweise
  zusammenfuehrende profilSpeichern()-Bequemlichkeitsfunktion)
- Blinden Fleck geprueft: importStatusLesen()/zustandLesen() lesen per
  REST, nicht aus lokalem Cache - dieselbe Racebedingung wie im Panel kann
  hier strukturell nicht auftreten
- Toten dateiwahl-Scaffolding entfernt (nie verdrahtet)

Beim Bauen echten, vorbestehenden Fehler gefunden: companion-app und Panel
verwenden unterschiedliche Feldnamen fuer Wartungsplan-Eintraege
(betrieb/notiz vs. werkstatt/kosten) - als eigene Aufgabe geflaggt statt
hier mitgefixt.

tsc --noEmit sauber, 146/146 Tests, vite build erfolgreich. Nicht live
getestet (kein laufender companion-app-Dev-Server mit Backend-Zugang in
dieser Sitzung).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 10:31:10 +02:00
tobias bfb7dec1e9 TANK_DISTANZ_SENSOR + Datensatz sichern/laden mit echtem CSV-Import (Panel)
- Neuer optionaler Sensor TANK_DISTANZ_SENSOR ersetzt die eigene
  Kilometerstand-Subtraktion fuer die Tankvorgang-Strecke, wo zugeordnet
  (Live-Erkennung, manuelle Erfassung, historischer Import)
- "Fahrzeugprofil" und "Daten ausgeben" zu einer Kachel zusammengelegt:
  "Datensatz sichern" (4 Exporte wie bisher) / "Datensatz laden" (neu:
  echter CSV-Import fuer Fahrten/Tankvorgaenge/Wartungsplan)
- Import gleicht per ID ab (Fahrten/Tankvorgaenge) bzw. Datum+Art
  (Wartungsplan, hat keine eigene ID) und aktualisiert nur die in der CSV
  enthaltenen Spalten - alles andere am Datensatz bleibt unangetastet
- Nebenbei gefunden und behoben: belege.tankvorgang_aktualisieren()
  ueberschrieb bisher immer alle Felder, auch mit None, wenn irgendeins
  geaendert wurde - fuer den CSV-Import gefaehrlich, jetzt nur noch
  tatsaechlich uebergebene Felder
- Ebenfalls gefunden und behoben: Panel las das Import-Ergebnis per
  HASS.states direkt nach dem Dienstaufruf - ein Wettlauf mit dem
  state_changed-Push. Nutzt jetzt denselben HASS.callWS(get_states)-Weg
  wie der bestehende historie_importieren-Ablauf.

companion-app-Portierung von "Datensatz sichern/laden" steht noch aus.

Version 2026.8.28.3, live im Testcontainer verifiziert (alle drei
CSV-Datensatztypen: anlegen + aktualisieren per ID/Datum+Art getestet,
Testdaten danach geloescht).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 10:19:06 +02:00
tobias edefdd66d8 Reifen/Version/Wartungsplan: erste Runde des Sammel-Feedbacks behoben
- Archiv-Knopf jetzt in Zeile mit "Montiert" (stale margin-top entfernt),
  bekommt einen sichtbaren Hintergrund
- companion-app: Wechseltermin-Datumsfeld (appearance-Reset gegen
  natives Safari-Chrome, Breite auf Inhalt geschrumpft)
- Mein Audi/Reifen-Box: "montiert: Sommerräder" links statt Marke/Modell,
  "offen" durch "-" ersetzt
- "Servicebuch" ueberall in "Wartungsplan" umbenannt (nur Anzeigetext,
  beide Codebasen)
- Version-Kachel: tote Fahrzeugdaten-/Position-/Dashboard-Zeilen entfernt
  (inkl. der nie aktualisierten CONFIG.version-Konstante)

Version 2026.8.28.1, live im Testcontainer verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 09:43:40 +02:00
tobias bdd8cb89a3 Batterie-Messwertliste: Zeilen sehen nicht mehr klickbar aus (Cursor/Presshighlight)
.leaf (Panel) und .dm-listenzeile (companion-app) galten fuer echte
Navigations-Buttons UND fuer die reine <div>-Messwertliste gleichermassen -
Hand-Cursor und Tipp-Rueckmeldung suggerierten dort faelschlich Klickbarkeit,
obwohl die Liste nur Wischen-zum-Loeschen unterstuetzt. Beide Regeln jetzt
auf button.leaf/button.dm-listenzeile beschraenkt - echte Zeilen-Buttons
(Fahrten, Tankvorgaenge, Servicebuch) bleiben unveraendert klickbar.

Version 2026.8.27.22, live verifiziert (Cursor auto statt pointer bei der
Messwertliste, pointer weiterhin bei echten Zeilen-Buttons).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 00:47:57 +02:00
tobias f4222b715f Batteriespannung-Fallback: zeigt letzte Ruhespannungsmessung ueber Neustarts hinweg
Der RAM-only Fallback fuer die Zustand-Kachel wurde bei jedem Neustart
geleert und zeigte "unbekannt", solange seit dem Neustart keine echte
Live-Messung eintraf (Dongle bei ausgeschaltetem Fahrzeug erwartungsgemaess
immer offline). spannung_cache_vorladen() laedt ihn jetzt beim Start aus
der gespeicherten Historie vor, begrenzt auf den vereinbarten Bereich
10,0-13,0 V (SPANNUNG_MIN_V bis AGM_RUHE_MAX_V) - eine Generatorspannung
(z.B. 15,4 V) soll dort nie als Batteriespannung erscheinen.

Version 2026.8.27.21, im Testcontainer verifiziert (12,149 V statt
"unbekannt", trotz Dongle weiterhin 0 V live).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 00:23:16 +02:00
tobias 90fff7b305 Vollaudit: sechs echte Fehler in Backend, Panel und companion-app behoben
- companion-app Batterie.tsx: Generatorspannung zeigte sich faelschlich als
  Ruhespannung/verzerrte den SOH-Trend, Panel-Filter (AGM_RUHE_MAX_V) fehlte
- Manuell angelegte Fahrten/Tankvorgaenge trugen naive Zeitstempel und wurden
  von der Import-Dublettenpruefung stillschweigend uebersprungen; neue
  gemeinsame zeit_normalisiert() in verlauf.py schliesst die Luecke
- Batteriespannungsverlauf fehlte im Backup (sicherung.py)
- Tankvorgangs-Import verlor stillschweigend aeltere Tankvorgaenge vor Beginn
  der Litersensor-Historie; laeuft jetzt zweigleisig (Prozent + Liter)
- Panel-Statistikseite behauptete faelschlich, der Verbrauch je Fahrt komme
  vom Fahrzeug (OBD) - ist eine Naeherung aus dem Tankfuellstand
- companion-app Statistik.tsx: Arbeitsweg-Segment war noch rot statt neutral

Version 2026.8.27.19, OTA-Buendel neu gebaut, im Testcontainer verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 00:05:58 +02:00
tobias 8ba3519090 Fix: ReferenceError bvRuhePunkte in vBatterieverlauf() - Batteriespannung-Klick brach das Rendering ab
Beim Zurueckbauen von bvPunkte/bvRuhePunkte auf eine einzige gefilterte
Liste (voriger Commit) blieb eine Fundstelle unbeachtet: die SOC-Berechnung
in vBatterieverlauf() las weiterhin aus dem entfernten bvRuhePunkte. Jeder
Klick auf "Batteriespannung" liess render() mit einem ReferenceError
abbrechen, bevor die Zielseite ueberhaupt gezeichnet wurde - von aussen
ununterscheidbar von "Klick tut nichts". Live im Browser gefunden (Konsole
zeigte den echten Fehler), nicht durch Codelesen - node --check haette das
nie gefangen, da es nur Syntax prueft, keine Laufzeitreferenzen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 23:33:49 +02:00
tobias e1ddf2cda1 AGENTS.md: dokumentiert den Update-Vorfall - Selbst-Update hat unveroeffentlichte Sitzungsarbeit ueberschrieben
Der Nutzer fuehrte ein Selbst-Update aus, das Gitea's letzten committeten Stand
(80d73e8) zog - alle Fixes dieser Sitzung waren bis dahin nur per docker cp an
audi_ha_test ausgeliefert, nie committet. Manifest fiel auf 2026.8.27.8 zurueck,
alle Symptome (Batteriespannung "unbekannt", Diagramm-Maximalwert 13,99V) passten
exakt zum Vor-Sitzungs-Stand. Container wiederhergestellt, jetzt committet -
Lehre fuer kuenftige Sitzungen im Dokument festgehalten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 23:24:24 +02:00
tobias 172db54e7a Statistik-Icon-Groesse, Batteriediagramm-Ueberarbeitung, Temperatur-Kopplung mit 300s-Fenster, letzte gueltige Spannung als Fallback
- Statistik-Tab-Icon per zentrierter Stauchung an die Fuellflaeche der
  anderen Tab-Icons angeglichen (Panel + companion-app), auf Nutzerwunsch
  anschliessend 10% groesser.
- Batteriediagramm neu gezeichnet: dynamische viewBox (1 Einheit = 1 Pixel,
  kein preserveAspectRatio-Verzerren mehr), ein Punkt je Ruhespannungsmessung
  statt Datumsbeschriftung, Tageshoechstwert nur noch im Tooltip. Generator-
  spannungen (> AGM_RUHE_MAX_V = 13,0 V, vom Nutzer festgelegt) bleiben aus
  dem Diagramm aussen vor; der SOC-Kurve-Eckwert "100% / voll" bleibt bei den
  ursprünglich recherchierten 12,8 V. companion-app auf dieselbe lineare
  SOC-Interpolation umgestellt (vorher eine abweichende Stufenkurve).
- Außentemperatur wird jetzt auch beim Batterieverlauf-Import nachgetragen
  (vorher nur live) - Kopplung an die naechstgelegene Messung innerhalb
  NEBENWERT_MAX_ABSTAND_S (verlauf.py), vom Nutzer nach Live-Daten-Analyse
  auf 300s festgelegt. Merge-Logik in ablage.py ergaenzt: ein erneuter Import
  kann eine zuvor fehlende Temperatur jetzt tatsaechlich nachtragen, auch
  wenn sich der Tagesminimalwert selbst nicht aendert.
- "Mein Audi"/Zustand zeigt bei unplausibler Live-Messung (z. B. Dongle
  offline) die zuletzt gemessene Spannung statt "unbekannt".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 23:23:12 +02:00
tobias 80d73e8afd Fahrten: Verbrauch-Näherung, 0V-Zustandsanzeige gefiltert, Plausibilitätsgrenze für die Live-Vervollständigung
Verbrauch (l/100km) war bislang totes Schema - kein Codepfad füllte
verbrauch_l_100km je. Jetzt aus der Literstand-Differenz (TANK_LITER_SENSOR)
über die Distanz genähert, live und beim Import, in beiden Frontends.

Die "Zustand"-Kachel zeigte die Batteriespannung ungefiltert direkt vom
Sensor, unabhängig von der Plausibilitätsgrenze der Verlaufsaufzeichnung -
ein Sensorausreißer (0V) zeigte sich dort weiterhin, obwohl die Messwertliste
ihn längst verwarf. Dieselbe Grenze gilt jetzt auch für diesen Anzeigepfad.

Reale Fahrtendaten zeigten eine Fahrt mit 22km in 67s (~1180 km/h) - die
Live-Vervollständigung (screening.py) hatte anders als der Import keine
Plausibilitätsprüfung der Durchschnittsgeschwindigkeit. Jetzt gemeinsam in
verlauf.py (UNPLAUSIBLE_KMH/durchschnitt_kmh) für beide Pfade. Dabei einen
zweiten echten Bug gefunden: _fahrt_screenen() zog sein Ergebnis nie ins
In-Memory-Objekt nach (nur in die Ablage) - eine im selben Durchlauf gerade
erst ermittelte Distanz blieb für spätere Schritte (z. B. Verbrauch) bis zum
nächsten Screening unsichtbar. Beide Stellen jetzt behoben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:01:57 +02:00
tobias cf57c5f1a5 Batteriespannungs-Diagramm: feste 170px-Höhe statt aspect-ratio, Y-Achse geklemmt
Die aspect-ratio-Lösung für die volle Spaltenbreite ließ auf großen Bildschirmen
die gesamte Koordinatenfläche mitwachsen - Achsenbeschriftungen (fester font-size
in SVG-Einheiten) wurden dadurch auf ~26px zu groß, das Diagramm unnötig hoch.
Zurück auf eine feste 170px-Höhe: der Y-Maßstab bleibt immer 1:1, nur die
X-Achse dehnt sich auf die volle Breite - normale Schriftgröße, kompaktes
Diagramm, trotzdem volle Spaltenbreite.

bvSkalaY()/y() klemmen jetzt auf [0,1] statt roh zu extrapolieren - ein Punkt
außerhalb von yMin/yMax (z. B. ein Ausreißer auf einer noch nicht aktualisierten
Instanz) zeichnet sich sonst weit außerhalb der Fläche und reißt die Linie über
den Rand hinaus, statt sichtbar am Achsenrand zu liegen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 18:33:31 +02:00
tobias b8e560e75b Batteriespannung: Diagramm-Y-Achse fest 10-15V, Plausibilitätsgrenze auf 10V angehoben
Die Y-Achse beider Batteriespannungs-Diagramme (Panel und companion-app) war bisher
aus den sichtbaren Punkten berechnet - jetzt fest 10-15V, auf Nutzerwunsch (zunächst
8-15V, dann auf 10-15V korrigiert). SPANNUNG_MIN_V (batterie.py) auf denselben Wert
angehoben, damit die Plausibilitätsgrenze zur Achse passt - historienimport.py
übernimmt den Wert automatisch, da er von dort importiert statt dupliziert wird.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 18:06:12 +02:00
tobias f337820fc8 Batteriespannung: 8V-Plausibilitätsgrenze; Fahrten: echte Start-/Zielposition und Streckenlinie
Batteriespannung: Werte unter 8V (Sensor-/Verbindungsfehler, nicht physikalisch
plausibel für eine 12V-Bleibatterie) werden nicht mehr erfasst - live und beim
Import. "Neue Räder anlegen"-Knopf sitzt jetzt auf derselben Zeile wie "Montiert".

Fahrten: start_lat/-lon, end_lat/-lon und route wurden bisher nur beim
rückwirkenden Import befüllt, nie bei live erkannten Fahrten (dem Normalfall) -
Screening trägt sie jetzt aus dem GPS-Verlauf nach, unabhängig vom
Kilometerstand-Status. fakeTrack() (eine erfundene Linie) ist entfernt, die
Karte zeichnet jetzt die echte Route oder eine ehrliche Gerade zwischen den
bekannten Punkten. CARTOs anonyme Kartenkacheln verlangen inzwischen einen
API-Schlüssel ("API key required" auf jeder Karte in beiden Apps) - auf
schlüssellose OpenStreetMap-Kacheln umgestellt. Start/Ziel zeigen jetzt Datum
und Uhrzeit über der Adresse; die fehlerhafte "Status"-Zeile ist entfernt.
Batteriespannungs-Diagramm nutzt auf großen Bildschirmen die volle
Spaltenbreite statt einer festen 400px-Deckelung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:23:32 +02:00
tobias 17149937a6 Batteriespannungs-Messwertliste, Wisch-Löschen, Reifensatz-Archiv, CI-Icons
Fünf vom Nutzer angeforderte Erweiterungen in einer Runde:

- Batteriespannungs-Kachel bekommt eine Messwertliste (Datum, Uhrzeit,
  Spannung, optional Außentemperatur über den neuen AUSSENTEMP_SENSOR),
  erreichbar über ein neues list-s-Symbol in der Diagramm-Kachel. Werte
  über AGM_RUHE_MAX_V (12,8 V) sind keine Ruhespannung, sondern
  Lichtmaschinenspannung - die Zeile markiert das jetzt mit
  "Generatorspannung" statt es unkommentiert als Messwert auszugeben.
- Jede Zeile der neuen Liste wischbar zum Löschen (neuer Dienst
  batterieverlauf_loeschen), über dieselbe Wisch-Mechanik wie Fahrten/
  Tankvorgänge in beiden Oberflächen.
- Batteriespannungs-Diagramm im Panel war auf großen Bildschirmen (≥860px,
  Spaltenlayout) stark in die Breite gezogen (preserveAspectRatio="none"
  bei fester Höhe) - gedeckelt wie die dort bereits vorhandenen Popups.
- Statistik-Tab-Symbol in beiden Oberflächen durch das Audi-CI-Symbol
  polls-s ersetzt.
- Neue Möglichkeit, einen Reifensatz zu archivieren ("Neue Räder
  anlegen"): der aktuelle Stand (km, Marke, Modell, DOT, Maße, Solldruck,
  Kommentar) wandert in ein Archiv, der laufende Satz beginnt bei 0 km neu.
  Archiv als aufklappbare Übersicht mit voller Bearbeitungsmöglichkeit,
  über drei neue, bewusst nicht über profilSchreiben laufende Dienste
  (reifen_archivieren/-aktualisieren/-loeschen) - aus demselben Grund wie
  reifen_wechseln: kein im Browser gehaltener Stand darf einen
  zwischenzeitlich fortgeschriebenen km-Wert überschreiben.

Live im Testcontainer geprüft (erstmals über :18123 statt :8123 erreichbar)
und dabei zwei echte Layout-Fehler gefunden, die kein Compile-/Testlauf
sehen konnte: die Archiv-Eingabefelder waren durch ein fälschlich
verwendetes .mitEinheit (feste 96px-Breite, eigentlich für Zahl+Einheit
gedacht) abgeschnitten ("Continenta" statt "Continental"), und die
Kilometerzahl in der eingeklappten Archiv-Zeile war klein an das Datum
gequetscht statt wie der Satzname lesbar. Beides behoben, live erneut
bestätigt.

Backend: py_compile clean, services.yaml ergänzt. Panel: node --check
clean. companion-app: tsc/Testsuite (146/146)/Build/OTA-Bündel alle grün.
Manifest 2026.8.25.2 → .8, jeder Neustart im Testcontainer sauber.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 11:44:24 +02:00
tobias 70253e83a5 Fahrterkennung: FMM003-Funklöcher reißen keine Fahrt mehr auseinander
Die Zündungs-Entität stammt vom FMM003-Tracker; verliert er unterwegs
kurz die Verbindung, meldet Home Assistant "unavailable" statt eines
echten Zündungszustands. Die Live-Erkennung wertete das bisher wie
"aus" - eine Funklücke über der Pausenzeit teilte eine durchgehende
Fahrt in zwei. zuendung_geaendert() ignoriert "unavailable"/"unknown"
jetzt vollständig und leitet "fährt gerade" aus dem eigenen
Zwischenstand (fahrt_start_ts) statt aus dem letzten Rohwert ab.

historienimport.py bekommt zusätzlich eine Plausibilitätsprüfung: eine
errechnete Durchschnittsgeschwindigkeit über 300 km/h deutet auf einen
Kilometerstand-Ausreißer an der Fahrtgrenze hin, nicht auf eine echte
Fahrt - die Strecke wird dann verworfen statt eine unmögliche Fahrt
anzuzeigen.

Tankerkennung: optionaler zweiter Sensor für das Tankvolumen in Litern
(TANK_LITER_SENSOR, direkt vom CAN) neben dem bisherigen
Prozent-Füllstand. Ist er zugeordnet, übernimmt er die automatische
Tankerkennung vollständig - genauer als der Umweg über das im
Fahrzeugprofil hinterlegte Tankvolumen, und der erkannte Anstieg
liefert gleich eine grobe Vorbelegung für die getankte Menge statt
eines leeren Feldes. Ohne den Sensor bleibt alles beim Alten. Der
historische Import zieht mit derselben Präferenz nach.

Manifest auf 2026.8.25.2, beide Änderungsrunden live im Testcontainer
verifiziert (py_compile, Neustart, sauberes Setup-Log).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 22:04:26 +02:00
tobias f97ceab3ad companion-app: Fehlergrenze um die App - Absturz sichtbar statt schwarzer Seite
Ohne React-Error-Boundary unmountet ein unbehandelter Renderfehler die
gesamte App; übrig bleibt nur der Seitenhintergrund (im Nacht-Theme
praktisch schwarz) - passt zum gemeldeten "schwarze Seite nach Update
prüfen". Behebt nicht die eigentliche Ursache, macht einen künftigen
Absturz aber sichtbar (Fehlertext, Neu-laden-Knopf) und loggt ihn in
die Konsole statt ihn stumm verschwinden zu lassen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 22:04:06 +02:00
tobias 0a0beb9eb3 Einzelfahrt-Dauer: "h min" statt "Std. Min." (Paritätsregel zu companion-app)
dauerText() zeigte "1 Std. 15 Min." - der Besitzer wollte exakt die Schreibweise,
die companion-app/src/format.ts' dauer() bereits verwendet: "1 h 15 min", Minuten
zweistellig gepolstert. Betrifft sowohl die Einzelfahrt-Detailansicht als auch die
Dauer-Anzeige im Fahrt-Bearbeiten-Formular (gemeinsamer Helfer).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 22:57:55 +02:00
tobias c234b87dea Einzelfahrt: Dauer in Std./Min. statt roher Minutenzahl
vTrip() zeigte "75 min" statt der bereits vorhandenen dauerText()-Formatierung
("1 Std. 15 Min."), die jede andere Dauer im Panel schon verwendet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 22:43:04 +02:00
tobias 4fccf21676 Selbst-Update: Ergebnis erscheint ohne Neuladen, Panel übersteht Neustart
Zwei Meldungen des Besitzers ("Update installieren" braucht manuelles
Neuladen, um den Neustart-Knopf zu zeigen; die "HA startet neu..."-
Anzeige ebenso) auf denselben Grund zurückgeführt: datenLaden() rief
render() nur bei Änderungen an profil/fahrten/tank/status/batt auf,
nie bei einer reinen Änderung des Versions-Sensors. Dazu ein bereits
im Code kommentiertes, bisher nur per Tab-Klick umgangenes Rennen
beim echten HA-Neustart gefunden und behoben: panel_custom baut das
Element neu auf, aber nichts zeichnete es automatisch, bis der Nutzer
irgendwo klickte. companion-app erhielt den passenden Fix für die
Neustart-Anzeige (Panel-Bug 1 betraf sie nicht, siehe AGENTS.md).

Alle drei Fixes live im Testcontainer über einen echten
homeassistant.restart bestätigt, nicht nur simuliert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 19:39:40 +02:00
tobias 0354ab8768 Update-Knopf: Seitensprung im Großbild-Layout behoben
Auf Update prüfen/Update installieren ließ die Seite im Seitenleisten-Layout
sichtbar nach oben springen - render()s eigene Scroll-Wiederherstellung
reichte hier nicht, vermutlich weil die Zustandsänderung des
Update-Sensors zusätzlich über HAs eigenes set-hass-Durchreichen an das
eingebettete Panel einen zweiten Render-Pfad anstößt. Statt die genaue
Ursache weiter zu jagen: fester Scroll-Merkwert vor dem Klick, per
requestAnimationFrame nach jedem eigenen render()-Aufruf dieses Ablaufs
erneut erzwungen. Live bei 1280x800 verifiziert: kein Sprung mehr.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 18:18:51 +02:00
tobias 7276d06a18 Sicherheit-Feinschliff, Nächster-Service-Sortierfehler behoben, Fahrzeugstatus umbenannt
- Türschlösser auf ein Sensor reduziert (Fahrertür genügt, Zentralverriegelung
  schließt alle Türen gemeinsam) - Kofferraum-/Motorhauben-Schloss entfernt.
- "Sicherheit" heißt im Panel jetzt "Fahrzeugstatus", wie in der companion-app.
- naechsterTermin() (Panel): sortierte null als Epoch 1970 und ließ dadurch
  einen datenlosen Termin (Hauptuntersuchung ohne Eintrag/Erstzulassung) immer
  vor einem echten, berechneten Termin (Ölwechsel) gewinnen - Ursache dafür,
  dass Übersicht nach dem Leeren des Servicebuchs nichts mehr zeigte.
- "Licht ausgeschaltet" statt "Kein Licht" (Formulierung zurückgenommen).
- Große Bildschirme: Unterseiten (Standort, Service, ...) zeigten ihren Titel
  in 26px statt der sonst überall genutzten 17px - wirkte neben dem
  durchgängig leichten Fließtext wie Fettschrift, obwohl font-weight nirgends
  wechselt. Jetzt dieselbe Standardgröße wie im Telefon-Layout.

Details und Verifikation in AGENTS.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 17:00:33 +02:00
tobias 5a048488ea Selbst-Update-Sicherung, Service-Prognose ohne Servicebuch und Sicherheit neu gestaltet
- aktualisierung.py: Backup-Ordner verliert sein manifest.json beim Sichern,
  sonst laedt HA ihn nach einem Neustart als zweite audi_dashboard-Integration
  (Ursache des raetselhaften "gleicher Pfad"-Umbenennungs-Fehlers).
- service.ts/audi-dashboard-app.js: Uebersicht faellt jetzt wie Service schon
  auf die Fahrzeugmeldung zurueck, wenn kein Servicebucheintrag existiert;
  eigenes (kuerzeres) Oelwechsel-Intervall wird jetzt auch ohne Servicebuch-
  eintrag naeherungsweise aus der Herstellermeldung hochgerechnet, statt
  weiterhin unveraendert die Herstellervorgabe zu zeigen.
- Sicherheit neu gestaltet: vier Sammelzeilen (Fahrzeug verriegelt / Tueren
  und Klappen geschlossen / Fenster und Dach geschlossen / Kein Licht) statt
  einer flachen Liste, mit neuen Tuerschloss-, Dach- und Standlicht-Sensor-
  rollen, Kreis-Haekchen-Symbolen und einer Detailseite je Tuer/Motorhaube/
  Kofferraum in beiden Frontends.

Details, Verifikation und Sensor-Rollen in AGENTS.md (Abschnitte J/M/N).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:29:37 +02:00
tobias 146f809e9b Selbst-Update: Ladezustand und echter Neustart-Knopf
Der erste echte Live-Test des Selbst-Updates gegen das private Gitea-Repo
lief erfolgreich (mehrfach im Test-Container bestätigt) - deckte aber zwei
UX-Lücken auf, die der Owner direkt gemeldet hat:

- Prüfen/Installieren gaben während der 15-25 Sekunden dauernden
  Netzwerkaktion keine sichtbare Rückmeldung - nicht von einem Hänger zu
  unterscheiden. Jetzt ein Ladeindikator (Panel: wiederverwendetes
  .lade-spinner; companion-app: neues .dm-spinner-Äquivalent, gab es dort
  noch gar nicht).
- Nach erfolgreicher Installation stand nur ein Hinweistext da, kein Weg
  zum eigentlich nötigen nächsten Schritt. Der Knopf wechselt jetzt zu
  "Installation abschließen - Jetzt neu starten" und stößt
  homeassistant.restart direkt an.

Das separat gemeldete "Lädt"-Hängenbleiben war kein Bug: reproduziert durch
Live-Test des neuen Neustart-Knopfs - eine echte HA-Verbindungsunterbrechung
während eines Neustarts zeigt exakt denselben, bereits bestehenden
Bootstrap-Ladebildschirm. Vermutlich derselbe Effekt durch den
Docker-Neustart früher in dieser Sitzung.

Verifiziert im Test-Container per Browser-Automatisierung: Neustart-Knopf
ausgelöst, Verbindungsabbruch beobachtet, per docker logs bestätigt, dass
Home Assistant tatsächlich neu gestartet ist und die neue Version aktiv
wurde.

Details in AGENTS.md, Abschnitt L.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 12:44:47 +02:00
tobias 7365180550 vCard-Import fürs Autohaus, Selbst-Update-Logging und UI-Feinschliff
- Service -> Autohaus -> Zahnrad hat jetzt einen "vCard importieren"-Knopf
  (öffnet den Dateiauswahldialog für .vcf). Liest FN/ORG, TEL, EMAIL, ADR
  und befüllt das Formular - in beiden Frontends, jeweils passend zum
  bestehenden Speicherverhalten (Panel speichert sofort, companion-app
  erst über den vorhandenen "Speichern"-Knopf).
- update_pruefen/update_installieren loggten bisher nur bei einem Fehler -
  ein erfolgreicher Aufruf war im Log nicht von "noch nicht ausgeführt" zu
  unterscheiden. Jetzt auch eine Erfolgsmeldung.
- Panel: der feste Hinweis auf den fehlenden Gitea-Token stand dauerhaft in
  der Integration-Update-Kachel, auch wenn ein Token längst hinterlegt war.
  Erscheint jetzt nur noch als Teil einer echten Fehlermeldung.

Details in AGENTS.md, Abschnitte J (Nachtrag) und K.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 12:30:24 +02:00
tobias fb251154c7 Ölwechsel/Inspektion-Anzeige repariert, Selbst-Update der Integration gebaut
Zwei getrennte Themen in einem Commit, beide in derselben Sitzung entstanden:

1. Ölwechsel/Inspektion wurden komplett ausgeblendet ("kein Eintrag im
   Servicebuch"), sobald kein Servicebucheintrag vorlag - selbst wenn der
   zugeordnete Sensor eine gültige Fälligkeit meldete. In beiden Frontends
   prüfte die Anzeige nur den Servicebuch-Zweig, bevor sie die
   Fahrzeugmeldung überhaupt las. Jetzt steht die Fahrzeugmeldung für sich;
   fehlt zusätzlich ein Servicebucheintrag, übernimmt eine neue,
   fahrtenlog-basierte Prognose (kmProTagAusFahrten()/meldungsPrognose())
   die Hochrechnung statt der Servicebuch-Rate - deckelt auf die vom
   Fahrzeug selbst gemeldete Zeitgrenze, falls zu wenig gefahren wird.

2. install.ps1 als Update-Weg wird von Windows Smart App Control blockiert,
   ohne Umgehungsmöglichkeit. Die Integration lädt sich jetzt auf
   Tastendruck selbst von Gitea (aktualisierung.py), verifiziert das
   Manifest vor jedem Tausch und tauscht per os.rename mit automatischem
   Rollback bei Fehlern - install.ps1 bleibt nur noch für die
   Erstinstallation nötig. Zugangstoken über einen neuen OptionsFlow in
   entry.options, nie in configuration.yaml.

Nebenbei: mehrere seit der HACS-Ausschluss-Entscheidung liegen gebliebene
falsche HACS-Referenzen in Code-Kommentaren und einem UI-Text korrigiert.

Details, Sicherheitsbegründung und Verifikationsstand in AGENTS.md,
Abschnitte I und J.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 12:01:01 +02:00
tobias 5ff33c92d9 install.ps1: Zugangsdaten direkt nach der Pfadeingabe abfragen
Bisher wurde nach dem manuell eingegebenen config-Pfad erst ein
Erreichbarkeits-Test versucht (der ohne bestehende SMB-Sitzung fast immer
scheitert) und erst danach nach Benutzername/Kennwort gefragt. Jetzt folgt
die Abfrage direkt auf die Pfadeingabe. Die Anmeldelogik selbst ist dafür
in die Funktion SambaAnmelden ausgelagert, die weiterhin auch als
Rückfallebene für automatisch gefundene oder per -Ziel übergebene Pfade
dient.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 10:43:15 +02:00
tobias 3c28c771fd install.ps1: net use /delete darf das Skript nicht abbrechen
"Nichts zu löschen" ist der Normalfall beim ersten Verbindungsversuch,
meldet sich aber als Fehler des net.exe-Aufrufs. Mit dem umgeleiteten
Fehlerstrom (*>) und $ErrorActionPreference "Stop" wurde daraus ein
terminierender Fehler, der die gesamte Installation abbrach, bevor der
eigentliche Anmeldeversuch überhaupt lief. Jetzt abgefangen statt nur
umgeleitet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 10:32:22 +02:00
tobias e999d629c5 install.ps1 fragt bei fehlgeschlagener Verbindung selbst nach Zugangsdaten
Test-Path auf einem UNC-Pfad fragt nie nach einem Passwort - ohne eine
bereits bestehende SMB-Sitzung liefert es bei einem geschuetzten Share
einfach "false", ganz ohne Fehlermeldung. Das Skript verliess sich bisher
darauf, dass eine Sitzung schon von Hand aufgebaut wurde (Explorer oder
"net use"), und meldete sonst "Ziel nicht erreichbar" - selbst wenn Pfad
und Zugangsdaten korrekt waren. Jetzt fragt es bei fehlender Erreichbarkeit
selbst nach Benutzername/Kennwort und baut die Sitzung per "net use" auf,
bevor es endgueltig abbricht.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 10:27:51 +02:00
tobias 2ea01ce2ce install.ps1 warnt vor einem veralteten OTA-Bündel
Beim Prüfen, ob die GitHub-Entfernung eine Versionserhöhung brauchte, fiel
eine echte Lücke auf: nichts hielt frontend/app/bundle.json auf demselben
Stand wie manifest.json. Bei der nächsten echten Versionserhöhung ohne
"npm run ota" hätte das zu einem stillen Widerspruch geführt - die
Hinweisleiste hätte "veraltet" gemeldet (liest die Version live), die
Update-Seite "aktuell" (vergleicht gegen das eingefrorene, dann falsche
Bündel) -, ohne dass ein Update über OTA erreichbar gewesen wäre, bis
jemand den Widerspruch bemerkt.

install.ps1 liest jetzt direkt nach der Manifest-Version auch
frontend/app/bundle.json und vergleicht. Bei Abweichung: deutliche Warnung
plus erster Eintrag in der Restliste. Fehlt das Bündel ganz, passiert
nichts - OTA ist optional, das ist der normale Zustand.

Beide Pfade an einem Mock-Zielordner geprüft: passende Versionen melden
"OTA-Bündel passt zur Integration" ohne Restliste-Eintrag; eine testweise
erhöhte Manifest-Version (danach byte-genau zurückgesetzt, gegen HEAD
gegengeprüft) erzeugt die Warnung und landet als Restliste-Punkt 1.

VERSIONIERUNG.md: "Was du tun musst" nennt den OTA-Neubau jetzt als
eigenen nummerierten Schritt, statt ihn ganz auszulassen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 09:32:36 +02:00
tobias ba182cb180 GitHub-Spiegel entfernt - HACS kann nicht helfen, Gitea bleibt die einzige Quelle
Der am 2026-08-24 angelegte Spiegel nach github.com/T130B/DM360 hatte keinen
Zweck erfüllt: HACS lehnt private Repositories laut eigener Dokumentation
kategorisch ab, unabhängig davon, ob sie auf Gitea oder GitHub liegen. Ein
GitHub-Konto zusätzlich zu pflegen kaufte also nichts. Auf Wunsch entfernt -
entwickelt wird ausschließlich auf der eigenen Gitea-Instanz.

- hacs.json entfernt: tote Konfiguration ohne GitHub-Ziel, das sie bedienen
  könnte.
- manifest.json: documentation/issue_tracker/codeowners zeigen jetzt auf
  gitea.nothaft.cloud/paul/audi-app statt auf den abgeschafften Spiegel.
- install.ps1: Kopfkommentar nennt keinen aktiven Spiegel mehr.
- AGENTS.md: die HACS-Ablehnung als endgültig festgehalten (nicht nur "heute
  privat"), samt der Begründung, warum ein erneuter GitHub-Anlauf nichts
  ändern würde - falls das je wieder aufkommt.

Am laufenden Testcontainer geprüft: Integration lädt mit dem neuen Manifest
fehlerfrei neu, keine Fehler im Protokoll.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 09:26:50 +02:00
tobias 1354ca6b06 OTA-Updates für die iOS-App; HACS-Fehlannahme korrigiert
HACS kann laut eigener Dokumentation grundsätzlich nicht mit privaten
GitHub-Repositories arbeiten (hacs.xyz/docs/faq/private_repositories) - keine
Ausnahme für Tokens oder verbundene Konten. Meine frühere Annahme, HACS käme
damit zurecht, wenn es unter dem richtigen Konto angemeldet ist, war falsch.
Da das Repository aus Lizenzgründen privat bleiben muss (Audi-Hausschrift,
Typenschilder), ist install.ps1 damit nicht die Rückfallebene, sondern der
einzige Installationsweg - README, INSTALL.md, ANLEITUNG.md, install.ps1 und
VERSIONIERUNG.md korrigiert.

Oberflächen-Updates für die iOS-App laufen jetzt ohne Xcode:
@capgo/capacitor-updater eingebaut, ein Update-Abschnitt in den
Einstellungen lädt ein neues Bündel und tauscht die Oberfläche aus. Kein
Selbstlauf (autoUpdate: false) - nur auf Tastendruck, nie während der
Benutzung.

Das Bündel liegt in der Integration selbst
(custom_components/audi_dashboard/frontend/app/), nicht unter /local/: so
reist es bei jeder Installation automatisch mit, ohne zweiten
Auslieferungsweg. Gebaut von companion-app/scripts/ota-paket.ps1 (neuer
Befehl: npm run ota), gemeldet über sensor.audi_dashboard_app_version
(neues Feld daten.buendel).

Ein echter Bug beim Bauen gefunden: [IO.Compression.ZipFile]::CreateFrom-
Directory schreibt unter Windows PowerShell 5.1 Backslashes in die
Zip-Einträge - iOS hätte das Archiv falsch entpackt. Behoben, indem die
Einträge von Hand mit "/" geschrieben werden.

Rückfallebene: notifyAppReady() läuft erst, wenn React nachweislich
gerendert hat (App.tsx). Kommt diese Meldung nicht, rollt das Plugin nach
20 Sekunden von selbst auf das vorherige Bündel zurück.

Am laufenden Testcontainer verifiziert: die ausgelieferte Zip hasht exakt
auf den in bundle.json hinterlegten Wert, 13 Einträge, index.html in der
Wurzel, keine Backslashes, keine Beschädigung. tsc sauber, 117/117 Tests
(5 davon neu für buendelPasst() - dabei eine echte Lücke gefunden: die
Funktion hätte bei unbekannter eigener Version fälschlich ein Update
angeboten, jetzt genauso vorsichtig wie versionVergleichen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 09:20:14 +02:00
tobias d8b12da36d pyscript-Backend zur echten HA-Integration umgebaut (HACS-fähig)
Das Backend liegt jetzt als custom_components/audi_dashboard/ vor - eine
normale Home-Assistant-Integration mit Config-Flow, einer sensor-Plattform
und 18 Diensten. Damit ist die App über HACS installierbar; bis das Repo auf
GitHub gespiegelt ist (HACS spricht ausschließlich mit GitHub), installiert
homeassistant/installationspaket/install.ps1 denselben Ordner ohne HACS.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 23:53:56 +02:00
tobias 99cef7c393 Fahrzustand aus der Zuendung statt aus dem Fahrtstatus, App-Version vergleichbar
BUG "Fahrzeug faehrt" aenderte sich nie. standortZustand() leitete den
Fahrzustand aus TRIPS[0].status === "offen" ab - zwei Fehler
uebereinander, die sich gegenseitig verstaerkt haben.

Erstens heisst "offen" nicht "unterwegs", sondern "Daten noch
unvollstaendig": _fahrt_beenden() legt die Fahrt mit diesem Status an,
wenn sie ENDET, und das Kilometerstand-Screening fuellt sie spaeter. Eine
Fahrt, die nie eine Strecke bekam - etwa weil damals kein KM_SENSOR
zugeordnet war -, bleibt fuer immer "offen". Zweitens ist TRIPS[0] die
AELTESTE Fahrt, nicht die neueste: fahrten_veroeffentlichen() reicht
profil.fahrten_lesen() unveraendert in Dateireihenfolge weiter, und die
ist aufsteigend. Zusammen hat die aelteste jemals unvollstaendig
gebliebene Fahrt das Fahrzeug dauerhaft als fahrend angezeigt; an der
Testinstanz war das eine seit 18 Tagen beendete Fahrt.

Nicht den Index geflickt, sondern die Quelle korrigiert: das Backend
veroeffentlicht jetzt "zuendung" im Fahrzeugstatus, gelesen aus dem
ohnehin zugeordneten ZUENDUNG_SENSOR - demselben Signal, das auch
fahrterkennung.py als massgeblich nimmt. Anzeige und Erfassung koennen
dadurch gar nicht mehr auseinanderlaufen. Ohne zugeordneten Sensor
(null) steht "Fahrzustand unbekannt" statt einer Behauptung.

companion-app hatte denselben Fehler spiegelverkehrt: liveZustandLesen()
las "zuendung" (gab es nie) und "lat"/"lon" (das Backend liefert
standort_lat/standort_lon), und der Typ Fahrzeug kannte keines dieser
Felder. Die Live-Ansicht meldete deshalb dauerhaft "Das Fahrzeug steht"
und zeigte nie eine Position. Felder in Fahrzeugstatus/Fahrzeug und im
Adapter ergaenzt, damit der Typ diese Fehlerklasse kuenftig faengt -
wovor sein eigener Kopfkommentar seit einem frueheren Vorfall warnt.

BUG "Standortzugriff verweigert" war irrefuehrend. Die Zeile steht
direkt unter dem Fahrzeugnamen, wo sonst der Abstand zum Auto steht,
handelt aber vom Standort DIESES Geraets - sie las sich, als sei der
Standort des Autos nicht abrufbar. Jetzt "GPS offline" wie gewuenscht.
Die verweigerte Freigabe behaelt eine eigene Meldung ("GPS-Freigabe
fehlt"): sie ist der einzige Fall mit anderer Abhilfe, und "GPS offline"
wuerde dort zur Signalsuche statt zum Freigabeschalter schicken.

VERSIONIERUNG: die Zahl, die alle fuer "die Version" hielten, ist keine.
Der Integer in audi-dashboard-version.json ist ein Cache-Brecher, den
install.ps1/update.ps1 bei jedem Deploy mit UtcNow neu setzen -
unabhaengig davon, ob sich Code geaendert hat. Zwei Builds derselben
Quelle bekommen verschiedene Zahlen. Er kann die Frage "ist das derselbe
Stand?" grundsaetzlich nicht beantworten.

Deshalb beide Aufgaben getrennt: neue Datei VERSION im Projektstamm
(2026.08.23.1) als Identitaet, von Hand erhoeht; der Integer bleibt
unveraendert der Cache-Brecher. VERSION fliesst in beide Seiten - als
zweites Feld "app" in audi-dashboard-version.json (alle drei
Deploy-Skripte uebernehmen es jetzt; sie haben die Datei bisher komplett
ueberschrieben und haetten es still zerstoert) und ueber vite define als
__APP_VERSION__ in den Companion-Build. Das Backend veroeffentlicht
pyscript.audi_dashboard_app_version, die App vergleicht und meldet eine
Abweichung in der Hinweisleiste - deren erklaerter Grundsatz "nie eine
stille Veraltung" genau dieser Fall ist, nur dass hier nicht die Anzeige
veraltet, sondern die App selbst.

Bewusst nur Gleichheitsvergleich, nie groesser/kleiner: die Version ist
eine Kennung, keine Zahl; Sortieren waere scheingenau und wuerde bei
einem Formatwechsel still falsch antworten. Fehlt eine der beiden
Seiten, wird nicht verglichen und nichts gemeldet - ein aelteres Backend
oder ein Start ohne Netz darf keinen Fehlalarm ausloesen. Der
vite-Build bricht dagegen hart ab, wenn VERSION fehlt, statt eine App zu
erzeugen, die ihre eigene Veraltung nicht erkennen kann. Das Panel
braucht nichts davon: es laedt bei jedem Seitenaufruf neu.

Geprueft: Backend meldet zuendung: False und app_version 2026.08.23.1 im
Testcontainer, Panel zeigt statt "Fahrzeug faehrt" jetzt "Geparkt seit
13 Tg. 13 Std." und statt der alten Meldung "GPS-Freigabe fehlt";
VERSION landet nachweislich im Build (im Bundle gegriffen) und der Build
bricht ohne die Datei ab (gegengeprueft); tsc sauber, Tests 112/112,
vite build sauber, HA-Start ohne Fehler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:59:14 +02:00
tobias 41f2a644d3 HACS-Entscheidung und die dabei aufgefallene Auslieferungsluecke festgehalten
HACS scheidet fuer den aktuellen Aufbau aus, gegen die installierte
Version 2.0.5 geprueft statt aus dem Gedaechtnis: HACS kann
ausschliesslich GitHub (fest verdrahtet, dieses Repo liegt auf Gitea),
und keine der sechs Kategorien installiert nach /config/pyscript/ - die
Zielpfade sind custom_components/, www/community/, python_scripts/,
themes/, custom_templates/ und appdaemon/apps/. Die App braucht aber
pyscript/, audi_dashboard/ und Eintraege in der configuration.yaml.

Einziger Weg waere die Konvertierung des pyscript-Backends zu einer
echten custom_component - die nebenbei panel_custom, das www-Kopieren,
die data-Umbenennung, allow_all_imports/hass_is_global und die
pyscript-Abhaengigkeit selbst erledigen wuerde. Umfang gemessen: 2.911
Zeilen, 21 Dienste, 15 State-Trigger, 10 Zeit-Trigger. Der Besitzer hat
entschieden, das bis nach der ersten echten Inbetriebnahme
zurueckzustellen.

Dabei aufgefallen und ebenfalls festgehalten: die Paritaetsregel sichert
Quell-Paritaet, nicht Auslieferungs-Paritaet. Das Panel holt sich
audi-dashboard-version.json bei jedem Seitenaufruf und ist damit sofort
aktuell; die Companion-App ist eine Capacitor-Huelle mit gebuendelten
Assets, package.json sagt 0.1.0 und in src/ prueft nichts jemals eine
Version. Die iOS-App kann also wochenlang hinterherhinken, ohne dass es
irgendwo sichtbar wird. Loesungsskizze im Abschnitt notiert, noch nicht
gebaut - die Entscheidung ueber den Auslieferungsweg steht aus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:36:30 +02:00
tobias d504de0645 ANLEITUNG.md an den neuen Stand angeglichen
Die Kurzanleitung im Installationspaket beschrieb noch den Stand vor dem
Historienimport: sie nannte die feste configuration.yaml.bak (jetzt
zeitgestempelt), kannte weder hass_is_global noch den recorder-Block und
endete bei Schritt 5.

Neu bzw. korrigiert: die Datenaufbewahrung als eigener, ausdruecklich
zeitkritischer Schritt 3 samt Speicherplatz-Hinweis fuer HA OS; der
Import als Schritt 7; der Warnhinweis, beim Kilometerstand nicht den vom
FMM003 selbst berechneten GPS-Wert zu nehmen; der Hinweis, dass im
Auslieferstand kein Sensor vorbelegt ist und die drei trigger-gebundenen
Felder einen zweiten Neustart brauchen.

Dazu ein Abschnitt "Ist das fuer meine bestehende HA-Installation
gefaehrlich?", der die Sicherheitseigenschaften des Installers benennt,
statt sie voraussetzen zu lassen - kein Remove-Item, kein robocopy /MIR,
.storage/ unangetastet, kein Neustart, und als einzige fremde Datei die
configuration.yaml mit Sicherung, Konfliktabbruch und Rueckrollen. Und
die ehrliche Einordnung, dass das eigentliche Risiko nicht der Installer
ist, sondern der Plattenplatz bei einem Jahr Aufbewahrung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:44:42 +02:00
tobias 3b99f1f332 Vergangene Daten aus dem HA-Verlauf importierbar, Auslieferstand bereinigt
Datenaufbewahrung (recorder_snippet.yaml, neu): Home Assistant loescht
Sensor-Verlaeufe standardmaessig nach 10 Tagen - das war die stille
Obergrenze dafuer, wie weit sich ueberhaupt je etwas rekonstruieren
laesst, denn geloeschte recorder-Zeilen sind endgueltig weg. Der Block
hebt das auf 365 Tage. Die Bestaende der App selbst (fahrten.jsonl,
tankvorgaenge.jsonl, batteriespannung.jsonl, fahrzeugprofil.json) waren
davon nie betroffen, die kennen ohnehin keine Purge-Logik. Platzbedarf
an der Testinstanz gemessen statt geschaetzt: 90.858 Zeilen in 17 Tagen,
hochgerechnet grob 1,9 Mio. Zeilen und 1-1,5 GB im Jahr, davon 96 % aus
der EU-Data-Act-Integration - dafuer liegt eine auskommentierte
exclude:-Liste bei. Bewusst kein include:-Block, der wuerde jeder
anderen Integration den Verlauf nehmen und dabei kaum Platz sparen.

Historienimport (pyscript/historienimport.py, neu): neuer Dienst
audi_dashboard_historie_importieren(start, ende) liest denselben
Verlauf, den die Live-Trigger in Echtzeit sehen, und leitet rueckwirkend
dieselben Datensaetze ab - Fahrten aus dem Zuendungsverlauf (inklusive
der Pausenregel, kurze Unterbrechungen verschmelzen zu einer Fahrt),
Tankvorgaenge nach derselben Tiefststand-Logik und denselben Schwellen
wie tankerkennung.py, Tagesmin/-max der Batteriespannung. Mehrfach
ausfuehrbar: ueberschneidet sich eine Fahrt mit einer bereits erfassten,
wird sie uebersprungen statt doppelt angelegt. Erzeugte Datensaetze
tragen source: "import".

Gelesen wird ueber HAs eigene recorder-API (get_significant_states),
nicht per direktem SQL auf home-assistant_v2.db: das Schema ist
HA-intern und aendert sich zwischen Versionen, die Funktion ist die
stabile Schnittstelle. Preis dafuer ist hass_is_global: true im
pyscript-Block; ohne die Zeile laeuft alles andere unveraendert weiter,
nur der Import meldet, dass er den Verlauf nicht lesen kann.

Oberflaeche in beiden Codebasen: Knopf "Daten importieren aus Home
Assistant" unter Einstellungen -> Einrichten, dahinter ein Fenster mit
Von/Bis (Vorbelegung: letzte 30 Tage), "Importieren" als Hauptaktion und
"Abbrechen" als zweite Wahl. Das Fenster bleibt offen und zeigt das
Ergebnis, statt optimistisch zu schliessen - hier ist das Ergebnis der
Zweck des Aufrufs. Fortschritt ueber die Entitaet
pyscript.audi_dashboard_import_status, weil pyscript-Dienste sofort
zurueckkehren. In companion-app bewusst NICHT ueber die
Offline-Warteschlange: ein spaeter aus dem Nichts abgefeuerter
Importlauf waere fuer den Nutzer nicht nachvollziehbar.

Beim Testen gefunden und mitgefixt: profil.batterieverlauf_tageswert_
aktualisieren() haengte in Einfuegereihenfolge an. Solange nur die
Live-Aufzeichnung schrieb, war das dasselbe wie sortiert (sie traegt
immer den heutigen Tag ein) - der Import traegt vergangene Tage nach,
die waeren hinter den neueren gelandet und das Diagramm haette zeitlich
rueckwaerts gelaufen. Sortiert jetzt nach Datum, wie der eigene
Docstring es ohnehin versprach.

Auslieferstand bereinigt: einstellungen.py brachte zwei Entity-IDs einer
laengst abgeraeumten Testinstanz mit (testzone_fmm003_...). Beim Testen
unsichtbar, weil die Testinstanz sie ueber entitaeten.json ueberschreibt
- auf einer Neuinstallation waeren sie die wirksamen Werte gewesen. Und
schlimmer als nur wirkungslos: fahrterkennung.py registriert seinen
@state_trigger nur, wenn das Feld belegt ist, ausdruecklich als Schutz
gegen eine leere Entity-ID. Ein gesetzter, aber nicht existierender Wert
hebelt genau diesen Schutz aus. Beide Felder jetzt leer wie alle
anderen; Kopfkommentar der Datei ueberarbeitet, er behauptete noch, die
EU-Data-Act-Integration werde nicht mehr verwendet.

install.ps1 abgesichert, da die erste echte Installation auf HA OS
ansteht: die feste configuration.yaml.bak wurde bei jedem Lauf
ueberschrieben, ausgerechnet das Original war nach dem zweiten Lauf weg
- Sicherungen jetzt zeitgestempelt. Nach dem Schreiben wird die Datei
zurueckgelesen und geprueft (Laenge, genau ein Markerblock, bisheriger
Inhalt unveraendert); bei der kleinsten Abweichung rollt das Skript
automatisch zurueck. Die Sicherheitseigenschaften stehen jetzt im Kopf
der Datei, statt dass man ihnen glauben muss.

Standort-Blatt: "Teilen" war im Tagmodus unsichtbar - .standort-pille
nutzte var(--tile), das Blatt darunter var(--tile-deckend), und die
iOS-Auflage setzt im Tagmodus beide auf #FFFFFF. Gemessener Kontrast
1,00:1, weiss auf weiss. Beide Pillen folgen jetzt der Knopfsprache des
Panels (.aktion / .aktion.primaer): Umriss fuer die Nebenaktion, var(--fg)
gefuellt fuer "Route". Danach 17-21:1 Textkontrast in beiden Themes.
Nebenbei ist damit --line-strong - eine Linienfarbe - nicht laenger als
Knopffuellung im Einsatz.

Geprueft: Import zweimal ueber die echte Oberflaeche im Browser gegen
eine in die recorder-DB eingespielte Kunsthistorie (der Testcontainer
laeuft nicht, waehrend das Auto faehrt, echte Fahrten liegen dort also
nicht vor) - drei Fahrten wie erwartet, die 5-Minuten-Unterbrechung
korrekt zu einer 50-Minuten-Fahrt verschmolzen, die 30-Sekunden-Zuendung
verworfen, Strecken kilometergenau; zweiter Lauf legte 0 an und meldete
3 als vorhanden. Neuinstallation in einem Wegwerf-Container: nur
Profilvorlage und Parser, keine Fahrten-/Tank-/Batteriedatei, null
Fehler im Log. install.ps1 gegen Attrappen: -Pruefen schreibt nichts,
echter Lauf laesst automations.yaml bytegleich (SHA-256) und fremde
Bloecke stehen, Ergebnis parst als gueltiges HA-YAML, zweiter Lauf
idempotent, bei fremdem pyscript:/panel_custom: bleibt die Datei
bytegleich. tsc --noEmit sauber, companion-app-Tests 106/106, vite build
sauber, HA-Configcheck und Start ohne Fehler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:42:54 +02:00
tobias f115af50ab Versicherung/Steuer: Wortlaut-Feinschliff und Vertrag komplett editierbar
Mein-Audi-Kachel "Versicherung/Steuer" (vAudi()) und Kfz-Steuer-Detail
(vSteuer()): "Zusammen" heisst jetzt "Summe", die Fusszeile "feste
Kosten im Jahr" darunter entfaellt ersatzlos. Die Kfz-Steuer-Zeile
zeigt "im Jahr" statt des rohen Datenwerts "jaehrlich", deckungsgleich
mit der Versicherungs-Zeile darueber - nur wenn steuer.zeitraum
tatsaechlich "jaehrlich" ist. Der gespeicherte Wert und die
"jaehrlich"/"halbjaehrlich"-Auswahl in vSteuerBearbeiten() bleiben
unveraendert, das war ein reiner Anzeige-Fix, keine Datenmodell-
Aenderung. companion-app hatte an keiner der beiden Stellen ein
Gegenstueck (kein "Zusammen"-Tile, keine Nur-Lese-Anzeige des
Zeitraums) - dokumentiert als vorbestehende strukturelle Luecke statt
kommentarlos uebersprungen.

Vertrag-Kachel: war komplett nur lesbar (Gesellschaft, Umfang,
Vertragsnummer, Selbstbeteiligung, Schadenfreiheitsklasse). Neue
vVertragBearbeiten()-Ansicht (Panel, Route "vertrag") ueber ein neues
Zahnrad auf der Vertrag-Kachel - alle Felder editierbar,
Selbstbeteiligung zusaetzlich mit Hinzufuegen/Loeschen pro Zeile (neue
.zeile-loeschen-Klasse, 44x44pt-Tippflaeche wie .km-edit). Die
aktuelle Schadenfreiheitsklasse bleibt bewusst nur ueber "Beitrag
anpassen" editierbar (Querverweis statt Duplikat), nur "zuvor" (sfAlt)
ist neu in der Vertrag-Ansicht.

companion-app: neue Vertrag()-Seite (Route "vertrag", eigene
NaviKachel auf der Versicherungs-Seite) nach dem dortigen etablierten
Muster (lokaler Entwurf-State + expliziter Speichern-Knopf statt
Panel-Autosave pro Feld - gleiches Verhalten, eigenes Idiom). Die
bisherige Nur-Lese-Selbstbeteiligung in Vertragsdetails() entfaellt,
da sie jetzt (editierbar) in Vertrag() lebt. Schadenfreiheitsklasse
wird in companion-app bewusst nicht ergaenzt: das Feld war dort noch
nie sichtbar, auch nicht lesend - eine komplette neue UI-Sektion dafuer
waere kein "Zeile editierbar machen" mehr, sondern ein neues Feature;
als Luecke dokumentiert statt still uebergangen.

tsc --noEmit sauber, companion-app-Tests 100/100 (inkl. dem
Alle-Seiten-Rendertest, der jetzt auch "vertrag" abdeckt), vite build
erfolgreich. Panel live im Docker-Testcontainer geprueft: Zeile
hinzufuegen/bearbeiten/loeschen, Daten bleiben nach Re-Render erhalten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 16:50:58 +02:00
tobias 23b6defa68 Service-Termine: CI-Icons statt Wortlaut, Anstehende-Termine neu gegliedert
Naechster-Service-Kachel (Uebersicht): das "bis zum Oelwechsel"/
"bis zur Inspektion" vor der Kennzahl entfaellt zugunsten der echten
Audi-CI-Icons oil-change/inspection (vom Nutzer als SVG geliefert,
verbatim uebernommen wie die uebrigen CI-Icons). Hauptuntersuchung hat
kein eigenes Icon (kein Restkilometer-Ziel) und zeigt stattdessen ihr
Datum in derselben 40px-Groesse wie die km-Zahlen, mit dem Wort
"Hauptuntersuchung" darunter statt "bis zur Hauptuntersuchung" - vorher
war das Datumsfeld kleiner (30px), um Umbruch zu vermeiden; am
laufenden Panel nachgemessen, dass 40px keinen Umbruch verursacht.
"vsl." wird ausgeschrieben zu "voraussichtlich am". bisText()/ART_BIS
(Panel) und bisText()/BIS_TEXT (companion-app) wurden durch die
Aenderung ueberfluessig und als Orphans entfernt.

Anstehende-Termine-Box (Service-Seite): aus der dt/dd-Liste werden drei
sichtbar getrennte Bloecke (Kopfzeile mit Icon + Name + optionalem
Werkstatt-Chip, optionale Herstellervorgabe-Zeile, grosse Kennzahl,
optionale Prognose-Fusszeile) - Apple HIG (Hierarchie ueber
Position/Groesse, Inhalt nicht mit Nebensaechlichem ueberladen) plus
Audi-CI (Flaeche mit Haarlinien statt Karten). Durchlief drei
Mockup-Runden als Artefakt vor der Umsetzung, zwei davon vom Nutzer
zurueckgewiesen ("Major Information is missing" bzw. Kommentare zu
Icon-Groesse/Herstellervorgabe/Ausrichtung).

Herstellervorgabe-Logik korrigiert: eine Fahrzeugmeldung stammt immer
aus dem werkseigenen Wartungsprogramm des Bordcomputers, unabhaengig
vom in "Einrichten" gewaehlten App-Intervall - zaehlt also immer als
Herstellervorgabe, nicht nur wenn der App-Modus zufaellig "hersteller"
ist. Die Prognose-Fusszeile zeigt die Restkilometerzahl nur noch beim
eigenen (kuerzeren) Intervall, bei Herstellervorgabe nur noch das
Datum. "Eigenes" in der Ölwechsel-Intervall-Auswahl umbenannt zu
"Individuell".

companion-app-Portierung ist eine echte Neustrukturierung: die
bisherige "Naechster Oelwechsel"-Kachel mischte Oelwechsel-,
Inspektions- und Hauptuntersuchungs-Zeilen in einer gemeinsamen
Werteliste, jetzt "Anstehende Termine" mit denselben drei Bloecken wie
im Panel. Neuer Export letzterInspektion() in service.ts. Zwei
dokumentierte, vorbestehende Luecken bleiben bestehen statt neu
kaschiert zu werden: kein Werkstatt-Chip (companion-app hat keine
"Termin vereinbaren"-Funktion), Herstellervorgabe kommt aus
fahrzeug.oel.modus statt einem intervalle()-Aequivalent (das es dort
nicht gibt).

npm run typecheck sauber, npm run test 99/99, npm run build erfolgreich.
Panel deployed als Version 1787013003, synchron in installationspaket/,
live im Docker-Testcontainer per DOM-Abfrage geprueft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 00:03:16 +02:00
tobias 2848b5286f Parity-Regel: Uebersicht-Service-Block in companion-app portiert
Nutzer stellte klar: Panel und companion-app sollen immer denselben
funktionalen Stand haben, auch wenn eine Anfrage nur das Panel nennt -
das ist reiner Testkomfort (Docker-Instanz laesst sich am schnellsten
ansehen), keine Scope-Entscheidung. Regel als verbindlicher Absatz in
AGENTS.md verankert, direkt unter der bestehenden Maintenance-Regel.

Erste Anwendung im selben Zug: die eben im Panel gebaute
"Naechster Service"-Kachel nach companion-app/ portiert.

- daten/service.ts: neues naechsterService() als Gegenstueck zu
  naechsterTermin() im Panel - waehlt ueber Oelwechsel-/Inspektions-
  Prognose und die von Hand gepflegte Hauptuntersuchung hinweg den
  zeitlich naechsten Termin. bisText() liefert denselben Artikel wie
  ART_BIS im Panel ("bis zum Oelwechsel" / "bis zur Inspektion").
- screens/Uebersicht.tsx: die bisherigen Kacheln "Reichweite"/
  "Kilometerstand" nebeneinander plus eine separate "Service"-Kachel
  mit Werteliste weichen einer Reichweiten-Kachel plus einer
  Service-Kachel mit .dm-serviceblock (Knopf, nur der obere Teil) und
  .dm-servicezeile (reine Anzeige) darunter - dieselbe Control/Content-
  Trennung wie im Panel.
- stile/screens.css: .dm-serviceblock/.dm-servicezeile ergaenzt.

Tests: 4 neue in service.test.ts (naechsterService waehlt das frueher
faellige Datum, nimmt die Hauptuntersuchung auf, liefert nichts ohne
Servicebuch/HU, bisText-Artikel je Art), 1 neuer in screens.test.tsx,
der mit einem vi.fn() als geheZu wirklich belegt, dass ein Klick auf
den Serviceblock navigiert und ein Klick auf die Kilometerstand-Zeile
es nicht tut - dafuer bekam zeige() in screens.test.tsx erst einen
injizierbaren geheZu-Parameter (vorher hart auf () => {} verdrahtet).

npm run typecheck sauber, npm run test 100/100 (von 95), npm run build
erfolgreich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:44:31 +02:00
tobias 600c92fd0f Service-Kennzahl als Kachel mit Hintergrund statt flach
Nutzerwunsch nach dem ersten Stand: der Block soll als Box mit Hintergrund
erscheinen. .tile.flat -> .tile; die Haarlinie trennt jetzt innerhalb der
Kachel, statt die Kachel zu ersetzen.

Der Chevron bleibt unveraendert auf right:0 relativ zum Knopf - da der
Knopf innerhalb des Kachel-Paddings sitzt, landet er damit automatisch auf
demselben Innenabstand wie ein normales .go in einer gepolsterten Kachel,
ohne Sonderregel.

Neu: .serviceblock:active{opacity:.55} als Tipp-Rueckmeldung. Ein
Hintergrundwechsel wie bei .tilebtn geht hier nicht, weil der Knopf schon
auf der Kachelflaeche liegt und nur ihr oberer Teil ist.

Beide Themes geprueft: Kachelhintergrund und Haarlinie kommen aus Tokens
(Tag rgb(255,255,255) / Nacht rgba(255,255,255,.055)), der Knopf selbst
bleibt transparent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:36:01 +02:00
tobias e56f2040e8 Uebersicht: fuehrende Service-Kennzahl statt zwei gleichrangiger Kacheln
Kilometerstand und Service standen als zwei identisch aussehende Kacheln
nebeneinander - 166x113px, gleiche 29px-Zahl, gleiche Farbe -, obwohl nur
die rechte ein Knopf war (erkennbar allein an einem 12px-Chevron) und die
beiden Zahlen Grundverschiedenes meinen: ein gemessener Ist-Wert gegen
einen taeglich schrumpfenden Countdown.

Neu, nach Abstimmung mit dem Nutzer und belegt an beiden Regelwerken
(Apple HIG "Layout"/"Widgets" live gelesen, Audi-CI aus dem
projekteigenen AUDI_CI_ARCHIV):

  Naechster Service:                  >
  9.029 km
  bis zum Oelwechsel · vsl. 15.04.2027
  ------------------------------------
  Kilometerstand             20.845 km

Das .duo-Raster entfaellt; der Block liegt in einer .tile.flat, also in
derselben Ebene wie die Reichweite darueber, statt in einem eigenen
Kachelcontainer. Der Chevron sitzt nur am .serviceblock-Knopf, nicht ueber
der ganzen Flaeche - damit bleibt erkennbar, welcher Teil bedienbar ist
und welcher reine Anzeige (HIG: "differentiate controls from content").

"Naechster Service" meint jede Serviceart; naechsterTermin() waehlte
ohnehin schon ueber Oelwechsel/Inspektion/Hauptuntersuchung aus, neu ist
die Beschriftung samt ART_BIS fuer den richtigen Artikel ("bis zum
Oelwechsel", aber "bis zur Inspektion"). Ohne Restkilometer
(Hauptuntersuchung) rueckt das Datum selbst ins Zahlenfeld, dann bei 30px
statt 40px.

Von der Aenderung verwaist und mitentfernt: mmjjjj() und die
.duo-Regeln in beiden Stylesheets.

Live geprueft bei 375px und 1280px: Block und Wertezeile fluchten mit der
Kachelkante, Chevron buendig rechts, Tippflaeche 88px; Klick auf den Block
oeffnet "Service", Klick auf die Kilometerstand-Zeile navigiert nicht.
Keine neuen Konsolenfehler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:34:10 +02:00
tobias 7319501ee6 Service-Seite: Prognose-Fussnote ueber volle Breite statt umgebrochen
Gemeldet: das Datum der Oelwechsel-Prognose steht in einer eigenen Zeile.
Nachgemessen: das <small> in der rechten Spalte war 29px hoch bei 217px
Breite (zwei Zeilen); .row ist ein Flex aus dt/dd, und dd bekommt nur den
Rest neben dem Titel - fuer "9.029 km, voraussichtlich am 14.04.2027"
reicht das bei 12,5px nicht.

Statt an Schriftgroesse oder Text zu drehen, haengt die Fussnote jetzt als
eigenes Element (.row-fussnote) neben dt/dd und bricht per flex:1 0 100%
auf eine eigene Zeile ueber die volle Kachelbreite um. flex-wrap:wrap ist
per :has(.row-fussnote) auf genau diese Zeilen eingegrenzt, damit die
uebrigen .row-Verwendungen zweispaltig bleiben.

Die verschachtelte fm-/nicht-fm-Verzweigung im Markup faellt dabei zu
einem hatMeldung-Zweig fuer den Wert plus einem separaten fussnote-String
zusammen; die Anzeigelogik selbst ist unveraendert.

Live geprueft bei 375px und 1280px: beide Fussnoten einzeilig (14px),
307px bzw. 636px breit; alle 17 .row auf "Mein Audi" weiterhin
zweispaltig; keine neuen Konsolenfehler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:08:06 +02:00
tobias 7fc378f427 Übersicht: Wert und Kontext trennen statt "9,0 tkm/ 04/27"
Auf Nachfrage, wie Apple HIG und die Audi-CI die beiden Kacheln loesen
wuerden - beide laufen auf dieselbe Antwort hinaus.

Die Oelwechsel-Kachel presste zwei verschiedene Groessen in ein
Zahlenfeld: Restkilometer und Prognosedatum, getrennt durch einen
Schraegstrich. Zwei Einheiten, davon eine erfundene ("tkm" schreibt
niemand - die Mein-Audi-Servicebox nannte dieselbe Zahl schon immer
"9.029 km"), zwei Schraegstriche mit zwei Bedeutungen, und auf 375px
zweizeilig umgebrochen.

Apple HIG "Layout" ("don't obscure essential information by crowding it
with nonessential details") und "Typography" (Hierarchie ueber Groesse
und Farbe) verlangen die Trennung; die Audi-CI definiert .fig
ausdruecklich als "large numeric figures" - eine Zahl, nicht zwei.

- Wert gross (9.029 km), Kontext leise darunter (vsl. 04/2027)
- Echte Einheit statt "tkm"
- Kachel ist jetzt ein Knopf mit Chevron zur Service-Seite; vorher eine
  Sackgasse, obwohl es die Seite gibt
- mmjj() dadurch verwaist, ersetzt durch mmjjjj() mit vollem Jahr
- HU-/Inspektionszweig faellt mit dem Oelwechselzweig zusammen, weil sie
  nach der Trennung dieselbe Form haben

companion-app hatte diesen Fehler nicht (dort standen die Termine schon
als Label/Wert/Zusatz). Dort stattdessen der benachbarte
Gruppierungsfehler behoben: die Service-Zeilen hingen innerhalb der
Kilometerstand-Kachel und fuehrten nirgendwohin - jetzt eigene,
antippbare Kachel zur Service-Seite.

Live geprueft im Panel (Tag und Nacht, 375px): beide Kacheln einzeilig,
Tippen oeffnet Service. companion-app: typecheck sauber, 95/95 Tests,
Build erfolgreich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 13:54:27 +02:00
tobias edf3845375 companion-app: catch up six days of panel drift before the native build
The phone-app codebase (companion-app/) hadn't been touched since
2026-08-11 - every panel fix and feature since then (HIG audit rounds,
receipt upload, trip editing, Inspektion forecast, ...) existed only in
the HA panel. Found while auditing what's needed for a real iPhone
build; user decision was to port everything now rather than ship a
stale app.

Six real, verified gaps (not blind copies of panel CSS/markup, which
doesn't transfer to the @audi-dash/ui component set):

- Battery voltage cutoff was still 13.2V, not the panel's 12.8V fix.
- Dead wlan_name field (WLAN trip detection was fully removed from the
  backend 2026-08-12) - removed from the adapter and the settings screen
  instead of leaving a form field that silently does nothing.
- No Inspektion forecast - added inspektionPrognose() alongside the
  existing oelwechselPrognose(), sharing a refactored core.
- Arbeitsweg pill used the design-system's "work" variant, which is
  documented as recoloring to red - stopped passing it, same fix as the
  panel.
- Trip creation only took Beginn/Ende/Art; extended with Startort/
  Zielort/Kilometerstand/Distanz via a new shared FahrtFelder.tsx.
- FahrtDetail.tsx was read-only - added an edit mode using the same
  shared fields, backed by a new DataMetricApi.fahrtAktualisieren()
  calling the backend service built earlier this session.

Explicitly checked and found not applicable: price rounding (already
2 decimals here), pull-to-refresh CSS (no native gesture to fix),
the panel's drag/paste receipt dialog (solves a desktop-browser problem
this native app doesn't have - the plain file picker already gets iOS's
native Files integration), and the purely cosmetic panel CSS fixes.

npm install run at the repo root (node_modules was incomplete/stale),
package-lock.json reflects the real dependency tree. Verified with
npm run typecheck (clean), npm run test (95/95, up from 90 - added
interaction tests for the new form and inspektionPrognose), and
npm run build (succeeds).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:16:40 +02:00
tobias 188dc8247e AGENTS.md: record that companion-app has drifted 6 days behind the panel
Found while auditing what's needed for a real iPhone build: src/screens/
hasn't changed since 2026-08-11, so none of the panel work since then
(HIG audit rounds, receipt upload, trip editing, Inspektion forecast,
Heckklappenschloss removal, ...) exists in the phone app yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 11:55:41 +02:00
tobias 126033e4dd Fahrt-Formular: Ankunftszeit statt Dauer, Art gross, neutrale Pille
Fuenf gemeldete Punkte plus ein Theme-Bug beim Nachpruefen:

- "Dauer" ist kein Eingabefeld mehr - stattdessen Ankunftszeit, die Dauer
  wird aus Start/Ankunft berechnet und live angezeigt (fahrtZeitraum()/
  dauerText()), inklusive Fahrten ueber Mitternacht.
- Art-Werte werden grossgeschrieben angezeigt (Privat/Arbeitsweg), der
  gespeicherte Wert bleibt klein (artText()-Helfer).
- Arbeitsweg-Pille ist nicht mehr rot - sieht jetzt aus wie Privat.
- "tippen zum Umschalten" unter der Pille entfernt.
- Bild-Platzhalter (Einstellungen) war im Tag-Theme unsichtbar: --tile wird
  dort vom iOS-Overlay auf reines Weiss gesetzt, identisch mit Canvas/
  Kacheln. Auf --ios-fill umgestellt, in beiden Themes sichtbar grau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 11:49:55 +02:00
tobias 2d76f10c1c Fahrten bearbeitbar, Inspektionsprognose, Bootstrap-Takt reisst nicht mehr ab
Sechs gemeldete Punkte plus ein Fund beim Nachlesen des Ladevorgangs:

- HECKKLAPPENSCHLOSS_SENSOR ueberall entfernt (einstellungen.py,
  entitaeten.py-Katalog, _sicherheitscheck()). HECKKLAPPE_SENSOR bleibt.
- Bild-Platzhalter in den Einstellungen war halb abgeschnitten: die bare
  .carfix-Regel der Desktop-Container-Query traf auch den Platzhalter-Div,
  dessen inset:0 von der festen Hoehe uebersteuert wurde. Auf .bildbox
  eingegrenzt.
- Inspektion bekommt eine eigene "voraussichtlich am ..."-Zeile (nur Datum).
  oelwechselPrognose() dafuer auf den gemeinsamen Kern servicePrognose()
  zurueckgefuehrt.
- "Neue Fahrt" bietet jetzt alle Felder, die die Einzelfahrt anzeigt, und die
  Einzelfahrt laesst sich bearbeiten - gemeinsames fahrtFelder()/
  fahrtFormularWerte() nach dem Muster von tankFelder(). Backend: Service
  audi_dashboard_fahrt_aktualisieren, profil.fahrt_bearbeiten() (edited_fields
  schuetzt gegen die Automatik, nicht gegen den Nutzer). Die Art-Pille
  speichert jetzt ebenfalls statt nur lokal umzuschalten.
- Titelzeile steht auf dem Handy buendig zu den Kacheln: .back:not(.on) gibt
  seine Breite und den Gap auf allen Breiten frei, .topbar links 16px.
- Ladebildschirm: nachladeAnstossen() und datenLaden() stiegen bei fehlendem
  HASS aus, ohne eine naechste Runde zu planen - genau die Sackgasse, aus der
  bisher nur ein Menueklick half. Beide planen jetzt weiter, der Timer wird
  beim Abhaengen gestoppt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 11:40:36 +02:00
tobias 91dfd4807a Drop the Strg+V hint: PC gets drag-and-drop and file picker only
Ctrl+V does not reach the dialog either - Chrome only delivers a paste
gesture when there is a paste target, and the dialog has no input field.
So the shortcut is no longer mentioned anywhere; the computer now offers
exactly two routes, dragging and the file picker, and the phone keeps the
clipboard button. The paste listener stays as a silent bonus for browsers
that do deliver the event, and the now-orphaned tastenkuerzelEinfuegen()
is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 00:44:05 +02:00
tobias e5e82b3634 Fix clipboard paste on PC, round price per litre to 2 decimals
The "paste from clipboard" button could never work on a desktop:
clipboard.read() only ever exposes text, HTML and images by spec, so a
file copied in Explorer is not offered through it - and the dialog then
wrongly blamed the file for not being a PDF. It is now hidden on
pointer-coarse devices only, where it does work; on a PC the drop zone
names Strg+V, which is the path that carries the actual file.

Detection also relied solely on the MIME type, but a file copied or
dragged from Explorer can arrive with an empty type and was rejected -
istPdf() now falls back to the extension.

Price per litre now shows 2 decimals everywhere (list, receipt detail,
both entry forms, CSV) instead of 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 00:38:55 +02:00
tobias 5aaf1cd958 Fix pull-to-refresh on touch devices, add drag/paste receipt upload
The pull-to-refresh gesture logic was already correct (verified by driving
real pointer events through it); what was missing was overscroll-behavior
on the scroll container, so on real touch hardware the browser's own
native overscroll could take the gesture over before it hit the threshold.

"Beleg hochladen" now opens a dialog instead of the native file picker:
drag a PDF in at the computer, paste it from the clipboard on the phone
(copied out of a mail), file picker kept as a third route. All three
sources share one upload path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 00:29:05 +02:00
tobias 8a751fe441 Run a full Apple HIG audit; fix silent save failures and popup polish
Read Tab Bars, Sidebars, Sheets, Alerts, Action Sheets, Toolbars, Buttons,
Pickers, Loading, Feedback, Privacy, and Undo/Redo off developer.apple.com
and cross-checked each against the actual code. profilSpeichern() (the
shared save path for ~25 fields) had no error handling at any call site,
so a failed backend write was an unhandled promise rejection with zero
user feedback - fixed centrally, plus the same for serviceRufen(). Also
added a spinner to the "Ladt ..." bootstrap screen and gave the
Anzugsmoment/km-correction popups the same primary-button styling the
Setup popup already used.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 23:53:02 +02:00
tobias 6bf7435434 Add real Audi CI battery/edit icons, manual km-correction for tire sets
Replaced the hand-drawn placeholder battery icon with the real Audi CI
battery-12v-l/-s (user supplied the actual SVGs) next to "Ruhespannung",
matching the existing poi-l/poi-s size-variant convention.

Added a km-correction control to the Sommerräder/Winterräder tiles: a
pencil icon (Audi CI edit-s) opens an inline field to manually correct
the tire-set's accumulated km. The tricky part was persistence -
configCarZuProfil() deliberately never writes km back (a stale browser
copy could otherwise clobber a since-elapsed automatic increment), so
this needed its own backend service, audi_dashboard_reifen_km_setzen,
that overwrites only the stored counter and leaves referenz_odo_km
alone - the existing odometer-delta tracking in reifenzaehler.py then
continues accumulating from the corrected value on its own, no other
backend change required.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:16:08 +02:00
tobias 8b3e5b2b8f Rename Sommer/Winter to Sommerräder/Winterräder, reflow Reifen tile headline
The tile headline now reads "Sommerräder"/"Winterräder" and renders as an
actual headline (new .tile-headline class, 20px/var(--fg)) instead of the
small muted .label styling used for other in-tile section titles.

Headline + km now sit above "Montiert" instead of the wheel photo
overlaying it: new .radkopf-row (flex) puts headline/km and the photo
side by side at the tile's top; Montiert dropped position:absolute (no
longer needed once the photo stopped sharing its space) and is now a
normal-flow pill below the headline block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:00:47 +02:00
tobias ec55e5808e Fix real tab-bar-icons-vanish bug, dropdown popup color, wheel/Montiert swap
The reported "whole tab bar disappears when hiding labels" bug was real,
just one level deeper than first checked. .tabbar.ohne .tab span{display:
none} targeted the label span, but the icon is also wrapped in a <span
class="tabpille"> - a bare "span" type selector doesn't care about class,
so the icon's own wrapper collapsed too. Confirmed by measuring the <svg>
directly (0x0 bounding rect) after a live screenshot showed all 5 tab
buttons empty. Fixed with :not(.tabpille) in both stylesheets.

The Modell dropdown was still bright despite the color-scheme hint from
the last commit: a <select>'s opened option list is rendered by the
browser/OS as its own surface, entirely outside the page's paint tree -
confirmed when a screenshot call hung 30s trying to capture it open.
color-scheme only gets partial credit there; explicit background-color/
color on <option> is what Chrome/Firefox/Edge actually honor for the
popup rows. Added that, verified via getComputedStyle since the open
popup itself can't be screenshotted by this tooling.

Swapped the wheel photo (now right-aligned via margin-left:auto on the
now-correctly-square .bildbox.radbild) and Montiert (already left) per
request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:52:46 +02:00
tobias 17b25d492a Fix wheel-photo squareness bug, native select popups, image-cycle crash
.bildbox.radbild lost to a same-specificity, later-declared .bildbox rule,
so the wheel photo stayed a full-width 4:3 box despite the earlier crop
fix - only which slice showed ever changed. Fixed with a compound selector
and moved "Montiert" to top-left to match the now-correctly-sized photo.

Native <select> popups had no color-scheme hint and rendered in the
browser's default light palette regardless of the app's dark theme -
this is what made "Modell" and other dropdowns flash bright white.

bildWeiter() (Mein Audi image-cycle tap) referenced a CSS class that was
never emitted (.platzhalter-datei vs. the real .platzhalter-aktion),
throwing and aborting before the page-dot indicator update - the photo
advanced but the dots never moved.

Merged the separate "Fahrzeugbilder" upload grid into "Bild der
Übersicht": pick a view from the existing dropdown, tap its preview to
upload/replace/delete - same generic popup plumbing, no new mechanism.
Removed the now-dead BILDER_UPLOAD_SLOTS/.bildgrid/.bildslot/.carfix.mini.

Back arrow changed from red to the neutral headline color, matching every
other navigation-color decision in this project.

Investigated but could not reproduce: user-reported "whole tab bar
disappears" when hiding labels. Traced the exact commit that added the
icon+label highlight and confirmed it's correctly scoped to :not(.ohne);
live DOM/computed-style testing across toggle, navigation, and both
mobile/desktop widths showed the tab bar staying visible throughout.
Left unchanged pending a repro from the user.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:40:14 +02:00
tobias 367985ba44 UI polish batch + fix genuinely-invisible map pins, Apple HIG audit
Battery voltage stat cutoff 13.2V -> 12.8V (values above that are already
alternator output, not the battery); fixed the battery chart's Y-axis
"stretching" on zoom by computing it once from the full dataset; removed
two explanatory paragraphs from the battery detail view; Tankfuellung now
shows "X % / X l"; removed the "noch etwa X l im Tank" suffix; fuel bar
color now matches the headline instead of a red/yellow/green gradient;
active/primary buttons are grey again, not red (destructive stays red per
HIG); select fields gained an Apple-HIG pull-down chevron (appearance:none
had removed the native one with nothing replacing it); selected menu icon
is white on desktop, matching mobile; Reifenfoto crops from the right edge;
"Montiert" button rebuilt as a compact top-right pill with a 44px invisible
tap target; tab-bar active highlight now covers icon+label, not just the
icon; removed two leftover explanatory texts from Einstellungen.

Root-caused the map pins looking "transparent": the CI poi-car/poi icons
are pure contour forms (nonzero-fill-rule ring + thin detail lines, only
13-40% of their own bounding box actually filled). Fixed by extracting each
icon's own first sub-path (the true outer balloon silhouette, verified by
rasterizing it in isolation) as a solid-filled layer behind the original
icon, applied to both the vehicle and station markers.

Read Color/Typography/Layout/Buttons/Materials on developer.apple.com and
applied the user-approved subset (11px text floor, 44px tap targets);
declined items (font weight, materials) documented with rationale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 19:05:54 +02:00
tobias 899b770cfa Einzelbeleg: CI poi pin from the receipt address, plus a centre button
The station marker was a red circleMarker. It is now the CI poi pin
(poi-l/poi-s via tankstellenMarkerSVG) - the plain pin rather than poi-car, so
the vehicle and the station stay distinguishable on a map.

While testing it turned out nothing in the backend ever writes station_lat /
station_lon, so that marker had never been reachable in practice. The receipt
address is now geocoded in the frontend via Nominatim /search
(adresseAufloesen, the forward counterpart to the existing reverse lookup),
with results cached in localStorage - negative ones too, since a station
address does not move and Nominatim's terms ask for caching. Verified live:
"Shell, SÖLDEN" resolves to 46.9756 / 11.0111.

The map card gained a centre button (CI.gps, which is byte-identical to the
delivered gps-s.svg), disabled until the coordinates arrive. .mapbox needed
position:relative so the control anchors to the card instead of an ancestor.
Only in the Einzelbeleg, as requested - the Standort fullscreen already has
its own centre controls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 13:15:20 +02:00
tobias 484f2ffe6d Add one-click installer to installationspaket
Installieren.cmd (double-click) wraps install.ps1, which automates steps 1-3
of ANLEITUNG.md: find the HA config share, copy pyscript/, data/ ->
audi_dashboard/ and www/, merge the configuration.yaml snippet, optionally
write ha_token.txt. The .cmd wrapper exists because double-clicking a .ps1
opens it in an editor and the execution policy blocks it; the bypass applies
to that one call only.

Designed to be safe to re-run:
- Never overwrites fahrzeugprofil.json, fahrten.jsonl, tankvorgaenge.jsonl,
  entitaeten.json or ha_token.txt, so re-running on a live install keeps the
  vehicle profile, trips, fills and sensor mapping.
- configuration.yaml is backed up to .bak first; the inserted part sits
  between marker lines and gets replaced instead of appended twice.
- If pyscript: or panel_custom: are already there from another source, the
  file is left untouched and the script prints what to merge by hand -
  duplicate top-level keys would be invalid YAML and HA would not start.
- Aborts before writing if the target has no configuration.yaml.

Steps that cannot be automated from a Windows share (HA restart, pip install
pypdf, sensor mapping) are collected into a closing to-do list. -Pruefen shows
what would happen without writing.

install.ps1 is stored UTF-8 *with* BOM - PowerShell 5.1 reads scripts as ANSI
otherwise and mangles the umlauts; the files it writes stay BOM-less.

Verified against a fake config tree: fresh install, re-run idempotency,
existing-data preservation, foreign-key refusal, wrong-directory abort, and
the merged configuration.yaml parses as valid YAML.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 13:02:02 +02:00
tobias fb38c297bd Station name as "brand, street, city", grey box on all editable fields
The "grey box around the icons" report turned out not to be a defect: the box
on "10.000 km"/"1 Jahr"/"15" is the wanted pattern, and every value the user
can change should carry it. All .feld inputs and selects now get the grey iOS
fill; inputs with their own visual language (checkbox/radio/range/file) keep
the transparent base rule, so the switches are unaffected.

Fuel stations are now displayed as "Shell, Pascalstr. 8, Ingolstadt" instead of
the bare operator name. New _marke()/_ist_strasse()/_tankstelle() helpers are
shared by both parser paths so Shell and non-Shell receipts format the same.
The brand is only matched against the receipt header - searching the whole text
would let "Total" hit a totals line. Missing parts are dropped instead of
leaving empty comma slots, and without a known brand the operator name takes
its place. station_address still carries the full street and postcode.
test_bekannte_stationen updated to the new format; suite stays green.

installationspaket/ is versioned from now on (user request). It contains no
real vehicle data - only the example profile with empty FIN/plate placeholders.
Keep it in sync whenever pyscript/ or www/ changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:52:31 +02:00
tobias 39da68cd87 Fix broken map rendering, split GPS cleanup, receipt parser and design backlog
Leaflet's stylesheet was appended to document.head, so it never reached the
panel's shadow root: .leaflet-tile{position:absolute} never applied, tiles laid
out as in-flow images (~1040px inside a 190px box) and the marker pane ended up
far below the visible area. That is the real cause of the long-standing
"fragmented Leaflet rendering" finding and of the invisible vehicle pin - every
tile request had actually succeeded. The stylesheet now goes into the shadow
root and is awaited before the map is built.

Also in this round:
- Map tiles are always light (Google-Maps-style), no dark variant at night.
- Vehicle marker uses the real CI poi-car icons (poi-car-l >=34px, poi-car-s
  below), with a white halo so the outline stays readable on tiles.
- Removed the obsolete combined STANDORT_TRACKER field; only the split
  lat/lon sensors remain.
- Receipt upload: widened the try block so base64/save failures surface, and
  the frontend call site now reports a rejected service call.
- Generic receipt parser: total detection is line-based (letter-spaced
  headings, no more matching the SUMME-EUR column header, tax lines excluded),
  address heuristic handles 4-digit postcodes and single-line address blocks,
  and "Preis/L" matches without a spelled-out "Liter".
- Design backlog: red hairline frame on list rows (delete button bled through
  at fractional row heights) and select fields now use the grey background box.

Shell 10-receipt regression suite still passes; all changes verified live in
the audi_ha_test container.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:46:48 +02:00
tobias 25d1cebd12 Fuenf gemeldete Bugs behoben: Haubenschloss/Tuerschloesser entfernt, Bestaetigungsdialog-Transparenz, geteilte FMM003-Koordinaten, Laedt-Haenger, generischer Beleg-Parser
Entfernt Haubenschloss-/Tuerschloss-Erkennung dauerhaft aus Setup-Katalog und
Sicherheitscheck (auf ausdruecklichen Wunsch, nicht nur leer/unzuordenbar wie
der Rest der abgeloesten VAG-Integration). Behebt eine durchscheinende
Bestaetigungs-Sheet ("Doppelt zugeordnet" u.a.) - derselbe --tile-2-
Transparenz-Fehler, der fuer das Setup-Popup schon behoben war, hier
nachgezogen. Ergaenzt einen zweiten GPS-Pfad (STANDORT_LAT_SENSOR/
STANDORT_LON_SENSOR) fuer Integrationen wie flespi, die Breiten-/Laengengrad
als zwei eigene Sensoren statt als device_tracker-Attribute liefern. Haertet
den bekannten "Laedt ..."-Haenger beim App-Start zusaetzlich ab: Tab-Klicks
pruefen jetzt aktiv nach, ob Daten inzwischen da sind. Ergaenzt
shell_beleg_parser.py um einen stationsunabhaengigen Fallback fuer
Tankbelege unbekannter Formate (Adresse/Gesamtbetrag/Menge/Rabatt-Herleitung
wie vom Nutzer vorgegeben) - die bestehende Shell-Erkennung bleibt
unveraendert und weiterhin durch die zehn echten Testbelege abgedeckt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 11:32:15 +02:00
tobias 440ff22bda EU-Data-Act-Reste bereinigt, Slider-Bug behoben, zwei Design-Audit-Runden umgesetzt
Entfernt die letzten Verweise auf die abgeloeste TommiG1/HA_VAG-EU-Data-Act-
Integration (Versionstile, Testumgebungs-Fixture) und ersetzt sie durch die
FMM003-Werte. Behebt eine CSS-Spezifitaetskollision, durch die alle iOS-
Toggle-Switches viel zu breit gerendert wurden. Dokumentiert und behebt sieben
Layout-Befunde aus zwei Design-Audit-Runden (Zahnrad-Ueberlauf auf breiten
Bildschirmen, zu kleine Ringe als mobiler Einstellungen-Zugang, zu breite
Popups, Kopfzeilen-Versatz, ueberlappende Kachel-Pfeile, fehlender
Editierbarkeits-Hinweis bei Auswahlfeldern, Haekchen-artige Auf/Zu-Pfeile) -
siehe DESIGN_AUDIT_2026-08-13.md fuer Root-Cause und Beleg je Befund.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 20:52:07 +02:00
tobias 5fc86d2b5e iOS-Optik: Rot-Semantik korrigiert, Popup-Radius vereinheitlicht
Zwei unabhaengig vom iOS-Entscheid offene Befunde aus DESIGN_REVIEW_2026-08-13.md
§B behoben:

- .aktion (audi-dashboard-ios.css) fuellte bisher JEDE Aktion rot statt nur
  destruktive - jetzt neutrale --ios-fill-Flaeche fuer die Sekundaeraktion,
  neue .aktion.primaer-Regel traegt die rote Fuellung als einzige Stelle
  (deckt sich mit dem Sekundaer/Primaer/Loeschen-Schema, das die Basis-CSS
  schon vorgibt). Gleiche Ursache auch bei Schalter-Ein-Zustand (jetzt --ok
  gruen statt rot, echtes iOS-Systemgruen) und aktiver Desktop-Sidebar-
  Navigation (Flaeche jetzt neutral, Rot bleibt Text-/Icon-Akzent).
- Zwei Balkensegmente in audi-dashboard-app.js nutzten --red fuer eine
  neutrale Kategorie (Arbeitsweg-Fahrten, Versicherungsbeitrag-Aufteilung) -
  jetzt wie die Nachbarsegmente auf --fg umgestellt.
- .setup-popup/.standortmenu (audi-dashboard.css) hatten 20px als Literal
  statt var(--r-tile) - folgen jetzt dem Token wie alle anderen Kacheln.

Im Docker-Testcontainer verifiziert: Setup-Popup, Einstellungen (mobil und
Desktop-Sidebar), Schalter - korrekte Fuellungen, keine Konsolenfehler.
2026-08-13 19:31:16 +02:00
tobias f5976e6865 iOS-Optik offiziell machen, Audi-CI-Spezifikation vorher archivieren
Entscheidung nach DESIGN_REVIEW_2026-08-13.md §A, Option 1: die additive
iOS-Auflage (audi-dashboard-ios.css) wird zur verbindlichen Optik erklaert,
statt die App auf reine Audi-CI zurueckzuschneiden. Der bisherige
SPECIFICATION.md-§2-Wortlaut (Audi-CI-Farben, 20px-Radius) ist wortgleich
in AUDI_CI_ARCHIV_2026-08-13.md gesichert, inklusive Anleitung fuer den
Rueckweg - der Code selbst braucht dafuer kein Backup, da die iOS-Auflage
rein additiv ist und die Audi-CI-Basiswerte in audi-dashboard.css
unveraendert erhalten bleiben.

SPECIFICATION.md §2 beschreibt jetzt die tatsaechlichen iOS-Werte aus
audi-dashboard-ios.css. bauauftrag.md §8 bleibt unangetastet (Projekt-
konvention: historisches Dokument), gilt aber ab hier als ueberholt.

Zwei aus demselben Review noch offene Implementierungsfehler (Rot-Semantik
invertiert, 20px/16px-Radius-Inkonsistenz bei Popups) sind unabhaengig von
dieser Richtungsentscheidung und bleiben offen.

Zusaetzlich: Versionsbump nach dem Redeploy in audi_ha_test.
2026-08-13 19:20:28 +02:00
tobias 163efe1392 Merge branch 'umsetzung-datametric360': Companion-App-Phasen 1-10 uebernehmen
Bringt die vollstaendig gebaute DataMetric360-App zusammen (React/Vite,
21 Screens, Audi-Assets, PWA + native Capacitor-Huelle, 90+9 Tests) sowie
die profil_lesen()-Haertung gegen fehlende/kaputte Profildatei zusammen.

Konfliktaufloesung:
- AGENTS.md, INSTALL.md, README.md: main-Fassung war jeweils die
  chronologisch neuere, uebernommen und um die durch den Merge tatsaechlich
  erledigten Punkte ergaenzt (profil_lesen()-Haertung, Audit-Reste-Entscheidung).
- fahrterkennung.py: toten WLAN-Zweig vom Branch verworfen, Zuendungs-
  basierte Erkennung von main behalten.
- homeassistant/FMM003_MAPPING.md (MQTT/Mosquitto-Ansatz vom 2026-08-11,
  vor der Umstellung auf flespi) bewusst nicht uebernommen - main nutzt seit
  2026-08-12 flespi als alleinigen FMM003-Datenweg. UMSETZUNGSPLAN.md Phase 13
  entsprechend als ueberholt markiert, verweist auf AGENTS.md als massgeblich.
- REVIEW_main_2026-08-13.md, ha_install.md (add/add): main-Fassung war die
  spaetere Revision derselben Dokumente, uebernommen.

Die von REVIEW_main_2026-08-13.md befuerchtete Merge-Falle (profil_lesen()
gibt jetzt None zurueck, main-seitige Aufrufer pruefen das nicht) wurde
verifiziert als bereits entschaerft: alle 5 Aufrufstellen im gemergten Stand
(backup.py x2, fahrterkennung.py, frontend_veroeffentlichung.py,
reifenzaehler.py, tankerkennung.py) sind None-sicher.

installationspaket/ nicht Teil dieses Commits (gitignored, wird bei Bedarf
neu zusammengestellt).
2026-08-13 19:03:20 +02:00
Paul Nothaft 6230648f29 Design-Review des main-Stands gegen die Audi-CI-Vorgaben ablegen
Code-Review aller UI-Schichten plus Headless-Browser-Screenshots des
Panels mit Mock-hass. Kernbefunde: die iOS-Auflage ersetzt Palette,
Radien und die Rot-Semantik ohne dokumentierte Entscheidung; das
design-system traegt noch den Vor-Audit-Stand (fg3, Fokus, Feldgroessen);
vier im Browser nachgewiesene Darstellungsfehler. Nur Bericht, keine
Fixes.
2026-08-13 18:15:38 +02:00
Paul Nothaft 2a3c4f2ab5 Zugangswege zum privaten Repository dokumentieren und trennen
Neue Option --update-repo trennt, woher installiert wird, von dem, was als
Updatequelle hinterlegt wird. Damit laesst sich mit vollen Zugangsdaten
installieren, ohne sie abzulegen (--update-repo aus). Enthaelt die hinterlegte
URL Zugangsdaten, weist das Skript darauf hin und maskiert sie in der Ausgabe.

Neuer Abschnitt 1b in ha_install.md beantwortet die drei naheliegenden Fragen
und benennt den Haken dahinter:

- Werkzeuge sind vorhanden. git 2.54, curl, unzip und ssh liegen im
  offiziellen HA-Container bereits vor, an der Instanz nachgesehen.
- Passwort in der URL funktioniert genauso wie ein Token - es ist aber das
  Kontopasswort, im Klartext in /config und in jeder Sicherung, und bei
  Zwei-Faktor-Anmeldung verweigert Gitea es ohnehin.
- Ein vorher im Terminal eingerichteter Anmeldehelfer hilft der
  Selbstaktualisierung NICHT. Sie klont aus dem Home-Assistant-Prozess und
  allein anhand von UPDATE_REPO_URL; _git() setzt keine Umgebung. Unter HA OS
  laeuft das Terminal-Add-on ausserdem in einem eigenen Container, geteilt ist
  nur /config. Fuer die Installation selbst genuegt der Helfer dagegen.

Dazu eine Optionstabelle mit Bewertung, der Hinweis dass auch curl fuer das
Skript selbst Zugang braucht, und was sich nach der Umstellung auf die
Integration daran aendert: der Zugang wandert in den Konfigurationsdialog und
Home Assistants eigene Ablage statt in eine Python-Datei.
2026-08-13 17:21:19 +02:00
Paul Nothaft 1afcad25d3 install.sh in ha_install.md aufnehmen
Das Dokument beschrieb bisher nur die kuenftige Integration und liess offen,
wie eine Installation heute ablaeuft. Neuer Abschnitt 1a: was install.sh
abnimmt, die drei Eigenschaften, auf die es dabei ankommt (mehrfach
ausfuehrbar, vorhandene Daten unangetastet, Sicherung der
configuration.yaml), und eine Tabelle, was danach von Hand bleibt - jeweils
mit der Spalte, was davon die Integration aufloest.

Dazu zwei Stellen nachgezogen: die Erstinstallation der Integration laesst
sich genauso automatisieren, das Skript schrumpft dann auf einen
Kopiervorgang; und der erste Punkt der Reihenfolge (die drei Setup-Fehler)
ist seit dem 13.08. erledigt. Ausserdem ein toter Verweis auf
UMSETZUNGSPLAN.md korrigiert - die Datei liegt nur im Feature-Branch.
2026-08-13 16:52:13 +02:00
Paul Nothaft c11c492d86 Installationsskript: eine Zeile statt dreissig Minuten Handarbeit
homeassistant/install.sh richtet das Panel samt Backend auf einer
Home-Assistant-Instanz ein und verdrahtet dabei die Selbstaktualisierung, so
dass danach alles Weitere ueber die Weboberflaeche laeuft: Sensoren zuordnen,
nach Updates suchen, einspielen.

Gedacht fuer das Add-on "Terminal & SSH" direkt auf der Instanz:
  bash <(curl -fsSL <ROH-URL>/homeassistant/install.sh)
Alternativ von einem Rechner aus mit --ziel gegen ein eingebundenes
config-Verzeichnis, oder mit --von gegen eine lokale Arbeitskopie.

Was es tut: pyscript installieren (neueste Version von GitHub, falls nicht
vorhanden), pyscript/ und www/ einspielen, den Belegleser mitliefern (er
liegt in data/, ist aber Code), fehlende Datenbestaende aus der Vorlage
anlegen, den Abschnitt in die configuration.yaml eintragen und
UPDATE_REPO_URL setzen.

Drei Eigenschaften, auf die es dabei ankommt:

- Mehrfach ausfuehrbar. Der Abschnitt in der configuration.yaml steht
  zwischen Markern und wird beim zweiten Lauf ersetzt statt angehaengt.
  Stehen pyscript oder panel_custom bereits ausserhalb dieses Abschnitts,
  weist das Skript darauf hin, statt einen doppelten Schluessel zu erzeugen.
- Vorhandene Daten bleiben unangetastet. Fahrzeugprofil, Fahrten,
  Tankvorgaenge und ein bereits hinterlegtes Token werden nie ueberschrieben,
  nur fehlende Dateien angelegt.
- Die configuration.yaml wird vor jeder Aenderung mit Zeitstempel gesichert.

Geprueft gegen eine frische Home-Assistant-Instanz im Container: HA startet
ohne Konfigurationsfehler, alle neun pyscript-Entitaeten werden
veroeffentlicht, das Panel ist als "Mein Audi" mit mdi:car-sports unter
/audi-dashboard-panel registriert und rendert im Browser mit fuenf Tabs. Ein
zweiter Durchlauf laesst Profil und Token unveraendert und den
Konfigurationsabschnitt genau einmal stehen.

Ausserdem den blockierenden Verlaufsabruf in fahrtabschluss_logik.py
uebernommen: urlopen lief direkt statt ueber task.executor, was Home
Assistant seit 2026.8 abbricht - jede Fahrt waere ohne Strecke geblieben.
Der Fix lag bisher nur im Branch umsetzung-datametric360; eine frische
Installation haette den Fehler sonst mitgebracht.

INSTALL.md nennt den schnellen Weg jetzt vor der ausfuehrlichen Anleitung.
2026-08-13 16:20:34 +02:00
Paul Nothaft 78d70fa4ca Review-Dokument als umgesetzt kennzeichnen
Haelt fest, was bewusst anders geloest wurde als vorgeschlagen, welche drei
Fehler beim Umsetzen dazukamen und welcher pyscript-Fallstrick dabei
aufgefallen ist.
2026-08-13 16:10:38 +02:00
Paul Nothaft e1180f8e34 Kleinere Befunde und Dokumentation aus dem Review
Frontend, kleinere Befunde:
- Das Standort-Menue liess sich nur wischen oder ueber den Kartenmarker
  oeffnen. Der Griff ist jetzt ein Knopf mit aria-expanded, damit auch per
  Tastatur erreichbar; der Umschalter Strasse/Satellit hat aria-pressed und
  ein Label, das den Zustand nennt.
- Der Filterschalter "Nur passende Sensoren anzeigen" wirkte nicht, solange
  eine Auswahlliste offen war: der Klick-Handler ersetzte das Overlay-DOM,
  bevor das change-Ereignis des Kontrollkaestchens ausgeliefert wurde. Er
  wird jetzt vor dem Schliessen der Liste behandelt.
- Der Theme-Wechsel zeichnete nicht neu, die Leaflet-Kacheln blieben bis zum
  naechsten Backend-Update im alten Stil - seit der Dauerkarte auf der
  Uebersicht deutlich sichtbar.
- Toter Code entfernt (fahrzeugGlyphPfade, .dot.neutral) und zwei Kommentare
  berichtigt, die Gegenteiliges behaupteten.

Dokumentation:
- AGENTS.md widersprach sich an sechs Stellen. Berichtigt: Fahrterkennung
  laeuft ueber die Zuendung, nicht ueber WLAN; Datenweg ist flespi, nicht
  MQTT; Modulzahl und Zeilenzahl stimmen wieder; Entity-IDs werden im
  Setup-Menue zugeordnet, nicht in einstellungen.py; STANDORT_TRACKER ist
  belegt, nicht leer.
- SPECIFICATION.md beschreibt durchgehend den Stand vor der FMM003-
  Umstellung. Statt es zu Teilen umzuschreiben und dabei Ungenauigkeiten zu
  riskieren, steht jetzt ein datierter Hinweis am Anfang, der die drei
  geaenderten Punkte benennt und auf AGENTS.md verweist. Alles Uebrige des
  Dokuments gilt unveraendert weiter.
- INSTALL.md: Die zirkulaere Schrittfolge (Schritt 4 verweist auf 9, Schritt
  7 auf 4) ist als solche benannt und aufgeloest. Die Override-Datei ist mit
  Pfad genannt, samt dem Hinweis, dass sie gesichert wird. Und die drei mit
  Test-Entitaeten der Entwicklungsinstanz vorbelegten Rollen sind erwaehnt -
  auf einer frischen Installation zeigen sie ins Leere.
- README, Profilvorlage: WLAN-Reste entfernt, zwei Falschaussagen aus dem
  ersten Review nachgezogen (Statistik rechnet echt, die
  97-Prozent-Volltankungsregel wurde entfernt), Dateiuebersicht um die
  fuenf fehlenden pyscript-Dateien und entitaeten.py ergaenzt.
- update.ps1 liefert jetzt auch shell_beleg_parser.py aus. Die Datei liegt in
  data/, ist aber Code - Aenderungen am Belegleser, dem einzigen getesteten
  Teil des Projekts, kamen bisher auf keiner Instanz an.
- design/README: Zwei Dateien der Inhaltstabelle liegen gar nicht in dem
  Ordner, weshalb das Board aus der Repo-Kopie heraus leer bleibt - jetzt
  vermerkt. Ausserdem die Behauptung berichtigt, die Auflage ruehre
  audi-dashboard-app.js nicht an: der Stylesheet-Loader wurde dort ergaenzt.

Geprueft: Panel laedt, alle neun pyscript-Entitaeten werden veroeffentlicht,
Zuordnen und Zuruecksetzen funktionieren, keine neuen Fehler im Protokoll.
2026-08-13 16:10:19 +02:00
Paul Nothaft bc2834e867 Bedienbarkeit, CSS-Robustheit und drei Anzeigefehler
Befund 9: In Telefonbreite blendet die iOS-Auflage das Zahnrad aus, die Ringe
waren dort der einzige Zugang zu den Einstellungen - als div mit
pointer-events:none aber weder fokussierbar noch fuer Sprachausgabe
erreichbar. Sie sind jetzt ein echter Knopf mit aria-label, in jeder Breite
bedienbar und unabhaengig davon, ob die Auflage geladen wurde.

Befund 10: .navmarke{display:none} stand nur in der iOS-Auflage. Ohne sie
waere der Markenklon ein sechstes Element im fuenfspaltigen Raster und die
Menueleiste zerfiele. Die Regel steht jetzt in der Basis-CSS, die
Sichtbarkeit in der Auflage - genau andersherum als bisher.

Befund 13: Das Setup-Fenster deklariert role=dialog aria-modal=true, hielt den
Vertrag aber nicht ein. Escape schliesst jetzt (Reihenfolge: Auswahlliste,
dann Fenster), der Tabulator wandert im Fenster im Kreis statt in den
verdeckten Hintergrund, und der Filterschalter hat einen zugaenglichen Namen.

CSS-Spezifitaet: main{padding} in der Auflage verlor gegen main#view in der
Basis - auf grossen Bildschirmen blieb der Seitenrand bei 20px statt 44px.
Der Rand liegt jetzt in einer Variablen, die auch der negative Rand der
randlosen Standortkarte benutzt; sonst haette deren Ausgleich nach dem Fix
nicht mehr gepasst.

Drei Anzeigefehler, im Browser gegen die Testinstanz gefunden und behoben:
ein unlesbares Datum ergab NaN/N statt eines Strichs (das Profil wird laut
INSTALL.md von Hand gepflegt, ein ISO-Datum genuegt), ein fehlender
Tanksensor ergab 0 Prozent statt unbekannt, und neben dem Typenschild stand
die Ausfuehrung doppelt, wenn sie schon im Modellnamen steckt.

Alles im echten Panel geprueft: #marke ist ein BUTTON mit aria-label, der
Markenklon ist ohne die Auflage versteckt, der Seitenrand folgt der
Variablen, und die drei Anzeigen zeigen jetzt Striche statt Rechenreste.
2026-08-13 16:04:05 +02:00
Paul Nothaft 7a43be9764 Standort: Dauerabfrage, abgebrochene Gesten, Aufraeumen
Befund 8: USER_POS_TS wurde nur im Erfolgsfall gesetzt. Lehnt der Nutzer die
Standortfreigabe ab, griff die 30-Sekunden-Drossel damit nie - bei einem
GPS-Tracker als Quelle waren das dutzende Hochgenauigkeits-Abfragen pro
Stunde. Der Fehlschlag zaehlt jetzt als Versuch, und die Drossel prueft auf
den Zeitstempel statt auf ein Ergebnis.

Befund 11: Das Standort-Menue hatte als einzige der vier Zeigergesten keinen
pointercancel-Handler. Uebernimmt das System die Beruehrung, blieb der
Startpunkt gesetzt und preventDefault() blockierte das Scrollen in der
ganzen App. Zuruecksetzen jetzt gemeinsam fuer pointerup und pointercancel.

Befund 12: Ein fehlgeschlagener Adressabruf setzte trotzdem den
Cache-Schluessel - fuer diese Zelle blieb es danach die ganze Sitzung bei den
blossen Koordinaten, auch wenn das Netz laengst wieder da war.

Befund 15: disconnectedCallback raeumte nur eine von drei Karten ab.

Nicht umgesetzt, mit Begruendung im Code: die Karte nur bei echtem
Ansichtswechsel neu aufzubauen. Ich hatte das zunaechst geaendert und wieder
zurueckgenommen - render() ersetzt den Inhalt per innerHTML, eine
ueberlebende Leaflet-Instanz zeigte danach auf ein abgehaengtes Element und
bliebe leer. Der Neuaufbau haengt am 20-Sekunden-Takt des Backends; das
liesse sich nur durch einen Umbau der Render-Architektur aendern.
2026-08-13 15:56:35 +02:00
Paul Nothaft 4c62b9b529 Setup-Menue: Datenverlust verhindern, Vorbelegung korrigieren
Befund 1 (Datenverlust): Solange pyscript.audi_dashboard_entitaeten nicht
veroeffentlicht ist, liefert setupKatalog() eine leere Liste. Das Fenster ging
trotzdem auf - ohne eine Zeile, ohne Meldung - und ein Klick auf Speichern
schrieb {}. Weil overrides_schreiben() die Datei vollstaendig ersetzt, waren
damit alle Zuordnungen weg, sichtbar erst beim naechsten Neustart. Jetzt drei
Riegel: das Setup laesst sich ohne Katalog gar nicht oeffnen, Speichern bricht
bei leerer Zuordnung ab, und die Entitaet wird im Nachlade-Pfad mitgeholt -
der sie bisher als einzige der sechs ausliess.

Befund 3 (gleiche Sensoren): Der Vorschlag haengt nicht vom Index ab, also
bekamen alle vier Positionen einer Liste denselben Sensor. Der
Sicherheitscheck haette danach viermal dieselbe Tuer geprueft und Sicher
abgestellt gemeldet, obwohl drei Tueren nie geprueft wurden. Vergebene IDs
werden jetzt mitgefuehrt und uebersprungen.

Befund 4 (beliebige Sensoren): entitaetScore vergibt fuer einen reinen
Domain-Treffer bereits 3 Punkte - die Schwelle 3 war damit immer erfuellt und
belegte jedes leere Feld mit dem erstbesten Sensor der Domain, in willkuer-
licher Reihenfolge. Schwelle jetzt 5, also mindestens ein Stichworttreffer.

Geprueft mit den echten Funktionen aus der Datei: vier Tuerpositionen
erhalten vier verschiedene Sensoren, ein Bewegungsmelder wird der Zuendung
nicht mehr zugeordnet, ein Sensor mit passendem Namen dagegen schon.

Ausserdem wird die Sensor-Zuordnung jetzt im Browser-Backup mitgesichert und
beim Import wieder eingespielt.
2026-08-13 15:54:04 +02:00
Paul Nothaft 64f457dd87 Setup-Zuordnung: Zuruecksetzen wirkt, Sicherung, Absicherung
Drei Befunde aus REVIEW_main_2026-08-13.md, alle an der Testinstanz geprueft.

Zuruecksetzen (Befund 2 und 5): overrides_anwenden() uebersprang leere Werte
und setzte nur belegte. Weil setattr das Modul-Attribut dauerhaft veraendert,
blieb ein einmal gesetzter Wert danach fuer immer stehen - bei 15 von 17
Feldern hatte Zuruecksetzen keine Wirkung, und die Oberflaeche zeigte wieder
den alten Wert, als sei das Speichern fehlgeschlagen. Umgekehrt wurde eine
Liste aus leeren Eintraegen gesetzt statt uebersprungen, was den
Sicherheitscheck mit zwoelf unbekannt-Zeilen fuellte. Jetzt wird fuer jedes
bekannte Feld geschrieben: der Override, wenn belegt, sonst der eingebaute
Standardwert. Damit ist der Vorgang zugleich wiederholbar.

Sicherung (Befund 6): entitaeten.json war weder in backup.py noch in der
Wiederherstellung noch im Browser-Export enthalten - die gesamte Zuordnung
waere nach einem Rueckspielen still weg gewesen. Jetzt ueberall dabei; fehlt
der Abschnitt in aelteren Sicherungen, bleibt die aktuelle Zuordnung stehen.

Absicherung (Befund 7): overrides_lesen() fing kaputtes JSON nicht ab. Der
Aufruf steht in beim_start() vor alles_veroeffentlichen() - eine unlesbare
Zeile liess das Panel komplett leer bleiben. Geprueft: es folgt jetzt eine
verstaendliche Meldung, alle sechs Entitaeten werden trotzdem veroeffentlicht.

Dabei ein pyscript-Fallstrick gefunden und im Code vermerkt: Generator-
ausdruecke sind nicht implementiert (not implemented ast ast_generatorexp),
Mengen-, Listen- und Dict-Comprehensions dagegen schon.
2026-08-13 15:52:05 +02:00
Paul Nothaft 12418a3d52 Review des aktuellen Stands und Plan fuer die native HA-Integration
Zwei Dokumente, beide reine Analyse - kein Code geaendert.

REVIEW_main_2026-08-13.md: 15 Befunde aus den Commits c66ed82..d5562c7,
nach Schaden sortiert, jeder mit Datei-Zeile und der Kette vom Ausloeser zur
Auswirkung. Die drei schwersten liegen im neuen Setup-Menue:

1. Speichern, solange der Katalog noch nicht geladen ist, ueberschreibt
   entitaeten.json mit {} und loescht alle Zuordnungen. Faellt erst beim
   naechsten Neustart auf, weil die setattr-Werte im Prozess bestehen
   bleiben - und entitaeten.json wird von backup.py nicht gesichert.
2. "Zuruecksetzen" wirkt bei 15 von 17 Feldern nicht, weil
   overrides_anwenden() leere Werte ueberspringt und der Standardwert bei
   fast allen Feldern leer ist. Isoliert nachgestellt.
3. Alle vier Listenpositionen bekommen denselben Sensor, weil der
   Vorschlag den Index nicht auswertet. Folge: der Sicherheitscheck prueft
   viermal dieselbe Tuer und meldet "Sicher abgestellt", obwohl drei Tueren
   nie geprueft wurden.

Jeder Befund ist als belegt oder plausibel gekennzeichnet.

ha_install.md: Plan fuer die Umstellung von pyscript auf eine eigene
Integration mit Konfigurationsdialog - Einrichtung komplett ueber die
Oberflaeche, kein YAML, Updates als Knopfdruck. Alle genannten
Schnittstellen sind an der laufenden 2026.8.1-Instanz geprueft, inklusive
Signaturen.

Zur Verteilung: HACS kann ausschliesslich GitHub und scheidet fuer das
Gitea-Repository aus. App-Repositories akzeptieren zwar beliebige Git-URLs,
Apps sind aber Docker-Container und damit der falsche Behaelter fuer eine
Integration. Nativ bleibt eine eigene UpdateEntity, deren Logik in
updateverwaltung.py bereits zu grossen Teilen existiert.

Festgehalten ist ausserdem, welche der Review-Befunde die Umstellung
konstruktionsbedingt aufloest - unter anderem alle drei oben genannten sowie
die Token-Datei im Klartext, weil eine Integration den Recorder direkt
abfragen kann statt sich selbst ueber HTTP.
2026-08-13 13:43:25 +02:00
Paul Nothaft b1f8b71104 Plan fuer die native HA-Integration als Dokument ablegen
Umstellung von pyscript auf eine eigene Integration mit
Konfigurationsdialog: Einrichtung komplett ueber die Oberflaeche, kein YAML,
Updates als Knopfdruck. Alle genannten Schnittstellen sind an der laufenden
2026.8.1-Instanz geprueft, nicht aus der Dokumentation uebernommen -
inklusive Signaturen.

Zur Verteilung festgehalten: HACS kann ausschliesslich GitHub und scheidet
fuer das Gitea-Repository aus; App-Repositories akzeptieren zwar beliebige
Git-URLs, Apps sind aber Docker-Container und damit der falsche Behaelter
fuer eine Integration. Nativ bleibt die eigene UpdateEntity, deren Logik in
updateverwaltung.py bereits zu grossen Teilen existiert.

Dazu festgehalten, welche Befunde aus dem main-Review die Umstellung
konstruktionsbedingt aufloest - vor allem die drei Setup-Menue-Fehler und
die Token-Datei im Klartext.
2026-08-13 13:40:31 +02:00
Paul Nothaft 509de6ef97 Review des main-Branch als Dokument ablegen
15 Befunde aus den 18 neuen Commits, nach Schaden sortiert und mit
Datei-Zeile-Belegen. Die drei schwersten liegen im neuen Setup-Menue:
Speichern bei noch nicht geladenem Katalog ueberschreibt entitaeten.json
mit {} und loescht damit alle Zuordnungen, Zuruecksetzen wirkt bei 15 von
17 Feldern nicht, und alle vier Listenpositionen bekommen denselben Sensor,
wodurch "Sicher abgestellt" gesichert meldet, obwohl drei Tueren nie
geprueft wurden.

Jeder Befund ist als belegt oder plausibel gekennzeichnet; die belegten
sind am Code nachvollzogen, die Zuruecksetz-Regel zusaetzlich isoliert
nachgestellt.
2026-08-13 13:29:06 +02:00
Paul Nothaft 7b9fba04ce Abweichung zu main festhalten samt der Falle beim Zusammenfuehren
main ist 18 Commits voraus (FMM003, Setup-Menue, iOS-Auflage). Zusammenfuehren
ist bewusst aufgeschoben. Festgehalten ist vor allem die eine Stelle, die ein
sauberer Merge nicht auffangen wuerde: profil_lesen() gibt in diesem Branch bei
fehlender Profildatei None zurueck, profil.py ist auf main unveraendert und
geht damit konfliktfrei durch - aber die dortigen Aufrufstellen pruefen das
nicht und wuerden abstuerzen statt eine verstaendliche Meldung zu schreiben.
2026-08-13 13:03:31 +02:00
tobias d5562c71ef Setup-Menü: Wertvorschau, Unavailable-Warnung, Duplikat-Check, Reset, Neustart-Hinweis
Fünf Erweiterungen des bestehenden Setup-Popups: Live-Wert neben jedem
Such-Kandidaten, Warnhinweis bei unavailable/unknown/fehlender Entität,
Duplikat-Check mit Bestätigung vor dem Speichern, Zurücksetzen-Button je
Feld (Standardwerte-Snapshot in entitaeten.py, vor jedem Override
genommen), und ein bestätigter "Jetzt neu starten"-Knopf nach dem
Speichern, falls sich eines der drei trigger-gebundenen Felder geändert
hat (neuer Service audi_dashboard_neustart). Im Docker-Testcontainer
Feld für Feld verifiziert.
2026-08-12 18:48:35 +02:00
tobias 3548f773c9 update.ps1: fehlende ios.css/badges-Kopie fixen, Installationspaket-Konzept
update.ps1 kopierte audi-dashboard-ios.css und www/badges/ nie, obwohl beide
von der laufenden App gebraucht werden - jedes Update seit Einfuehrung dieser
Dateien liess den deployten Stand dort veraltet zurueck. Ausserdem zwei
verwaiste *.bak-before-audit-merge-Dateien aus www/ entfernt (bereits
gitignored, nie versioniert) und installationspaket/ (generiertes,
gitignored Deployment-Bundle fuer eine frische HA-Installation) als neuen
Ignore-Eintrag ergaenzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 17:17:15 +02:00
tobias 7980f9672d Desktop-Sidebar-Markenklon (.navmarke) aus Claude Design nachziehen
audi-dashboard-app.js war bereits mit dem Design-Projekt identisch, aber
audi-dashboard-ios.css war seit einer Weile nicht mehr neu gezogen worden:
Design hatte inzwischen einen Rings-Klon oben in der Desktop-Sidebar
(.navmarke) sowie einen gefuellten Pill-Stil fuer den aktiven Tab ergaenzt.
Nach Byte-Diff-Pruefung (nur additive CSS-Aenderungen) komplett uebernommen,
in Docker deployt und bei 402px/1400px verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 16:20:19 +02:00
tobias 2c6e8e5ce4 INSTALL.md an Setup-Menü und FMM003 anpassen
Ersetzt die veralteten Installationsschritte (manuelles Eintragen von
WLAN_SENSOR/TommiG1-Entity-IDs in einstellungen.py) durch eine Anleitung
für das neue Setup-Menü in der App und die aktuellen FMM003-Feldnamen
(ZUENDUNG_SENSOR, STANDORT_TRACKER, BATTERIE_SENSOR). AGENTS.md entsprechend
nachgeführt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 16:07:47 +02:00
tobias 9794193803 Setup-Menü für Sensor-Zuordnung + Umstellung von WLAN/VAG-Integration auf FMM003
Setup-Menü (Einstellungen -> Fahrzeug einrichten -> Setup): ordnet alle von
der App genutzten Sensor-Rollen echten HA-Entitäten zu, statt sie in
einstellungen.py von Hand einzutragen - durchsuchbares Dropdown je Feld,
Vorauswahl aus vorhandenen Entitäten, Schalter für "nur passende Sensoren".
Neues Modul entitaeten.py (Katalog + JSON-Override, zur Laufzeit über
setattr() auf einstellungen angewendet, kein Neustart nötig außer für die
drei trigger-gebundenen Felder).

Zusätzlich: Fahrzeug-WLAN-Erkennung und alle Entity-IDs der nicht mehr
genutzten TommiG1/HA_VAG-EU-Data-Act-Integration entfernt. Fahrterkennung
läuft jetzt über den Zündungs-/ACC-Status des neu angebundenen Teltonika
FMM003 (ersetzt die WLAN-Verbindungserkennung); Standort und 12V-
Batteriespannung kommen ebenfalls vom FMM003. Kilometerstand, Tankfüllstand,
Reichweite, Türen/Fenster/Schlösser sowie Ölwechsel-/Inspektionsdaten haben
dadurch vorerst keine Quelle mehr und zeigen "unbekannt" - die Funktionen
selbst bleiben erhalten und lassen sich über das neue Setup-Menü jederzeit
neu zuordnen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 15:55:59 +02:00
tobias 7e979ecfab Standort: Feinschliff aus Claude Design übernehmen + Satellit/Straßenkarte-Umschalter
Übernimmt den in Claude Design überarbeiteten Stand der Standort-Funktion
(echte Audi-CI-Icons statt Platzhalter-SVGs, robustere Abstands-/Adress-
Anzeige, dynamisch gemessene Menü-Zugweg-Höhe) und ergänzt lokal den
Layer-Knopf so, dass er wie gewünscht zwischen Satellit (Esri World
Imagery, kostenlos ohne Key) und Straßenkarte umschaltet statt zwischen
Tag/Nacht-Theme.
2026-08-12 12:32:34 +02:00
tobias dc4c1ff0bf Standort-Kachel: Live-Fahrzeugposition auf der Übersicht
Neue Kachel oberhalb von "Zuletzt" mit echter Leaflet-Mini-Karte,
öffnet per Klick eine Vollbild-Standortansicht (Kartendarstellung
wählen, auf Fahrzeug/User zentrieren, beide zeigen) mit einem
ausziehbaren myAudi-Stil-Menü: Distanz zum Gerät, Adresse (Reverse-
Geocoding via Nominatim), fährt/steht/Letzter-Parkplatz-Status,
Tankfüllstand/Reichweite, Route- und Teilen-Aktionen.

Backend: STANDORT_TRACKER-Einstellung (device_tracker-Entity) plus
_standort()-Veröffentlichung, bewusst leer gelassen bis eine echte
GPS-Quelle (FMM003/flespi) angebunden ist - Kachel zeigt bis dahin
"kein GPS-Signal". Verifiziert in audi_ha_test mit einer manuell
gesetzten Test-Position.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 11:07:49 +02:00
tobias b98d624359 Höhen-Kollaps-Bug in audi-dashboard-ios.css beheben
Die iOS/Großbildschirm-Auflage setzte :host > div{height:100%}
bedingungslos, was die App auch im mobilen Layout brach, da HA die
reale Höhe durch panel_custom nicht zuverlässig durchreicht. Jetzt
bleibt die viewport-verankerte 100dvh-Höhe (mit 880px-Deckel) bei
allen Breiten erhalten; der Deckel entfällt nur noch innerhalb der
@container (min-width:860px)-Desktop-Auflage. Fix lokal in Docker und
im Claude-Design-Board (DataMetric360 Board.dc.html) verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 22:46:54 +02:00
tobias a84f7c81f4 DuckDNS Let's Encrypt erfolgreich: Ursache war ein falscher aliases-Eintrag
Tatsaechliche Ursache der deploy_challenge-Fehlschlaege gefunden: ein
ueberfluessiger/falscher aliases-Eintrag (alias: datametric360 ohne
.duckdns.org) in der DuckDNS-Add-on-Konfiguration, zusammen mit
accept_terms: false. Nach Entfernen von aliases und accept_terms: true lief
die Zertifikatsanfrage sofort durch. Der DNS-Server war entgegen der
vorherigen Vermutung kein Faktor - Erfolg trat auch mit dem Speedport als
DNS ein.

configuration.yaml's alter http:-Block entfernt, HA migriert SSL-Pfade und
interne/externe URL jetzt ueber die Oberflaeche (Einstellungen > System >
Netzwerk). Dabei eine HA-Eigenheit dokumentiert: Netzwerkaenderungen muessen
innerhalb 5 Minuten per Dialog bestaetigt werden, sonst automatischer
Rollback.

HA ist jetzt per HTTPS mit gueltigem Let's-Encrypt-Zertifikat erreichbar.
2026-08-11 17:13:23 +02:00
tobias 7c57cec92a DuckDNS: Let's Encrypt bewusst fallen gelassen, Portfreigabe erledigt
deploy_challenge (DNS-01) scheiterte reproduzierbar am dig-Aufruf im
DuckDNS-Add-on-Container selbst, obwohl DuckDNS den TXT-Eintrag nachweislich
korrekt setzt und dieser ueberall sonst (PC, HA-SSH-Konsole) sofort sichtbar
war - Ursache innerhalb des Containers blieb trotz gruendlicher Diagnose
ungeklaert. Entscheidung: abschalten statt weiter debuggen, da HA ohnehin
nur ueber Tailscale erreichbar ist. DuckDNS selbst (IP-Update) laeuft
zuverlaessig weiter - das ist der fuer den FMM003-Pfad relevante Teil.

configuration.yaml's http:-Block bleibt deshalb dauerhaft auskommentiert.
Portfreigabe 8883 und FMM003-Datenversand sind bestaetigt erledigt.
2026-08-11 16:04:48 +02:00
tobias b10ce7c0a5 iOS/Grossbildschirm-Auflage im HA-Panel scharf schalten
audi-dashboard-app.js laedt jetzt zusaetzlich audi-dashboard-ios.css in
den Shadow-Root (zweiter <link>, rein additiv neben audi-dashboard.css).
Im Docker-Testcontainer bei 375px (Tab-Leiste unten) und 1280px (Grid mit
264px-Seitennavigation) verifiziert.

Zwei Fixes an audi-dashboard-ios.css waren dafuer noetig:
- :host-Selektoren werden von diesem Browser innerhalb von @container
  verworfen (kein Fehler, die Regel fehlt einfach im CSSOM) - die alte
  412px-Zentrierung aus audi-dashboard.css wird deshalb unbedingt statt
  nur ab 860px aufgehoben.
- audi-dashboard-version.json hochgezaehlt, sonst liefert der Browser die
  alte Datei aus dem HTTP-Cache trotz neuem Serverstand.

design/README.md dokumentiert beide Fixes fuer kuenftige Aenderungen an
dieser Datei.
2026-08-11 15:12:51 +02:00
tobias 838ea7f362 DataMetric360 Board aus Claude Design importieren: iOS/Großbildschirm-Optikauflage
Neuer Vergleichsentwurf (Smartphone + Großbildschirm nebeneinander) direkt per
Claude-Design-MCP importiert. Enthaelt die responsive Grossbildschirm-Umsetzung
(Tab-Leiste wird zur Seitennavigation ab 860px) als reine CSS-Auflage.

audi-dashboard-app.js aus dem Export weicht 641 Zeilen vom produktiven Stand ab
(fehlende Vier-Kacheln-Kurzwahl, Balkenfarbe, Resttank-Liter) - deshalb NICHT
uebernommen, nur die CSS-Auflage (audi-dashboard-ios.css) nach homeassistant/www/
kopiert, aber noch nicht in die Panel-Ladelogik eingebunden. Entscheidung zur
Aktivierung und zur Aufloesung der JS-Abweichung steht noch aus.
2026-08-11 14:55:00 +02:00
tobias 619d8247f7 Umsetzungsstand MQTT/FMM003 nachtragen: Zertifikate erzeugt, DuckDNS/Dual-Stack bestätigt, Cloudflare-Frage geklärt
- Eigene CA + Server-/Client-Zertifikate erzeugt und Mosquitto konfiguriert
- Dateinamen-Endungs-Stolperstein am FMM003-Configurator dokumentiert (.pem/.pem.crt/.pem.key)
- Dual-Stack am Router bestaetigt, Portfreigabe-Risiko damit ausgeraeumt
- Klargestellt: Cloudflare ersetzt DuckDNS+Portfreigabe+Mosquitto nicht, sondern ergaenzt sie
  fuer einen anderen Zweck (App-Erreichbarkeit statt Fahrzeug-MQTT)
- AGENTS.md Open-Items-Liste entsprechend abgehakt
2026-08-11 14:21:04 +02:00
Paul Nothaft f7f1a238e5 Stand nach der nativen Huelle in AGENTS.md und Plan nachziehen 2026-08-11 12:44:32 +02:00
Paul Nothaft 4395ff6ac6 Native iOS-Huelle in Betrieb, drei Darstellungsfehler behoben
Xcode ist vorhanden, die Huelle liess sich also wirklich bauen statt nur
vorzubereiten: Capacitor 8 fuer iOS und Android, Build fuer den Simulator
erfolgreich, App laeuft und zeigt echte Daten vom Server.

CapacitorHttp eingeschaltet. Das ist keine Feinheit: die native Huelle
liefert die Oberflaeche unter eigenem Ursprung aus, jede Anfrage an Home
Assistant ist damit ursprungsuebergreifend, und die WebView lehnt sie ohne
CORS-Freigabe ab. Nativ gestellte Anfragen kennen keine CORS-Pruefung -
die App braucht dadurch keine CORS-Einstellung am Server.

Drei Fehler, die erst die Bildschirmfotos zeigten:

1. Der Fahrzeug-Block auf der Uebersicht war unsichtbar. Ursache war der
   Flexbox-Fallstrick: "overflow: hidden" setzt die automatische Mindesthoehe
   auf 0, und sobald die Seite laenger ist als der Bildschirm, quetscht
   flex-shrink den Block auf Hoehe 0. Modellname, Typenschild und Kennzeichen
   waren damit schlicht weg.
2. Neben dem Typenschild stand der Modellname noch einmal komplett, obwohl
   das Schild ihn bereits zeigt. Das alte Panel loest das laengst richtig -
   nur der Zusatz gehoert daneben. Nachgezogen, samt Entdopplung, wenn die
   Ausfuehrung schon im Namen steckt.
3. In der Fotogalerie liefen die Dateinamen ueber den Rand der Miniaturen.

Dazu: Datumszeile in Listen bricht um statt abzuschneiden, Statistik nutzt
auf der Flaeche mehrere Spalten.

Im Backend war das Kilometerstand-Screening kaputt: es rief urlopen direkt
auf, was Home Assistant seit 2026.8 als blockierenden Aufruf abbricht. Jede
Fahrt blieb dadurch ohne Strecke - sichtbar nur als Warnung im Protokoll.
Laeuft jetzt ueber task.executor und rechnet an der Testinstanz wieder echte
Strecken aus dem Verlauf.

Die CORS-Freigabe fuer die Web-Fassung ist in der Testkonfiguration
hinterlegt. Sie greift in Home Assistant 2026.8 allerdings nicht - deshalb
wird die App aus Home Assistant selbst ausgeliefert (gleicher Ursprung, kein
CORS), was ohnehin der geplante Weg ist.

Die erzeugten Ordner ios/ und android/ bleiben ungetrackt; sie entstehen
jederzeit neu aus dem Webbuendel.
2026-08-11 12:44:11 +02:00
tobias 6448c9eea4 Broker-Erreichbarkeit entscheiden: Portfreigabe 8883 statt VPS-Bridge
Owner-Entscheidung: der Mehraufwand einer VPS-Bridge steht in keinem Verhaeltnis zum
zusaetzlichen Schutz, da TLS-Zwang plus Pflicht-Client-Zertifikat den Port bereits
praktisch unangreifbar macht.
2026-08-11 12:14:44 +02:00
tobias 2c79ed445c FMM003 MQTT: "AWS IoT Custom" als bestätigten Weg statt "Custom server" dokumentieren
"Custom server" ist im Configurator des Besitzers nicht auswählbar. Ein zweiter unabhängiger
Praxisbericht (mdworld.nl) bestätigt "AWS IoT Custom" + eigener Server als funktionierenden Weg
und deckt zwei kritische Stolpersteine auf: umgekehrte Zertifikatsketten-Reihenfolge und ein
Topic-Namensschema mit %imei%.
2026-08-11 11:56:54 +02:00
tobias 3e730fdf2b MQTT Client Type "Custom server" bestätigen, FMM003-Warnsignal aus Community-Thread dokumentieren
Configurator-Hilfetext (Screenshot) bestätigt einen vierten, in der offiziellen Doku nicht
beschriebenen Wert für eigene Broker. Ein Community-Thread bestätigt den Ansatz praktisch, zeigt
aber auch einen ungelösten FMM003-spezifischen Fehlerbericht - TCP/Codec8 bleibt der Rückfallplan.
2026-08-11 11:46:20 +02:00
tobias 12c6ec7970 Merge branch 'main' of https://gitea.nothaft.cloud/paul/audi-app 2026-08-11 11:12:30 +02:00
tobias a310c1e6f2 Design-Brief um Marken-Assets-Erlaubnis und Fernsteuerungs-Ausschluss ergänzen
Zwei Punkte aus COMPANION_APP_ARCHITECTURE.md fehlten bisher im Design-Brief für Claude Design.
2026-08-11 10:31:08 +02:00
286 changed files with 62697 additions and 9404 deletions
+79
View File
@@ -0,0 +1,79 @@
---
description: Zeigt den aktuellen Stand von DM360 - was zu tun ist, und ob Repo, Versionen und ausgelieferte App zusammenpassen.
---
> **Wo dieser Befehl greift.** Claude Code sucht Projektbefehle ausschliesslich
> in `<Arbeitsverzeichnis>/.claude/commands/`. Seit dem Umzug der Arbeitskopie
> nach `C:\Users\tobia\Projekte\audi-app` (10.09.2026) wird das Repository direkt
> geoeffnet - `/stand` steht damit ohne Zutun zur Verfuegung. Der frueher noetige
> Zeiger unter `.claude\sessions\.claude\commands\stand.md` entfaellt; er war
> unversioniert und ging mit jeder verlorenen Arbeitsumgebung mit. Wird doch
> einmal eine Ebene darueber gearbeitet, greift dieselbe Regel wie zuvor: dort
> findet Claude Code diese Datei nicht.
Zeige den aktuellen Stand dieses Projekts. Zwei Teile: den **gemessenen**
(unten selbst nachrechnen, nie aus dem Gedächtnis) und den **gepflegten** (aus
`OFFEN.md`).
## 1. Messen
Führe genau diesen Block aus:
```bash
R=$(git rev-parse --show-toplevel) && cd "$R"
echo "== Repository =="
echo " Zweig $(git branch --show-current) | HEAD $(git rev-parse --short HEAD) | origin $(git rev-parse --short @{u} 2>/dev/null || echo '?')"
printf " Arbeitsbaum: "; [ -z "$(git status --porcelain)" ] && echo sauber || git status --short
echo "== Versionen =="
python - <<'PY'
import json, hashlib, pathlib, zipfile, plistlib, re
b = "custom_components/audi_dashboard/"
m = json.load(open(b + "manifest.json"))["version"]
print(f" Integration {m}")
try:
j = json.load(open(b + "frontend/app/bundle.json"))
z = pathlib.Path(b + "frontend/app/bundle.zip").read_bytes()
ok = j["version"] == m and hashlib.sha256(z).hexdigest() == j["sha256"] and len(z) == j["bytes"]
print(f" OTA-Buendel {j['version']} {'passt' if ok else 'PASST NICHT - npm run ota faellig'}")
except Exception as e:
print(" OTA-Buendel nicht lesbar:", e)
try:
zf = zipfile.ZipFile("companion-app/auslieferung/App.ipa")
n = zf.namelist()
p = plistlib.loads(zf.read([x for x in n if re.match(r"Payload/[^/]+\.app/Info\.plist$", x)][0]))
print(f" .ipa auf dem Telefon {p.get('CFBundleShortVersionString')}")
c = [x for x in n if re.match(r"Payload/[^/]+\.app/capacitor\.config\.json$", x)]
if c:
L = json.loads(zf.read(c[0])).get("packageClassList", [])
fehlt = [k for k in ("BelegZwischenablagePlugin", "KalenderTerminPlugin") if k not in L]
print(" eigene Plugins registriert:", "ja" if not fehlt else "NEIN - " + ", ".join(fehlt))
except Exception as e:
print(" .ipa nicht lesbar:", e)
PY
echo "== Wartet auf einen Xcode-Lauf =="
B=$(git log --format=%H -1 -- companion-app/auslieferung/App.ipa)
N=$(git log --oneline "$B..HEAD" -- companion-app/native/ companion-app/scripts/ios-*.sh companion-app/scripts/ios-*.mjs companion-app/capacitor.config.ts)
[ -z "$N" ] && echo " keine nativen Aenderungen seit der ausgelieferten .ipa" || echo "$N" | sed 's/^/ /'
```
## 2. Lesen
Lies danach `OFFEN.md`. Steht dort etwas unter „Wartet auf einen Xcode-Lauf",
oder meldet die Messung nicht registrierte Plugins, lies zusaetzlich
`APPLE_DEV_BACKLOG.md`.
## 3. Berichten
Kurz und in dieser Reihenfolge:
1. **Stimmt der mechanische Stand?** Nur melden, was *nicht* passt - Repo nicht
synchron, Buendel-Version abweichend, Pluginliste unvollstaendig, native
Aenderungen aufgelaufen. Passt alles, genuegt ein Satz.
2. **Was wartet auf den Eigentuemer** (Abschnitt 1 und 2 von `OFFEN.md`).
3. **Was eine Sitzung bauen koennte** (Abschnitt 3), mit einer Empfehlung.
Nenne die Punkte aus `OFFEN.md`, statt sie neu herzuleiten - und wenn dir beim
Messen etwas auffaellt, das dort fehlt, sag es und trage es nach.
**Starte nichts von selbst.** Ein Xcode-Lauf ist immer die Handlung des
Eigentuemers (Apple-Regel in `AGENTS.md`).
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "companion-app",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--prefix", "companion-app"],
"port": 5173
}
]
}
+7
View File
@@ -18,3 +18,10 @@ Thumbs.db
# Protokolle
*.log
# Arbeitsdateien dieser Werkzeugkette (Patch-Skripte, Zwischenstaende)
.tmp-*
# Python-Bytecode, entsteht beim Kompilieren und gehoert nicht ins Repo
__pycache__/
*.pyc
+12796 -53
View File
File diff suppressed because it is too large Load Diff
+216
View File
@@ -0,0 +1,216 @@
# Apple Dev Backlog (DM360)
Alles, was einen **Xcode-Lauf** oder das **Apple Developer Portal** braucht,
wird hier gesammelt - und nur hier.
**Warum:** ein Xcode-Lauf lohnt sich nicht für eine einzelne native Änderung.
Er wird erst gestartet, wenn eine nennenswerte Menge zusammengekommen ist; die
Entscheidung darüber trifft der Eigentümer, nicht eine Sitzung.
## Arbeitsregel (verbindlich)
* Jede Aufgabe, die Xcode oder das Apple-Portal braucht, kommt **in diese
Datei**, in derselben Sitzung, in der sie auffällt - nicht in eine Notiz im
Bericht und nicht nur in `AGENTS.md`.
* **Nichts davon wird von sich aus gestartet.** Ein Xcode-Lauf ist eine
Handlung des Eigentümers am Mac; eine Sitzung bereitet ihn vor und meldet,
wenn sich genug angesammelt hat.
* Was **ohne** Xcode geht, gehört nicht hierher: Änderungen an der Oberfläche
der App reisen im OTA-Bündel (`@capgo/capacitor-updater`), Änderungen am Panel
und am Backend über die Selbstaktualisierung der Integration. Nur nativer
Code, Plugins, Berechtigungen, Signatur und Auslieferungsweg brauchen einen
Lauf.
* Erledigtes wird **nicht gelöscht**, sondern nach unten unter „Erledigt"
verschoben - mit der Fassung, in der es ausgeliefert wurde. Sonst wird
dieselbe Frage in einem halben Jahr wieder aufgemacht.
---
## Stand der ausgelieferten App
Gemessen am Artefakt `companion-app/auslieferung/App.ipa`, nicht aus dem
Gedächtnis (06.09.2026):
| | |
|---|---|
| Fassung | **2026.9.4.17** (Build 1), App und Erweiterung gleich |
| Bundle-ID | `app.datametric360`, Erweiterung `app.datametric360.teilen` |
| Profile | beide Ad Hoc, **2 Geräte**, gültig bis **2027-08-29** |
| Berechtigungen | Standort, Kalender (beide Schlüssel), Kamera - alle vorhanden |
| `packageClassList` | **6 Klassen - beide eigenen Plugins fehlen** |
Zum Vergleich: das Repository steht auf `2026.9.6.5`. Der Abstand ist **kein**
Rückstand - alles dazwischen war Oberfläche und Backend und reist über OTA. Was
wirklich auf einen Lauf wartet, steht unten.
---
## Wie ein Lauf abläuft
**Diese Datei ist die Übergabe.** Wer am Mac sitzt - mit oder ohne Claude am
Xcode - braucht keine weitere: hier steht der Stand, der eine Befehl, und was
danach zu prüfen ist. `AGENTS.md` ist nur für das *Warum* nötig.
### Vorher
**Nichts vorzubereiten** - auch kein `git pull`. Das Skript holt `origin/main`
selbst und bringt den Klon darauf, solange das nachweislich verlustfrei ist:
ein reiner Rückstand wird vorgespult, und Commits, die zwar nur lokal liegen,
gegenüber dem gemeinsamen Vorfahren aber **keine einzige Datei ändern**, werden
verworfen. Beides sagt es an; das Reflog hält Verworfenes noch 90 Tage.
> **Warum kein `git pull`:** am 07.09.2026 sind drei Commits am Ende von `main`
> entfernt und der Branch per Force-Push zurückgesetzt worden. Ein Klon von davor
> steht damit **vor** `origin/main` - dort wäre `git pull` genau das Falsche: der
> Merge hängt die entfernten Commits wieder in die Historie, und der nächste Push
> stellte sie auf dem Server wieder her (er ginge sogar ohne `--force` durch).
> Genau diese Lage räumt das Skript von sich aus auf.
Ab **hier** entscheidet es nichts mehr allein: liegen lokale Commits, die **auch
den Inhalt** ändern, bricht es ab und legt dir beide Wege vor (pushen oder
verwerfen) - ungepushte Arbeit wirft es nie weg.
Ein nicht sauberer Arbeitsbaum bricht den Lauf ebenfalls ab - er ist die
Voraussetzung dafür, dass Zurechtrücken oben gefahrlos ist. Und ebenso bricht er
ab, wenn das Zielgerät nicht im Team eingetragen ist („Your team has no
devices"); das ist dann eine Portal-Sache, kein Projektfehler.
### Der Lauf
```bash
bash companion-app/scripts/ios-signieren.sh
```
Ein Befehl, von jedem Verzeichnis im Repository aus. Er wechselt selbst nach
`companion-app/` und erledigt der Reihe nach: Git-Stand prüfen, Webbündel bauen,
native Hülle synchronisieren, Standort-/Kalender-/Kamera-Berechtigung eintragen,
Share-Erweiterung einrichten, App-Symbol einspielen, Versionsnummern setzen,
Archiv bauen und signieren, `.ipa` exportieren, **die fertige `.ipa` gegenlesen**
und die Auslieferung über die Luft vorbereiten.
**Die Fassung kommt aus `custom_components/audi_dashboard/manifest.json`** - es
gibt nichts von Hand hochzuzählen. Beim Schreiben dieser Zeilen wäre das
`2026.9.6.5`.
**Einmalig beim ersten Signieren:** der Schlüsselbund fragt, ob `codesign` den
Schlüssel benutzen darf. **„Immer erlauben"** wählen, nicht „Erlauben" - ein
Archiv signiert über 25 Programmteile und fragte sonst jedes Mal erneut. Bleibt
der Lauf minutenlang beim Signieren stehen, wartet er auf genau diesen Dialog.
### Danach
```bash
git add companion-app/auslieferung && git commit && git push
```
Das Telefon lädt die `.ipa` aus dem Repository - ohne diesen Push ändert sich
dort nichts. Danach in **Safari** auf dem Telefon öffnen (andere Browser reichen
`itms-services://` nicht an das System weiter):
```
itms-services://?action=download-manifest&url=https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/manifest.plist
```
Die Adresse trägt **kein** `?v=` - ein zweites Fragezeichen zerlegt den
Parameter, und weil iOS die Rückfrage erst aus dem geladenen Manifest baut,
passiert dann sichtbar gar nichts (Abschnitt CK).
Zum Schluss die eine Prüfung, die kein Skript abnehmen kann - siehe Abschnitt C
unten.
---
## A. Wartet auf einen Xcode-Lauf
### A1. Zwei eigene Plugins sind auf dem Telefon tot
**Auswirkung: Zwischenablage und Kalendereintrag funktionieren nicht.** Das ist
die einzige Position mit sichtbarem Ausfall.
Capacitor 8 registriert ausschließlich, was in `packageClassList` der
`capacitor.config.json` im Bündel steht (`CapacitorBridge.swift`,
`registerPlugins()`). Diese Liste erzeugt `cap sync` aus den installierten
npm-Paketen - app-eigene Plugins sind keine. Beide Klassen liegen nachweislich
im Programm der ausgelieferten `.ipa` und werden trotzdem nie registriert.
*Korrektur liegt im Repo* (`a99ae43`): `ios-teilen-einrichten.mjs` trägt die
Klassen nach jedem `cap sync` nach und liest ihre Namen aus den Swift-Dateien
selbst, damit sie nicht an zwei Stellen gepflegt werden. `ios-signieren.sh`
**bricht ab**, wenn eine davon im fertigen Bündel fehlt.
Hintergrund vollständig: `AGENTS.md`, Abschnitt DH und CM.
### A2. Installationslink ohne zweites Fragezeichen
`ios-luftweg.sh` hängte den Cache-Brecher `?v=` auch an die Manifest-Adresse
**innerhalb** des `itms-services`-Links - damit steht dort ein zweites `?`, der
Abruf scheitert, und weil iOS die Rückfrage erst aus dem geladenen Manifest
baut, passiert sichtbar gar nichts.
*Korrektur liegt im Repo* (`6365136`). Die bereits ausgelieferte
`auslieferung/index.html` wurde von Hand nachgezogen; das Skript zieht beim
nächsten Lauf nach. Hintergrund: `AGENTS.md`, Abschnitt CK.
---
## B. Wartet auf das Apple Developer Portal
Derzeit **nichts offen.** App-Gruppe, beide App-IDs und die Gerätefreigabe sind
eingerichtet (Abschnitt AK), die Profile laufen bis 2027-08-29.
Vorzumerken, aber noch lange hin:
* **2027-07-17** - das Entwicklerzertifikat läuft ab.
* **2027-08-29** - beide Bereitstellungsprofile laufen ab.
* Ein **neues Gerät** braucht eine Registrierung im Portal *und* frisch
ausgestellte Profile. Ein bereits ausgestelltes Profil erfährt von einem neuen
Gerät nichts - deshalb räumt `ios-signieren.sh` die zwischengespeicherten
Profile vor dem Bauen weg (Abschnitt AI).
---
## C. Nach dem Lauf zu prüfen
Am **fertigen Bündel**, nicht am Projekt. Die Begründung steht in Abschnitt AK:
von fünf Fehlern der nativen Hülle haben vier einen erfolgreichen Archivlauf
erzeugt und sind erst am entpackten `.ipa` aufgefallen.
Das Skript prüft selbst und bricht ab, wenn etwas fehlt:
1. Die Erweiterung hat ein echtes Programm.
2. App und Erweiterung tragen dieselbe `CFBundleShortVersionString` **und** eine
brauchbare `CFBundleVersion` (ohne die lehnt iOS die Installation der ganzen
App ab - Abschnitt CD).
3. Die App-Gruppe steht in den Entitlements **beider** Ziele.
4. Beide eigenen Plugin-Klassen stehen in `packageClassList`.
**Von Hand bleibt genau eine Prüfung** - auf dem Gerät: einen Beleg über das
Teilen-Blatt schicken, ein PDF aus der Zwischenablage einfügen, einen
Kalendereintrag anlegen. Ob die Plugins *registriert* sind, sagt Punkt 4; ob sie
*tun*, was sie sollen, sagt nur das Telefon.
---
## Was der Lauf von selbst erledigt
Damit es niemand ein zweites Mal von Hand macht: `ios-signieren.sh` trägt bei
jedem Lauf Standort-, Kalender- und Kamera-Berechtigung ein, richtet die
Share-Erweiterung ein, spielt das App-Symbol ein und setzt die Versionsnummern
in beide Ziele. Das muss sein, weil `companion-app/ios/` gitignored ist und von
`npx cap add ios` jederzeit neu erzeugt wird - alles, was man in Xcode
anklickt, wäre danach weg (Abschnitt AI).
Unvermeidlich von Hand bleiben: die einmalige Portal-Einrichtung, das
Schlüsselbund-„Immer erlauben" beim ersten Signieren, und `git push` der
fertigen `.ipa`, weil das Telefon sie aus dem Repository lädt.
---
## Erledigt
| Fassung | Was |
|---|---|
| 2026.9.4.17 | Kamera-Berechtigung für den QR-Scanner |
| 2026.9.4.7 | `CFBundleVersion` in beiden Bündeln - die App war vorher nicht installierbar |
| 2026.9.4.5 | Versionsnummern von App und Erweiterung angeglichen |
| 2026.9.2.7 | Share-Erweiterung, Zwischenablage- und Kalender-Plugin gebaut und signiert |
| 2026.8.30.8 | Erste signierte `.ipa`, Luftweg über Gitea eingerichtet |
+89
View File
@@ -0,0 +1,89 @@
# Audi-CI-Design — Archiv (Stand 2026-08-13, vor der iOS-Offizialisierung)
**Warum diese Datei existiert:** `SPECIFICATION.md` §2 „Concept & Design System" beschrieb bis
2026-08-13 das ursprüngliche, reine Audi-CI-Design des Panels — Nacht-Canvas `#161b23`, 20-px-
Kachelradius, Rot nur als Akzent, keine Schatten. Mit der Entscheidung, die iOS-Auflage
(`audi-dashboard-ios.css`) offiziell zur verbindlichen Optik zu machen (siehe
`DESIGN_REVIEW_2026-08-13.md` §A, Option 1, und der entsprechende `AGENTS.md`-Eintrag), wurde §2
umgeschrieben, um die iOS-Werte zu beschreiben. Diese Datei bewahrt den vorherigen Wortlaut
**wortgleich**, falls die Entscheidung später zurückgenommen wird.
**Wichtig — das ist nicht die einzige Absicherung:** Der Code selbst macht die Rückkehr trivial,
unabhängig von dieser Datei. `audi-dashboard-ios.css` ist eine rein additive **Auflage**
(`homeassistant/www/audi-dashboard-app.js`, Zeile ~39904000 lädt zuerst `audi-dashboard.css`, dann
zusätzlich `audi-dashboard-ios.css`) — die Basisdatei `audi-dashboard.css` enthält die Audi-CI-Werte
weiterhin vollständig und unverändert. Um zur reinen Audi-CI zurückzukehren, genügt es, das Laden
von `audi-dashboard-ios.css` in `_aufbauen()` zu entfernen bzw. bedingt zu machen — kein Restore
aus einem Backup nötig. Diese Datei dokumentiert die **Spezifikationstexte**, nicht den Code (der
bleibt ohnehin vorhanden).
Bekannter Nebenpunkt aus dem Design-Review: die korrigierte `--fg3`-Kontrastfarbe (`#8a94a3`, siehe
unten) ist in `bauauftrag.md` §8 **nicht** nachgezogen — dort steht noch der ursprüngliche,
kontrastschwache Wert `#657081`. Diese Archivdatei hier gibt daher den zuletzt tatsächlich gültigen
Stand wieder, nicht `bauauftrag.md`.
---
## Ursprünglicher Wortlaut von `SPECIFICATION.md` §2 (vor 2026-08-13)
### Visual language
Dark ("Nacht") is the default and primary theme; a light ("Tag") theme exists as a toggle. No
shadows, no gradients anywhere except the fade under the vehicle photo (`.szene`). Flat tiles,
pill-shaped controls, hairline dividers.
### Colors (CSS custom properties, defined per-theme via `:root` / `[data-theme="tag"]`)
| Role | Nacht (default) | Tag |
|---|---|---|
| `--canvas` (page background) | `#161b23` | `#FFFFFF` |
| `--tile` | `#1f2733` | `#f2f2f2` |
| `--tile-2` (inputs, hover) | `#2a3341` | `#e5e5e5` |
| `--line` | `rgba(255,255,255,.10)` | `rgba(0,0,0,.10)` |
| `--line-strong` | `rgba(255,255,255,.20)` | `rgba(0,0,0,.22)` |
| `--fg` | `#FFFFFF` | `#000000` |
| `--fg2` (secondary text) | `#9aa1ad` | `#4c4c4c` |
| `--fg3` (labels/eyebrows) | `#8a94a3` | `#666666` |
| `--ok` | `#15da15` | `#0DA20D` |
| `--warn` | `#ffaa00` | `#ffaa00` |
| `--shade` | `rgba(255,255,255,.05)` | `rgba(0,0,0,.04)` |
| `--bad` | `#fd2c4e` | `#eb0d3f` |
| `--red` (accent, theme-independent) | `#F50537` | `#F50537` |
`--red` is used sparingly (accents, active states, destructive actions), not as a background fill.
### Radii
- `--r-tile: 20px` — tiles, the vehicle-photo "scene" container.
- `--r-pill: 999px` — buttons, switches, segmented controls, progress bars.
- Small functional elements (form inputs, placeholder/mini image boxes, popups) use **hardcoded
literal pixel values** (6px / 12px / 14px respectively) rather than a shared token.
### Typography
Three font-family names in play, all under the trademarked "Audi Type" family, embedded inline as
base64 `woff2` directly in `audi-dashboard.css` (private-install only):
- `"Audi Type"` — body/UI text, weight 400.
- `"Audi Type Wide"`, weight 300 — `.fig` class, used for large numeric figures (odometer, range,
tank %, prices, service countdowns).
- `"Audi Type Extended"`, italic — `.sport` class and the vehicle-title badge suffix
(`.badge .zusatz`).
Only weights 300 and 400 are shipped — never use `font-weight: 600` or higher; the browser would
synthetically bold. Numbers are formatted via `de-DE` locale (thousands separator, comma decimals)
through the `de()`/`eur()` helpers.
### Licensing constraint (hard rule)
The Audi Type font, the four-rings SVG, and the model badge SVGs (`www/badges/*.svg`) are
**licensed/trademarked assets, cleared only for this one private, non-published installation**
(bauauftrag §8, §12). Never extract, republish, or reuse them anywhere outside this specific Home
Assistant instance.
---
## So kommst du zurück
1. In `homeassistant/www/audi-dashboard-app.js`, `_aufbauen()`: die vier Zeilen, die `linkIos`
erzeugen und an `ROOT` anhängen, entfernen (oder hinter eine Bedingung stellen).
2. `SPECIFICATION.md` §2 durch den obigen Wortlaut ersetzen (oder auf diese Datei verweisen und den
iOS-Absatz als „überholt" markieren, symmetrisch zur jetzigen Umkehrung).
3. `AGENTS.md` entsprechend nachziehen (die Design-Review-Entscheidung als zurückgenommen
vermerken).
4. `audi-dashboard-ios.css` selbst muss nicht gelöscht werden — ungeladen hat sie keine Wirkung.
+8 -2
View File
@@ -2,10 +2,16 @@
## Claude Code
- `AGENTS.md` above is the single source of truth for project state, rules, and open items —
edit it there, keep this file as a thin shim.
- **`OFFEN.md` says what is to be done right now** — start there, not in AGENTS.md's 12,000
lines. `/stand` measures the mechanical half itself (repo, versions, shipped `.ipa`) and reads
`OFFEN.md` alongside it. Keep `OFFEN.md` current in the same session something changes.
- `AGENTS.md` above is the single source of truth for **why** things are as they are — history,
rules, and the record of decisions including rejected ones. Edit it there, keep this file a
thin shim.
- Remember the AGENTS.md maintenance rule: update it (in English) in the same session whenever
your changes affect anything it records.
- Anything needing **Xcode or the Apple Developer Portal** goes in `APPLE_DEV_BACKLOG.md`, and
runs are batched — never start one; that call is the owner's (Apple rule in AGENTS.md).
<!-- Claude Code does not read AGENTS.md natively; this import is the officially recommended
pattern (code.claude.com/docs/en/memory, "AGENTS.md" section). -->
+129 -31
View File
@@ -1,7 +1,7 @@
# DataMetric360 — Companion App Architecture (design decided, not yet built)
**App name: `DataMetric360`** (decided 2026-08-10). Public hostname: a subdomain of
**`datametric360.app`**,
**`datametric360.de`**,
a dedicated domain registered at all-inkl solely for this purpose (see §5.4 for why it must be a
separate domain). Both the app name and the domain are deliberately neutral — they don't advertise
"Audi", "car", or "GPS tracker" to anyone who sees the hostname or the app icon, which is a small but
@@ -130,10 +130,46 @@ Entschlüsseln des binären Codec8-Protokolls. Das Gerät liefert die Daten dann
- **Der zugehörige Transportweg ist MQTT**, nicht ein HTTP-Webhook: *GPRS Server Settings → MQTT*.
Das war die wichtigste Überraschung — die erste Vermutung „JSON heißt HTTP-POST an einen
HA-Webhook" ist falsch.
- **Ein eigener Broker ist vorgesehen:** in den MQTT-Einstellungen werden schlicht IP und Port des
Brokers eingetragen; Login und Passwort werden verwendet, wenn der Broker Anmeldung verlangt.
- **Ein eigener Broker ist vorgesehen und bestätigt möglich:** *GPRS Server Settings → MQTT Settings →
MQTT Client Type* bietet laut der Hilfe im Configurator (Screenshot vom Besitzer, 2026-08-11) vier
Werte: `0 = AWS IoT Shadow`, `1 = AWS IoT Custom`, `2 = Azure IoT`, **`3 = Custom server`**. Die
öffentliche Teltonika-Doku (Wiki, PDF-Guides) kennt nur die ersten drei — „Custom server" ist offenbar
erst mit einer späteren Firmware/Configurator-Version dazugekommen und deshalb dort nicht beschrieben.
Für „Custom server" werden IP/Domain und Port des eigenen Brokers (unter *Server Settings*, nicht im
MQTT-Block selbst) eingetragen; Login und Passwort kommen zum Einsatz, wenn der Broker das verlangt.
- **Praktisch bestätigt, mit einem offenen Warnsignal:** ein Teltonika-Community-Thread
([FMC003 - Custom MQTT Broker?](https://community.teltonika.lt/t/fmc003-custom-mqtt-broker/14784),
Juli 2025) zeigt einen Nutzer, der genau unser Setup (eigener Mosquitto auf einem VPS, eigene CA,
Client-Zertifikat vom Gerät) über den Modus „AWS IoT Custom" nachgebaut hat — 24 Stunden stabil
getestet, damals gab es „Custom server" noch nicht als eigene Option. Zwei Monate später berichtet
ein anderer Nutzer **speziell für den FMM003**, dass bei ihm über MQTT keine Pakete am Server
ankamen — nach Rückwechsel auf TCP lief es wieder. Keine Auflösung im Thread sichtbar.
- **Konsequenz:** die MQTT-Anbindung gilt als grundsätzlich machbar, aber **nicht als sicher
funktionierend, bevor sie mit dem echten Gerät getestet wurde** — TCP/Codec8 (der bisherige,
bekannt funktionierende Weg über Traccar) bleibt der Rückfallplan, falls MQTT am FMM003 in der
Praxis Probleme macht.
- **„Custom server" beim Besitzer nicht auswählbar (2026-08-11):** im eigenen Configurator lässt
sich Wert 3 nicht anklicken. Ein zweiter, unabhängiger Praxisbericht
([mdworld.nl, FMC003 + eigener OpenRemote-Server](https://mdworld.nl/asset-tracking), Aug. 2025)
bestätigt den gleichen Umweg wie der Community-Thread: **„AWS IoT Custom" wählen und dort auf den
eigenen Server zeigen** — funktioniert nachweislich, unabhängig davon ob „Custom server" gerade
wählbar ist oder nicht. Das ist damit der empfohlene Standardweg, nicht nur ein Fallback.
- **Zertifikatsketten-Reihenfolge ist umgekehrt zur Konvention (wichtiger Stolperstein, aus
demselben Praxisbericht):** Teltonika-Geräte erwarten die hochgeladene Zertifikatskette in
**umgekehrter Reihenfolge** (Wurzelzertifikat zuerst, nicht zuletzt) — anders als das übliche
`fullchain.pem`-Format (Server-Cert zuerst). Wird das ignoriert, schlägt der TLS-Handshake mit
einem kryptischen Fehler fehl (`SSL routines::tlsv1 alert unknown ca`), der nicht offensichtlich
auf die Reihenfolge hindeutet. **Vor dem Hochladen im Security-Tab die Kette umdrehen.**
- **Topic-Namensschema (aus demselben Praxisbericht, als Orientierung):** z. B.
`<name>/teltonika/%imei%/data` und `.../commands``%imei%` wird vom Gerät automatisch ersetzt.
Exakte eigene Werte erst bei der Inbetriebnahme festlegen.
- **TLS ist Pflicht, nicht optional:** ohne hochgeladene Zertifikate arbeitet MQTT nicht. Zertifikat,
privater Schlüssel und Wurzelzertifikat werden im Configurator unter *Security* hinterlegt.
- **Dateinamen-Endung ist strikt geprüft (2026-08-11, live am Configurator beobachtet):** hochgeladene
Dateien müssen auf `.pem`, `.pem.crt` oder `.pem.key` enden, sonst Fehler „File format is not valid
or not supported". Reiner Inhalt (PEM-Format) reicht nicht, die Endung muss stimmen. Schema folgt
offenbar der AWS-IoT-Download-Konvention: Root-Zertifikat `*.pem`, Geräte-Zertifikat `*.pem.crt`,
privater Schlüssel `*.pem.key`. Eigene CA-Dateien entsprechend umbenennen, nicht nur `.crt`/`.key`.
- **Firmware-Abhängigkeit:** Codec JSON ist nicht in jeder Firmware enthalten (bei mehreren
Modellen erst ab 03.28.00 dokumentiert). Der Besitzer bestätigt, dass es auf diesem Gerät
verfügbar ist — vor der Inbetriebnahme trotzdem die Firmware-Version notieren.
@@ -266,12 +302,13 @@ never reachable from the public internet. Only a narrow, purpose-built slice of
tunnel from the HAOS host (the OptiPlex) to Cloudflare's edge; no router port ever opens. Confirmed
capable of routing **multiple public hostnames to multiple local ports/services** — so it points at
the reverse-proxy add-on's port below, not at HA's own port 8123.
2. **Nginx Proxy Manager or Traefik add-on** — a path-scoped reverse proxy sitting between the tunnel
and HA's internal API, on the Supervisor's internal Docker network. Configured with an **allowlist**
of only the specific paths the companion app needs (e.g. `/api/states/sensor.audi_*`,
`/api/services/pyscript/audi_dashboard_*` — exact path list still TBD, depends on the final entity/
service names once the FMM003 mapping is built). Everything else — `/lovelace`, `/config`, `/auth`,
the HA frontend itself — is blocked at this layer, never reaching the tunnel at all.
2. **Nginx Proxy Manager add-on** (decided, 2026-08-28 — see §5 item 3) — a path-scoped reverse proxy
sitting between the tunnel and HA's internal API, on the Supervisor's internal Docker network.
Configured with an **allowlist** of only the specific paths the companion app needs (`/api/states/
sensor.audi_dashboard_*`, `/api/services/audi_dashboard/*`, plus a handful more — the exact,
current list lives in `homeassistant/REVERSE_PROXY.md`, not duplicated here since entity/service
names can change and this doc would only go stale again). Everything else — `/lovelace`, `/config`,
`/auth`, the HA frontend itself — is blocked at this layer, never reaching the tunnel at all.
3. **Auth stays a second, independent layer:** every proxied call still requires the same Bearer LLAT
HA's REST API already demands. Even a path that slipped through the allowlist would be useless
without that token, and any individual LLAT can be revoked from the HA profile if the phone is ever
@@ -297,28 +334,87 @@ surface *is* now internet-facing (even though HA itself never is) — accepted a
Deliberately deferred until hardware arrives / the next architecture review — not blocking anything
else in this document:
1. **Exact reverse-proxy path allowlist** — depends on the final pyscript entity/service names once the
FMM003 combined-entity mapping is implemented; write these down here once decided.
1. **ERLEDIGT (2026-08-28): reverse-proxy path allowlist defined and current.** Lives in
`homeassistant/REVERSE_PROXY.md`, kept in sync with the real entity/service names
(`sensor.audi_dashboard_*` / `audi_dashboard.<name>` since the native-integration conversion,
`AGENTS.md` §H) rather than duplicated here. The step-by-step setup runbook that applies it in
Nginx Proxy Manager is `homeassistant/INTERNET_ZUGRIFF_EINRICHTEN.md`. One correction worth
recording: the allowlist originally (2026-08-11 planning) assumed the app itself would be served as
a web build under `/local/dm360/` — that was never built. Distribution stayed **native/sideload
only** (§1), so the allowlist instead needed the app's own OTA surface
(`/audi_dashboard_static/app/*`, the `@capgo/capacitor-updater` bundle) rather than a served
web app.
2. **No-hardcoding mapping system** for the FMM003 combined entity — discussed in an earlier session,
not yet written to a file or finalized in detail. **Kann erst nach der ersten echten
MQTT-Nachricht fertig werden** (§2b): die Feldnamen im Codec-JSON sind noch unbekannt und werden
mitgeschnitten, nicht geraten.
2a. **Erreichbarkeit des MQTT-Brokers für das Fahrzeug** — Portfreigabe 8883 gegen VPS-Broker mit
Mosquitto-Bridge, siehe §2b. Betrifft nur den Weg Fahrzeug→Zuhause und ist unabhängig von der
Anbindung der App (§4, dort bleibt es bei Cloudflare Tunnel).
2b. **TLS-Zertifikate für Mosquitto und das Gerät** — bei der Inbetriebnahme zu erzeugen; ohne sie
verweigert der FMM003 die MQTT-Verbindung.
3. **Reverse-proxy add-on: Nginx Proxy Manager vs. Traefik** — both viable (§4), pick deferred to the
next audit pass. Doesn't depend on the FMM003 — could be set up and tested against HA's existing API
before the hardware arrives, if worth doing ahead of time.
4. **Cloudflare Tunnel domain** — ✅ **ERLEDIGT: `datametric360.app` ist bei all-inkl registriert**
(Stand 2026-08-11). Eigens dafür angelegt, ohne Webseite und ohne Postfach darauf — genau so, wie
es die unten stehende Einschränkung verlangt. Vorgesehene Adresse der App später:
`https://datametric360.app`.
2a. **ERLEDIGT (Entscheidung, 2026-08-11): Portfreigabe 8883**, nicht VPS-Bridge. Begründung: ein
Broker mit TLS-Zwang und Pflicht-Client-Zertifikat lässt vor dem eigentlichen Login niemanden
auch nur ansetzen — der Mehraufwand einer dauerhaften VPS-Miete plus zweitem Mosquitto plus
Bridge-Verbindung stand in keinem Verhältnis zum zusätzlichen Schutz für ein einzelnes
Privatfahrzeug. Betrifft nur den Weg Fahrzeug→Zuhause und ist unabhängig von der Anbindung der
App (§4, dort bleibt es bei Cloudflare Tunnel — kein Widerspruch, siehe dort die Begründung, warum
ein eng begrenzter, zertifikatsgesicherter Port etwas anderes ist als HA selbst offenzulegen).
**Umsetzungsstand (2026-08-11):** DuckDNS-Hostname `datametric360.duckdns.org` angelegt und als
Add-on in HA eingerichtet, aktualisiert zuverlässig die öffentliche IP — das ist der für den
FMM003-Pfad tatsächlich entscheidende Teil. Router bestätigt **Dual-Stack** (kein DS-Lite), die
Portfreigabe 8883 am Speedport Smart 4 Plus ist eingerichtet, FMM003 sendet bestätigt Daten.
`.app` steht auf der HSTS-Preload-Liste: Browser erzwingen dort HTTPS bedingungslos. Das passt zum
Cloudflare Tunnel (immer HTTPS) und schließt eine versehentliche Klartextverbindung von vornherein
aus.
**Let's-Encrypt-Zertifikat für die HA-Oberfläche selbst — ✅ ERLEDIGT (2026-08-11), nach
ausführlicher Fehlersuche.** Der `deploy_challenge`-Hook (DNS-01, TXT-Eintrag via DuckDNS-API)
scheiterte zunächst wiederholt mit `timeout 120s` beim Warten auf den eigenen TXT-Eintrag, obwohl
DuckDNS den Eintrag nachweislich korrekt setzte (bestätigt per `nslookup` gegen `8.8.8.8` vom PC
*und* per `dig` aus der HA-eigenen SSH-Konsole gegen den internen Supervisor-Resolver). Der DNS-
Server selbst war am Ende kein Faktor (Erfolg trat auch mit dem Speedport als DNS-Server ein) —
**tatsächliche Ursache war ein fehlerhafter `aliases`-Eintrag** in der DuckDNS-Add-on-Konfiguration
(`alias: datametric360` ohne `.duckdns.org`, überflüssig und falsch, da keine externe Domain per
CNAME verwendet wird — `aliases` ist nur für diesen Fall gedacht) zusammen mit `accept_terms:
false`. Nach Entfernen von `aliases` und Setzen von `accept_terms: true` lief die Zertifikatsanfrage
beim nächsten Versuch sofort erfolgreich durch (`Challenge is valid! ... Creating fullchain.pem...
Done!`), Zertifikat gültig bis 2026-11-09.
Anschließend `configuration.yaml`'s alter `http:`-Block (der HA-Start blockiert hatte, solange die
Zertifikatsdateien nicht existierten) entfernt — HA migriert SSL-Zertifikatspfad/-Schlüssel und die
interne/externe URL inzwischen in die Oberfläche (Einstellungen → System → Netzwerk). **Eigenheit
dabei:** eine geänderte Netzwerkkonfiguration muss innerhalb von 5 Minuten über einen Dialog in der
Oberfläche bestätigt werden, sonst macht HA sie automatisch rückgängig und startet mit dem vorigen
Stand neu (`Pending HTTP config was not confirmed within 0:05:00` im Log) — beim ersten Versuch
deshalb ungewollt zurückgerollt, beim zweiten Versuch bewusst sofort bestätigt, seitdem stabil.
Externe URL bewusst leer gelassen (Port 8123 ist nicht freigegeben, HA bleibt nicht öffentlich
erreichbar); interne URL auf `https://192.168.2.216:8123` gesetzt — das Zertifikat ist auf den
Hostnamen ausgestellt, nicht auf die IP, der Browser zeigt deshalb bei IP-Zugriff weiterhin eine
Namensabgleich-Warnung (Verbindung bleibt trotzdem verschlüsselt); ein lokaler DNS-Eintrag, der
`datametric360.duckdns.org` intern auf die lokale IP auflöst, wäre die sauberere, aber optionale
Nachbesserung.
Nebenbei geklärt: ein Vorschlag, stattdessen komplett auf Cloudflare zu setzen, wurde geprüft und
**ist keine Alternative** — Cloudflare Tunnel kann kein rohes MQTT/TCP transportieren (nur HTTP),
der FMM003 kennt außerdem kein Tunnel-Konzept, nur IP/Domain+Port. Bleibt bei der klaren Trennung:
Cloudflare Tunnel für die App (§4), Portfreigabe+DuckDNS nur für die Fahrzeug-MQTT-Verbindung.
2b. **TLS-Zertifikate für Mosquitto und das Gerät** — ✅ **ERLEDIGT (2026-08-11):** eigene private CA
(10 Jahre gültig) erzeugt, Server-Zertifikat (CN `datametric360.duckdns.org`) und Client-Zertifikat
für das Gerät signiert, beide gegen die CA verifiziert. Mosquitto ist mit `certfile`/`keyfile`/
`cafile`/`require_certificate: true` konfiguriert. **Noch offen:** die drei Geräte-Dateien (Root,
Client-Zertifikat, privater Schlüssel) müssen noch in den FMM003-Configurator hochgeladen werden —
dabei auf die Dateinamen-Endung achten (siehe Anmerkung oben bei „Dateinamen-Endung ist strikt
geprüft").
3.**ERLEDIGT (2026-08-28): Nginx Proxy Manager**, not Traefik. Both remained viable per §4; NPM was
picked and is what `homeassistant/REVERSE_PROXY.md` and `INTERNET_ZUGRIFF_EINRICHTEN.md` are written
against. Setup itself (Cloudflare account, nameserver switch, add-on installation) is still the
owner's own action to perform — see the runbook for the exact steps.
4. **Cloudflare Tunnel domain** — ✅ **ERLEDIGT: `datametric360.de` ist bei all-inkl registriert**
(ursprünglich `.app`, seither auf `.de` umgestellt — Stand 2026-08-28). Eigens dafür angelegt, ohne
Webseite und ohne Postfach darauf — genau so, wie es die unten stehende Einschränkung verlangt.
Vorgesehene Adresse der App später: `https://datametric360.de`.
**Korrektur 2026-08-28:** die ursprüngliche `.app`-Wahl hatte einen zusätzlichen Vorteil, der mit dem
Wechsel auf `.de` entfällt — `.app` steht auf Chromes HSTS-Preload-Liste, `.de` nicht, der Browser
erzwingt dort also nicht von sich aus HTTPS ohne vorherigen Kontakt zur Domain. Das ändert nichts an
der Sicherheit dieses Aufbaus: Cloudflare Tunnel liefert ohnehin ausschließlich HTTPS aus, und Nginx
Proxy Manager kann zusätzlich "Force SSL" erzwingen (siehe `INTERNET_ZUGRIFF_EINRICHTEN.md` Schritt
4) - nur der zusätzliche, browserseitig *vorab* erzwungene Schutz vor dem allerersten Verbindungs-
aufbau (bevor die App überhaupt einmal erfolgreich verbunden war) entfällt. Für ein sideload-only
installiertes, nicht öffentlich beworbenes Gerät eine vernachlässigbare Einbuße.
**Noch offen, und der eigentliche Knackpunkt:** die Nameserver der Domain müssen bei all-inkl auf
Cloudflare umgestellt werden („Full setup") — erst dann kann der Tunnel einen Hostnamen darunter
@@ -326,10 +422,12 @@ else in this document:
setup"), gibt es nur im Business-Tarif für 200 $/Monat. Weil auf dieser Domain nichts liegt außer
diesem Vorhaben, ist die Umstellung folgenlos.
Noch zu entscheiden (Kleinigkeit, erst bei der Einrichtung): ob die App unter der Domain selbst
liegt und die abgesicherte Schnittstelle unter einer Unteradresse (etwa `api.datametric360.app`),
oder umgekehrt. Beides funktioniert; ein getrennter Hostname für die Schnittstelle macht die
Pfad-Freigabeliste im Reverse Proxy übersichtlicher.
**ERLEDIGT (2026-08-28): ein einziger Hostname genügt**, kein Split nötig. Der ursprüngliche
Gedanke (App-Domain vs. API-Subdomain) setzte voraus, dass die App selbst als Web-Build unter einer
eigenen Adresse ausgeliefert würde — das wurde nie gebaut (Distribution blieb nativ/sideload, §1).
Es gibt nur eine Adresse, die überhaupt gebraucht wird: `https://datametric360.de`, die die App
sowohl für API-Aufrufe als auch für ihr eigenes OTA-Bündel verwendet (siehe §5 Punkt 1 oben,
`homeassistant/REVERSE_PROXY.md`).
Die Einschränkung, die zur eigenen Domain geführt hat:
@@ -340,7 +438,7 @@ else in this document:
($200/month)**, i.e. not realistic here.
**Therefore: never point this at an existing all-inkl domain carrying live websites or email**
hence the dedicated `datametric360.app`. Nothing about the owner's existing domains/mail is touched.
hence the dedicated `datametric360.de`. Nothing about the owner's existing domains/mail is touched.
Note the all-inkl "Neue Domain anlegen" dialog asks for a target (Webspace / Redirect /
Webbaukasten) — that choice is irrelevant here, since DNS gets delegated to Cloudflare afterwards
+191
View File
@@ -0,0 +1,191 @@
# Design Audit — 2026-08-13 (Grossbildschirm-Layout)
Auslöser: zwei konkrete User-Reports ("Einstellungen-Knopf sitzt am Bildschirmrand, obwohl
die App-Spalte schon vorher endet" und "die Ringe als Einstellungen-Knopf sind zu klein").
Beide sind bestätigt, behoben und live im `audi_ha_test`-Container verifiziert (Desktop
1920px + Mobil 375px, Chrome-Konsole fehlerfrei bis auf die vorbestehenden, unbezogenen
Service-Worker-/Platzhalterbild-404-Meldungen). Im Zuge der Fehlersuche wurde ein dritter,
verwandter Fehler im selben Muster gefunden und mitbehoben.
Geprüft wurden Übersicht, Mein Audi, Fahrten, Statistik, Tanken und Einstellungen (inkl.
Setup-Popup) bei 375px und 1920px. Die drei unten dokumentierten Befunde sind die einzigen
gefundenen Abweichungen; der Rest folgt konsistent den bestehenden Tokens (`--r-tile`,
Farbrollen aus dem iOS-Overlay, `.aktion`/`.switch`-Semantik).
## Befund 1 — Zahnrad/Kopfzeile sass bei breiten Fenstern am Fensterrand statt am Inhaltsrand
**Root Cause:** `.topbar` (enthält den Einstellungen-Knopf `.profilbtn`) liegt im
Grossbildschirm-Raster (`@container (min-width:860px)` in `audi-dashboard-ios.css`) in
Spalte 2 (`grid-column:2`), derselben Spalte wie `main#view`. `main#view` hat dort ein
`max-width:860px`, `.topbar` hatte keins - die Spalte selbst ist aber `minmax(0,1fr)`, füllt
also die volle restliche Fensterbreite. `.head{flex:1}` in der Kopfzeile hat den Rest der
Zeile aufgefüllt und `.profilbtn` bis an den rechten Rand der vollen Spalte geschoben, weit
hinter das Ende des sichtbaren Inhalts.
**Beleg (1920px Fenster):** `main#view` endete bei `x=1380`, `.topbar` ging bis `x=1920`,
`.profilbtn` sass bei `x=1832-1876` - eine Lücke von über 450px zwischen Inhaltsende und
Knopf.
**Fix:** `.topbar`, `.phone:has(.back.on) .topbar` und `.ptr` bekommen im
860px-Container-Query dasselbe `max-width:860px` wie `main#view`. Nach dem Fix enden
`.topbar` und `main#view` an derselben Kante (`x=1380`), der Knopf sitzt bei `x=1292-1336`,
sichtbar über dem Inhalt.
**Datei:** `homeassistant/www/audi-dashboard-ios.css`
## Befund 2 — Audi-Ringe als einziger Einstellungen-Zugang in Telefonbreite zu klein
**Root Cause:** In Telefonbreite blendet die iOS-Auflage das Zahnrad-Icon (`.profilbtn`) aus
- die Audi-Ringe (`#marke`/`.rings`) sind dort laut eigenem Code-Kommentar "der einzige
Zugang zu den Einstellungen" und seit einer früheren Session bewusst ein echter,
tastaturbedienbarer Knopf. Die Knopfgrösse war mit 42×24px aber deutlich kleiner als jeder
andere Symbol-Knopf in derselben Kopfzeile (`.themebtn`/`.profilbtn`/`.back` sind alle
44×44px) und lag unter der üblichen 44px-Mindestgrösse für Tippflächen.
**Fix:** `.rings` von 42×24px auf 58×32px vergrössert (Seitenverhältnis beibehalten,
`.rings svg{width:58px}`). Funktion (Klick navigiert zu Einstellungen) unverändert getestet.
**Datei:** `homeassistant/www/audi-dashboard.css`
## Befund 3 — Popups/Sheets auf breiten Bildschirmen fast bildschirmbreit (gleiches Muster wie Befund 1)
Beim Nachprüfen von Befund 1 fiel derselbe Fehler bei vier weiteren Elementen auf: sie sind
`position:absolute` mit `left`/`right` relativ zu `.phone` (der vollen Rasterbreite,
Seitenspalte inklusive) statt zur schmaleren Inhaltsspalte, ohne eigenes `max-width` für
grosse Bildschirme:
- `.setup-popup` (Sensor-Zuordnung) - **bestätigt live:** 1644px breit bei 1920px Fenster
(nur 10px Rand auf jeder Seite), praktisch bildschirmfüllend statt eines ruhigen Dialogs.
- `.sdpopup` (SmartDeal-Aktivierung)
- `.sheet` (Action-Sheet, ersetzt confirm()/alert() beim Löschen)
- `.standortmenu` (Standort-Bottom-Sheet auf der Kartenansicht)
**Fix:** Im selben 860px-Container-Query `max-width:560px` (Popups/Sheet) bzw. `max-width:
640px` (Standortmenü, mehr Inhalt) plus `margin-left/right:auto` ergänzt - zentriert die
Elemente innerhalb ihres bestehenden `left`/`right`-Abstands, ohne die
Transform-basierte Einblend-Animation dieser Elemente anzufassen (eine
`transform:translateX(-50%)`-Zentrierung hätte mit der `sheet-rein`-Keyframe-Animation
kollidiert, die am Ende selbst `transform:none` setzt). `border-radius` bleibt jeweils
unverändert (`.standortmenu` behält oben abgerundete/unten eckige Kanten, passend zur
weiterhin am unteren Fensterrand andockenden Bauweise).
**Beleg nach Fix (Setup-Popup, 1920px Fenster):** 560px breit, zentriert bei `x=808-1368`.
**Datei:** `homeassistant/www/audi-dashboard-ios.css`
## Nicht verändert (bewusst ausserhalb dieses Audits)
- `.bildmenu` (Kontextmenü bei Fahrzeugbildern) ist bereits `min-width:160px` und wächst
nicht mit der Fensterbreite - kein Fehler.
- Zentrierung der Popups erfolgt relativ zur vollen `.phone`-Breite (Seitenspalte
inklusive), nicht exakt zur Mitte der 860px-Inhaltsspalte - bei 1920px ein Versatz von
ca. 138px nach rechts gegenüber echter Inhaltsmitte. Sichtbar besser als der vorige
Zustand (praktisch volle Fensterbreite), aber für pixelgenaue Zentrierung müssten die
Popups im Markup in die Grid-Spalte 2 verschoben werden statt als Geschwister von
`main#view` direkt in `.phone` zu liegen - eine grössere strukturelle Änderung, hier
bewusst zurückgestellt.
## Runde 2 — sechs weitere User-Findings
Vier bestätigt, ursächlich geklärt und behoben; zwei trotz gezielter Suche (Computed-Style-
Vergleich, 2.5x-Zoom per temporärem `transform:scale()` auf das Live-DOM, Tag- und
Nacht-Theme) nicht reproduzierbar - siehe unten.
### Befund 4 — Kopfzeile (Übersicht/Fahrten/...) auf breiten Bildschirmen 30px zu weit rechts
**Root Cause:** `.back` (Zurück-Pfeil) ist auch inaktiv (`opacity:0`, kein `.on`) weiterhin
ein 44px breites Flex-Element in `.topbar` plus `gap`. Auf Wurzelseiten ohne Zurück-Pfeil
(Übersicht, Fahrten, ...) schiebt dieser unsichtbare Platzhalter `.head`/`.title` sichtbar
weiter nach rechts als `main#view` darunter beginnt.
**Beleg:** `.title` links bei `x=594`, die erste Kachel in `main#view` links bei `x=564` -
30px Versatz.
**Fix:** `.back:not(.on){width:0;min-width:0;margin:0;overflow:hidden}` im
860px-Container-Query. Nur für Wurzelseiten relevant; auf Detailseiten (`.back.on`, z. B.
Einzelfahrt) bleibt der Knopf unverändert 44px breit und funktionsfähig - live getestet
(Klick navigiert zurück zur Liste).
**Beleg nach Fix:** Versatz auf 4px reduziert (der verbleibende `gap` zum nächsten
Flex-Element, nicht mehr wahrnehmbar).
**Datei:** `homeassistant/www/audi-dashboard-ios.css`
### Befund 5 — Graue Pfeile auf "Mein Audi" (Fahrzeug/Service/Versicherung/Reifen) überlappen Text
**Root Cause:** `.tilebtn .go{top:50%;margin-top:-5px}` zentriert den Kachel-Chevron
vertikal auf die **gesamte** Kachelhöhe. Diese fünf Kacheln sind aber keine Einzeiler,
sondern `<span class="label">Titel</span>` + Chevron + eine ganze `dl.rows`-Liste darunter -
die 50%-Mitte der gesamten Kachel liegt damit mitten in der Zeilenliste statt neben dem
Titel, und überlappt besonders bei mehrzeilig umgebrochenen Werten (z. B. "2.9 TFSI quattro
· 331 kW · 450 PS") sichtbar den Text.
**Fix:** `top:50%;margin-top:-5px` aus der iOS-Auflage entfernt - fällt zurück auf
`top:var(--sp-5)` aus der Basis, die den Chevron wieder neben den Titel oben in der Kachel
setzt (dafür existiert dort bereits extra Padding: `.tile:has(> .go) > .label:first-child
{padding-right:44px}`).
**Betroffen:** alle 5 `.go`-Kacheln (Fahrzeug, Service, Versicherung/Steuer, Reifen,
Schutzbrief Mobilität) - identisches Markup-Muster überall.
**Datei:** `homeassistant/www/audi-dashboard-ios.css`
### Befund 6 — "10.000 km" / "1 Jahr" (Ölwechsel-Intervall) sehen nicht editierbar aus
**Root Cause:** Beide Werte sind echte `<select>`-Dropdowns, aber die iOS-Auflage entfernt
Rahmen und Fläche komplett (`background:transparent;border:0`) und färbt den Wert in
`--fg2` - demselben gedämpften Grau wie jeder andere Fliesstext. Kombiniert mit
`appearance:none` (entfernt zusätzlich den nativen Auswahlpfeil des Browsers, base-CSS) gibt
es keinerlei visuellen Hinweis, dass hier eine Auswahl möglich ist.
**Fix:** `.feld select{color:var(--ios-tint)}` ergänzt - der Wert erscheint jetzt in der
Marken-Akzentfarbe (Rot), dem iOS-üblichen Signal "hier gibt es eine Auswahl" bei
Picker-artigen Feldern (anders als bei freien Texteingaben, die bewusst unverändert bleiben).
**Datei:** `homeassistant/www/audi-dashboard-ios.css`
### Befund 7 — Auf/Zu-Pfeile bei Gruppenkopfzeilen (z. B. "2026", "August" in Fahrten) sehen wie Häkchen aus
**Root Cause:** `.mark` baut den Chevron aus der klassischen "zwei Kanten + 45°-Drehung"-
CSS-Technik (`border-right`+`border-bottom`, `transform:rotate(-45deg/45deg)`). Diese
Technik braucht zwingend ein **Quadrat** für eine saubere Pfeilform - `.mark` war aber
6×10px (für die SVG-Chevrons `.chev`/`.go` gedacht, die eine eigene, absichtlich
nicht-quadratische Pfad-Form nutzen). Die ungleich langen Kantenabschnitte (10px vs. 6px)
ergeben verdreht eine Form, die eher wie ein Häkchen als ein Pfeil aussieht - besonders im
per Default aufgeklappten Zustand (`rotate(45deg)`), in dem "2026"/"August" standardmässig
starten.
**Fix:** `.mark` auf 8×8px quadratisch gesetzt.
**Datei:** `homeassistant/www/audi-dashboard.css`
### Nicht reproduziert — Befund A: "Alle Menü-Icons haben eine graue Box"
Programmatisch geprüft (`.tab`, `.tabpille`, `.tab svg`: `background`, `border`,
`box-shadow`, `outline` für alle 5 Tabs sowohl im Seitenmenü (Desktop) als auch in der
Tableiste unten (375px)): nur der **aktive** Tab hat eine (sehr dezente, 7% Deckkraft)
graue Füllung - `.tab.on .tabpille{background:var(--tile-2)}`/`.tab.on{background:
var(--ios-fill)}`, exakt wie beabsichtigt. Die vier inaktiven Tabs sind computed
`rgba(0,0,0,0)` (vollständig transparent) auf allen geprüften Ebenen. Falls auf dem
tatsächlichen Gerät etwas anderes zu sehen ist (z. B. alle 5 Icons mit sichtbarer Box),
bräuchte es einen Screenshot vom echten Gerät/Browser, um die Abweichung von diesem
Testcontainer einzugrenzen.
### Nicht reproduziert — Befund B: "Roter Teilrahmen rechts an jeder Zeile" (Fahrten/Tanken)
Geprüft: `.swipe-content{background:var(--tile-deckend)}` (deckend, #171B21 Nacht /
#FFFFFF Tag) deckt den dahinterliegenden roten Löschen-Button (`.swipe-delete`,
`rgb(255,69,58)`) vollständig ab - beide Boxen exakt deckungsgleich (`x`/`width`/`right`
identisch bis auf Subpixel), kein CSS-seitiger Spalt gefunden. Bei 2.5x-Zoom (temporärer
`transform:scale()` auf das Live-DOM, nicht deploybar, nur zur Inspektion) war am rechten
Rand in Tag- und Nacht-Theme kein Rot sichtbar. Möglich, dass dies nur unter bestimmten
Bedingungen auftritt (Swipe-Geste mitten in der Transition, ein bestimmter Zoom-/DPI-Stand
auf dem echten Gerät) - ein Screenshot oder eine genauere Beschreibung, wann genau es
auftritt (beim Laden, nach einer Wischgeste, permanent?), würde helfen.
## Deployment
Alle sieben Fixes deployed und verifiziert im `audi_ha_test`-Docker-Container
(`audi-dashboard-version.json``1786744000`), nach `homeassistant/installationspaket/`
synchronisiert. Konsole fehlerfrei bis auf die vorbestehenden, unbezogenen
Service-Worker-/Platzhalterbild-404-Meldungen.
+10
View File
@@ -11,6 +11,16 @@ system attached. Then iterate screen by screen. See the step-by-step in the chat
Entwirf eine App namens "DataMetric360" — eine private Fahrzeug-App für ein einzelnes Auto.
Nutze ausschließlich die Komponenten aus dem angehängten Design-System (Audi Dashboard UI).
WICHTIG — Marken-Assets:
Anders als das angehängte Design-System (das bewusst neutral gehalten ist) darf diese App echte
Audi-Marken-Assets verwenden: die vier Ringe, die Audi-Type-Schrift, die Modell-Typenschilder
(RS 4 Avant competition). Kein generischer Ersatz nötig.
WICHTIG — kein Fernsteuerungs-Charakter:
Diese App ist eine reine Anzeige- und Erfassungs-App. Keine Bedienelemente zum Verriegeln,
Entriegeln, Starten oder sonstigen Fernsteuern des Fahrzeugs entwerfen — auch nicht als Mockup
oder Platzhalter, der das suggeriert.
WICHTIG — Grundlagen:
- Dunkles Theme ("Nacht") ist Standard, helles Theme ("Tag") existiert als Umschaltung.
- Keine Schatten, keine Verläufe. Flache Kacheln, Pill-Buttons, Haarlinien als Trenner.
+258
View File
@@ -0,0 +1,258 @@
# Design-Review des `main`-Branch — Abgleich gegen die Audi-CI-Vorgaben des Repos
**Datum:** 2026-08-13 · **Gegenstand:** `origin/main` @ `2a3c4f2` („Zugangswege zum privaten
Repository dokumentieren und trennen") · **Nur Befunde, keine Änderungen.**
> Hinweis vorab: Das **lokale** `main` hängt 28 Commits hinter `origin/main`. Dieses Review
> basiert auf `origin/main` — der Stand mit FMM003-Umstellung, Setup-Menü, Standort-Feature und
> der scharf geschalteten iOS-/Großbildschirm-Auflage.
## Methode
1. **Vorgaben-Checkliste** aus den Repo-Dokumenten destilliert (`bauauftrag.md`,
`SPECIFICATION.md`, `AUDIT_2026-08-10.md`, `DESIGN_BRIEF_DATAMETRIC360.md`, `AGENTS.md`,
`design-system/`-Tokens) — Farben, Typografie, Layout, Marke, Formate, Interaktion, A11y.
2. **Code-Review** aller UI-Schichten auf `main` gegen diese Checkliste:
`homeassistant/www/` (Panel-JS + beide CSS), `design/`-Export, `design-system/`,
`companion-app/`.
3. **Browser-Test mit Screenshots:** Das Panel wurde in einem Harness mit Mock-`hass`-Objekt
und realistischen Beispieldaten (Beispielprofil, 5 Fahrten, 3 Tankvorgänge, Fahrzeugstatus,
Setup-Katalog) headless in Chromium gerendert — iPhone-Viewport (390×844, hell + dunkel) und
Desktop (1440×900, Großbildlayout ≥860 px), alle 5 Tabs plus 10 Unterseiten, Einstellungen,
Einrichten-Formular und Setup-Popup. ~40 Screenshots, Pfade im Annex.
**Grenzen:** Kein echtes Home Assistant, kein echtes iOS-Gerät. Native Steuerelemente
(`<input type="date">`) und Leaflet-Verhalten können im Harness abweichen — solche Befunde sind
unten ausdrücklich als „am Gerät verifizieren" markiert.
---
## Kernaussagen (TL;DR)
1. **Die iOS-Auflage (`audi-dashboard-ios.css`) ist der größte offene CI-Konflikt.** Sie ersetzt
die dokumentierte Audi-Palette durch iOS-Systemfarben, führt Schatten und Blur ein, ändert den
Kachelradius auf 16 px und macht Rot zur Flächenfüllung — alles Verstöße gegen in mehreren
Dokumenten wiederholte, bindende Regeln, und **nirgends als Entscheidung dokumentiert**
(`design/README.md` sagt selbst, das sei „noch zu klären", faktisch ist es seit 2026-08-11
produktiv).
2. **Die Rot-Semantik ist dadurch invertiert.** `ios.css` füllt jeden `.aktion`-Knopf rot. Sichtbare
Folge (Screenshots): im Setup-Popup ist **„Abbrechen" rot gefüllt und „Speichern" schwarz**;
„Einrichten", „Setup — Sensoren zuordnen", „In den Kalender übernehmen" und die
Notruf-/Telefonnummern sind großflächig rote Balken. Der Audit-Befund A7 („Rot bleibt Akzent
und Destruktiv") ist damit rückgebaut.
3. **`design-system/` (Basis für DataMetric360) trägt den Vor-Audit-Stand:** kontrastschwaches
`--fg3 #657081` (WCAG-AA-Fail, im Panel längst korrigiert), Versalien-Sperrschrift in 6+
Komponenten, **kein einziger Fokus-Stil in der ganzen Bibliothek**, Formularfelder 13,5 px
(iOS-Zoom-Falle), IconButton 34 px. Wer die App darauf aufbaut, erbt behobene Fehler zurück.
4. **Vier echte Darstellungs-/Zustandsfehler im Panel, im Browser nachgewiesen** (unten F1F4),
darunter gestreckte Schalter mit widersprüchlicher Zustandsanzeige (Ursache identifiziert:
`.feld label { flex: 1 1 auto }` trifft auch `label.switch`).
5. **Positiv:** Schriftgewichts-Regel (nur 300/400) wird zu 100 % gehalten, de-DE-Formate sind
praktisch lückenlos, `design-system/` ist nachweislich **marken-frei** (Lizenzregel bestanden),
die Marke steht auf jedem Layout genau einmal, und die neuen main-Features sind bei
aria-Attributen überdurchschnittlich sorgfältig.
---
## A. Grundsatzkonflikt: iOS-Auflage vs. Audi-CI (hoch)
Verbindlich laut `bauauftrag.md` §Design, `SPECIFICATION.md` §Design und Audit: Palette als
Rollen (Nacht `#161b23`/`#1f2733`, Tag `#FFFFFF`/`#f2f2f2`), Signalfarben `#15da15/#ffaa00/#fd2c4e`,
Kachelradius 20 px, **keine Schatten, keine Verläufe** (einzige Ausnahme Fahrbahn/`.szene`),
Rot `#F50537` nur als Akzent. Die seit 2026-08-11 aktive `audi-dashboard-ios.css` bricht das
systematisch:
| Regel | ios.css-Ist | Fundstelle |
|---|---|---|
| Nacht-Canvas `#161b23` | `#0C1014` | `audi-dashboard-ios.css:21-41` |
| Tag: Canvas weiß, Kachel `#f2f2f2` | **invertiert**: Canvas `#F2F2F7`, Kachel `#FFFFFF` | ebd. |
| Audi-Signalfarben | iOS-Systemfarben `#30D158/#FFD60A/#FF453A` bzw. `#34C759/#FF9F0A/#FF3B30` | ebd. |
| Kachelradius 20 px (`--r-tile`) | `--r-tile: 16px` | `audi-dashboard-ios.css:16` |
| Keine Schatten/Blur | `box-shadow` auf Segmented Control, Switch-Knopf, Popups; `backdrop-filter: blur()` auf Tabbar/Popups | `:116, :135, :156-166` |
| Rot nie als Fläche | `.aktion{background:var(--ios-tint)}`, roter Schalter, `.tab.on` rot hinterlegt | `:118-136, :221` |
| Safe-Area-Insets (Audit B1) | ersetzt durch Festwerte `padding:56px` / `26px` | `:65, :163` |
| Trefferfläche ≥44 px (Audit B6) | `.back` auf **34×34 px** gedrückt | `:69` |
Dazu kommt: `.setup-popup` und `.standortmenu` schreiben `border-radius: 20px` als **Literal**
fest (`audi-dashboard.css:895, :744`) — mit dem ios-Token 16 px sind Popups sichtbar runder als
alle Kacheln daneben.
**Empfehlungscharakter (keine Umsetzung hier):** Entweder die iOS-Optik als neue verbindliche
Linie dokumentieren (und `bauauftrag.md`-/`SPECIFICATION.md`-Regeln als ÜBERHOLT markieren, wie
es die Projektkonvention für Entscheidungen vorsieht) — oder die Auflage auf das zurückschneiden,
was mit der CI vereinbar ist. Der jetzige Zustand ist ein unbeschlossener Bruch.
## B. Rot-Semantik invertiert (hoch, visuell belegt)
- **Setup-Popup: „Abbrechen" rot gefüllt, „Speichern" schwarz** (Screenshot
`iphone-hell-15e-setup-popup.png`). Nach der eigenen Systematik (`.aktion` =
Umriss-Sekundäraktion, `.primaer` = gefüllt, Rot = destruktiv) liegt die Signalfarbe auf der
falschen Aktion.
- Gleiche Ursache (`ios.css .aktion`-Füllung) auf: „Einrichten" (Einstellungen), „Setup —
Sensoren zuordnen" + schwarzes „Fertig" daneben (Einrichten-Formular), „In den Kalender
übernehmen" (Service, Reifen), beide Notrufnummern (Versicherung — drei rote Großflächen
untereinander auf einer Seite, `iphone-hell-07-versicherung.png`).
- Ebenfalls rot gefüllt: alle Schalter im Ein-Zustand (A7-Rückbau), aktive Navigation
(`.tab.on` rosa hinterlegt, Desktop-Sidebar).
- Kleinere Fälle: `--red` als Balkensegment für die neutrale Kategorie „Arbeitsweg"
(`audi-dashboard-app.js:2423`) und im Versicherungs-Beitragsbalken (`:1628`).
## C. `design-system/` — Vor-Audit-Stand als DataMetric360-Basis (hoch)
| Befund | Fundstelle | Schwere |
|---|---|---|
| `--fg3: #657081` — der im Audit als kontrastschwach identifizierte Wert (3,0:1 auf `--tile`), im Panel seit 2026-08-10 auf `#8a94a3` korrigiert; Bibliothek **und** `design/_ds`-Bundle tragen ihn weiter | `design-system/src/tokens/tokens.css:33`, `design/_ds/.../_ds_bundle.css:20` | hoch |
| **Kein einziger Fokus-Stil** in Tokens + allen 20 Komponenten (Audit fordert `:focus-visible` 2 px `#F50537`; das Panel hat ihn) | gesamtes Paket, grep-verifiziert | hoch |
| Formularfelder **13,5 px** → iOS-Auto-Zoom (Audit-Regel ≥16 px); propagiert bis in den `design/`-Entwurf (Bundle-Regel gewinnt gegen `.dm-in`) | `Feld/Feld.css:24-33`; `DM360.dc.html:24/41/44/422-428` | hoch |
| Versalien-Sperrschrift in 6 Komponenten über die bekannten `.ads-label`/`.ads-eyebrow` hinaus; StatGrid/TabBar mit nur **9 px** | `ActionButton.css:7`, `Pill.css:6`, `Seg.css:14`, `SwipeRow.css:16`, `StatGrid.css:15`, `TabBar.css:39` | mittel |
| TabBar: aktiver Tab = **roter Oberkantenstrich** statt gefüllter Pille — exakt das Muster, das der Audit (B5) im Panel abgeschafft hat | `TabBar.css:22-24` | mittel |
| Trefferflächen: IconButton 34×34, Switch 46×27, Popup-Items ≈34 px, Seg ≈35 px | jeweilige Komponenten-CSS | mittel |
| Literalfarben (`#fff` auf Switch-Knopf und SwipeRow) | `Switch.css:35`, `SwipeRow.css:11` | niedrig |
| Englischer Default-UI-Text `deleteLabel = "Delete"` | `SwipeRow.tsx:27` | niedrig |
| **Positiv: Markenreinheit bestanden** — keine Fonts/Ringe/Typenschilder im Paket (Lizenzregel eingehalten); keine Gewichte ≥500, keine Schatten, Radius über Token | — | ✓ |
## D. `design/`-Export (mittel)
- **Der RS-6-Fehler reicht bis in die Fachdaten**, nicht nur Badge/Name: „V8 biturbo · 600 PS",
441 kW, 3.996 cm³, 2.150 kg, Tank **73 l** (RS 4: 58 l), Reifen **285/30 R22**, FIN-Baureihe
„4G" statt „8W" (`DM360.dc.html:116-628` diverse). Die korrekten RS-4-Schilder liegen bereits
unter `design/uploads/RS-S Badges/` — nur `assets/` enthält die RS-6-Kopien.
- **`design/datametric360-ios.css` und `homeassistant/www/audi-dashboard-ios.css` sind divergente
Geschwister**, keine Kopien: der `.ads`-Variante fehlen Fokusring, gefüllte Tab-Pille (sie färbt
stattdessen das Icon rot) und das komplette ≥860-px-Layout; die Panel-Variante hat dafür die
Schatten. Zwei Wahrheiten für dieselbe Optik → Drift vorprogrammiert.
- **Einstellungen-Einstieg widersprüchlich:** Entwurf + `DESIGN_BRIEF` sagen Zahnrad oben rechts,
Panel/`SPECIFICATION` sagen Audi-Ringe (Zahnrad erst ≥860 px). Vor der App-Umsetzung entscheiden.
- IconButton-Hints 36 px (`DM360.dc.html:78, 90`); variable Audi Type mit `font-weight: 100 900`
registriert (lädt zur Verletzung der 300/400-Regel ein, genutzt werden nur 300/400);
Literalfarben/Schatten im Präsentationsrahmen (nur Board, nicht App-UI).
- `companion-app/` auf `main` ist nur die Datenschicht; der dort noch offene Feldnamen-Defekt
(`tank_prozent`/`sicher_abgestellt`/`sicherheit` vs. Backend) ist auf dem Arbeitsbranch behoben
und löst sich beim Merge — bis dahin auf `main` latent.
## E. Panel-Frontend: weitere Verstöße gegen die Vorgaben (Auswahl)
**Farben nur als Tokens:**
- **iOS-Systemblau `#0A84FF`** als palettenfremde Farbe für den Nutzer-Pin
(`audi-dashboard.css:702, :707`) — Blau existiert in der Palette nicht. (hoch)
- Kartensteuerung komplett aus Literalen (`rgba(16,20,26,.84)`, `#fff`, `#101418`;
`audi-dashboard.css:722-734`), Leaflet-Startmarker `#fff`/`#000`
(`audi-dashboard-app.js:459`), Scrims 2× `rgba(0,0,0,.45)` ohne Token. (mittel/niedrig)
**Typografie:**
- **Zweite und dritte Versalien-Stelle** neben `.eyebrow`: `.setup-gruppe-titel` (uppercase,
.1em — im Setup-Popup sichtbar) und `.marke-logo .ph` (`audi-dashboard.css:913, :481`). (mittel)
- Die selbst gesetzte Regel „`--fg3` nie unter 12 px" wird an ≥7 Stellen gebrochen
(`.sync` 10,5 px, `.quad .l` 11 px, `row dd small` 11,5 px, mehrere Inline-11-px;
Kontrast 4,6:1 ist unter der WCAG-Großtext-Schwelle für so kleine Schrift). (mittel)
**Layout/A11y:**
- Schatten/Blur auch im **Haupt-CSS** (nicht nur ios.css): Pins, Kartensteuerung
(+`backdrop-filter`), Standortmenü, Setup-Entitätenliste (`audi-dashboard.css:697-745, :950`). (mittel)
- Trefferflächen <44 px bei neuen Elementen: Standortmenü-Schließen 32 px,
Setup-Reset 26 px, Fahrtart-Pille ≈31 px, `.rings` im Basis-CSS nur 42×24 px (erst ios.css
hebt an — ohne geladene Auflage ist der einzige Einstellungszugang zu klein). (mittel)
- Bei ausgeschalteter Tab-Beschriftung sind **alle fünf Tabs namenlos** (Text per
`display:none`, SVG `aria-hidden`, kein `aria-label`; `audi-dashboard-app.js:3072-3074`,
`audi-dashboard.css:266`). (mittel)
- Escape schließt Sheet und Setup, aber nicht Bildmenü/SmartDeal-Popup/Standortmenü. (niedrig)
- Koordinaten als einzige nicht-de-DE-Zahl im UI (`toFixed(4)` mit Punkt,
`audi-dashboard-app.js:786-789`). (niedrig)
**Auf `main` bereits behoben** (bekannte Befunde, die nicht mehr gelten): die Ringe sind
inzwischen ein echter `<button aria-label="Einstellungen">`, und das Setup-Popup **hat** Fokusfalle
+ Escape (`audi-dashboard-app.js:3054-3070`) — die Einträge in `REVIEW_main_2026-08-13.md` §7.10/6.13
sind insoweit überholt (Rest: Trefferfläche).
## F. Im Browser nachgewiesene Darstellungs-/Zustandsfehler
**F1 — Schalter werden gestreckt und zeigen widersprüchlichen Zustand (Ursache identifiziert).**
`audi-dashboard.css:493` `.feld label { flex: 1 1 auto }` trifft auch `label.switch` — der Schalter
in einer `.feld`-Zeile wächst mit dem freien Platz (gemessen: **277319 px statt 51 px** im
Desktop-Einrichten-Formular; auch am iPhone auf der Reifen-Seite sichtbar verbreitert). Da der
Knopf fix um `translateX(20px)` wandert, steht er auf der gestreckten Bahn optisch **links
(= „Aus"), obwohl die Bahn rot (= „Ein") ist**. Screenshots `desktop-hell-14-einstellungen.png`,
`iphone-hell-08-reifen.png`. (hoch — Zustandsanzeige mehrdeutig)
**F2 — Offene Fahrten zeigen zweimal „offen" untereinander.** In der Fahrtenliste rendert der
Wert-Slot `"offen"` und das `<small>` darunter `t.status` = „offen"
(`audi-dashboard-app.js:2275`). Screenshot `iphone-hell-09-trips.png`. (niedrig)
**F3 — Ölwechsel-Kachel der Übersicht bleibt komplett leer** bei leerem Servicebuch: `termine()`
baut nur auf Servicebuch-Einträgen auf (`audi-dashboard-app.js:1522-1534`); ohne Eintrag sind
`km`/`datum`/`kmDatum` null und die Kachel zeigt **nur das Label ohne jeden Wert** — kein
Leerzustand nach der eigenen D5-Regel („Ursache und nächste Handlung nennen"), und die vom
Fahrzeug gemeldeten Fälligkeiten (`oelwechsel_faellig_ts/km` waren im Test gesetzt) werden auf
der Übersicht nicht herangezogen. Auch die Service-Seite zeigt daneben nur „kein Eintrag im
Servicebuch". Screenshots `iphone-hell-01-home.png`, `iphone-hell-06-service.png`. (mittel)
**F4 — Alle drei Leaflet-Karten rendern im Harness nur fragmentarisch** (Übersichts-Kachel,
Standort-Vollbild, Einzelfahrt/Beleg): Kacheln erscheinen nur in Teilbereichen, der Rest bleibt
canvas-farbig — unverändert auch nach 4 s Wartezeit, also kein Ladeproblem, sondern
Initialisierungs-/`invalidateSize`-Timing (Karte wird aufgebaut, bevor der Container seine
endgültige Größe hat). **Am echten HA/Gerät verifizieren** — falls dort reproduzierbar, ist es
der sichtbarste Fehler der neuen Standort-Features. Screenshots `iphone-hell-02b-standort-lang.png`,
`desktop-hell-01-home.png`, `iphone-hell-10-trip-detail.png`. (verifizieren)
**Weitere Beobachtungen (verifizieren, evtl. Harness-Artefakte):**
- Native Datumsfelder zeigten `mm/dd/yyyy` (Werkstatttermin, Reifenwechsel) — `<input type=date>`
formatiert nach System-Locale; am deutschen iPhone vermutlich korrekt, auf Desktop-Browsern mit
englischer UI nicht. Kein Codefehler, aber ein bekannter Plattform-Seiteneffekt.
- FIN-Wert im Einrichten-Formular rechts abgeschnitten ohne Ellipse
(`iphone-hell-15-setup.png`).
- Im Desktop-Layout wirkte der Fahrzeugbild-Platzhalter der Übersicht gestaucht
(Beschriftung ragt an die Kachelkante, `desktop-hell-01-home.png`).
## G. Was konsequent eingehalten wird (positiv)
- **Schriftgewichte:** kein einziges `font-weight` ≥500 in ~5.300 Zeilen Panel-Code; alle
`<strong>`-Kontexte bewusst auf 400 neutralisiert; `tabular-nums` global. Auch design-system
und Entwurf nutzen nur 300/400.
- **de-DE-Disziplin:** zentrales `de()/eur()/dedat()`-Trio, jeder Locale-Aufruf explizit
`"de-DE"`, ISO nur wo die Plattform es verlangt; im Entwurf durchgehend deutsche Texte und
Formate. Einzige Grauzone: Koordinaten (E, niedrig).
- **Marke:** Typenschild exakt 18 px mit CSS-Theme-Wechsel (positive/negative-SVG); Marke steht
in jedem Layout genau einmal (Ringe mobil, `.navmarke` in der Desktop-Sidebar + Zahnrad);
`design-system/` nachweislich asset-frei — die harte Lizenzregel wird eingehalten.
- **A11y der neuen Features:** Kartensteuerung mit präzisen `aria-label` + `aria-pressed`,
Standortmenü-Griff mit `aria-expanded/-controls` und 88×44-px-Trefferfläche, Setup-Dialog mit
`role="dialog"`, Fokusfalle und Escape; 16-px-Formularregel im Panel konsequent (inkl. neuem
Combo-Input).
- Ehrliche Leerzustände („kein Eintrag im Servicebuch", „Fahrt ohne Ortsangabe", Startort
„unbekannt") statt erfundener Werte — mit Ausnahme F3.
## H. Widersprüche in den Vorgaben selbst (fürs Aufräumen der Dokumente)
1. `--fg3` hat **drei Werte im Repo**: `#657081` (bauauftrag, design-system, \_ds-Bundle) vs.
`#8a94a3` (SPECIFICATION, Panel) — der Audit hat nur SPEC nachgezogen.
2. Kontrastangabe inkonsistent: CSS-Kommentar sagt „4,6:1", der Audit rechnet 5,6:1/4,9:1.
3. `SPECIFICATION.md` ist bei Platzhaltern (nennt noch „exact expected filename", vom Audit
abgeschafft) und bei `confirm()`/Action-Sheet (B4) veraltet; §7.8 behauptet, `--sp-*`/`--r-func`
existierten nicht — sie existieren, werden aber (das stimmt) teils nicht genutzt.
4. `DESIGN_BRIEF` (Zahnrad, Live-Telemetrie) widerspricht `bauauftrag.md`/`SPECIFICATION.md`
(Ringe, „keine Live-Telemetrie"); Auflösung über FMM003 ist nirgends explizit festgehalten.
5. Bildmaße: `bauauftrag.md` nennt 206/165/78 px, der Code nutzt aspect-ratio + 96 px — Absicht
erfüllt, Zahlen nie als überschrieben dokumentiert.
## Zählung (konsolidiert, ohne Doppelzählung der Sammelthemen)
| Bereich | hoch | mittel | niedrig | verifizieren |
|---|---|---|---|---|
| A/B iOS-Auflage + Rot-Semantik | 2 Themenkomplexe (≥10 Einzelstellen) | — | — | — |
| C design-system | 3 | 3 | 2 | — |
| D design/-Export | — | 4 | 3 | — |
| E Panel-Einzelbefunde | 1 | 8 | 6 | — |
| F Browser-Nachweise | 1 | 1 | 1 | 4 |
## Annex: Screenshots
Liegen (sitzungsgebunden, nicht im Repo) unter
`/private/tmp/claude-501/-Users-paul-Development-audi-app/a6c2c477-dbf4-43ec-9a25-88d762b653bf/scratchpad/harness/shots/`.
Benennung: `iphone-hell-*` (390×844 hell), `iphone-dunkel-*`, `desktop-hell-*` (1440×900).
Harness: `…/scratchpad/harness/` (`index.html` = Mock-`hass`, `shots*.mjs` = Playwright-Läufe,
`profil.json` = Beispielprofil) — reproduzierbar gegen jeden Stand von `homeassistant/www/`.
Aussagekräftigste Belege: `15e-setup-popup` (Abbrechen rot/Speichern schwarz),
`14-einstellungen` + `desktop-14` (rote Flächen, gestreckte Schalter), `07-versicherung`
(3 rote Großflächen), `08-reifen` (Schalter-Zustand mehrdeutig), `09-trips` (doppeltes „offen"),
`01-home` (leere Ölwechsel-Kachel, Kartenfragment), `02b-standort-lang` (Kartenfragment nach 4 s).
+149
View File
@@ -0,0 +1,149 @@
# Was jetzt zu tun ist (DM360)
**Die eine Datei, die den aktuellen Stand nennt.** Wer wissen will, was ansteht,
liest hier - nicht in 12.000 Zeilen `AGENTS.md`.
Die drei Dateien teilen sich die Arbeit so:
| Datei | beantwortet |
|---|---|
| **`OFFEN.md`** (hier) | **Was ist zu tun?** |
| `APPLE_DEV_BACKLOG.md` | Was davon braucht Xcode oder das Apple-Portal? |
| `AGENTS.md` | **Warum** ist etwas so, wie es ist - die Geschichte, nicht der Zustand |
**Arbeitsregel (verbindlich):** diese Datei wird in **derselben Sitzung**
nachgeführt, in der sich etwas ändert - erledigt, dazugekommen, entschieden.
Eine Liste, die man erst am Monatsende pflegt, ist keine.
Aufruf: **`/stand`** prüft den mechanischen Teil selbst nach und liest diese
Datei dazu.
---
## 1. Wartet auf Sie
### 1.1 Ein Xcode-Lauf - **zwei Funktionen sind auf dem Telefon tot**
Zwischenablage und Kalendereintrag sind in der installierten App wirkungslos.
Beide Korrekturen liegen im Repository und warten nur auf einen Lauf.
*Was zu tun ist:* am Mac nur
`bash companion-app/scripts/ios-signieren.sh`, danach die fertige `.ipa` pushen
(das Telefon lädt sie aus dem Repository). Ein Befehl - das Skript prüft sich
selbst und bricht ab, wenn etwas fehlt; von Hand bleiben das
Schlüsselbund-„Immer erlauben" beim ersten Signieren und die Probe auf dem
Gerät.
**Die Datei, die man dafür in die Hand nimmt, ist
[`APPLE_DEV_BACKLOG.md`](APPLE_DEV_BACKLOG.md)** - sie trägt den Ablauf, den
Stand und die Prüfliste; Abschnitt „Wie ein Lauf abläuft", dann A1 und A2.
### 1.2 Gitea-Token für den Laborzweig DM180
Nicht dieses Repository - der Laborzweig (`tobias/Data-Metric-180`, privat).
Seine Selbstaktualisierung zeigt seit dem 06.09.2026 auf das eigene Repository,
ihr fehlt nur noch der Zugang.
*Was zu tun ist:* in der Laborinstanz unter Einstellungen -> Geräte & Dienste ->
Audi Dashboard -> Konfigurieren einen Gitea-Token mit Lesezugriff eintragen.
### 1.3 `steuer.faellig` ist leer
Betriebsdaten, kein Code - die Kfz-Steuer hat kein Fälligkeitsdatum.
*Was zu tun ist:* in der App unter Mein Audi -> Versicherung/Steuer eintragen.
---
## 2. Wartet auf eine Entscheidung von Ihnen
### 2.1 Die Übersicht-Box „Versicherung/Steuer" neu gestalten
Seit dem 19.08.2026 vorgemerkt und **bewusst nicht begonnen**: Sie hatten
gesagt, Sie wüssten noch nicht, was Sie dort sehen wollen. Ein Entwurf ohne
diese Antwort wäre geraten.
*Was zu tun ist:* ein kurzes Gespräch darüber, was auf einen Blick sichtbar sein
soll und was auf die Detailseiten gehört. Danach wie üblich erst ein Muster,
dann Code.
---
## 3. Wartet auf eine Sitzung
Buildbar, ohne Rückfrage, in dieser Reihenfolge sinnvoll:
### 3.1 Versicherung: PDF der Vertragsdetails ablegen
Die Police als PDF an den Vertrag hängen und später wieder ansehen können.
*Was zu tun ist:* zuerst den Ablageort entscheiden - spiegelt es die vorhandene
Fotoablage unter `/config/audi_dashboard/`, oder wird es ein eigenes
Anhang-Konzept? Danach Dienst, Ablage und beide Oberflächen (Paritätsregel).
### 3.2 Versicherung: freies Notizfeld
Eine eigene Zusammenfassung zur Versicherung, aus keinem strukturierten Feld
abgeleitet.
*Was zu tun ist:* Profilfeld, Textfeld in beiden Oberflächen. Das Panel hat mit
`.feld--breit` bereits das Muster für ein Feld über die volle Breite.
### 3.3 Restliche Dokumentendrift
`SPECIFICATION.md`, `homeassistant/README.md` und die Profilvorlage nennen noch
den WLAN-Sensor, der am 12.08.2026 entfallen ist; dazu eine falsche Aussage über
Beispielzahlen in der Statistik und ein überholter TODO-Kommentar.
*Was zu tun ist:* reine Textkorrekturen, kein Code.
---
## 3b. Optisch noch nicht gesehen
**Erledigt am 07.09.2026.** Die Markierung überschrittener Servicetermine
(Abschnitt DJ) ist jetzt auch im **Panel** live gesehen und gemessen -
Übersicht und „Mein Audi", Tagmodus, Fassung 2026.9.6.9.
Das Erstzulassungsdatum im Testcontainer war dafür vorübergehend auf
01.03.2020 zurückdatiert und ist am 07.09.2026 auf **01.03.2025**
zurückgestellt; die Sicherung `fahrzeugprofil.json.ansicht` ist entfernt.
*Offen bleibt allein:* der **Nachtmodus** ist nur gerechnet, nicht gesehen.
Die Beschreibung zum Nachziehen in einem Spin-off steht in
`PORTIERUNG_UEBERSCHRITTEN.md`; als teilbare Seite unter
<https://claude.ai/code/artifact/8f2eb15e-1107-45bc-bd04-78b19fa06a4b>.
## 4. Gemeldet, aber nicht reproduzierbar
Beide mehrfach gesucht und **nicht gefunden**. Sie bleiben stehen, bis sie
wieder auftreten - raten hilft hier nicht.
* **Das Kartenebenen-Symbol im Standort-Blatt** wirkt fehlerhaft. Dreimal
geprüft (Markup, Funktion, gerechnete Farbwerte), jedes Mal deckungsgleich mit
dem Panel. *Was hilft:* ein Bildschirmfoto vom Gerät.
* **Die Batteriespannungs-Zeile lässt sich am Rechner nicht antippen.** Mit
Trefferpunkt-Prüfung, synthetischen und echten Klicks getestet - navigiert
jedes Mal korrekt. *Was hilft:* die Fensterbreite und ein Blick in die
Browser-Konsole im Moment des Fehlversuchs.
---
## 5. Bekannte Grenzen - bewusst so, nicht offen
Damit sie nicht als Fehler wiederkommen:
* **Der Fahrtbeginn liegt bis zu neun Minuten zu spät.** Der Dongle wacht erst
über den Beschleunigungssensor auf; die verschlafenen Kilometer holt die App
seit `2026.9.6.2` aus dem Kilometerstand zurück, den *Zeitpunkt*
zurückzurechnen haben Sie abgelehnt, solange es eine gerechnete statt
gemeldeten Zahl wäre.
* **Werkbank und Fahrzeug lassen sich nicht automatisch trennen.** Ein
Werkbank-Schalter wurde am 06.09.2026 angeboten und abgelehnt - Sie
entscheiden selbst, welche Messwerte bleiben, über das Wischen in der
Messwertliste. **Nicht erneut vorschlagen.**
* **Das Panel wird nicht gelöscht, sondern archiviert** - aber erst, wenn die App
es wirklich abgelöst hat. Heute nicht.
* **HACS ist ein Sackgassenweg**, dauerhaft: es lehnt private Repositories
kategorisch ab. `install.ps1` ist der einzige Installationsweg, die
Selbstaktualisierung der einzige Aktualisierungsweg.
+246
View File
@@ -0,0 +1,246 @@
# Überschrittene Servicetermine — Beschreibung zum Nachziehen
Stand 07.09.2026, Fassung `2026.9.6.9`, Commit `33c2ffa` (und Vorläufer).
Gedacht für ein Spin-off dieser App: alles, was gebraucht wird, um dieselbe
Markierung dort noch einmal zu bauen — einschließlich der Gründe, damit die
Entscheidungen nicht neu erraten werden müssen.
**Teilbare Fassung:** dieselbe Beschreibung als Seite, mit echt gerenderten
Farbmustern für Tag und Nacht -
<https://claude.ai/code/artifact/8f2eb15e-1107-45bc-bd04-78b19fa06a4b>
(standardmäßig privat; teilbar über das Teilen-Menü auf der Seite). Sie ist
markenfrei gehalten: keine lizenzierten Schriften, Embleme oder Modellzeichen.
Änderungen an dieser Datei dort nachziehen, sonst laufen die beiden Fassungen
auseinander.
Betroffen sind **zwei Codebasen**, die nach der Paritätsregel immer denselben
funktionalen Stand haben: das Home-Assistant-Panel
(`custom_components/audi_dashboard/frontend/`) und die Companion-App
(`companion-app/src/`). Wer nur eine davon nachzieht, hat einen latenten Fehler.
---
## 1. Was die Markierung leistet
Ein Servicetermin, dessen Fälligkeitstag erreicht oder überschritten ist, wird
an drei Stellen hervorgehoben:
| Ort | Darstellung |
|---|---|
| Service-Blatt | die betroffene Termingruppe blass gelb hinterlegt, Marke „überschritten" im Kopf |
| „Mein Audi", Service-Kachel | **nur die betroffene Zeile** blass gelb, Marke links vom Wert |
| Übersicht, Kachel „Nächster Service" | Kennzahl in kräftigem Orange, Marke rechts daneben |
Zwei Randbedingungen, die der Eigentümer ausdrücklich gesetzt hat:
* Auf der Übersicht darf die Marke die Kachel **nicht höher machen** — sie steht
deshalb in derselben Zeile wie die Kennzahl, nicht darunter.
* In „Mein Audi" wird **nicht die ganze Kachel** hinterlegt, sondern nur das
überschrittene Feld. Eine Zwischenfassung hatte die Kachel als Ganzes getönt;
das wurde zurückgewiesen.
---
## 2. Die Fachregel: entschieden wird am Datum
`terminUeberschritten(...kandidaten)` — im Panel in `audi-dashboard-app.js`, in
der App exportiert aus `src/daten/service.ts`.
**Regel:** Nimm das **erste lesbare Datum** der übergebenen Kandidaten und melde
`true`, sobald es nicht mehr in der Zukunft liegt. Der Fälligkeitstag selbst
zählt mit.
Drei Punkte, die beim Nachbau leicht falsch gemacht werden:
1. **Nicht an den Restkilometern entscheiden.** Die Kilometerseite trägt je nach
Quelle ein anderes Vorzeichen: die Fahrzeugsensoren melden negativ, solange
noch Strecke bleibt (gemessen: `oil_change_distance` = 28600 bei Fälligkeit
2028), und die Prognosefunktion klemmt ihre Restkilometer mit
`Math.max(0, …)`. Welches Vorzeichen ein überschrittener Fahrzeugwert trägt,
ist an den vorliegenden Daten **nicht beobachtbar**. Der km-Fall fällt ohnehin
unter die Datumsregel, weil die Prognose das Datum auf heute setzt, sobald die
Restkilometer auf 0 laufen.
2. **Das erste lesbare Datum nehmen, nicht das früheste.** Es ist genau das
Datum, das an der Stelle auch angezeigt wird — so kann die Markierung nicht
von dem abweichen, was daneben steht.
3. **Volle ISO-Zeitstempel mitlesen.** Die Fahrzeugmeldung liefert
`"2027-10-21T00:00:00+00:00"`, und sie ist bei Ölwechsel und Inspektion die
Hauptquelle. Der verankerte Textparser lehnt Zeitstempel ab; ohne einen
eigenen Zweig dafür gälte die Hauptquelle stillschweigend **nie** als
überschritten. Im Panel macht das `zeitstempelAlsDatum()` mit einem auf
`JJJJ-MM-TT` plus `T` verankerten Ausdruck; in der App kann `alsZeitpunkt()`
das bereits. Der Fehler wurde vom Test gefangen, nicht im Betrieb — er wäre
still geblieben.
Bewusst eng gefasst statt einem allgemeinen `new Date()`: sonst deutete der
Rückfall auch Zeichenketten, die der Textparser gerade abgelehnt hat.
---
## 3. Die Farben: jedes Element an seiner eigenen WCAG-Schwelle
Das ist der Teil, der zwei Anläufe gebraucht hat, und der Grund dafür ist
übertragbar: **kräftiges Orange ist bei 4,5:1 nicht zu haben.** Apples
systemOrange `#FF9500` kommt auf Weiß auf 2,20:1; jedes Orange, das 4,5:1
schafft, ist so dunkel, dass es als Braun gelesen wird. Zwei Anläufe mit einer
einzigen Textfarbe (`#A15800`, dann Apples `#C55300`) wurden beide als „braun
statt gelb" zurückgewiesen — zu Recht.
Die Lösung ist nicht ein besserer Kompromisston, sondern **die Trennung der
Rollen**, jede an ihrer eigenen Schwelle:
| Rolle | Größe | Schwelle | Tag | Nacht |
|---|---|---|---|---|
| `--warn` (Fläche, 12 % hinterlegt) | — | — | `#FF9F0A` | `#FFD60A` |
| `--warn-fig` (Kennzahl) | 32 px = Großtext | 3:1 | `#DD6000` | `#FF9F0A` |
| `--warn-marke-grund` (Pille) | — | — | `#C55300` | `#FFA056` |
| `--warn-marke-schrift` | 10,5 px = Kleintext | 4,5:1 | `#FFFFFF` | `#171B21` |
| `--warn-text` (Schrift auf hellem Grund) | klein | 4,5:1 | `#C55300` | `#FFA056` |
Gemessen (WCAG 2.1, Hülle = 12 % `--warn` auf dem jeweiligen Kachelgrund):
* `#DD6000` → 3,65:1 auf Weiß, 3,34:1 auf der Hülle (Schwelle 3:1) ✔
* Weiß auf `#C55300` → 4,55:1 (Schwelle 4,5:1) ✔
* `#FF9F0A` auf `#171B21` → 8,41:1 ✔
* `#171B21` auf `#FFA056` → 8,57:1 ✔
**Der entscheidende Kniff bei der Marke:** keine dünne Schrift, sondern eine
**gefüllte Pille**. Derselbe Ton liest sich als Fläche eindeutig als Orange,
während er als 1 px starker Buchstabenstrich bräunlich wirkt. Weiße Schrift auf
`#C55300` erfüllt die 4,5:1 exakt so, wie es umgekehrt nicht ginge.
Tokens liegen in `frontend/audi-dashboard-ios.css` (Panel) und
`design-system/src/tokens/tokens.css` (App) — beide Sätze müssen gleich sein.
**Zur Frage HIG oder Audi CI:** die Fläche ist Apple (iOS-Palette, 2026-08-13
übernommen); das Audi-CI-Gelb `#ffaa00` wurde damals abgelöst und liefert als
Schrift nur 1,91:1. Die Schriftwerte sind **gerechnet, nicht übernommen**
Apples eigener Kontrastwert `#A16A00` liefert auf der 12 %-Hülle nur 4,19:1.
Nebenbefund: Apples HIG-Seite führt inzwischen die neue („unified") Palette;
einen einzelnen Wert daraus zu übernehmen wäre inkonsistent gewesen.
---
## 4. CSS
Gleiche Regeln in beiden Codebasen, nur andere Klassennamen
(Panel `audi-dashboard.css` / App `src/stile/screens.css`):
| Panel | App |
|---|---|
| `.termingruppe.ueberschritten` | `.dm-termingruppe--ueberschritten` |
| `.row.ueberschritten` | `.dm-wertzeile--ueberschritten` |
| `.ueberschritten-marke` | `.dm-ueberschritten-marke` |
| `.serviceblock.ueberschritten .fig` | `.dm-serviceblock--ueberschritten .ads-fig` |
| `.fig-zeile` | `.dm-servicefig` |
Die Tönung:
```css
background: color-mix(in srgb, var(--warn) 12%, transparent);
border-radius: var(--r-tile);
padding-left: 12px; padding-right: 12px;
margin-left: -12px; margin-right: -12px;
```
Die negativen Außenabstände sind der Punkt: sie lassen die Tönung bis an den
Kachelrand laufen, obwohl die Zeile selbst im Innenabstand der Kachel sitzt.
Prüfbar am gemessenen Breitenunterschied — getönte Zeile 752 px gegenüber 728 px
der übrigen, also genau 2 × 12 px.
Die Pille:
```css
font-size: 10.5px; font-weight: 600;
color: var(--warn-marke-schrift); background: var(--warn-marke-grund);
border-radius: var(--r-pill); padding: 3px 8px;
white-space: nowrap; flex: 0 0 auto;
display: inline-block; vertical-align: middle;
```
Platzierung: `.fig-zeile .ueberschritten-marke { margin-left: 10px }` (Übersicht,
rechts der Kennzahl in derselben Zeile) und
`.row .ueberschritten-marke { margin-right: 8px }` („Mein Audi", links des Werts).
Dazu `.termingruppe.ueberschritten, .termingruppe.ueberschritten + .termingruppe
{ border-top: 0 }` — sonst schneidet die Trennlinie durch die getönte Fläche.
---
## 5. Markup
**Übersicht** — die Marke muss in dieselbe Zeile wie die Kennzahl, sonst wächst
die Kachel. Die Zeile wird deshalb **immer** gebaut, auch ohne Marke:
```js
const figMarkup = `<div class="fig-zeile">${artIcon ? ciSVG(artIcon, 31) : ""}`
+ `<div class="fig" style="font-size:${zahlGroesse}px">${hauptwert}</div>`
+ `${ueber ? UEBERSCHRITTEN_MARKE : ""}</div>`;
```
Die Kennzahl steht auf **32 px** (vorher 40 px, auf Rückmeldung „wirkt sehr groß"
reduziert). 32 px liegt über der 24-px-Grenze für Großtext — das ist die
Voraussetzung dafür, dass `--warn-fig` mit 3:1 auskommen darf.
**„Mein Audi"** — Marke **vor** dem Wert, Klasse an der Zeile, **nicht** an der
Kachel:
```js
return `<div class="row${ueber ? " ueberschritten" : ""}" ${letzte}>
<dt>…</dt>
<dd>${ueber ? UEBERSCHRITTEN_MARKE : ""}${haupt}…</dd></div>`;
```
In der App reicht `Wertzeile` dafür eine Eigenschaft `ueberschritten?: boolean`
durch, die die Klasse am Zeilen-`div` setzt.
---
## 6. Tests
* `service.test.ts` — Fälle für `terminUeberschritten()` (Grenztag, Zukunft,
Vergangenheit, leere und unlesbare Kandidaten, Zeitstempel).
* `paritaet_ueberschritten.test.ts` — 14 Fälle, die die Panel-Funktion **aus dem
Quelltext herausschneiden** und gegen die App-Funktion laufen lassen. Das ist
der Mechanismus, der die Paritätsregel überhaupt durchsetzt; ohne ihn driften
die beiden Fassungen auseinander, ohne dass es auffällt.
* `screens.test.tsx` — Render-Tests gegen ein überschrittenes Profil.
* `buendelversion.test.ts` — hält `manifest.json`-Version, `bundle.json` und die
tatsächliche `bundle.zip` zusammen. Nach jeder UI-Änderung Version heben und
Bündel neu bauen, sonst schlägt dieser Test fehl.
---
## 7. Fallen, die Zeit gekostet haben
1. **Stylesheets im Panel greifen asynchron.** Direkt nach dem Neuladen liefert
eine Messung die UA-Vorgabe — die Pille erbt dann `13,3333px` schwarz aus dem
umgebenden `<button>`. Das sieht exakt so aus, als würde die Regel nicht
greifen, und hat hier eine lange Fehlersuche ausgelöst (bis hin zum Einspielen
einer Testregel zur Laufzeit, die aus demselben Grund ebenfalls „nicht
griff"). **Vor dem Messen warten und auf den Zielwert pollen**, nicht einmal
messen und daraus schließen.
2. **Ein Testfall, der den Fall gar nicht auslöst.** Der erste Render-Test war
mit einem Wartungsplan-Datum bestückt, das noch in der Zukunft lag — grün,
aber wertlos. Ebenso: synchrone Zusicherungen gegen asynchron geladene Daten
brauchen `waitFor`.
3. **Der Fall muss erst erzeugt werden.** Zum Ansehen wurde die Erstzulassung im
Testcontainer vorübergehend auf `01.03.2020` zurückdatiert (Sicherung daneben,
hinterher zurückgestellt). Ohne das ist nichts überschritten und es gibt
nichts zu sehen.
4. **Der Nachtmodus ist hier nur gerechnet, nicht gesehen.** Wer ihn im Spin-off
nutzt, sollte ihn einmal ansehen.
---
## 8. Reihenfolge zum Nachziehen
1. `terminUeberschritten()` in beiden Codebasen, mit dem Zeitstempel-Zweig.
2. Tokens `--warn-fig`, `--warn-marke-grund`, `--warn-marke-schrift` in beide
Token-Dateien.
3. CSS-Blöcke in beiden Stildateien.
4. Markup: Übersicht (Zeile immer bauen, 32 px), „Mein Audi" (Klasse an der
Zeile), Service-Blatt (Termingruppe).
5. Tests, **einschließlich** des Paritätstests.
6. Version heben, Design-System bauen, OTA-Bündel bauen.
7. Live ansehen — mit zurückdatiertem Datum, und mit Wartezeit vor der Messung.
+108
View File
@@ -0,0 +1,108 @@
# Audi Dashboard — „Mein Audi" für Home Assistant
Fahrtenbuch, Tankstatistik, Reifen- und Servicebuch für ein einzelnes
Fahrzeug — als eigener Sidebar-Eintrag in Home Assistant und als iOS-App, die
dieselben Daten zeigt.
Die App wertet aus, was Home Assistant ohnehin über das Fahrzeug weiß
(Zündung, Kilometerstand, Tankfüllstand, Bordspannung, GPS — etwa über einen
Teltonika FMM003 oder eine EU-Data-Act-Integration des Herstellers), und macht
daraus einen dauerhaften Bestand: erkannte Fahrten, erkannte Tankvorgänge,
Belegdaten aus hochgeladenen Tank-PDFs, ein Spannungsverlauf über Jahre.
> Privates Projekt für ein bestimmtes Fahrzeug. Es ist nicht als allgemein
> nutzbare Integration gedacht und steht in keinem Verhältnis zur AUDI AG.
---
## Installation
HACS scheidet aus, und zwar endgültig: laut eigener Dokumentation
(hacs.xyz/docs/faq/private_repositories) kann HACS **grundsätzlich nicht**
mit privaten GitHub-Repositories arbeiten — „HACS can only get publicly
available information", ohne Ausnahme für Tokens oder verbundene Konten. Und
öffentlich machen ist keine Option: das Repository enthält die
Audi-Hausschrift und die Typenschilder, die nur für diese eine private
Installation lizenziert sind.
Der Installer unten ist deshalb nicht die zweite Wahl, sondern der einzige
Weg für die **Erstinstallation** — die Integration muss laufen, bevor sie
sich selbst aktualisieren kann.
Für **Updates danach** gibt es seit 2026-08-24 einen zweiten, bevorzugten
Weg: die Integration lädt ihre neueste Fassung direkt von Gitea und ersetzt
sich selbst (Einstellungen → Geräte & Dienste → Audi Dashboard →
Konfigurieren, dort ein Gitea-Zugriffstoken eintragen; danach in der App
unter Einstellungen → Integration-Update). Grund: Windows Smart App Control
blockiert die Ausführung von `install.ps1` zuverlässig und lässt sich —
anders als der SmartScreen davor — nicht per „trotzdem ausführen" umgehen.
Der Installer unten bleibt der Weg für die Erstinstallation und für den
seltenen Fall, dass das Selbst-Update selbst nicht mehr erreichbar ist.
Von einem Windows-Rechner aus, der das `config`-Verzeichnis der HA-Instanz
erreicht (Samba-Add-on oder gemounteter Pfad):
```
homeassistant\installationspaket\Installieren.cmd
```
Das Skript kopiert `custom_components\audi_dashboard\` in die Instanz und
fasst sonst nichts an — keine `configuration.yaml`, keine Fahrzeugdaten. Mit
`-Pruefen` zeigt es vorher, was es tun würde. Details und die
Sicherheitszusagen stehen im Kopf von
[`install.ps1`](homeassistant/installationspaket/install.ps1).
Danach ebenfalls: neu starten, Integration hinzufügen.
## Nach der Installation
**Sensoren zuordnen****Einstellungen → Fahrzeug einrichten → Setup** in der
App. Im Auslieferstand ist bewusst *kein* Sensor vorbelegt: welche Entity-IDs
richtig sind, hängt an der Instanz, und eine gesetzte, aber falsche ID ist
schlechter als eine leere. Zwingend ist allein der Zündungs-/ACC-Sensor — er
trägt die Fahrterkennung. Änderungen wirken sofort, ohne Neustart.
**Datenaufbewahrung verlängern** — Home Assistant löscht Sensor-Verläufe nach
10 Tagen. Das begrenzt, wie weit „Daten importieren aus Home Assistant"
zurückreichen kann, und was einmal gelöscht ist, kommt nicht wieder. Der Block
aus [`recorder_snippet.yaml`](homeassistant/recorder_snippet.yaml) hebt das
auf ein Jahr an; dort steht auch der gemessene Platzbedarf. Bewusst nicht
automatisch eingefügt: das ist eine Entscheidung über den Plattenplatz der
Instanz.
**Vergangenes nachholen** — **Einstellungen → Einrichten → Daten importieren
aus Home Assistant** leitet Fahrten, Tankvorgänge und Spannungswerte
rückwirkend aus dem bereits aufgezeichneten Verlauf ab.
## Was die Integration mitbringt
| | |
|---|---|
| Panel | Sidebar-Eintrag „Mein Audi", von der Integration selbst angemeldet |
| Entitäten | `sensor.audi_dashboard_*` — Profil, Fahrten, Tankvorgänge, Fahrzeugstatus, Batterieverlauf, Reifenzähler |
| Dienste | `audi_dashboard.*` — 18 Stück, in Entwicklerwerkzeuge → Aktionen dokumentiert |
| Daten | `/config/audi_dashboard/` — JSON und JSON Lines, dauerhaft, nie von einem Update angefasst |
| Abhängigkeit | `pypdf` (für Tankbelege), von Home Assistant automatisch nachinstalliert |
Die Nutzlast der Entitäten steckt im Attribut `daten` und ist ausdrücklich von
der Aufzeichnung ausgenommen — das Fahrtenarchiv wächst über Jahre auf
Hunderte Kilobyte und hat in der Recorder-Datenbank nichts verloren.
## Companion-App (iOS)
`companion-app/` ist dieselbe Oberfläche als eigenständige App (React +
Capacitor), die über die HA-API auf dieselben Entitäten und Dienste zugreift.
Sie trägt ihre Version fest einkompiliert und meldet selbst, wenn sie älter
ist als die installierte Integration — siehe
[`VERSIONIERUNG.md`](VERSIONIERUNG.md).
## Ordner in diesem Repository
| Ordner | Inhalt |
|---|---|
| `custom_components/audi_dashboard/` | die Integration — Backend und Panel-Dateien |
| `companion-app/` | die iOS-App |
| `design-system/` | gemeinsame UI-Bausteine (`@audi-dash/ui`) |
| `homeassistant/` | Installationspaket, Konfigurationsschnipsel, Anleitungen |
| `testumgebung/` | Wegwerf-Home-Assistant in Docker für Entwicklung und Tests |
| `tests/belegparser/` | Regressionstest des Tankbeleg-Lesers |
+419
View File
@@ -0,0 +1,419 @@
# Review: Branch `main`, Commits `c66ed82..d5562c7`
**Datum:** 2026-08-13 · **Umfang:** 18 Commits, 24 Dateien, +2752/259 Zeilen
**Gegenstand:** Setup-Menü zur Sensor-Zuordnung, Umstellung von WLAN/VAG auf FMM003,
Standort-Kachel mit Live-Position, iOS-/Großbildschirm-Optikauflage, DuckDNS und Zertifikate.
Geprüft wurde gegen eine Arbeitskopie von `origin/main`. Die mit **[belegt]** markierten Befunde
sind am Code nachvollzogen oder nachgestellt; **[plausibel]** heißt: aus dem Code abgeleitet, aber
nicht im Browser reproduziert.
---
> ## ✅ Umgesetzt am 2026-08-13
>
> Alle Befunde dieses Dokuments sind behoben — siehe die fünf Commits von
> `Setup-Zuordnung: Zuruecksetzen wirkt…` bis `Kleinere Befunde und Dokumentation…`.
> Das Dokument bleibt als Befundprotokoll stehen: es begründet, warum die
> Änderungen so aussehen, wie sie aussehen.
>
> **Zwei Punkte wurden bewusst anders gelöst als hier vorgeschlagen:**
>
> 1. *Karte nur bei echtem Ansichtswechsel neu aufbauen* — nicht umgesetzt. `render()` ersetzt den
> Inhalt per `innerHTML`; eine überlebende Leaflet-Instanz zeigte danach auf ein abgehängtes
> Element und bliebe leer. Der Neuaufbau hängt am 20-Sekunden-Takt des Backends und ließe sich
> nur durch einen Umbau der Render-Architektur ändern. Die Begründung steht im Code.
> Behoben ist dagegen die Standortabfrage, die tatsächlich ungedrosselt lief.
> 2. *`SPECIFICATION.md`* wurde nicht in Teilen umgeschrieben, sondern hat einen datierten
> Statushinweis am Anfang bekommen. Ein Umschreiben der §4/§5-Tabellen hätte neue
> Ungenauigkeiten riskiert.
>
> **Drei Fehler kamen beim Umsetzen dazu**, sichtbar erst im laufenden Panel: ein unlesbares Datum
> ergab „NaN/N" statt eines Strichs, ein fehlender Tanksensor „0 %" statt „unbekannt", und neben dem
> Typenschild stand die Ausführung doppelt. Alle drei sind mit behoben.
>
> Und ein pyscript-Fallstrick ist dabei aufgefallen und im Code vermerkt: **Generatorausdrücke sind
> nicht implementiert** (`not implemented ast ast_generatorexp`), Mengen-, Listen- und
> Dict-Comprehensions dagegen schon.
---
## Gesamteindruck
Handwerklich stark. Das Setup-Menü ersetzt „vor der Installation `einstellungen.py` editieren"
durch eine Oberfläche mit Live-Wertvorschau, Unavailable-Warnung, Duplikat-Prüfung und einem
ehrlichen Neustart-Hinweis für die drei triggergebundenen Felder. Der Kniff dahinter — `setattr`
auf dem laufenden Modul-Objekt, weil alle Leser `einstellungen.KM_SENSOR` als lebenden
Attributzugriff machen — ist elegant und im Kopfkommentar sauber begründet.
Positiv außerdem: Escaping ist im neuen Code durchgängig (kein XSS gefunden), Karten-Ebenen werden
vor dem Neuaufbau entfernt, die Haversine-Distanz ist mathematisch korrekt, Listenfelder sind gegen
zu kurze Arrays abgesichert, und die Python-Syntax aller acht geänderten pyscript-Dateien ist in
Ordnung.
Die Fehler unten liegen fast alle im selben Bereich — dem neuen Setup-Menü — und **drei davon
werden erst nach einem Neustart sichtbar**, also mit maximalem zeitlichem Abstand zur Ursache.
## Übersicht
| # | Befund | Wirkung | Ort |
|---|---|---|---|
| 1 | Speichern bei fehlendem Katalog löscht alle Zuordnungen | Datenverlust | `audi-dashboard-app.js:1773, 2031` |
| 2 | „Zurücksetzen" wirkt bei 15 von 17 Feldern nicht | falscher Zustand | `entitaeten.py:188` |
| 3 | Alle vier Listenpositionen bekommen denselben Sensor | falsche Sicherheitsaussage | `audi-dashboard-app.js:1976` |
| 4 | Vorschlagsschwelle akzeptiert beliebige Sensoren | falscher Fahrt-Trigger | `audi-dashboard-app.js:1980` |
| 5 | Geleerte Listen setzen `["","","",""]` | Status dauerhaft „unbekannt" | `entitaeten.py:188` |
| 6 | Zuordnung wird nirgends gesichert | Verlust bei Wiederherstellung | `backup.py:32` |
| 7 | Kaputte `entitaeten.json` legt das Panel lahm | Totalausfall | `entitaeten.py:159` |
| 8 | Standortabfrage im Dauerfeuer bei abgelehnter Freigabe | Akku, Netz | `audi-dashboard-app.js:553/557` |
| 9 | Kein Tastaturweg in die Einstellungen unter 860 px | Bedienbarkeit | `audi-dashboard-app.js:3849` |
| 10 | `.navmarke` nur in der iOS-Auflage versteckt | Tab-Leiste zerfällt | `audi-dashboard-ios.css:194` |
---
# Schwerwiegend
## 1. Speichern bei fehlendem Katalog löscht die gesamte Zuordnung [belegt]
**Kette:**
1. `ENTITAETEN` wird an genau einer Stelle gefüllt (`audi-dashboard-app.js:3723`), aus `hass.states`.
2. Der Nachlade-Pfad nach einem HA-Neustart (`:3699-3703`) holt Profil, Fahrten, Tankvorgänge,
Status und Batterieverlauf nach — **nicht** aber `pyscript.audi_dashboard_entitaeten`.
3. `DATEN_GELADEN` hängt allein am Profil. Die App gilt also als geladen, während der Katalog fehlt.
4. Der Knopf „Setup — Sensoren zuordnen" (`:1773`) ist nur an `einrichtenOffen` gekoppelt, nicht an
den Katalog. `setupKatalog()` liefert `[]`, das Popup öffnet **ohne eine Zeile und ohne
Fehlermeldung**, `setupZuordnung` bleibt `{}`.
5. „Speichern" schickt `"{}"` (`:2031`). `overrides_schreiben()` ersetzt die Datei vollständig,
ohne Zusammenführen (`entitaeten.py:166-173`).
**Auswirkung:** `data/entitaeten.json` enthält danach `{}`. Weil die `setattr`-Werte im laufenden
Prozess bestehen bleiben, fällt der Verlust **erst beim nächsten Neustart** auf. Zusammen mit
Befund 6 (keine Sicherung) gibt es dann nichts zurückzuholen.
**Vorschlag:** Setup-Knopf sperren, solange `ENTITAETEN` null ist, und in
`setupSpeichernAusfuehren()` abbrechen, wenn `setupZuordnung` leer ist. Zusätzlich die Entität in
den Nachlade-Pfad aufnehmen.
## 2. „Zurücksetzen" wirkt bei 15 von 17 Feldern nicht [belegt, nachgestellt]
`entitaeten.py:188` überspringt leere Werte:
```python
if wert in (None, "", []):
continue
```
Der Kopfkommentar derselben Datei beschreibt das Problem exakt richtig — `setattr` verändert das
Modul dauerhaft, „Zurücksetzen" muss den Standardwert *aktiv* zurückschreiben. Das Frontend tut das
auch (`setupStandardwert`). Nur ist der Standardwert bei 15 der 17 Katalogfelder `""` oder `[]`
und genau die werden übersprungen.
Nachgestellt:
```
nach Zuordnung: KM_SENSOR = 'sensor.alt_vag_mileage'
nach Zurücksetzen: KM_SENSOR = 'sensor.alt_vag_mileage' ← erwartet: ''
```
**Auswirkung:** Für den Nutzer sieht es aus, als hätte das Speichern versagt — `aktueller_stand()`
liest aus dem laufenden Modul, das Feld zeigt beim nächsten Zeichnen wieder den alten Wert.
Funktioniert nur bei `ZUENDUNG_SENSOR`, `BATTERIE_SENSOR` und `STANDORT_TRACKER`, den drei Feldern
mit nicht-leerem Standard.
## 3. Alle vier Listenpositionen bekommen denselben Sensor [belegt]
`audi-dashboard-app.js:1976-1981`:
```js
zuordnung[feld.key] = feld.positionen.map((_, i) => {
const vorhanden = (werte[feld.key] || [])[i];
if (vorhanden) return vorhanden;
const vorschlag = entitaetKandidaten(feld.key, "", "")[0]; // i geht nicht ein
return vorschlag && entitaetScore(feld, vorschlag, "") >= 3 ? vorschlag.id : "";
});
```
`entitaetKandidaten()` hängt nicht vom Index ab und schließt bereits vergebene IDs nicht aus —
alle vier Positionen bekommen dieselbe Entität. Betrifft `TUER_SENSOREN`, `FENSTER_SENSOREN`,
`TUERSCHLOSS_SENSOREN`.
**Auswirkung:** Bei einer Frischinstallation (genau der Fall, für den das Menü gebaut wurde) prüft
`_sicherheitscheck()` danach viermal dieselbe Tür. **„Sicher abgestellt" meldet gesichert, obwohl
drei Türen nie geprüft wurden.** Die Duplikat-Warnung fängt das beim Speichern ab, ist aber mit
„Trotzdem speichern" wegklickbar.
## 4. Die Vorschlagsschwelle akzeptiert beliebige Sensoren [belegt]
`entitaetScore()` (`:1936`) vergibt: Stichwort +5, Domain +3, device_class +3, Einheit +2. Die
Schwelle ist `>= 3`**ein Domain-Treffer allein genügt**, ohne dass ein einziges Stichwort passt.
Verschärfend: `setupNurPassend` wird zwei Zeilen vorher auf `true` gesetzt (`:1971`), wodurch
`entitaetKandidaten()` ohnehin hart auf die Domain filtert. Die Schwelle ist damit *immer* erfüllt.
Die Reihenfolge kommt aus `Object.keys(HASS.states)`, ist also faktisch willkürlich.
**Auswirkung:** Ohne passenden Zündungssensor wird `ZUENDUNG_SENSOR` stumm mit dem erstbesten
`binary_sensor.*` vorbelegt — Bewegungsmelder, Fensterkontakt, was zuerst kommt. Das Feld sieht
ausgefüllt aus, wird mitgespeichert, und **die gesamte Fahrterkennung hängt an einem
Zufallssensor**. Bei Einzelfeldern greift keine Duplikat-Warnung. Dasselbe für `TANK_SENSOR` (jeder
%-Sensor, etwa Luftfeuchte) und `REFRESH_BUTTON` (jedes `button.*`).
**Vorschlag:** Schwelle auf `>= 5` — also mindestens ein Stichworttreffer.
## 5. Geleerte Listenfelder setzen `["","","",""]` [belegt]
Die Skip-Regel aus Befund 2 prüft auf `[]`. Ein Array aus vier leeren Zeichenketten ist das nicht:
```python
["","","",""] in (None, "", []) # False -> wird per setattr gesetzt
```
`_sicherheitscheck()` zippt dann über vier leere Entity-IDs und erzeugt vier „unbekannt"-Zeilen —
mal drei Listen sind das zwölf.
**Auswirkung:** Weil die Gesamtaussage bewusst `None` wird, sobald *eine* Prüfung unbekannt ist,
steht die Übersicht danach **dauerhaft auf „Zustand unbekannt"** statt auf grün.
Kurios ist die Kombination mit Befund 2: Bei Einzelfeldern wirkt „Zurücksetzen" gar nicht, bei
Listenfeldern zu stark. Beides verschwindet mit demselben Fix — statt leere Werte zu überspringen,
immer den Standardwert aus `_STANDARDWERTE` zurückschreiben:
```python
def overrides_anwenden():
overrides = overrides_lesen()
for key in _SCHLUESSEL:
wert = overrides.get(key)
leer = wert in (None, "", []) or (isinstance(wert, list) and not any(wert))
setattr(einstellungen, key, _STANDARDWERTE[key] if leer else wert)
```
## 6. Die Sensor-Zuordnung wird nirgends gesichert [belegt]
`backup.py:32` sichert `fahrzeugprofil.json`, `fahrten.jsonl`, `tankvorgaenge.jsonl`
`entitaeten.json` fehlt. Auch `audi_dashboard_backup_wiederherstellen` kennt es nicht, `update.ps1`
fasst `data/` bewusst nicht an, und in `homeassistant/.gitignore` steht es nicht neben den anderen
Laufzeitdateien.
**Auswirkung:** Nach einer Wiederherstellung ist die komplette Sensor-Zuordnung still weg. In
Verbindung mit Befund 1 gibt es keinen Rückweg.
## 7. Eine beschädigte `entitaeten.json` legt das Panel lahm [belegt]
`overrides_lesen()` (`entitaeten.py:159`) ruft `json.loads` ohne Absicherung. Der Aufruf steht in
`beim_start()` **vor** `alles_veroeffentlichen()` — wirft er, wird gar nichts veröffentlicht, das
Panel bleibt komplett leer.
Geschrieben wird zwar atomar über `.tmp` + `os.replace`, das Risiko ist also gering — der Ausfall
wäre aber total. Dieselbe Fehlerklasse, die im Branch `umsetzung-datametric360` bei `profil_lesen()`
bereits abgefangen wurde.
---
# Mittel
## 8. Standortabfrage im Dauerfeuer, Karte bei jedem Render neu [belegt]
`USER_POS_TS` wird nur im Erfolgs-Callback gesetzt (`:553`), nicht im Fehlerfall (`:557`). Die
30-Sekunden-Drossel in `standortErfassenFallsNoetig()` (`:560`) greift damit nie, wenn der Nutzer
die Standortfreigabe ablehnt oder kein Fix zustande kommt.
Parallel dazu zerstört `render()` die Vorschaukarte und baut sie neu (`:2879-2880`), sobald die
Route `home` ist — bei **jedem** Backend-Update.
**Auswirkung:** Mit einem GPS-Tracker als Quelle (genau der FMM003-Fall) sind das dutzende
`getCurrentPosition({enableHighAccuracy:true})`-Anfragen pro Stunde plus sichtbares Kachelflackern.
**Vorschlag:** `USER_POS_TS = Date.now()` auch im Fehlerpfad; Karte nur bei echtem Ansichtswechsel
neu bauen — die Variable `gleicheAnsicht` gibt es in `render()` bereits.
## 9. Kein Tastaturweg in die Einstellungen unter 860 px [belegt]
`audi-dashboard-ios.css:81` versteckt `.profilbtn` — den echten `<button aria-label="Einstellungen">`
und holt ihn erst in der Container-Abfrage ab 860 px zurück (`:230`). Der Ersatz ist ein
Klick-Handler auf `#marke` (`:2965`), und das ist ein `<div class="rings" aria-label="Audi">`
(`:3849`): ohne `role`, ohne `tabindex`.
**Auswirkung:** In Telefonbreite gibt es für Tastatur- und Screenreader-Nutzer **keinen** Zugang zu
den Einstellungen. Ein `<div>` mit `aria-label` ohne Rolle wird von den meisten Screenreadern nicht
einmal angesagt. Gleiche Fehlerklasse wie das Swipe-Löschen aus `AUDIT_2026-08-10.md`.
**Vorschlag:** `#marke` zu einem `<button aria-label="Einstellungen">` machen; dann kann auch
`pointer-events:auto` in `audi-dashboard-ios.css:78` entfallen.
## 10. `.navmarke` nur in der iOS-Auflage versteckt [belegt]
`audi-dashboard-app.js:2956-2963` hängt den Ringklon **unbedingt** in `#tabbar`.
`.navmarke{display:none}` steht ausschließlich in `audi-dashboard-ios.css:194`. `.tabbar` ist ein
`display:grid` mit `repeat(5, 1fr)` (`audi-dashboard.css:211`).
**Auswirkung:** Lädt `/local/audi-dashboard-ios.css` nicht (404, Cache), wird der Klon ein sechstes
Grid-Element — alle Tabs rutschen eine Spalte weiter, „Tanken" fällt in eine zweite Zeile, die
Menüleiste zerfällt. Kein theoretischer Fall: Commit `3548f77` dokumentiert genau dieses
Deployment-Loch.
**Vorschlag:** `.navmarke{display:none}` gehört in die Basis-CSS, die Sichtbarkeit in die Auflage.
## 11. Standort-Menü ohne `pointercancel` [belegt]
`standortMenuVerdrahten()` (`:706-731`) setzt `smY0` nur in `pointerup` zurück. Alle drei anderen
Zeigergesten der Datei haben einen `pointercancel`-Handler (`:2929`, `:3055`, `:3122`), diese nicht.
**Auswirkung:** Übernimmt das System die Berührung (iOS-Randgeste, Multitouch, eingehender Anruf),
bleibt `smY0` gesetzt. Der `pointermove`-Handler ruft dann `preventDefault()` für jede weitere
Bewegung — **Scrollen ist in der ganzen App blockiert**, bis irgendwo ein `pointerup` kommt, der
dann ein `dy` aus einem veralteten Startpunkt auswertet.
## 12. Fehlgeschlagenes Reverse-Geocoding wird dauerhaft gecacht [belegt]
`:572-586` setzt `STANDORT_ADRESSE_KEY` auch im `catch`-Zweig.
**Auswirkung:** Ein einziger fehlgeschlagener Nominatim-Abruf (Rate-Limit, kurzer Netzausfall) für
eine Zelle führt dazu, dass für dieselbe Zelle die **gesamte Sitzung** lang nur Koordinaten
angezeigt werden, auch wenn das Netz längst wieder da ist.
**Vorschlag:** Key nur im Erfolgsfall setzen.
## 13. Setup-Popup hält den `role="dialog"`-Vertrag nicht ein [belegt]
`:2774` deklariert `role="dialog" aria-modal="true"`. Es fehlen: Escape zum Schließen (in der Datei
gibt es keinen `keydown`-Handler), eine Fokusfalle (Tab wandert in den überdeckten Hintergrund),
und der Schalter „Nur passende Sensoren anzeigen" (`:2779`) hat keinen zugänglichen Namen — der
Beschriftungstext steht im Geschwister-`<span>` außerhalb des `<label>`.
Dazu: Die Entitäts-Auswahl öffnet ausschließlich im `click`-Handler (`:3181`). Wer per Tab ins Feld
springt und tippt, löst zwar den `input`-Handler aus, der bricht aber ab, weil `setupSucheOffen`
noch `null` ist — sichtbar passiert nichts. `role="combobox"`, `aria-expanded` und `aria-controls`
fehlen ebenfalls.
## 14. Standort-Menü nicht per Tastatur bedienbar [belegt]
Im eingeklappten Zustand steht das Blatt per `transform` überwiegend außerhalb des Sichtbereichs,
bleibt aber vollständig im DOM und im Tab-Fokus — Adresse, Route-Link und Teilen-Knopf sind
fokussierbar, ohne sichtbar zu sein (kein `inert`, kein `aria-hidden`). Öffnen geht gar nicht per
Tastatur: `.standortmenu-griffzone` ist ein `<div>` mit reinen Pointer-Handlern. Der Umschalter
Straße/Satellit (`:773`) hat kein `aria-pressed`, sein Label bleibt in beiden Zuständen gleich.
## 15. `disconnectedCallback()` räumt nur eine von drei Karten ab [belegt]
`:3815-3817` zerstört `MAP`, nicht aber `SMAP`/`TMAP`. Wird das Panel abgehängt, während Standort-
oder Übersichtsseite offen ist, bleibt eine Leaflet-Instanz samt Resize-Listener und
Kachel-Abrufen am Leben.
---
# Klein
| Ort | Befund |
|---|---|
| `audi-dashboard-ios.css:88, 208` | `main{padding:…}` (0,0,1) verliert gegen `main#view` (1,0,1) in `audi-dashboard.css:197`. Auf Großbildschirmen bleibt der Seitenrand bei 20 px statt der beabsichtigten 44 px. **Achtung:** `.standort-vollbild{margin:0 -20px}` (`audi-dashboard.css:704`) passt heute genau dazu — beides gehört zusammen angefasst. |
| `audi-dashboard-app.js:3190/3612` | „Nur passende Sensoren anzeigen" wirkt nicht, solange ein Dropdown offen ist: Der Klick-Handler ersetzt das Overlay-DOM, bevor das `change`-Event des Kontrollkästchens ausgeliefert wird. [plausibel] |
| `audi-dashboard-app.js:2966` | Theme-Umschalten ruft kein `render()` — die Leaflet-Kacheln bleiben bis zum nächsten Backend-Update im alten Stil. Durch die neue Dauerkarte auf der Übersicht deutlich sichtbarer als früher. |
| `audi-dashboard-app.js:804` | `${de(CAR.tankPct)} %` zeigt ohne Tanksensor „0 %“, während direkt daneben korrekt „Reichweite unbekannt“ steht. |
| `audi-dashboard-app.js:496, 518, 117` | Toter Code: `fahrzeugGlyphPfade()` nie aufgerufen, `zustand.parkplatz` nie gelesen, `standortGenauigkeitM`/`standortZeit` gemappt aber ungenutzt. Kommentare behaupten das Gegenteil. |
| `audi-dashboard-app.js:595` | Einzige Interpolation im neuen Code, die ohne `esc()` in ein Attribut geht. Heute nicht ausnutzbar, weil `_zu_zahl()` im Backend nur Zahlen durchlässt — als Härtung trotzdem sinnvoll, ebenso `Number.isFinite` in `standortBekannt()`. |
---
# Dokumentation
## AGENTS.md widerspricht sich an sechs Stellen [belegt]
Die Datei ist auf 427 Zeilen gewachsen; neue Einträge wurden angehängt statt eingearbeitet.
| Stelle | Aussage | Widerspruch |
|---|---|---|
| Z. 94 | „trip detection via the iPhone WLAN sensor" | Z. 249 ff.: läuft über `ZUENDUNG_SENSOR`, WLAN „fully removed" |
| Z. 110 | „Codec JSON → MQTT/TLS → Mosquitto" | Z. 227 ff.: Schwenk auf flespi, „not MQTT at all" |
| Z. 93 | „4 modules" | tatsächlich 5 (`entitaeten.py` neu) |
| Z. 97 | „3.130 lines" | tatsächlich 3.873 |
| Z. 100 | „`einstellungen.py` — the one file edited before install" | genau das ersetzt jetzt das Setup-Menü |
| Z. 320 | `STANDORT_TRACKER` „left empty" | steht auf `device_tracker.testzone_fmm003` |
Dazu veraltete Codeverweise: `fakeTrack()` liegt bei `:421` statt `:414`, die Statistik-Behauptung
in `INSTALL.md` bei Z. 229 statt Z. 204.
Die Datei verlangt selbst „nicht festhalten, was Code und Git-History schon zeigen" (Z. 47) —
Z. 282401 sind 120 Zeilen Changelog-Prosa für fünf Einträge, und Z. 249279 dupliziert inhaltlich
Z. 133153.
## INSTALL.md [belegt]
- **Zirkuläre Schrittfolge:** Schritt 4 verweist auf „siehe Schritt 9 zuerst" (Z. 96), Schritt 7
setzt Schritt 4 voraus (Z. 176). Die Reihenfolge 4→9→7→4 wird nirgends aufgelöst.
- **Ausgelieferte Default-Entities werden verschwiegen:** `ZUENDUNG_SENSOR`, `BATTERIE_SENSOR` und
`STANDORT_TRACKER` sind mit den `testzone_fmm003`-Entities der Autoren-Instanz vorbelegt. Bei
einer Frischinstallation zeigen sie ins Leere. Z. 108110 behauptet nur für KM/TANK/RANGE
„unbelegt".
- **Override-Datei nicht benannt:** Z. 104 spricht von „einer Override-Datei" — der Pfad
`/config/audi_dashboard/entitaeten.json` steht nirgends, und ihre fehlende Sicherung (Befund 6)
ist nicht erwähnt.
- Schritt 2 verweist auf `fahrzeugprofil.example.json`, deren Z. 2 weiterhin den „WLAN-Namen"
verlangt, obwohl `fahrzeug.wlan_name` im selben Branch entfernt wurde.
## WLAN-Reste in der übrigen Doku [belegt]
Im Code ist die Entfernung vollständig (keine Fundstelle). In der Doku nicht:
`SPECIFICATION.md` (Z. 16, 147, 181, 196, 217), `homeassistant/README.md:106` und die Profilvorlage
nennen WLAN-Sensor und `TommiG1/HA_VAG-EU-Data-Act` weiterhin als aktuellen Stand.
## `update.ps1` [belegt]
Für `www/` ist das Skript nach dem Fix **vollständig**`audi-dashboard-ios.css` und `badges/`
sind ergänzt, Cache-Busting greift auch für die neue CSS-Datei.
**Verbleibende Lücke:** `homeassistant/data/shell_beleg_parser.py` ist Code, kein Datenbestand —
`belegverarbeitung.py:41` lädt ihn aus `/config/audi_dashboard/`. `update.ps1` schließt `data/`
pauschal aus, `INSTALL.md` behandelt ihn als Einmal-Kopie. **Änderungen am Beleg-Parser, dem
einzigen getesteten Teil des Repos, kämen auf keiner Instanz an.**
## `design/` [belegt]
`dm360-host.html` lädt `audi-dashboard-app.js` (Z. 12) und `audi-dashboard-ios.css` (Z. 33) — beide
existieren in `design/` nicht. Aus der Repo-Kopie ist das Board funktionslos; es läuft nur im
Claude-Design-Projekt. Nirgends dokumentiert.
`design/README.md:19` führt `audi-dashboard-ios.css` in der Inhaltstabelle des Ordners (die Datei
liegt in `homeassistant/www/`) und behauptet, die Auflage „rührt `audi-dashboard-app.js` nicht an" —
Z. 5759 derselben Datei beschreibt, dass genau dort der Stylesheet-Loader ergänzt wurde.
**Markenassets:** Der Diff fügt keine neuen Binärdateien hinzu. `DataMetric360 Board.dc.html`
verwendet die vorhandenen Audi-Type-Dateien per `@font-face` — das Lizenzrisiko bleibt unverändert,
nicht erhöht.
## `.gitignore` [belegt]
`installationspaket/` ist korrekt ergänzt und schließt nichts Getracktes aus. Es fehlt
`data/entitaeten.json` neben den anderen drei Laufzeitdateien — heute harmlos, weil die Datei nur
unter `/config/audi_dashboard/` entsteht, aber inkonsistent zum eigenen Grundsatz der Datei.
---
# Was den Branch `umsetzung-datametric360` betrifft
**Der flespi-Schwenk macht `homeassistant/FMM003_MAPPING.md` überholt.** Das Dokument beschreibt
Codec JSON über MQTT/TLS mit eigener CA und einem `mosquitto_sub`-Mitschnitt. Mit flespi läuft es
über den nativen Codec8/TCP-Kanal mit IMEI-Authentifizierung, ohne Zertifikate und ohne eingehenden
Port; die Feldzuordnung entstünde aus der flespi-REST-API. Beim Zusammenführen anpassen oder
ersetzen.
**Das CORS-Rätsel ist damit auch erklärt.** Die Notiz auf `main`, der `http:`-Block sei aus der
`configuration.yaml` entfernt worden, „nachdem HA angefangen hat, ihn zu migrieren beziehungsweise
zu ignorieren", erklärt, warum `cors_allowed_origins` in HA 2026.8 wirkungslos blieb — auch bei
gleichem Ursprung. Gehört in `homeassistant/REVERSE_PROXY.md` nachgetragen.
**Die Falle beim Zusammenführen** steht bereits in `AGENTS.md` dieses Branches: `profil_lesen()`
gibt hier bei fehlender Profildatei `None` zurück, `profil.py` ist auf `main` unverändert und geht
konfliktfrei durch — die dortigen Aufrufstellen prüfen das aber nicht.
---
# Empfehlung
Reihenfolge nach Schaden:
1. **Befund 1** (Datenverlust) — zwei Riegel, wenige Zeilen.
2. **Befunde 2 und 5** (Zurücksetzen) — ein gemeinsamer Fix in `overrides_anwenden()`.
3. **Befunde 3 und 4** (falsche Vorbelegung) — Index in den Vorschlag einbeziehen, Schwelle auf 5.
4. **Befund 6** (Sicherung) — eine Zeile in `backup.py`, plus Wiederherstellung.
5. **Befund 7** (Absicherung beim Lesen), **8** (Akku), **9/10** (Bedienbarkeit, Robustheit).
Die Punkte 1 bis 4 betreffen alle das neue Setup-Menü und lassen sich in einem Zug erledigen.
+54 -20
View File
@@ -1,5 +1,21 @@
# Audi Dashboard — Technical Specification
> ## ⚠️ Teilweise überholt (Stand 2026-08-13)
>
> Dieses Dokument beschreibt den Stand **vor** der FMM003-Umstellung. Drei Dinge haben sich seither
> geändert und gelten in diesem Dokument durchgehend als veraltet:
>
> 1. **Fahrterkennung** läuft über den Zündungssensor des FMM003, nicht mehr über den
> iPhone-WLAN-Sensor. `sensor.iphone_wifi_connection` und `fahrzeug.wlan_name` sind entfernt.
> 2. **Datenquelle** ist der FMM003 über flespi (Codec8/TCP), nicht mehr die HACS-Integration
> `TommiG1/HA_VAG-EU-Data-Act`.
> 3. **Entity-IDs** werden im Setup-Menü der App zugeordnet (Einstellungen → Fahrzeug einrichten →
> Setup) und in `data/entitaeten.json` abgelegt. `einstellungen.py` enthält nur noch die
> eingebauten Vorgabewerte und ist keine Pflicht-Bearbeitung mehr vor der Installation.
>
> Alles Übrige — Datenmodell, Geschäftsregeln, Frontend-Aufbau, §7 „Known Gaps" — gilt weiterhin.
> Den aktuellen Stand führt `AGENTS.md`, Abschnitt B.
**Audience:** an AI coding agent picking up this codebase with no prior context.
**Scope:** the Home Assistant panel (`homeassistant/`) — the finished, in-use app. A separate sibling project, `design-system/`, is a standalone React component-library replica of this app's visual language, built solely to feed Claude Design (`claude.ai/design`) via the `/design-sync` skill; it is **not** part of this app and deliberately excludes Audi brand assets. `COMPANION_APP_ARCHITECTURE.md` documents a third, **not-yet-built** sibling project — an iOS/Android/HA-iframe companion app fed by a Teltonika FMM003 tracker instead of the WLAN-sensor/VAG-integration data sources described below; its design is decided but no code exists yet. Don't confuse any of the three.
@@ -25,33 +41,51 @@ A private, single-user Home Assistant `panel_custom` dashboard that replaces the
## 2. Concept & Design System
### Visual language
Dark ("Nacht") is the default and primary theme; a light ("Tag") theme exists as a toggle. No shadows, no gradients anywhere except the fade under the vehicle photo (`.szene`). Flat tiles, pill-shaped controls, hairline dividers.
> **iOS-Optik ist seit 2026-08-13 die verbindliche Linie** (Entscheidung nach
> `DESIGN_REVIEW_2026-08-13.md` §A, Option 1: „iOS-Optik als neue verbindliche Linie
> dokumentieren"). Das ursprüngliche, reine Audi-CI-Design unten war bis dahin gültig und bleibt
> wortgleich archiviert in [`AUDI_CI_ARCHIV_2026-08-13.md`](AUDI_CI_ARCHIV_2026-08-13.md) —
> inklusive einer kurzen Anleitung, wie man technisch zurückwechselt (die Auflage ist rein additiv,
> die Basis-Werte unten sind im Code nach wie vor vorhanden). `bauauftrag.md` §8 beschreibt weiterhin
> das alte Design und gilt ab hier als überholt, wie es die Projektkonvention für Entscheidungen
> vorsieht (siehe `AGENTS.md`).
>
> Bekannter, noch offener Folgefehler aus demselben Review: die Rot-Semantik ist in der iOS-Auflage
> invertiert (`.aktion` füllt jede Primäraktion rot statt nur destruktive) — Befund B, noch nicht
> behoben.
### Colors (CSS custom properties, defined per-theme via `:root` / `[data-theme="tag"]`)
### Visual language
iOS-native Formensprache (`audi-dashboard-ios.css`, additive Auflage über der Audi-CI-Basis unten):
große Titel, gruppierte 44-px-Zeilen, transluzente Tab-Leiste mit Blur, iOS-Schalter/-Segmente,
gefüllte Aktionsknöpfe. Dunkel ("Nacht") bleibt Standard, "Tag" als Umschalter. Audi Type und das
Rot `#F50537` bleiben führend (Typografie, Akzentfarbe) — Fläche, Radius und Signalfarben folgen
iOS-Konventionen statt der ursprünglichen Audi-Palette.
### Colors (CSS custom properties, iOS-Auflage überschreibt die Audi-CI-Basis pro Theme)
| Role | Nacht (default) | Tag |
|---|---|---|
| `--canvas` (page background) | `#161b23` | `#FFFFFF` |
| `--tile` | `#1f2733` | `#f2f2f2` |
| `--tile-2` (inputs, hover) | `#2a3341` | `#e5e5e5` |
| `--line` | `rgba(255,255,255,.10)` | `rgba(0,0,0,.10)` |
| `--line-strong` | `rgba(255,255,255,.20)` | `rgba(0,0,0,.22)` |
| `--canvas` (page background) | `#0C1014` | `#F2F2F7` |
| `--tile` | `rgba(255,255,255,.055)` | `#FFFFFF` |
| `--tile-2` | `rgba(255,255,255,.10)` | `#EFEFF4` |
| `--line` | `rgba(255,255,255,.13)` | `rgba(60,60,67,.13)` |
| `--line-strong` | `rgba(255,255,255,.24)` | `rgba(60,60,67,.24)` |
| `--fg` | `#FFFFFF` | `#000000` |
| `--fg2` (secondary text) | `#9aa1ad` | `#4c4c4c` |
| `--fg3` (labels/eyebrows) | `#8a94a3` | `#666666` |
| `--ok` | `#15da15` | `#0DA20D` |
| `--warn` | `#ffaa00` | `#ffaa00` |
| `--shade` | `rgba(255,255,255,.05)` | `rgba(0,0,0,.04)` |
| `--bad` | `#fd2c4e` | `#eb0d3f` |
| `--red` (accent, theme-independent) | `#F50537` | `#F50537` |
| `--fg2` | `#BFC4CC` | `#3C3C43` |
| `--fg3` | `#8E8E93` | `#8E8E93` |
| `--ok` | `#30D158` | `#34C759` |
| `--warn` | `#FFD60A` | `#FF9F0A` |
| `--bad` | `#FF453A` | `#FF3B30` |
| `--ios-tint` (Akzent/Aktionsfüllung, theme-unabhängig) | `#F50537` | `#F50537` |
`--red` is used sparingly (accents, active states, destructive actions), not as a background fill.
`.aktion` füllt sich vollflächig mit `--ios-tint` — abweichend von der ursprünglichen Regel „Rot nur
als Akzent" (bekannter, offener Befund, siehe Kasten oben).
### Radii
- `--r-tile: 20px` — tiles, the vehicle-photo "scene" container.
- `--r-pill: 999px`buttons, switches, segmented controls, progress bars.
- Small functional elements (form inputs, placeholder/mini image boxes, popups) use **hardcoded literal pixel values** (6px / 12px / 14px respectively) rather than a shared token — see §7 for why this is a discrepancy from the brief.
- `--r-tile: 16px` (iOS-Wert; Audi-CI-Basis war 20px, siehe Archiv) — Kacheln, Popups, Bildbereiche.
- `--r-pill: 999px` unverändertButtons, Schalter, Segmente, Fortschrittsbalken.
- `.setup-popup`/`.standortmenu` schreiben weiterhin `border-radius: 20px` als Literal
(Inkonsistenz zum 16px-Token, ebenfalls im Review dokumentiert, noch nicht bereinigt).
### Typography
Three font-family names in play, all under the trademarked "Audi Type" family, embedded inline as base64 `woff2` directly in `audi-dashboard.css` (private-install only — see Licensing below):
@@ -231,7 +265,7 @@ These are places the shipped code and the original build brief disagree — load
## 8. File map
```
Audi_app_TG/
audi-app/
├── bauauftrag.md original build brief (German, historical — see §7)
├── dashboard-muster.html the original static prototype (superseded, kept for reference)
├── SPECIFICATION.md this document
+82 -40
View File
@@ -52,7 +52,7 @@ KI-Modell oder in einer frischen Session ohne Vorwissen abgearbeitet werden kann
| 7 | Alle Screens umsetzen | 3, 6 | nein |
| 8 | Audi-Assets einbauen | 7 | nein |
| 9 | Tests (authentifiziert + Komponenten) | 6 | nein |
| 10 | PWA + Capacitor/iOS-Sideload | 7 | Mac mit Xcode, iPhone |
| 10 | PWA + Capacitor/iOS-Sideload | 7 | erledigt bis auf das Signieren aufs Geraet |
| 11 | HA-Einbettung, Ablösung + Archivierung des Panels | 710 | HA-Produktivinstanz |
| 12 | Externer Zugriff: Cloudflare Tunnel + Reverse Proxy | teils unabhängig | Domain/DNS, Besitzer |
| 13 | FMM003: MQTT, Zertifikate, Mapping, Fahrterkennung | Hardware | **ja: FMM003 verbaut** |
@@ -416,10 +416,24 @@ nicht ein Riesencommit.
---
## Phase 10 — PWA und Capacitor 🟡 STUFE 1 ERLEDIGT (2026-08-11)
## Phase 10 — PWA und Capacitor ERLEDIGT (2026-08-11)
> PWA fertig. Capacitor-Konfiguration, Secure-Storage-Adapter und die QR-Seite
> liegen bereit; die Hülle selbst braucht einen Mac mit Xcode und ein Gerät.
> PWA fertig. Native Hülle gebaut und in Betrieb: Capacitor 8 für iOS und
> Android, Build im Simulator (iPhone 17 Pro, iOS 26.4) erfolgreich, App zeigt
> echte Daten. `CapacitorHttp` umgeht die CORS-Beschränkung der WebView.
> Offen bleibt allein das Signieren aufs eigene Gerät — das braucht das
> angeschlossene iPhone und die Apple-ID des Besitzers.
>
> **2026-08-29: Schritt 7 erledigt.** Der Besitzer hat das bezahlte
> Entwicklerkonto gelöst und das iPhone im Portal registriert; die Team-Kennung
> blieb dabei `RMACS9VLS4` (die bezahlte Mitgliedschaft stuft das bestehende
> Team hoch, sie legt kein neues an). Ergebnis: `companion-app/auslieferung/App.ipa`,
> 2,4 MB, **Ad-hoc** signiert mit `Apple Distribution: Paul Nothaft`, Profil
> gültig bis 29.08.2027. Zwei Skripte tragen das:
> `scripts/ios-signieren.sh` (bauen und signieren) und `scripts/ios-luftweg.sh`
> (Manifest und Installationsseite für die Übertragung über die Luft).
> Offen bleibt allein ein HTTPS-Host, der die Dateien ausliefert — siehe
> Schritt 8. Fallstricke und Diagnosen in `AGENTS.md` Abschnitt AI.
**Ziel:** Die App läuft auf dem iPhone. Zwei Stufen — erst PWA (sofort nutzbar), dann Capacitor
(Keychain + QR-Scan).
@@ -446,11 +460,27 @@ nicht ein Riesencommit.
(Offline-JS-QR-Bibliothek, keine Netzabfrage) als QR anzeigt. Inhalt des QR: JSON
`{"url": "...", "token": "..."}`. Wenn das zusammen > 1 Tag Aufwand wird: weglassen —
manuelles Einfügen ist die beschlossene, ausreichende Lösung.
7. **iOS-Sideload:** `npx cap open ios`, in Xcode Signing mit eigener Apple-ID, aufs Gerät bauen.
⚠️ Entscheidungspunkt für den Besitzer: mit kostenlosem Apple-Konto läuft die Signatur nach
**7 Tagen** ab (App neu aufspielen); ein bezahltes Entwicklerkonto (99 €/Jahr) macht 1 Jahr.
Bei 7-Tage-Schmerz ist die PWA-Stufe die Alltagslösung, Capacitor das Extra für Keychain/QR.
8. ✅ Fertig wenn: App startet nativ auf dem iPhone, Token liegt im Keychain (Test: App löschen und
7. **iOS-Sideload (erledigt 2026-08-29):** `bash companion-app/scripts/ios-signieren.sh`
baut Webbündel, synchronisiert die Hülle, archiviert signiert und exportiert die `.ipa`
nach `companion-app/auslieferung/`. Die Team-Kennung steht im Skript, weil `ios/` gitignored
ist und jede in Xcode geklickte Einstellung beim nächsten `npx cap add ios` verschwinden würde.
Der Entscheidungspunkt Kostenlos-vs-Bezahlt ist entschieden: **bezahltes Konto**, damit
Signatur ein Jahr gültig statt sieben Tage, und Geräteregistrierung über das Portal ohne
angeschlossenes Telefon.
⚠️ **Falle, die viel Zeit kostet:** beim ersten Signieren fragt der Schlüsselbund per Dialog
um Erlaubnis. Wird der nicht beantwortet, hängt `xcodebuild` wortlos minutenlang und endet
mit `errSecInternalComponent`. Im Dialog **„Immer erlauben"** wählen — ein Archiv signiert
über 25 Binärdateien und würde sonst jedes Mal erneut fragen.
8. **Übertragung über die Luft** (statt Kabel): `bash companion-app/scripts/ios-luftweg.sh
https://<host>` erzeugt `manifest.plist`, Installationsseite und Symbole in
`ios/build/luftweg/`. Der Ordner muss über **HTTPS mit öffentlich vertrauenswürdigem
Zertifikat** ausgeliefert werden — iOS lehnt einfaches HTTP und selbstsignierte Zertifikate
ab, Home Assistant unter `/local/` genügt also **nicht**. Passend: `tailscale serve --bg
<ordner>` (echtes Let's-Encrypt-Zertifikat auf `*.ts.net`, kein offener Port, iPhone ohnehin
im Tailnet); der Cloudflare-Tunnel aus Phase 12 täte es später ebenso. Link auf dem iPhone in
**Safari** öffnen — andere Browser reichen `itms-services://` nicht ans System weiter.
⏳ Offen: dieser Mac ist bei Tailscale abgemeldet, es läuft also noch kein Host.
9. ✅ Fertig wenn: App startet nativ auf dem iPhone, Token liegt im Keychain (Test: App löschen und
neu installieren → Token weg; Backup/Restore-Verhalten notieren), QR-Einrichtung funktioniert
oder ist dokumentiert entfallen.
9. Committen; `AGENTS.md` Block A Punkte QR/Secure-Storage abhaken.
@@ -481,48 +511,60 @@ nicht ein Riesencommit.
## Phase 12 — Externer Zugriff: Cloudflare Tunnel + Reverse Proxy 🟡 VORBEREITET
> Fertige Freigabeliste samt Prüfbefehlen: `homeassistant/REVERSE_PROXY.md`.
> Ausführung braucht Cloudflare-Konto und die Nameserver-Umstellung.
> **2026-08-28 aktualisiert/teilweise überholt.** Die Schritte unten stammen aus der frühen
> Planungsphase (2026-08-11) und enthalten inzwischen überholte Annahmen: zwei getrennte Hostnamen
> (App-Domain + API-Subdomain — entfällt, die App wird nie als Web-Build ausgeliefert, siehe
> `COMPANION_APP_ARCHITECTURE.md` §5 Punkt 4) und pyscript-Entitäts-/Dienstnamen (seit der
> Integrations-Umstellung 2026-08-23 `sensor.audi_dashboard_*`/`audi_dashboard.<name>`, siehe
> `AGENTS.md` Abschnitt H). Reverse-Proxy-Wahl (Nginx Proxy Manager) und die vollständige, aktuelle
> Freigabeliste stehen bereits fest. **Maßgeblich für die tatsächliche Einrichtung sind
> [`homeassistant/INTERNET_ZUGRIFF_EINRICHTEN.md`](homeassistant/INTERNET_ZUGRIFF_EINRICHTEN.md)
> (Schritt-für-Schritt-Anleitung) und [`homeassistant/REVERSE_PROXY.md`](homeassistant/REVERSE_PROXY.md)
> (die Freigabeliste selbst)** - die Liste unten bleibt nur als Entscheidungsprotokoll stehen, nicht
> als aktuelle Anleitung.
**Ziel:** Die App funktioniert von unterwegs (Mobilfunk), ohne dass HA selbst erreichbar ist.
Referenz: `COMPANION_APP_ARCHITECTURE.md` §4/§5. Teilweise Besitzer-Aufgaben (Konten/DNS).
1. **[Besitzer] Nameserver umstellen:** `datametric360.app` bei all-inkl auf die
1. **[Besitzer] Nameserver umstellen:** `datametric360.de` bei all-inkl auf die
Cloudflare-Nameserver zeigen lassen (Cloudflare-Konto → Site hinzufügen → „Full setup";
Domain trägt sonst nichts, Umstellung ist folgenlos). ✅ wenn `dig NS datametric360.app` die
Domain trägt sonst nichts, Umstellung ist folgenlos). ✅ wenn `dig NS datametric360.de` die
Cloudflare-Server liefert.
2. **Hostnamen festlegen** (Empfehlung aus dem Architekturdokument übernehmen): App unter
`https://datametric360.app`, API unter `https://api.datametric360.app` — getrennter
API-Hostname hält die Allowlist übersichtlich. Entscheidung in `AGENTS.md` protokollieren.
3. **Cloudflared-Add-on** in HA installieren, Tunnel erstellen, zwei öffentliche Hostnamen:
`datametric360.app` → Reverse-Proxy-Port (statische App-Dateien + nichts weiter),
`api.datametric360.app` → Reverse-Proxy-Port (API-Pfade). Kein Router-Port wird geöffnet.
4. **Reverse Proxy:** Nginx Proxy Manager-Add-on (Standard-Empfehlung; Traefik nur, falls NPM
an Grenzen stößt — Entscheidung dokumentieren). Allowlist ausschließlich:
- `GET /api/states/pyscript.audi_dashboard_*` und `GET /api/states/pyscript.reifen_*`
- `POST /api/services/pyscript/audi_dashboard_*`
- `GET /api/` (Verbindungsprüfung) und der WebSocket-Pfad `/api/websocket`
Alles andere (v. a. `/auth`, `/lovelace`, `/config`, `/api/config`) wird geblockt.
⚠️ Ehrliche Einschränkung dokumentieren: `/api/websocket` lässt sich nicht pfadgenau
beschneiden — nach `auth_ok` sind darüber alle States lesbar. Schutzschicht bleibt der LLAT
(ohne Token keine Verbindung); das ist die im Architekturdokument akzeptierte Abwägung.
5. **Prüfen von außen** (Mobilfunk, VPN aus): App lädt, Login klappt, `curl` auf einen
Nicht-Allowlist-Pfad (z. B. `/auth/authorize`) liefert 403/404; HA-Port 8123 ist von außen
nicht erreichbar.
6. ✅ Fertig wenn: Schritt 5 vollständig besteht. Die endgültige Allowlist wortwörtlich in
`COMPANION_APP_ARCHITECTURE.md` §5.1 eintragen (dort als offener Punkt vorgemerkt) und in
`AGENTS.md` Block B abhaken.
2. ~~Hostnamen festlegen (App-Domain + getrennter API-Hostname)~~ — **überholt, siehe Hinweis oben:**
ein einziger Hostname (`https://datametric360.de`) genügt, da die App nativ/sideload-only bleibt
und nie als eigener Web-Build ausgeliefert wird.
3. **Cloudflared-Add-on** in HA installieren, Tunnel erstellen, **ein** öffentlicher Hostname
(`datametric360.de` → Reverse-Proxy-Port). Kein Router-Port wird geöffnet. Genaue Klicks:
`INTERNET_ZUGRIFF_EINRICHTEN.md` Schritt 3/5.
4. **Reverse Proxy:** Nginx Proxy Manager-Add-on (entschieden, nicht Traefik). Die Allowlist ist
umfangreicher als unten ursprünglich skizziert (aktueller Entitäts-/Dienststand plus das
OTA-Bündel unter `/audi_dashboard_static/app/*`) - wortwörtlich in `REVERSE_PROXY.md` gepflegt,
nicht hier dupliziert. ⚠️ Ehrliche Einschränkung bleibt bestehen: `/api/websocket` lässt sich
nicht pfadgenau beschneiden — nach `auth_ok` sind darüber alle States lesbar. Schutzschicht
bleibt der LLAT (ohne Token keine Verbindung).
5. **Prüfen von außen** (Mobilfunk, VPN aus): die Prüfbefehle stehen in `REVERSE_PROXY.md`
("Nach der Einrichtung prüfen"). HA-Port 8123 ist von außen nicht erreichbar.
6. ✅ Fertig wenn: Schritt 5 vollständig besteht und die Server-Adresse in der App eingetragen ist
(`INTERNET_ZUGRIFF_EINRICHTEN.md` Schritt 7). In `AGENTS.md` Block B abhaken.
Schritte 24 können gegen die bestehende HA-API **vor** der FMM003-Hardware erledigt werden.
Schritte 34 können gegen die bestehende HA-API erledigt werden (die FMM003-Hardware läuft seit
2026-08-13 bereits über flespi, siehe Phase 13 unten — diese Phase hängt nicht mehr daran).
---
## Phase 13 — FMM003-Inbetriebnahme 🟡 VORBEREITET (wartet auf Hardware)
## Phase 13 — FMM003-Inbetriebnahme ✅ ÜBERHOLT — Hardware lief bereits über flespi
> Gerüst der Zuordnungstabelle und alle Schritte: `homeassistant/FMM003_MAPPING.md`.
> **Diese Phase ist gegenstandslos.** Die FMM003-Hardware ist seit 2026-08-13 in Betrieb, aber über
> den in `AGENTS.md` (§ FMM003/Datenpfad) beschriebenen Weg: **flespi (natives Codec8/TCP, IMEI-
> Auth), nicht MQTT/Mosquitto.** Die Schritte unten (eigene CA, Mosquitto-Add-on, Zertifikate,
> `mosquitto_sub`-Mitschnitt) beschreiben die zuerst geplante, dann verworfene Variante — stehen
> nur noch als Entscheidungsprotokoll. `homeassistant/FMM003_MAPPING.md` (unten referenziert)
> wurde beim Merge 2026-08-13 deshalb bewusst nicht übernommen. Maßgeblich ist ausschließlich
> `AGENTS.md`.
**Ziel:** Live-Daten vom Fahrzeug fließen nach HA; Fahrterkennung läuft über die Zündung.
Referenz: `COMPANION_APP_ARCHITECTURE.md` §2b. **Nichts hiervon vorab raten oder simulieren.**
**Ziel (nicht mehr aktuell):** Live-Daten vom Fahrzeug fließen nach HA; Fahrterkennung läuft über
die Zündung. Referenz: `COMPANION_APP_ARCHITECTURE.md` §2b. **Nichts hiervon vorab raten oder
simulieren.**
1. **Mosquitto-Add-on** in HA installieren, Benutzer für das Gerät anlegen.
2. **Zertifikate:** kleine eigene CA (drei `openssl`-Befehle: CA-Schlüssel+Zertifikat,
@@ -585,6 +627,6 @@ kommen asynchron über Entitäten (Muster: Beleg-Upload).
Die App gilt als fertig, wenn: alle Screens aus Phase 7 in beiden Layouts funktionieren; Onboarding,
Offline-Queue und Beleg-Upload gegen die Produktiv-HA laufen; die App als PWA und (falls Capacitor
umgesetzt) nativ auf dem iPhone installiert ist; der externe Zugriff über `datametric360.app`
umgesetzt) nativ auf dem iPhone installiert ist; der externe Zugriff über `datametric360.de`
funktioniert, ohne dass HA exponiert ist; das alte Panel archiviert ist; und `AGENTS.md` den
Endstand widerspiegelt (alle Blöcke AD abgehakt oder begründet gestrichen).
+179
View File
@@ -0,0 +1,179 @@
# Versionierung — eine Zahl, drei Verbraucher
Kurzfassung für den Alltag: **Wenn du etwas änderst, erhöhe `version` in
`custom_components/audi_dashboard/manifest.json`.** Alles Weitere ergibt sich
daraus von selbst.
---
## Warum es diese Zahl braucht
Das Panel und die iOS-App werden völlig unterschiedlich ausgeliefert:
- **Panel:** gehört zur Integration und wird mit ihr zusammen installiert. Es
kann gar nicht hinterherhinken — ein Update der Integration ist automatisch
ein Update des Panels.
- **iOS-App:** eine Capacitor-Hülle mit **fest gebündelten** Dateien. Sie
bleibt auf dem Stand, der beim Signieren in Xcode eingebaut wurde —
unbegrenzt.
Die Paritätsregel in `AGENTS.md` sichert, dass beide Codebasen in derselben
Sitzung geändert werden. Über das, was *läuft*, sagte lange nichts etwas: die
iOS-App konnte wochenlang zurückliegen, ohne dass es irgendwo sichtbar wurde.
Die Versionszahl schließt genau diese Lücke — nicht, indem sie die Abweichung
verhindert (das kann keine Zahl), sondern indem sie sie **sichtbar** macht.
## Wo sie steht
In `custom_components/audi_dashboard/manifest.json`, Feld `version`. Format
`2026.8.23.2` (Datum + laufende Nummer).
Genau dort und nirgends sonst. Home Assistant verlangt das Feld für jede
benutzerdefinierte Integration — eine zweite Quelle für dieselbe Angabe wäre
eine zu viel. Sie hätten irgendwann auseinandergelegen, und dann wäre der
Vergleich still falsch geworden statt laut.
> HACS würde dieses Feld ebenfalls lesen und danach entscheiden, ob ein
> Update bereitliegt — spielt hier aber keine Rolle: HACS kann laut eigener
> Dokumentation grundsätzlich nicht mit privaten GitHub-Repositories
> arbeiten, und das Repository ist privat (lizenzierte Audi-Assets). Das
> Manifest-Feld bleibt trotzdem die einzig richtige Stelle - Home Assistant
> selbst braucht es unabhängig von HACS.
> Bis 2026-08-23 gab es dafür eine eigene Datei `VERSION` in der Repo-Wurzel,
> daneben eine Unix-Sekundenzahl in `www/audi-dashboard-version.json` als
> Cache-Brecher. Beide sind mit dem Umbau zur Integration entfallen: die
> Version steht jetzt im Manifest, und die Oberflächen-Dateien tragen sie als
> `?v=…` in ihrer URL — die Integration hängt sie beim Anmelden des Panels an.
> Ein Cache-Brecher, der sich nur bei echten Änderungen ändert, ist der
> bessere: er lädt nichts unnötig neu.
## Wie sie durchs System läuft
```
manifest.json { "version": "2026.8.23.2" }
├─► Home Assistant führt sie als Version der Integration
├─► die Integration veröffentlicht sie als
│ sensor.audi_dashboard_app_version
│ └─► die iOS-App vergleicht sie mit ihrer eigenen, einkompilierten
│ Zahl und zeigt bei Abweichung einen Hinweis in der
│ Hinweisleiste
├─► sie hängt als ?v=… an der Panel-URL
│ └─► Browser laden Panel, CSS und Badges nur dann neu, wenn sich
│ wirklich etwas geändert hat
└─► companion-app: vite liest das Manifest beim Bauen und setzt sie als
__APP_VERSION__ ein (bricht ab, wenn das Feld fehlt)
```
Der Vergleich läuft in eine Richtung: **die Integration sagt, welcher Stand
installiert ist; die App sagt, welchen sie hat.** Stimmen sie nicht überein,
ist die App zu alt (oder, seltener, die Integration).
**Verglichen wird der Reihe nach, nicht nur auf Gleichheit — geändert am
04.09.2026.** Bis dahin galt hier ausdrücklich das Gegenteil (die Version sei
eine Kennung und keine Zahl). Der Anlass war ein echter Fall: nach einem
Xcode-Lauf lief die App auf `2026.9.4.17`, die Instanz stand noch auf
`2026.9.4.5` — und die App bot an, sich auf `.5` zu erneuern. Ein Rückschritt um
zwölf Fassungen, den eine Gleichheitsprüfung gar nicht sehen kann. Vorgabe des
Eigentümers: **erkannt wird eine neuere Fassung und keine andere.**
Der damalige Einwand ist nicht verworfen, sondern eingebaut: sortiert wird nur,
was dem Format `JJJJ.M.T.N` entspricht, und zwar Stelle für Stelle als Zahl —
zeichenweise stünde `.17` vor `.5`. Passt eine Seite nicht ins Schema, gilt sie
als **nicht sortierbar**; dann meldet die App eine Abweichung ohne Richtung und
bietet nichts an, statt eine Reihenfolge zu erfinden. Fehlt eine der beiden
Seiten ganz, wird gar nicht verglichen und nichts gemeldet — ein älteres Backend
oder ein Start ohne Netz darf keinen Fehlalarm auslösen.
Daraus folgen vier Verhaltensweisen, entschieden in `src/daten/appVersion.ts`:
| Lage | Streifen | Startblatt | Update-Knopf |
|---|---|---|---|
| App älter als der Server | bitte aktualisieren | ja | ja |
| App neuer (frisch signiert) | still | nein | **nein** |
| verschieden, nicht sortierbar | neutraler Hinweis | nein | nur bei Serverübereinstimmung |
| gleich / nicht vergleichbar | still | nein | nein |
## Was du tun musst
**Bei einer Änderung an der Oberfläche oder am Backend:**
1. `version` in `manifest.json` erhöhen — bei mehreren Änderungen am selben Tag
die laufende Nummer: `2026.8.23.2``2026.8.23.3`, am nächsten Tag
`2026.8.24.1`.
2. **OTA-Bündel neu bauen: `npm run ota`** (in `companion-app/`). Baut die
Oberfläche und packt sie mit der neuen Versionsnummer — siehe
[OTA-Updates](#ota-updates-seit-2026-08-24) unten. Ohne diesen Schritt
trägt `frontend/app/bundle.json` noch die alte Nummer: die Hinweisleiste
sagt dann „du bist veraltet" (liest die Version live), die Update-Seite
sagt „du bist aktuell" (vergleicht gegen das eingefrorene, jetzt falsche
Bündel) — ein Widerspruch, aus dem es ohne diesen Schritt keinen Ausweg
gibt. `install.ps1` prüft das seit 2026-08-24 selbst und warnt, falls
vergessen — aber besser, es passiert gar nicht erst.
3. Integration ausliefern: nach `git push` entweder in der App selbst unter
Einstellungen → Integration-Update auf „Auf Update prüfen" → „Update
installieren" (seit 2026-08-24, ersetzt install.ps1 als laufenden
Update-Weg — Windows Smart App Control blockiert dessen Ausführung
zuverlässig), danach Home Assistant neu starten; oder weiterhin
`homeassistant\installationspaket\Installieren.cmd` von Hand
(HACS scheidet aus — siehe Kasten oben, das Repository ist privat).
4. iOS-App für Xcode neu bauen (`npm run build`), damit eine über App Store
Connect / Sideload signierte Fassung dieselbe Zahl einkompiliert bekommt.
Für alle, die die App schon installiert haben, erledigt Schritt 2 das
automatisch über OTA — dieser Schritt ist nur für die nächste
Neuinstallation oder Signatur-Erneuerung nötig.
Vergisst du Schritt 4, ist das kein stiller Fehler mehr: die App meldet selbst,
dass sie älter ist als der Server. Vergisst du Schritt 2, bleibt es leider
still — deshalb die Prüfung in `install.ps1`.
**Bei einer reinen Neuinstallation** ohne Codeänderung: nichts tun.
## OTA-Updates (seit 2026-08-24)
`@capgo/capacitor-updater` ist eingebaut. Für reine Oberflächen-Änderungen
(JavaScript, CSS, Bilder) entfällt Schritt 3 oben — die App lädt sich den
neuen Stand selbst, auf einen Tastendruck des Nutzers in Einstellungen →
App-Update. Xcode wird nur noch für echte native Änderungen gebraucht
(neue Plugins, Berechtigungen, `capacitor.config.ts`).
**Woher das Bündel kommt.** `companion-app/scripts/ota-paket.ps1` packt
`dist/` (ohne Sourcemaps) in
`custom_components/audi_dashboard/frontend/app/bundle.zip`, daneben
`bundle.json` mit Version, SHA-256 und Größe. Weil das im Integrationsordner
liegt, reist es bei jeder Auslieferung automatisch mit — kein zweiter Weg, den
man vergessen könnte. Ausgeliefert wird es unter
`/audi_dashboard_static/app/bundle.zip`, gemeldet über dasselbe Feld, das
schon den Versionsvergleich trägt: `sensor.audi_dashboard_app_version`,
Attribut `daten.buendel`.
**Was die App damit macht** (`companion-app/src/daten/ota.ts`): erscheint ein
Bündel, dessen Version von der eigenen abweicht, zeigt Einstellungen →
App-Update einen Knopf. Ein Tastendruck lädt die Zip, prüft sie clientseitig
gegen die mitgelieferte SHA-256 (das Plugin selbst, nicht diese App) und
tauscht die Oberfläche aus — die App startet dabei neu.
**Die Rückfallebene.** `notifyAppReady()` läuft in `App.tsx`, sobald React
tatsächlich gerendert hat. Bleibt diese Meldung aus, weil das neue Bündel
die App zerlegt hat, rollt das Plugin nach der eingestellten Frist
(`appReadyTimeout` in `capacitor.config.ts`) von selbst auf das vorherige
Bündel zurück — ein kaputtes Update kann das Gerät deshalb nicht dauerhaft
unbrauchbar machen.
**Warum kein Selbstlauf.** `autoUpdate: false` — die App lädt nur auf
ausdrücklichen Tastendruck, nie im Hintergrund. Ein Update, das während einer
Fahrteintragung ungefragt die Oberfläche austauscht, wäre die falsche Sorte
Hilfsbereitschaft.
**HACS spielt hier keine Rolle und wird es auch nicht.** Das Update-Bündel
reist mit jedem Weg, der die Integration ausliefert, automatisch mit —
`install.ps1` für die Erstinstallation ebenso wie das Selbst-Update seit
2026-08-24 (aktualisierung.py, ersetzt install.ps1 als laufenden Update-Weg,
siehe Abschnitt J in AGENTS.md) — der Mechanismus hängt nicht daran, wie die
Integration selbst auf die Instanz kommt, nur daran, dass das Bündel im
Integrationsordner liegt.
+6
View File
@@ -2,3 +2,9 @@ node_modules/
dist/
*.local
.DS_Store
# Native Huellen: von `npx cap add` erzeugt, aus dem Webbuendel jederzeit
# wiederherstellbar. Die Projektdateien selbst (capacitor.config.ts) sind
# getrackt.
ios/
android/
+21 -1
View File
@@ -68,9 +68,29 @@ veröffentlicht**, und dieses Repository bleibt privat. `../design-system/`
kommt bewusst ohne diese Dateien aus, weil es nach außen hochgeladen wird —
dort niemals Markendateien ablegen.
## Die signierte App
`auslieferung/App.ipa` ist der fertige, **Ad-hoc signierte** Stand (Team
`RMACS9VLS4`, Profil gültig bis 29.08.2027, nur für das eingetragene iPhone).
Neu bauen: `bash scripts/ios-signieren.sh` - baut Webbündel, synchronisiert die
Hülle, signiert und legt die `.ipa` wieder hier ab.
Beim ersten Signieren fragt der Schlüsselbund um Erlaubnis. Dort **„Immer
erlauben"** wählen, sonst hängt `xcodebuild` wortlos und endet nach Minuten mit
`errSecInternalComponent`.
## Noch offen
- Capacitor-Hülle und sichere Ablage des Tokens (Keychain/Keystore)
- Native Hülle selbst: `ios/`/`android/` (von `npx cap add` erzeugt, absichtlich
gitignored) existieren in keinem Checkout dauerhaft und müssen auf einem Mac
(für iOS zwingend, Xcode) neu erzeugt werden - die sichere Tokenablage
(Keychain/Keystore) ist dagegen bereits vollständig angebunden
(`src/api/ablageNativ.ts`, von `main.tsx` aktiviert) und wird automatisch
aktiv, sobald die Hülle existiert
- Übertragung über die Luft: `scripts/ios-luftweg.sh https://<host>` erzeugt
Manifest und Installationsseite; es fehlt nur noch ein Host, der den Ordner
über **HTTPS mit gültigem Zertifikat** ausliefert (`tailscale serve`
angedacht, dieser Mac ist dort noch abgemeldet)
- QR-Einrichtung als Alternative zum Einfügen des Tokens
- Live-Ansicht der laufenden Fahrt: gebaut, aber über
`src/funktionen.ts` abgeschaltet, bis der FMM003 echte Werte liefert
Binary file not shown.
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="de">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DataMetric360 installieren</title>
<style>
body { font-family: -apple-system, sans-serif; background: #161b23; color: #eef1f6;
margin: 0; display: grid; place-items: center; min-height: 100vh; }
main { text-align: center; padding: 2rem; }
h1 { font-weight: 300; font-size: 1.5rem; }
a { display: inline-block; margin-top: 1.5rem; padding: 0.9rem 2rem; border-radius: 999px;
background: #eef1f6; color: #161b23; text-decoration: none; font-size: 1.1rem; }
p { color: #8a94a3; font-size: 0.9rem; line-height: 1.5; }
</style>
<main>
<h1>DataMetric360</h1>
<p>Version 2026.9.4.17</p>
<a href="itms-services://?action=download-manifest&amp;url=https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/manifest.plist">Installieren</a>
<p>In Safari oeffnen. Nach dem Antippen fragt iOS einmal nach,<br>danach erscheint die App auf dem Startbildschirm.</p>
</main>
</html>
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>items</key>
<array>
<dict>
<key>assets</key>
<array>
<dict>
<key>kind</key><string>software-package</string>
<key>url</key><string>https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/App.ipa?v=2026.9.4.17</string>
</dict>
<dict>
<key>kind</key><string>display-image</string>
<key>url</key><string>https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/symbol-192.png?v=2026.9.4.17</string>
</dict>
<dict>
<key>kind</key><string>full-size-image</string>
<key>url</key><string>https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/symbol-512.png?v=2026.9.4.17</string>
</dict>
</array>
<key>metadata</key>
<dict>
<key>bundle-identifier</key><string>app.datametric360</string>
<key>bundle-version</key><string>2026.9.4.17</string>
<key>kind</key><string>software</string>
<key>title</key><string>DataMetric360</string>
</dict>
</dict>
</array>
</dict>
</plist>
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+45 -24
View File
@@ -1,33 +1,15 @@
import type { CapacitorConfig } from "@capacitor/cli"
/**
* Capacitor-Konfiguration für die native Hülle.
*
* **Noch nicht in Betrieb.** Die Pakete sind bewusst nicht installiert; diese
* Datei liegt fertig da, damit die Inbetriebnahme nur noch aus den Befehlen in
* `../UMSETZUNGSPLAN.md` Phase 10 Stufe 2 besteht:
*
* npm i -D @capacitor/cli
* npm i @capacitor/core @capacitor/ios @capacitor/android
* npx cap add ios && npx cap add android
* npm run build && npx cap sync
* npx cap open ios # in Xcode signieren und aufs Gerät bauen
*
* Nur Sideload, nie App Store: die Audi-Schrift und die Typenschilder sind
* ausschließlich für diese private Installation freigegeben.
* ausschließlich für diese eine private Installation freigegeben.
*
* Der Typ ist absichtlich nicht aus @capacitor/cli importiert — das Paket
* fehlt ja noch, und ein toter Import würde die Typprüfung brechen.
* Ablauf: `npm run build && npx cap sync`, dann `npx cap open ios` und in
* Xcode signieren. Siehe `../UMSETZUNGSPLAN.md` Phase 10.
*/
interface CapacitorKonfiguration {
appId: string
appName: string
webDir: string
server?: { androidScheme?: string; iosScheme?: string; cleartext?: boolean }
ios?: { contentInset?: string; backgroundColor?: string }
android?: { backgroundColor?: string }
}
const konfiguration: CapacitorKonfiguration = {
const konfiguration: CapacitorConfig = {
appId: "app.datametric360",
appName: "DataMetric360",
webDir: "dist",
@@ -46,6 +28,45 @@ const konfiguration: CapacitorKonfiguration = {
android: {
backgroundColor: "#161b23",
},
plugins: {
// Leitet fetch() über die native HTTP-Schicht statt über die WebView.
//
// Das ist hier nicht Feinschliff, sondern notwendig: Die native Hülle
// liefert die Oberfläche unter einem eigenen Ursprung aus, jede Anfrage an
// Home Assistant ist damit ursprungsübergreifend, und die WebView lehnt sie
// ohne CORS-Freigabe ab. Nativ gestellte Anfragen kennen keine
// CORS-Prüfung — die App braucht dadurch keinerlei CORS-Einstellung am
// Server. Die WebSocket-Verbindung läuft unberührt weiter.
CapacitorHttp: { enabled: true },
// Oberflächen-Updates ohne Xcode (siehe src/daten/ota.ts und
// ../VERSIONIERUNG.md). Das Bündel liegt in der Integration und wird
// über Home Assistant ausgeliefert — keine fremde Cloud beteiligt.
CapacitorUpdater: {
// Kein Selbstlauf: die App lädt nur, wenn der Benutzer den Knopf in den
// Einstellungen drückt. Ein Update, das ungefragt die Oberfläche
// austauscht, während jemand gerade eine Fahrt einträgt, wäre die
// schlechtere Sorte Hilfsbereitschaft.
autoUpdate: false,
// Kommt eine frisch signierte App über Xcode aufs Gerät, gilt wieder
// deren eingebautes Bündel — sonst liefe ein älteres OTA-Bündel weiter
// und überdeckte genau die native Änderung, für die Xcode nötig war.
resetWhenUpdate: true,
// Meldet sich die App nach einem Wechsel nicht binnen dieser Zeit als
// startklar (notifyAppReady in src/main.tsx), kehrt das Plugin von
// selbst zum vorherigen Bündel zurück. Das ist die Rückfallebene: ein
// kaputtes Bündel kann das Gerät nicht dauerhaft lahmlegen. Bewusst
// großzügiger als die 10 Sekunden Vorgabe — die Erstladung holt Profil,
// Fahrten und Tankvorgänge, und ein langsames Netz darf nicht wie ein
// defektes Bündel aussehen.
appReadyTimeout: 20000,
// Ein Bündel, das beim Start durchgefallen ist, wird nicht aufbewahrt.
autoDeleteFailed: true,
},
},
}
export default konfiguration
+127
View File
@@ -0,0 +1,127 @@
# Tankbeleg aus dem Teilen-Blatt
Ein PDF aus Mail (oder einer anderen App) über **Teilen → DataMetric360** direkt
als Tankbeleg übernehmen.
## Wie es zusammenhängt
```
Mail ──Teilen──► DataMetric360Share (eigener Prozess)
│ PDF → Base64 → UserDefaults der App-Gruppe
│ group.app.datametric360
datametric360://beleg (öffnet die App)
App.tsx ──holt ab──► Tanken-Seite ──► belegLesen()
```
Eine Erweiterung ist ein eigener Prozess mit eigenem Dateibereich; der einzige
gemeinsame Boden mit der App ist die **App-Gruppe**. Der Beleg reist deshalb
Base64-kodiert durch die geteilten `UserDefaults` — die liest
`@capacitor/preferences` ohnehin schon, sobald man ihm die Gruppe nennt. Eine
Datei im Gruppen-Container wäre der sauberere Ort, bräuchte auf der App-Seite
aber eigenen nativen Code, also ein zusätzliches Plugin für einen Weg, den es
sonst nicht gäbe.
Der Preis ist eine Größengrenze von 4 MB (`grenzeBytes` im
`ShareViewController`). Ein Tankbeleg liegt bei einigen Dutzend Kilobyte; ein
gescanntes Mehrseiten-PDF kann darüber liegen — dann sagt die Erweiterung das,
und der Weg über **Beleg hochladen → Datei auswählen** in der App bleibt.
## Wo was liegt
| Datei | Rolle |
|---|---|
| `native/teilen/ShareViewController.swift` | nimmt das PDF entgegen, legt es ab, öffnet die App |
| `native/teilen/Info.plist` | wann DataMetric360 im Teilen-Blatt erscheint (genau ein PDF) |
| `native/teilen/DataMetric360Share.entitlements` | App-Gruppe für die Erweiterung |
| `native/App.entitlements` | dieselbe App-Gruppe für die App |
| `scripts/ios-teilen-einrichten.mjs` | hängt das alles ins erzeugte Xcode-Projekt |
| `src/daten/geteilterBeleg.ts` | holt den Beleg ab, räumt ihn weg |
| `src/App.tsx` | prüft beim Start und bei jeder Rückkehr, springt auf Tanken |
| `src/screens/Tanken.tsx` | nimmt ihn auf und lädt ihn hoch |
**`companion-app/ios/` ist gitignored** und wird von `npx cap add ios` neu
gebaut. Alles Native muss deshalb unter `native/` liegen und bei jedem Bau
eingespielt werden — dieselbe Regel, die schon die Team-Kennung und den
Standort-Schlüssel in `ios-signieren.sh` getrieben hat.
## Einmalig im Apple-Entwicklerportal
Ohne diese drei Schritte schlägt das Signieren fehl (die App-Gruppe muss
existieren, bevor ein Profil sie enthalten kann):
1. **Identifiers → App Groups**: `group.app.datametric360` anlegen.
2. **Identifiers → App IDs**: `app.datametric360.teilen` anlegen, Capability
*App Groups* aktivieren, die Gruppe zuordnen.
3. Bei der bestehenden App-ID `app.datametric360` ebenfalls *App Groups*
aktivieren und dieselbe Gruppe zuordnen.
Danach in Xcode einmal die Profile neu ziehen lassen — oder schlicht
`ios-signieren.sh` laufen lassen, das macht `-allowProvisioningUpdates`
selbst. **Merkposten aus Abschnitt AI:** ein zwischengespeichertes altes
Profil wird stillschweigend weiterverwendet. Ändert sich etwas an den
Berechtigungen, das passende `.mobileprovision` unter
`~/Library/Developer/Xcode/UserData/Provisioning Profiles` löschen und neu
exportieren.
## Bauen
```bash
bash scripts/ios-signieren.sh
```
Der Schritt „Share-Erweiterung einrichten" läuft dort automatisch, vor dem
Archivieren.
## Wenn das Skript abbricht
`ios-teilen-einrichten.mjs` bearbeitet die `project.pbxproj` über das
npm-Paket `xcode`. **Erstmals auf einem Mac gelaufen am 2026-09-02**; dabei kam
genau ein Fehler heraus, seither behoben: die Erweiterung wurde **zweimal**
eingebettet, weil `addTarget()` für den Typ `app_extension` von sich aus schon
eine Copy-Phase nach `PlugIns` anlegt und das Skript eine zweite hinzufügte.
Xcode quittierte das mit `error: Unexpected duplicate tasks`. Das Anlegen des
Ziels selbst, die Entitlements, das URL-Schema und die Idempotenz funktionierten
auf Anhieb.
Bricht es ab, ist nichts halb eingerichtet (es bricht vor dem Schreiben ab), und
dasselbe lässt sich in Xcode von Hand erledigen:
1. **File → New → Target… → Share Extension**, Name `DataMetric360Share`,
Sprache Swift, „Activate scheme" verneinen.
2. Die von Xcode erzeugten `ShareViewController.swift`, `Info.plist` und das
`MainInterface.storyboard` **löschen** und stattdessen die Dateien aus
`native/teilen/` in das Ziel legen (Storyboard wird nicht gebraucht, die
Erweiterung hat keine eigene Oberfläche).
3. Bundle-ID des Ziels auf `app.datametric360.teilen` setzen.
4. Beide Ziele → **Signing & Capabilities → + Capability → App Groups**,
`group.app.datametric360` anhaken.
5. Ziel `DataMetric360Share` → Build Settings → *Code Signing Entitlements* auf
`DataMetric360Share/DataMetric360Share.entitlements`; Ziel `App` auf
`App/App.entitlements`.
6. Ziel `App` → Build Phases → **Embed App Extensions** muss
`DataMetric360Share.appex` enthalten.
7. `ios/App/App/Info.plist`: URL-Schema `datametric360` unter
`CFBundleURLTypes` eintragen.
Schritt 7 ist der leiseste: fehlt er, wird der Beleg abgelegt, die App aber
nicht geöffnet — er taucht dann erst auf, wenn man die App das nächste Mal von
Hand startet.
## Prüfen, ob es wirkt
1. In Mail ein PDF antippen → **Teilen** → DataMetric360 muss in der Liste
stehen (nur bei genau einem PDF, sonst absichtlich nicht).
2. Antippen → die App kommt nach vorn, springt auf **Tanken**, das Formular ist
offen und liest den Beleg („Beleg wird gelesen …").
3. Danach ist der Eintrag weg: dieselbe Datei ein zweites Mal zu teilen muss
wieder funktionieren, ein bloßes Wechseln in die App darf den Beleg **nicht**
erneut verarbeiten.
## Was hier nicht steht
Android. Der Ablauf ist dort ein anderer (`intent-filter` statt App-Gruppe),
und die Android-Hülle dieses Projekts ist ohnehin nicht in Gebrauch.
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Entitlements der Haupt-App. Bislang hatte sie keine eigene Datei - erst die
App-Gruppe der Share-Erweiterung macht eine noetig. Derselbe Bezeichner wie
in native/teilen/DataMetric360Share.entitlements.
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.datametric360</string>
</array>
</dict>
</plist>
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

@@ -0,0 +1,38 @@
{
"images" : [
{
"filename" : "AppIcon-hell.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "AppIcon-dunkel.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"filename" : "AppIcon-getoent.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,99 @@
//
// BelegZwischenablage.swift
// DataMetric360
//
// Holt ein PDF aus der System-Zwischenablage der Weg, den die Web-API
// nicht anbietet.
//
// WARUM ES DAS BRAUCHT
// --------------------
// `navigator.clipboard.read()` gibt aus Datenschutzgründen nur Text, HTML
// und Bilder heraus; ein kopiertes PDF taucht dort NIE auf. Der Knopf Aus
// Zwischenablage einfügen" endete deshalb auf dem Telefon zwangsläufig mit
// In der Zwischenablage liegt kein PDF" (gemeldet vom Eigentümer,
// 02.09.2026). Das ist keine Lücke in unserem Code, sondern die Grenze der
// Schnittstelle.
//
// `UIPasteboard` kennt diese Grenze nicht: es gibt beliebige Typen heraus,
// auch `com.adobe.pdf`. Der Preis ist derselbe wie im Web iOS zeigt beim
// Zugriff seine eigene Einfügen-Rückfrage, und das ist richtig so.
//
// DREI FORMEN, IN DENEN EIN PDF IN DER ZWISCHENABLAGE LIEGT
// ---------------------------------------------------------
// 1. Als Daten unter `com.adobe.pdf` so legen es die meisten Apps ab.
// 2. Als Datei-URL Mail kopiert einen Anhang oft nur als Verweis auf eine
// temporäre Kopie. Ohne den zweiten Weg bliebe genau der Fall übrig, für
// den der Knopf gedacht ist.
// 3. Als Zusage (`NSItemProvider`) die Datei existiert dann noch gar nicht,
// die kopierende App liefert sie erst auf Anforderung. `data(forPasteboardType:)`
// gibt dabei nil zurück und `urls` bleibt leer; nur der Anbieter kennt sie.
// Das ist Apples eigener, empfohlener Weg und der einzige, der auch eine
// Datei aus einer Sandbox einer anderen App zuverlässig herausgibt Weg 2
// scheitert dort am Lesezugriff. Er ist asynchron, deshalb steht er hier
// zuletzt: die beiden schnellen Wege sollen nicht auf ihn warten.
//
// DIESE DATEI IST VERSIONIERT, ios/ NICHT.
// `npx cap add ios` erzeugt den nativen Ordner neu und würde alles darin
// verlieren. scripts/ios-teilen-einrichten.mjs kopiert sie bei jedem Bau
// wieder hinein und hängt sie an die Sources-Phase des App-Ziels.
//
import Capacitor
import Foundation
import UIKit
import UniformTypeIdentifiers
@objc(BelegZwischenablagePlugin)
public class BelegZwischenablagePlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "BelegZwischenablagePlugin"
public let jsName = "BelegZwischenablage"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "pdfHolen", returnType: CAPPluginReturnPromise)
]
/// Liefert `{ base64, name }`, wenn ein PDF in der Zwischenablage liegt
/// sonst ein leeres Ergebnis. Bewusst kein Fehler: nichts kopiert" ist
/// kein Fehlschlag, sondern eine Antwort, und die Oberfläche sagt dazu
/// ohnehin das Passende (zwischenablage.ts).
@objc func pdfHolen(_ call: CAPPluginCall) {
let ablage = UIPasteboard.general
if let daten = ablage.data(forPasteboardType: UTType.pdf.identifier) {
return call.resolve(["base64": daten.base64EncodedString(), "name": "beleg.pdf"])
}
// Mail legt den Anhang oft nur als Verweis ab.
for url in ablage.urls ?? [] where url.pathExtension.lowercased() == "pdf" {
// Sicherheitsbereich anfordern: die URL zeigt auf eine Datei
// außerhalb des eigenen Sandkastens, sonst schlägt das Lesen fehl.
let erlaubt = url.startAccessingSecurityScopedResource()
defer { if erlaubt { url.stopAccessingSecurityScopedResource() } }
if let daten = try? Data(contentsOf: url) {
return call.resolve([
"base64": daten.base64EncodedString(),
"name": url.lastPathComponent,
])
}
}
// Dritter Weg: die Datei liegt nur als Zusage vor.
let anbieter = ablage.itemProviders.first {
$0.hasItemConformingToTypeIdentifier(UTType.pdf.identifier)
}
guard let anbieter else {
return call.resolve([:])
}
anbieter.loadDataRepresentation(forTypeIdentifier: UTType.pdf.identifier) { daten, _ in
guard let daten, !daten.isEmpty else {
// Kein Fehler: die Oberfläche sagt zu einem leeren Ergebnis
// ohnehin das Passende (zwischenablage.ts).
return call.resolve([:])
}
let name = anbieter.suggestedName.map { $0.hasSuffix(".pdf") ? $0 : $0 + ".pdf" }
call.resolve([
"base64": daten.base64EncodedString(),
"name": name ?? "beleg.pdf",
])
}
}
}
+135
View File
@@ -0,0 +1,135 @@
//
// KalenderTermin.swift
// DataMetric360
//
// Legt einen Termin über EventKit an der Weg, den das Teilen-Blatt nicht
// anbietet.
//
// WARUM ES DAS BRAUCHT
// --------------------
// Bis zum 03.09.2026 ging der Werkstatt- und der Radwechseltermin als
// .ics-Datei ins iOS-Teilen-Blatt. Dort erscheint nie ein Kalender, und das
// liegt nicht an der Datei: Apples Kalender-App meldet sich beim System
// überhaupt nicht als Teilen-Ziel an, für kein Format. Das Blatt kann sie
// deshalb gar nicht zeigen, egal wie die Datei aussieht.
//
// Dass ein per Mail geschickter Anhang funktioniert, führt in die Irre: dort
// öffnet nicht das Teilen-Blatt, sondern die Dokumentvorschau des Systems
// (Quick Look). Die erkennt text/calendar und blendet Alle hinzufügen" ein.
// Ein anderer Mechanismus, den eine App nicht aus dem Teilen-Blatt heraus
// erreicht. Gemeldet vom Eigentümer am 02.09.2026.
//
// EventKit umgeht beides: der Termin geht direkt an den Kalender.
//
// WARUM DER SYSTEM-EDITOR UND NICHT `EKEventStore.save`
// -----------------------------------------------------
// `EKEventEditViewController` zeigt den fertig ausgefüllten Termin und lässt
// ihn den Menschen bestätigen. Das passt zur Vorgabe des Eigentümers, dass
// der Werkstatttermin keine eigene Verwaltung bekommt (siehe Abschnitt BQ in
// AGENTS.md): die App liefert was, wann, welche Werkstatt" und ist danach
// raus gespeichert wird im Kalender, nicht bei uns.
//
// Der zweite Grund ist die Berechtigung. Seit iOS 17 braucht der System-
// Editor keine (WWDC23, Discover Calendar and EventKit"): er läuft außerhalb
// unseres Zugriffs, wir übergeben nur den Entwurf. Ein direktes `save()`
// bräuchte dagegen eine Freigabe samt Rückfrage. Deshalb wird hier auch
// `event.calendar` NICHT gesetzt das würde `defaultCalendarForNewEvents`
// lesen und damit doch eine Freigabe verlangen; der Editor sucht den
// Standardkalender selbst.
//
// DIESE DATEI IST VERSIONIERT, ios/ NICHT.
// `npx cap add ios` erzeugt den nativen Ordner neu und würde alles darin
// verlieren. scripts/ios-teilen-einrichten.mjs kopiert sie bei jedem Bau
// wieder hinein und hängt sie an die Sources-Phase des App-Ziels.
//
import Capacitor
import EventKit
import EventKitUI
import Foundation
import UIKit
@objc(KalenderTerminPlugin)
public class KalenderTerminPlugin: CAPPlugin, CAPBridgedPlugin, EKEventEditViewDelegate {
public let identifier = "KalenderTerminPlugin"
public let jsName = "KalenderTermin"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "terminAnlegen", returnType: CAPPluginReturnPromise)
]
/// Muss den Editor überleben eine lokale Instanz wäre beim Erscheinen
/// des Blatts schon wieder abgeräumt.
private let speicher = EKEventStore()
/// Der Aufruf wartet, bis der Mensch im Editor entschieden hat.
private var offenerAufruf: CAPPluginCall?
@objc func terminAnlegen(_ call: CAPPluginCall) {
guard let titel = call.getString("titel"), !titel.isEmpty else {
return call.reject("Ohne Titel kein Termin.")
}
guard let isoTag = call.getString("isoTag"), let tag = tagAus(isoTag) else {
return call.reject("Kein gültiges Datum (erwartet JJJJ-MM-TT).")
}
let termin = EKEvent(eventStore: speicher)
termin.title = titel
termin.isAllDay = true
// Ganztägig heißt für EventKit: Beginn und Ende am selben Tag. Deckt
// sich mit dem DTSTART;VALUE=DATE der bisherigen .ics.
termin.startDate = tag
termin.endDate = tag
if let ort = call.getString("ort"), !ort.isEmpty { termin.location = ort }
if let text = call.getString("beschreibung"), !text.isEmpty { termin.notes = text }
// Zwei Tage vorher erinnern - dieselbe Vorwarnzeit wie im TRIGGER:-P2D
// der .ics, die dieser Weg ablöst.
termin.addAlarm(EKAlarm(relativeOffset: -2 * 24 * 60 * 60))
DispatchQueue.main.async { [weak self] in
guard let self else { return }
guard let eltern = self.bridge?.viewController else {
return call.reject("Kein Fenster zum Anzeigen des Kalender-Editors.")
}
// Ein noch offener Aufruf kann nur von einem Editor stammen, der
// ohne Rueckmeldung verschwunden ist. Ihn stehen zu lassen hiesse,
// ein Versprechen nie einzuloesen.
self.offenerAufruf?.resolve(["status": "abgebrochen"])
self.offenerAufruf = call
let editor = EKEventEditViewController()
editor.event = termin
editor.eventStore = self.speicher
editor.editViewDelegate = self
// Wegwischen wuerde den Delegaten nicht rufen und den Aufruf oben
// haengen lassen; so bleiben nur "Hinzufuegen" und "Abbrechen".
editor.isModalInPresentation = true
eltern.present(editor, animated: true)
}
}
public func eventEditViewController(
_ controller: EKEventEditViewController,
didCompleteWith action: EKEventEditViewAction
) {
controller.dismiss(animated: true)
let aufruf = offenerAufruf
offenerAufruf = nil
// .deleted gibt es hier nicht (der Termin ist neu), zaehlt aber wie ein
// Abbruch: gespeichert wurde nichts.
aufruf?.resolve(["status": action == .saved ? "gespeichert" : "abgebrochen"])
}
/// 2027-03-14" -> Mitternacht dieses Tages in der Zeitzone des Geräts.
private func tagAus(_ isoTag: String) -> Date? {
let teile = isoTag.split(separator: "-").compactMap { Int($0) }
guard teile.count == 3 else { return nil }
var komponenten = DateComponents()
komponenten.year = teile[0]
komponenten.month = teile[1]
komponenten.day = teile[2]
var kalender = Calendar(identifier: .gregorian)
kalender.timeZone = TimeZone.current
return kalender.date(from: komponenten)
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 51 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 51 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 51 KiB

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Die App-Gruppe ist der einzige gemeinsame Boden zwischen Erweiterung und
App - ohne sie kann die Erweiterung nichts uebergeben. Derselbe Bezeichner
muss in App.entitlements stehen und im Apple-Entwicklerportal als App Group
angelegt sein.
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.datametric360</string>
</array>
</dict>
</plist>
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Info.plist der Share-Erweiterung.
NSExtensionActivationRule entscheidet, wann DataMetric360 im Teilen-Blatt
ueberhaupt auftaucht. Bewusst eng: genau EIN PDF, sonst nichts. Die
Erweiterung kann nur Tankbelege, und ein Eintrag, der bei jedem Foto und
jedem Link mitangeboten wird und dann ablehnt, ist schlechter als keiner.
NSExtensionPrincipalClass zeigt auf die Swift-Klasse ohne Storyboard -
die Erweiterung hat keine eigene Oberflaeche, sie nimmt die Datei entgegen
und oeffnet die App. Deshalb steht hier auch kein NSExtensionMainStoryboard.
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>de</string>
<key>CFBundleDisplayName</key>
<string>DataMetric360</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsAttachmentsWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,172 @@
//
// ShareViewController.swift
// DataMetric360Share
//
// Nimmt ein PDF aus dem iOS-Teilen-Blatt entgegen (typischerweise ein
// Tankbeleg aus Mail) und reicht es an die App weiter.
//
// WIE DIE ÜBERGABE FUNKTIONIERT UND WARUM SO
// --------------------------------------------
// Eine Share-Erweiterung ist ein eigener Prozess mit eigenem Dateibereich.
// Sie kann der App nichts direkt geben; der einzige gemeinsame Boden ist
// eine App-Gruppe. Der Weg hier:
//
// 1. Das PDF wird in den Container der App-Gruppe geschrieben.
// 2. Die Erweiterung öffnet die App über ihr URL-Schema und gibt den
// Pfad mit: `datametric360://beleg?pfad=<file://>`.
// 3. Die App liest die Datei über @capacitor/filesystem (das volle
// file://-Pfade ausdrücklich unterstützt) und löscht sie danach.
//
// DER ERSTE ENTWURF GING ÜBER USERDEFAULTS UND HAT NIE FUNKTIONIERT.
// Er legte das PDF base64-kodiert unter `dm360_geteilter_beleg` in
// `UserDefaults(suiteName: gruppe)` ab, weil die App UserDefaults über
// @capacitor/preferences ohnehin liest. Nur liest dieses Plugin (8.0.1,
// ios/Sources/PreferencesPlugin/Preferences.swift) IMMER
// `UserDefaults.standard` der App `configure({ group })` wechselt nicht
// den Container, sondern setzt nur ein Schlüssel-Präfix. Die App suchte
// also `group.app.datametric360.dm360_geteilter_beleg` in ihrem eigenen
// Bereich, während hier der plain Schlüssel im Gruppen-Container lag:
// falscher Behälter und falscher Schlüssel. Gemeldet am 02.09.2026
// (es wird kein Tankvorgang angelegt beim Teilen mit der App).
//
// Die Datei hat zwei weitere Vorteile: keine Base64-Aufblähung um ein
// Drittel, und keine 4-MB-Grenze UserDefaults ist für Einstellungen
// gedacht, nicht für Anhänge. Geblieben ist eine Obergrenze, aber eine
// großzügige (GRENZE_BYTES).
//
// DIESE DATEI IST VERSIONIERT, ios/ NICHT.
// `npx cap add ios` erzeugt den nativen Ordner neu und würde alles darin
// verlieren. scripts/ios-teilen-einrichten.mjs kopiert diese Datei bei
// jedem Bau wieder hinein und hängt das Ziel ins Xcode-Projekt.
//
import UIKit
import Social
import UniformTypeIdentifiers
class ShareViewController: UIViewController {
/// Muss zur App-Gruppe in beiden Entitlements-Dateien passen.
private let gruppe = "group.app.datametric360"
/// Unterordner im Gruppen-Container, in dem geteilte Belege liegen.
private let ordner = "geteilte-belege"
/// 24 MB. Kein technisches Limit mehr, sondern eine Vernunftgrenze:
/// ein Tankbeleg liegt bei einigen Dutzend Kilobyte, alles jenseits
/// davon ist kein Beleg, sondern ein Versehen.
private let grenzeBytes = 24 * 1024 * 1024
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
pdfUebernehmen()
}
private func pdfUebernehmen() {
guard
let eintrag = (extensionContext?.inputItems as? [NSExtensionItem])?.first,
let anhaenge = eintrag.attachments
else {
return abschliessen()
}
let pdfTyp = UTType.pdf.identifier
guard let anhang = anhaenge.first(where: { $0.hasItemConformingToTypeIdentifier(pdfTyp) })
else {
return melden("Kein PDF dabei.")
}
anhang.loadItem(forTypeIdentifier: pdfTyp, options: nil) { [weak self] wert, _ in
guard let self else { return }
// Je nach Quelle kommt eine URL, ein Data-Objekt oder nichts
// Brauchbares an Mail liefert eine URL auf eine temporäre
// Kopie, andere Apps reichen die Bytes direkt.
var daten: Data?
var name = "beleg.pdf"
if let url = wert as? URL {
daten = try? Data(contentsOf: url)
name = url.lastPathComponent
} else if let roh = wert as? Data {
daten = roh
}
guard let daten else {
return self.melden("Der Beleg ließ sich nicht lesen.")
}
guard daten.count <= self.grenzeBytes else {
return self.melden(
"Der Beleg ist zu groß für diesen Weg. In der App über „Beleg hochladen“ → „Datei auswählen“.")
}
self.ablegen(daten: daten, name: name)
}
}
private func ablegen(daten: Data, name: String) {
guard
let container = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: gruppe)
else {
return melden("Die App-Gruppe ist nicht eingerichtet.")
}
let ziel = container.appendingPathComponent(ordner, isDirectory: true)
// Ein Zeitstempel im Namen: zwei rasch nacheinander geteilte Belege
// sollen sich nicht gegenseitig überschreiben, und der Name bleibt
// für den Nutzer lesbar, falls er den Container je zu sehen bekommt.
let datei = ziel.appendingPathComponent(
"\(Int(Date().timeIntervalSince1970))-\(name)")
do {
try FileManager.default.createDirectory(
at: ziel, withIntermediateDirectories: true)
try daten.write(to: datei, options: .atomic)
} catch {
return melden("Der Beleg ließ sich nicht übergeben.")
}
DispatchQueue.main.async { [weak self] in
self?.appOeffnen(pfad: datei)
self?.abschliessen()
}
}
/// Öffnet die Host-App. Eine Erweiterung darf `UIApplication.shared` nicht
/// benutzen, deshalb der Umweg über die Responder-Kette der einzige von
/// Apple vorgesehene Weg aus einer Erweiterung heraus.
private func appOeffnen(pfad: URL) {
// Der Pfad wandert als Abfrageparameter mit. Er zeigt in den
// Gruppen-Container, den die App mit derselben Berechtigung lesen
// darf - kein zusätzliches Plugin nötig.
var bau = URLComponents()
bau.scheme = "datametric360"
bau.host = "beleg"
bau.queryItems = [URLQueryItem(name: "pfad", value: pfad.absoluteString)]
guard let ziel = bau.url else { return }
var antwort: UIResponder? = self
while let r = antwort {
if let app = r as? UIApplication {
app.open(ziel, options: [:], completionHandler: nil)
return
}
antwort = r.next
}
}
private func melden(_ text: String) {
DispatchQueue.main.async { [weak self] in
let hinweis = UIAlertController(title: "DataMetric360", message: text, preferredStyle: .alert)
hinweis.addAction(UIAlertAction(title: "Verstanden", style: .default) { _ in
self?.abschliessen()
})
self?.present(hinweis, animated: true)
}
}
private func abschliessen() {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}
+15 -1
View File
@@ -11,15 +11,27 @@
"typecheck": "tsc --noEmit",
"smoke": "node --experimental-strip-types scripts/smoke.ts",
"smoke:auth": "node --experimental-strip-types scripts/smoke-auth.ts",
"ota": "powershell -ExecutionPolicy Bypass -File scripts/ota-paket.ps1 -Bauen",
"test": "vitest run"
},
"dependencies": {
"@aparajita/capacitor-secure-storage": "^8.0.0",
"@audi-dash/ui": "*",
"@capacitor/android": "^8.5.0",
"@capacitor/app": "^8.1.1",
"@capacitor/core": "^8.5.0",
"@capacitor/filesystem": "^8.1.3",
"@capacitor/ios": "^8.5.0",
"@capacitor/preferences": "^8.0.1",
"@capacitor/share": "^8.0.1",
"@capgo/capacitor-updater": "^8.51.14",
"jsqr": "^1.4.0",
"leaflet": "^1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@capacitor/cli": "^8.5.0",
"@testing-library/react": "^16.3.2",
"@types/leaflet": "^1.9.12",
"@types/node": "^24.13.3",
@@ -27,8 +39,10 @@
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^29.1.1",
"qrcode": "^1.5.4",
"typescript": "^5.7.2",
"vite": "^6.0.7",
"vitest": "^2.1.8"
"vitest": "^2.1.8",
"xcode": "^3.0.1"
}
}
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env bash
# Bereitet die Installation ueber die Luft vor: legt neben der signierten .ipa
# die manifest.plist und eine kleine Installationsseite ab.
#
# bash scripts/ios-luftweg.sh https://mein-mac.tailnetname.ts.net
#
# Warum das noetig ist: iOS installiert eine App nur dann ueber die Luft, wenn
# ein itms-services-Link auf eine manifest.plist zeigt, die wiederum die
# Adresse der .ipa enthaelt. Beide Adressen muessen ueber **HTTPS mit einem
# oeffentlich vertrauenswuerdigen Zertifikat** erreichbar sein - einfaches HTTP
# und selbstsignierte Zertifikate lehnt iOS kommentarlos ab. Home Assistant
# unter /local/ genuegt dafuer nicht, das laeuft im Tailnet unverschluesselt.
#
# Passend dazu und ohne offenen Port: `tailscale serve` liefert ein echtes
# Let's-Encrypt-Zertifikat auf <maschine>.<tailnet>.ts.net aus. Voraussetzung
# ist, dass in der Tailscale-Verwaltung HTTPS-Zertifikate aktiviert sind.
#
# tailscale serve --bg ios/build/luftweg
#
# Danach den ausgegebenen Link auf dem iPhone in **Safari** oeffnen (andere
# Browser reichen itms-services nicht an das System weiter).
set -euo pipefail
if [ $# -lt 1 ]; then
echo "Aufruf: bash scripts/ios-luftweg.sh <basis-url> [zielordner]" >&2
echo "Beispiel: bash scripts/ios-luftweg.sh https://mein-mac.tailnetname.ts.net" >&2
echo "Gitea: bash scripts/ios-luftweg.sh \\" >&2
echo " https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung \\" >&2
echo " auslieferung" >&2
exit 1
fi
BASIS="${1%/}"
if [ "${BASIS#https://}" = "$BASIS" ]; then
echo "FEHLER: Die Basis-Adresse muss mit https:// beginnen." >&2
echo "iOS verweigert die Installation ueber die Luft ueber unverschluesseltes HTTP." >&2
exit 1
fi
HIER="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP="$(dirname "$HIER")"
IPA="$APP/auslieferung/App.ipa"
# Zweites Argument erlaubt es, direkt in den versionierten Ordner zu schreiben -
# noetig, wenn nicht ein eigener Host ausliefert, sondern das Repository selbst.
ZIEL="$APP/${2:-ios/build/luftweg}"
[ -f "$IPA" ] || { echo "FEHLER: $IPA fehlt - erst scripts/ios-signieren.sh laufen lassen." >&2; exit 1; }
# Version aus der .ipa selbst lesen statt sie hier zu wiederholen: zwei Quellen
# fuer dieselbe Angabe waeren eine zu viel.
AUSPACK="$(mktemp -d)"
trap 'rm -rf "$AUSPACK"' EXIT
unzip -q "$IPA" -d "$AUSPACK"
PLIST="$(find "$AUSPACK/Payload" -maxdepth 2 -name Info.plist | head -1)"
KENNUNG="$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$PLIST")"
VERSION="$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$PLIST")"
mkdir -p "$ZIEL"
[ "$IPA" -ef "$ZIEL/App.ipa" ] || cp "$IPA" "$ZIEL/App.ipa"
cp "$APP/public/symbol-192.png" "$ZIEL/symbol-192.png"
cp "$APP/public/symbol-512.png" "$ZIEL/symbol-512.png"
# Die Adressen tragen die Version als Anhaengsel (?v=...). Sie zeigen sonst bei
# jedem Bau auf dieselbe Zeichenkette, und der Installations-Dienst von iOS haelt
# eine einmal geholte .ipa dann fuer schon bekannt - sichtbar als Wolke mit Pfeil,
# die nie fertig wird, oder als "kann nicht installiert werden". Dieselbe Vorsorge
# trifft das Panel seit jeher fuer sein eigenes Buendel (siehe ota.ts).
cat > "$ZIEL/manifest.plist" <<PLISTENDE
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>items</key>
<array>
<dict>
<key>assets</key>
<array>
<dict>
<key>kind</key><string>software-package</string>
<key>url</key><string>$BASIS/App.ipa?v=$VERSION</string>
</dict>
<dict>
<key>kind</key><string>display-image</string>
<key>url</key><string>$BASIS/symbol-192.png?v=$VERSION</string>
</dict>
<dict>
<key>kind</key><string>full-size-image</string>
<key>url</key><string>$BASIS/symbol-512.png?v=$VERSION</string>
</dict>
</array>
<key>metadata</key>
<dict>
<key>bundle-identifier</key><string>$KENNUNG</string>
<key>bundle-version</key><string>$VERSION</string>
<key>kind</key><string>software</string>
<key>title</key><string>DataMetric360</string>
</dict>
</dict>
</array>
</dict>
</plist>
PLISTENDE
# Das kaufmaennische Und muss in HTML maskiert werden, sonst schneidet Safari
# den Link hinter dem url-Parameter ab.
cat > "$ZIEL/index.html" <<HTMLENDE
<!doctype html>
<html lang="de">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DataMetric360 installieren</title>
<style>
body { font-family: -apple-system, sans-serif; background: #161b23; color: #eef1f6;
margin: 0; display: grid; place-items: center; min-height: 100vh; }
main { text-align: center; padding: 2rem; }
h1 { font-weight: 300; font-size: 1.5rem; }
a { display: inline-block; margin-top: 1.5rem; padding: 0.9rem 2rem; border-radius: 999px;
background: #eef1f6; color: #161b23; text-decoration: none; font-size: 1.1rem; }
p { color: #8a94a3; font-size: 0.9rem; line-height: 1.5; }
</style>
<main>
<h1>DataMetric360</h1>
<p>Version $VERSION</p>
<a href="itms-services://?action=download-manifest&amp;url=$BASIS/manifest.plist">Installieren</a>
<p>In Safari oeffnen. Nach dem Antippen fragt iOS einmal nach,<br>danach erscheint die App auf dem Startbildschirm.</p>
</main>
</html>
HTMLENDE
echo "Ordner fertig: $ZIEL"
echo
echo "Wenn ein eigener Host ausliefert:"
echo " tailscale serve --bg $ZIEL"
echo " dann auf dem iPhone in Safari oeffnen: $BASIS/"
echo
# Gitea liefert rohe Dateien grundsaetzlich als text/plain aus - die
# index.html wird dort also als Quelltext angezeigt statt gerendert. Deshalb
# hier zusaetzlich der fertige Link zum Einfuegen in die Safari-Adresszeile;
# der funktioniert unabhaengig davon, ob eine Seite drumherum existiert.
echo "Ohne Seite (z. B. aus dem Repository heraus) direkt in Safari einfuegen:"
echo " itms-services://?action=download-manifest&url=$BASIS/manifest.plist"
+424
View File
@@ -0,0 +1,424 @@
#!/usr/bin/env bash
# Baut die native iOS-Huelle und signiert sie zu einer installierbaren .ipa.
# Nur auf einem Mac mit Xcode lauffaehig. Siehe ../../UMSETZUNGSPLAN.md Phase 10.
#
# Warum es dieses Skript gibt: `ios/` ist absichtlich gitignored (aus dem
# Webbuendel jederzeit wiederherstellbar). Damit verschwindet aber auch jede
# Signatureinstellung, die man in Xcode von Hand setzt, beim naechsten
# `npx cap add ios` wieder. Die Team-Kennung gehoert deshalb hierher - in eine
# versionierte Datei - und wird dem Build von aussen mitgegeben.
#
# Voraussetzung, die dieses Skript nicht herstellen kann: das Zielgeraet muss
# im Team eingetragen sein. Das Ad-hoc-Profil traegt eine feste Liste von
# Geraetekennungen, ein unbekanntes Telefon lehnt die fertige .ipa ab. Ein
# neues Geraet wird einmalig angemeldet - per Kabel an diesem Mac, dann
# erledigt es -allowProvisioningUpdates selbst, oder von Hand auf
# developer.apple.com. Bricht der Archivschritt mit "Your team has no devices"
# ab, fehlt genau dieser Eintrag; das ist kein Fehler im Projekt.
#
# Stand des Kontos, am 2026-08-29 am ausgestellten Profil nachgesehen statt
# vermutet: bezahlte Mitgliedschaft, Team RMACS9VLS4, Laufzeit ein Jahr. Die
# frueher hier vermerkten sieben Tage galten dem kostenlosen Personal Team und
# gelten nicht mehr; ebensowenig das "keine Geraeteverwaltung auf
# developer.apple.com", das daran hing.
#
# Der Export ueber -exportArchive ist erprobt und kein offener Punkt mehr: die
# ausgelieferten .ipa vom 2026-08-29 und 2026-08-30 sind auf genau diesem Weg
# entstanden und wurden ueber die Luft installiert (siehe ios-luftweg.sh).
# Scheitert er doch einmal, bleibt der direkte Weg aufs angeschlossene Geraet:
# xcodebuild -project ios/App/App.xcodeproj -scheme App \
# -destination 'id=<UDID>' -allowProvisioningUpdates \
# DEVELOPMENT_TEAM=$TEAM install
set -euo pipefail
HIER="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP="$(dirname "$HIER")"
# Dieselbe Kennung wie zu Zeiten des kostenlosen Kontos: die bezahlte
# Mitgliedschaft hat das bestehende Team hochgestuft, statt ein neues
# anzulegen. Zwischenzeitlich stand hier das Gegenteil - widerlegt am
# 2026-08-29 durch das ausgestellte Profil (Team RMACS9VLS4, Laufzeit ein
# Jahr statt sieben Tage). Ueberschreibbar ueber APPLE_TEAM_ID.
TEAM="${APPLE_TEAM_ID:-RMACS9VLS4}"
AUSGABE="$APP/ios/build"
ARCHIV="$AUSGABE/DataMetric360.xcarchive"
cd "$APP"
# Ohne diese Pruefung baut/signiert das Skript zuverlaessig - aber nur den
# Stand, der gerade lokal ausgecheckt ist. Ein vergessener Abgleich mit dem
# Server waere damit ein stiller Veraltungsfehler wie schon mehrfach an anderer
# Stelle in diesem Projekt (Cache-Busting, OTA-Buendel-Abgleich,
# VERSIONIERUNG.md). Deshalb holt das Skript den Serverstand selbst und rueckt
# den Klon zurecht, wo das verlustfrei ist - und bricht sonst laut ab, statt
# einen optisch/inhaltlich alten Build auszuliefern.
echo "==> Git-Stand pruefen"
if [ -n "$(git status --porcelain)" ]; then
echo "FEHLER: Uncommittete Aenderungen im Arbeitsverzeichnis." >&2
git status --short >&2
echo "Erst committen oder stashen, dann erneut versuchen." >&2
exit 1
fi
git fetch origin
LOKAL="$(git rev-parse HEAD)"
FERN="$(git rev-parse origin/main)"
# Ziel dieses Blocks: der Klon steht danach auf origin/main - oder das Skript
# bricht ab. Zurechtruecken darf es sich selbst, solange dabei nachweislich
# nichts verlorengeht; der saubere Arbeitsbaum ist oben bereits Bedingung.
if [ "$LOKAL" != "$FERN" ]; then
BASIS="$(git merge-base "$LOKAL" "$FERN")"
if git merge-base --is-ancestor "$LOKAL" "$FERN"; then
echo " Klon war zurueck - wird vorgespult."
git merge --ff-only "$FERN" >/dev/null
LOKAL="$(git rev-parse HEAD)"
elif git diff --quiet "$BASIS" "$LOKAL"; then
# Hier liegen Commits, die origin/main nicht hat - aber ihr Tree ist derselbe
# wie der des gemeinsamen Vorfahren, sie tragen inhaltlich also NICHTS bei.
# Genau so sieht ein Klon aus, der noch vor einem Rewrite der Gegenseite steht
# (am 07.09.2026 einmal geschehen). Ein 'git pull' waere hier falsch: der Merge
# holt die entfernten Commits zurueck, und der naechste Push stellt sie auf dem
# Server wieder her - sogar ohne --force. Verwerfen kostet dagegen keine einzige
# Datei; das Reflog haelt sie noch 90 Tage.
echo " Nur hier vorhanden, inhaltlich ohne Wirkung - wird verworfen:"
git log --oneline "$BASIS..$LOKAL" | sed "s/^/ /"
git reset --hard "$FERN" >/dev/null
LOKAL="$(git rev-parse HEAD)"
echo " Zurueckgesetzt auf origin/main. Rueckholbar ueber: git reflog"
else
echo "FEHLER: Lokaler Stand ($LOKAL) weicht von origin/main ($FERN) ab." >&2
echo "" >&2
echo "Diese Commits liegen nur hier und aendern auch den Inhalt:" >&2
git log --oneline "$BASIS..$LOKAL" >&2
echo "" >&2
echo "Das sieht nach eigener, noch ungepushter Arbeit aus - deshalb entscheidet" >&2
echo "das Skript hier nichts. Entweder pushen:" >&2
echo " git push" >&2
echo "oder verwerfen, falls die Gegenseite sie absichtlich entfernt hat:" >&2
echo " git fetch origin && git reset --hard origin/main" >&2
echo "" >&2
echo "Kein 'git pull': ein Merge haengt die Commits wieder in die Historie." >&2
exit 1
fi
fi
echo " HEAD == origin/main ($LOKAL), aktuell."
echo "==> Webbuendel bauen"
npm run build
echo "==> Native Huelle synchronisieren"
if [ ! -d ios ]; then
echo "==> ios/ fehlt, wird neu erzeugt"
npx cap add ios
fi
npx cap sync ios
# Standort-Berechtigung ins Info.plist eintragen.
#
# Ohne diesen Schluessel zeigt iOS beim Aufruf von navigator.geolocation in
# der WebView (src/screens/Standort.tsx) ueberhaupt keine Rueckfrage: der
# Aufruf scheitert sofort, die App meldet "GPS-Freigabe fehlt", und unter
# Einstellungen > DataMetric360 > Ort fehlt die Auswahl "Beim Verwenden der
# App" - es bleiben nur "Nie" und "Beim Teilen". Nachgewiesen am 2026-08-31
# an der ausgelieferten App.ipa: kein einziger Location-Schluessel darin.
#
# Warum hier und nicht in Xcode: ios/ ist gitignored und wird von
# `npx cap add ios` aus Capacitors Vorlage neu erzeugt - die kennt keine
# Standort-Schluessel. Ein Eintrag von Hand in Xcode waere beim naechsten
# Erzeugen wieder verschwunden, genau wie es der Team-Kennung oben ergangen
# waere. Deshalb steht er versioniert hier und wird nach jedem `cap sync`
# neu gesetzt.
#
# Nur "WhenInUse": berechnet wird der Abstand zum geparkten Fahrzeug, waehrend
# jemand die Standort-Seite ansieht. Hintergrundortung braucht die App nicht,
# und was sie nicht braucht, soll sie auch nicht erfragen.
echo "==> Standort-Berechtigung eintragen"
PLIST="ios/App/App/Info.plist"
GRUND="Zeigt auf der Standort-Karte, wie weit du vom geparkten Fahrzeug entfernt bist."
if ! /usr/libexec/PlistBuddy -c "Set :NSLocationWhenInUseUsageDescription $GRUND" "$PLIST" 2>/dev/null; then
/usr/libexec/PlistBuddy -c "Add :NSLocationWhenInUseUsageDescription string $GRUND" "$PLIST"
fi
/usr/libexec/PlistBuddy -c "Print :NSLocationWhenInUseUsageDescription" "$PLIST" >/dev/null || {
echo "FEHLER: NSLocationWhenInUseUsageDescription liess sich nicht setzen." >&2
exit 1
}
# Kalender-Berechtigung, aus demselben Grund an derselben Stelle.
#
# Warum ZWEI Schluessel: seit iOS 17 gibt es einen eigenen Nur-Schreiben-Zugriff
# (NSCalendarsWriteOnlyAccessUsageDescription), aeltere Fassungen kennen nur den
# alten Sammelschluessel. Der System-Editor (native/KalenderTermin.swift) fragt
# auf iOS 17+ von sich aus gar nicht - die Schluessel sind die Absicherung fuer
# den Fall, dass EventKit doch einmal fragt. Fehlt der passende, beendet iOS die
# App wortlos, statt eine Rueckfrage zu zeigen.
echo "==> Kalender-Berechtigung eintragen"
KALENDERGRUND="Traegt Werkstatt- und Wechseltermine in deinen Kalender ein."
for SCHLUESSEL in NSCalendarsWriteOnlyAccessUsageDescription NSCalendarsUsageDescription; do
if ! /usr/libexec/PlistBuddy -c "Set :$SCHLUESSEL $KALENDERGRUND" "$PLIST" 2>/dev/null; then
/usr/libexec/PlistBuddy -c "Add :$SCHLUESSEL string $KALENDERGRUND" "$PLIST"
fi
/usr/libexec/PlistBuddy -c "Print :$SCHLUESSEL" "$PLIST" >/dev/null || {
echo "FEHLER: $SCHLUESSEL liess sich nicht setzen." >&2
exit 1
}
done
# Kamera, fuer den QR-Scanner der Ersteinrichtung.
#
# Gelesen wird der Code in der WebView (jsQR ueber getUserMedia), nicht von
# einem nativen Plugin - siehe den Kopf von src/screens/QrScanner.tsx, warum
# nicht GoogleMLKit. Dieser Schluessel ist trotzdem noetig: ohne ihn beendet
# iOS die App in dem Moment, in dem sie die Kamera anfordert.
echo "==> Kamera-Berechtigung eintragen"
KAMERAGRUND="Liest den QR-Code deines Zugangs-Tokens bei der Ersteinrichtung."
if ! /usr/libexec/PlistBuddy -c "Set :NSCameraUsageDescription $KAMERAGRUND" "$PLIST" 2>/dev/null; then
/usr/libexec/PlistBuddy -c "Add :NSCameraUsageDescription string $KAMERAGRUND" "$PLIST"
fi
/usr/libexec/PlistBuddy -c "Print :NSCameraUsageDescription" "$PLIST" >/dev/null || {
echo "FEHLER: NSCameraUsageDescription liess sich nicht setzen." >&2
exit 1
}
# Share-Erweiterung ins Xcode-Projekt haengen.
#
# Aus demselben Grund wie die Team-Kennung und der Standort-Schluessel oben:
# ios/ ist gitignored, also verschwindet jedes von Hand angelegte Ziel beim
# naechsten `npx cap add ios`. Die Quellen liegen versioniert unter native/,
# das Skript spielt sie bei jedem Bau wieder ein. Es ist idempotent - ein
# zweiter Lauf ueber ein fertiges Projekt aendert nichts.
#
# Schlaegt es fehl, bricht der ganze Bau ab: eine App ohne den Eintrag im
# Teilen-Blatt sieht fertig aus und ist es nicht, und der Unterschied faellt
# erst auf dem Geraet auf.
echo "==> Share-Erweiterung einrichten"
node scripts/ios-teilen-einrichten.mjs
# App-Symbol einspielen.
#
# Capacitors Vorlage bringt ein eigenes Platzhalter-Symbol mit, und ios/ ist
# gitignored - ein in Xcode eingesetztes Symbol waere beim naechsten
# `npx cap add ios` wieder weg. Die Vorlage liegt deshalb versioniert unter
# native/AppIcon.appiconset und wird hier ueber die von Capacitor erzeugte
# gelegt.
#
# Drei Ausfuehrungen, wie iOS 18 sie erwartet: hell mit eigenem Grund, dunkel
# und getoent ohne - dort legt das System seinen eigenen Grund darunter, und
# ein mitgeliefertes Schwarz bliebe darauf als Kasten sichtbar.
echo "==> App-Symbol einspielen"
SYMBOLZIEL="ios/App/App/Assets.xcassets/AppIcon.appiconset"
rm -rf "$SYMBOLZIEL"
mkdir -p "$SYMBOLZIEL"
cp -R native/AppIcon.appiconset/. "$SYMBOLZIEL"/
[ -f "$SYMBOLZIEL/Contents.json" ] || { echo "FEHLER: App-Symbol nicht eingespielt." >&2; exit 1; }
echo " hell, dunkel, getoent"
# Versionsnummer aus der manifest.json in die App schreiben.
#
# Capacitors Vorlage setzt 1.0/1 und laesst es dabei - zwei nacheinander
# aufgespielte .ipa sind auf dem Geraet dadurch nicht zu unterscheiden, und
# man weiss nicht, welche gerade drauf ist. Dieselbe Zahl, die schon Panel,
# OTA-Buendel und __APP_VERSION__ tragen (siehe VERSIONIERUNG.md): eine
# Quelle, kein zweiter Ort zum Vergessen.
echo "==> Versionsnummer eintragen"
VERSION="$(node -p "require('../custom_components/audi_dashboard/manifest.json').version")"
[ -n "$VERSION" ] || { echo "FEHLER: keine Version in der manifest.json." >&2; exit 1; }
if ! /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$PLIST" 2>/dev/null; then
/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string $VERSION" "$PLIST"
fi
BUILDNR="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$PLIST" 2>/dev/null || echo 1)"
# In der Info.plist der App steht dort KEINE Zahl, sondern der Platzhalter
# einer Build-Einstellung ($(CURRENT_PROJECT_VERSION), aus dem Capacitor-
# Geruest). Fuer das App-Ziel loest Xcode ihn zu 1 auf - das Ziel der
# Erweiterung kennt die Einstellung aber gar nicht, dort wurde daraus LEER,
# und ein Buendel ohne CFBundleVersion lehnt iOS beim Installieren ab
# ("kann nicht installiert werden"). Genau so ist die .ipa vom 04.09.2026
# unbrauchbar geworden - an der ausgelieferten Datei nachgemessen: App
# CFBundleVersion 1, Erweiterung gar keine. Deshalb hier nur eine echte Zahl
# weiterreichen, sonst die 1.
case "$BUILDNR" in
""|*'$('*|*[!0-9.]*) BUILDNR=1 ;;
esac
# Die Share-Erweiterung MUSS dieselben beiden Nummern tragen wie die App.
#
# Apples App Extension Programming Guide verlangt das ausdruecklich, und iOS
# haelt sich daran: stimmen CFBundleShortVersionString und CFBundleVersion
# nicht ueberein, registriert das System die Erweiterung nicht - sie taucht im
# Teilen-Blatt dann gar nicht erst auf, ohne jede Fehlermeldung. Genau das war
# in der .ipa vom 03.09.2026 der Fall: App 2026.9.3.3, Erweiterung 1.0 (an der
# ausgelieferten Datei nachgemessen, nicht vermutet - native/teilen/Info.plist
# traegt die 1.0 fest, und dieser Schritt schrieb bis dahin nur $PLIST).
ERW_PLIST="ios/App/DataMetric360Share/Info.plist"
if [ -f "$ERW_PLIST" ]; then
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$ERW_PLIST"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILDNR" "$ERW_PLIST"
echo " $VERSION ($BUILDNR), App und Erweiterung"
else
echo " $VERSION ($BUILDNR) - Erweiterung nicht gefunden, nur die App"
fi
# Zwischengespeicherte Bereitstellungsprofile dieser App wegraeumen.
#
# `-allowProvisioningUpdates` zieht ein neues Profil nur, wenn kein passendes
# herumliegt - ein vorhandenes wird stillschweigend weiterbenutzt, auch wenn
# sich seither die Berechtigungen geaendert haben. Genau daran ist am
# 2026-08-29 eine Installation gescheitert ("Integritaet konnte nicht
# geprueft werden"): das eingebettete Profil kannte ein Geraet, das Team
# hatte inzwischen zwei. Mit der App-Gruppe der Share-Erweiterung ist
# derselbe Fall erneut eingetreten - jedes vor dem 2026-08-31 gezogene
# Profil kennt sie nicht.
#
# Weggeraeumt wird nur, was zu dieser App gehoert (Bundle-ID im entpackten
# Profil gesucht), nicht der ganze Ordner - dort liegen auch die Profile
# anderer Projekte. Mit --profile-behalten laesst sich der Schritt
# ueberspringen.
if [ "${1:-}" != "--profile-behalten" ]; then
echo "==> Alte Bereitstellungsprofile wegraeumen"
PROFILE="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
if [ -d "$PROFILE" ]; then
ANZAHL=0
for datei in "$PROFILE"/*.mobileprovision; do
[ -e "$datei" ] || continue
if security cms -D -i "$datei" 2>/dev/null | grep -qF "app.datametric360"; then
rm -f "$datei"
ANZAHL=$((ANZAHL + 1))
fi
done
echo " $ANZAHL entfernt - xcodebuild zieht sie gleich neu"
else
echo " keine vorhanden"
fi
else
echo "==> Bereitstellungsprofile bleiben (--profile-behalten)"
fi
echo "==> Archiv bauen und signieren (Team $TEAM)"
rm -rf "$AUSGABE"
mkdir -p "$AUSGABE"
xcodebuild \
-project ios/App/App.xcodeproj \
-scheme App \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath "$ARCHIV" \
-allowProvisioningUpdates \
DEVELOPMENT_TEAM="$TEAM" \
archive
# Ad-hoc, nicht development: das ist die von Apple vorgesehene Art fuer die
# Installation ueber die Luft (siehe ios-luftweg.sh) und deckt den Weg per
# Kabel gleich mit ab. Sie verlangt ein Apple-Distribution-Zertifikat, das
# -allowProvisioningUpdates bei Bedarf selbst anlegt - moeglich erst seit der
# bezahlten Mitgliedschaft. Die Signatur laeuft damit ein Jahr.
cat > "$AUSGABE/ExportOptions.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>ad-hoc</string>
<key>teamID</key><string>$TEAM</string>
<key>signingStyle</key><string>automatic</string>
<key>stripSwiftSymbols</key><true/>
</dict>
</plist>
PLIST
echo "==> .ipa exportieren"
xcodebuild -exportArchive \
-archivePath "$ARCHIV" \
-exportOptionsPlist "$AUSGABE/ExportOptions.plist" \
-exportPath "$AUSGABE" \
-allowProvisioningUpdates
# Am FERTIGEN Buendel gegenlesen, nicht am Projekt.
#
# Abschnitt AK der AGENTS.md: von fuenf Fehlern in der nativen Huelle haben
# vier einen erfolgreichen Archivlauf erzeugt und sind erst am entpackten
# .ipa aufgefallen. Geprueft wird hier genau das, was still schiefgehen kann:
# eine Erweiterung ohne Programm, ohne App-Gruppe, oder mit abweichender
# Versionsnummer (dann registriert iOS sie nicht und sie fehlt im
# Teilen-Blatt).
echo "==> Fertige .ipa gegenlesen"
PRUEF="$(mktemp -d)"
if unzip -q "$AUSGABE/App.ipa" -d "$PRUEF"; then
ERW="$PRUEF/Payload/App.app/PlugIns/DataMetric360Share.appex"
if [ ! -x "$ERW/DataMetric360Share" ] && [ ! -f "$ERW/DataMetric360Share" ]; then
echo " WARNUNG: die Erweiterung hat kein Programm - sie wird nichts tun."
fi
ERW_V="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$ERW/Info.plist" 2>/dev/null || echo '?')"
APP_V="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$PRUEF/Payload/App.app/Info.plist" 2>/dev/null || echo '?')"
if [ "$ERW_V" != "$APP_V" ]; then
echo " WARNUNG: Erweiterung $ERW_V, App $APP_V - iOS registriert die"
echo " Erweiterung dann nicht, sie fehlt im Teilen-Blatt."
else
echo " Erweiterung und App tragen beide $APP_V"
fi
# CFBundleVersion ist Pflicht. Fehlt sie in EINEM der beiden Buendel,
# verweigert iOS die Installation der ganzen App - ohne Hinweis darauf,
# woran es liegt. Deshalb hart pruefen, nicht nur warnen.
for ZIEL in "$PRUEF/Payload/App.app" "$ERW"; do
BV="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$ZIEL/Info.plist" 2>/dev/null || echo '')"
case "$BV" in
""|*'$('*)
echo " FEHLER: $(basename "$ZIEL") hat keine brauchbare CFBundleVersion ('$BV')." >&2
echo " iOS wuerde die Installation ablehnen - Bau abgebrochen." >&2
rm -rf "$PRUEF"
exit 1 ;;
esac
done
for ZIEL in "$PRUEF/Payload/App.app" "$ERW"; do
if ! codesign -d --entitlements :- "$ZIEL" 2>/dev/null | grep -q "group.app.datametric360"; then
echo " WARNUNG: $(basename "$ZIEL") ohne App-Gruppe - Teilen kann nichts uebergeben."
fi
done
# Eigene Plugins muessen in packageClassList stehen. Capacitor 8 sucht
# Plugin-Klassen NICHT im Programm (registerPlugins() in
# CapacitorBridge.swift liest nur diese Liste) - eine einkompilierte
# Klasse, die dort fehlt, existiert fuer die Bruecke nicht. Genau so kam
# die .ipa vom 04.09.2026 heraus: Zwischenablage und Kalendertermin waren
# im Programm und trotzdem tot.
KONF="$PRUEF/Payload/App.app/capacitor.config.json"
for KLASSE in $(grep -ho "@objc([A-Za-z0-9_]*)" "$APP/native"/*.swift | sed "s/@objc(//;s/)//"); do
if ! grep -q "$KLASSE" "$KONF" 2>/dev/null; then
echo " FEHLER: $KLASSE fehlt in packageClassList - das Plugin bliebe stumm." >&2
rm -rf "$PRUEF"
exit 1
fi
done
echo " packageClassList: eigene Plugins eingetragen"
fi
rm -rf "$PRUEF"
# Erst JETZT ablegen. auslieferung/ ist versioniert und wird ausgeliefert -
# eine Datei, die die Pruefung oben nicht besteht, darf dort nicht landen und
# schon gar nicht committet werden. (ios/ ist gitignored, sonst existierte das
# Ergebnis nur auf diesem einen Mac.)
mkdir -p "$APP/auslieferung"
cp "$AUSGABE/App.ipa" "$APP/auslieferung/App.ipa"
# Auslieferung ueber die Luft gleich mitschreiben.
#
# manifest.plist und die Installationsseite hingen bisher an einem zweiten,
# von Hand aufzurufenden Skript - und ohne sie ist die frische .ipa auf dem
# Telefon nicht installierbar, obwohl sie fertig im Repo liegt. Ein Schritt,
# den man vergessen kann, gehoert in den Bau.
#
# Die Basis-Adresse ist die des Gitea-Repos: es liefert die Dateien bereits
# ueber HTTPS mit oeffentlich vertrauenswuerdigem Zertifikat aus, was iOS
# fuer diesen Weg zwingend verlangt (siehe ios-luftweg.sh). Ueberschreibbar
# ueber LUFTWEG_BASIS, falls der Weg einmal ueber einen eigenen Host geht.
echo "==> Auslieferung ueber die Luft vorbereiten"
LUFTWEG_BASIS="${LUFTWEG_BASIS:-https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung}"
bash "$HIER/ios-luftweg.sh" "$LUFTWEG_BASIS" auslieferung
echo
echo "Fertig: $APP/auslieferung/App.ipa"
echo "Version: $VERSION"
echo
echo "Noch zu tun:"
echo " 1. git add companion-app/auslieferung && git commit && git push"
echo " (die .ipa und manifest.plist muessen im Repo liegen, sonst kann"
echo " das Telefon sie nicht laden)"
echo " 2. Auf dem iPhone in Safari oeffnen:"
echo " itms-services://?action=download-manifest&url=$LUFTWEG_BASIS/manifest.plist"
echo
echo "Per Kabel stattdessen: xcrun devicectl device install app --device <UDID> $APP/auslieferung/App.ipa"
@@ -0,0 +1,311 @@
#!/usr/bin/env node
/**
* Hängt die Share-Erweiterung ins erzeugte Xcode-Projekt.
*
* WARUM ES DIESES SKRIPT GIBT
* ---------------------------
* `companion-app/ios/` ist absichtlich gitignored — `npx cap add ios` baut den
* Ordner aus dem Webbündel jederzeit neu. Damit verschwindet aber auch jedes
* Ziel, das man in Xcode von Hand anlegt. Dieselbe Falle, die schon die
* Team-Kennung und den Standort-Schlüssel in `ios-signieren.sh` getrieben hat:
* was den nächsten `cap add` überleben soll, muss versioniert sein und bei
* jedem Bau neu eingespielt werden.
*
* Die Quellen liegen deshalb unter `companion-app/native/`; dieses Skript
* kopiert sie nach `ios/App/` und trägt das Erweiterungsziel in die
* `project.pbxproj` ein.
*
* WAS ES ANLEGT
* -------------
* - Ziel `DataMetric360Share` (App-Erweiterung, Bundle-ID
* `app.datametric360.teilen`)
* - dessen Quelldateien, Info.plist und Entitlements
* - die App-Gruppe `group.app.datametric360` auf BEIDEN Zielen
* - die Einbettung der Erweiterung in die App (Build-Phase
* „Embed App Extensions")
* - das URL-Schema `datametric360://`, über das die Erweiterung die App holt
*
* IDEMPOTENT
* ----------
* Ein zweiter Lauf über ein bereits eingerichtetes Projekt ändert nichts und
* meldet das. Wichtig, weil `ios-signieren.sh` bei jedem Bau durchläuft und
* `ios/` nicht immer neu erzeugt wird.
*
* NICHT GETESTET AUF EINEM MAC
* ----------------------------
* Geschrieben ohne Zugriff auf macOS/Xcode. Die pbxproj-Bearbeitung folgt der
* API des npm-Pakets `xcode`; scheitert sie, bricht das Skript mit einer
* klaren Meldung ab und `docs/SHARE_EXTENSION.md` beschreibt die Handgriffe,
* die dasselbe in Xcode erledigen. Lieber ein lauter Abbruch mit Anleitung
* als ein halb eingerichtetes Projekt.
*/
import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync } from "node:fs"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import xcode from "xcode"
const HIER = dirname(fileURLToPath(import.meta.url))
const APP = dirname(HIER)
const ZIEL_NAME = "DataMetric360Share"
const BUNDLE_ID = "app.datametric360.teilen"
const GRUPPE = "group.app.datametric360"
/* Eigene Capacitor-Plugins der App - je eine Swift-Datei, die neben der
Erweiterung ins App-Ziel gehört. Sie liegen aus demselben Grund unter
native/ wie alles andere hier: ios/ ist gitignored und wird neu erzeugt. */
const PLUGIN_DATEIEN = ["BelegZwischenablage.swift", "KalenderTermin.swift"]
/** pbxproj-Wert ohne die optionalen Anfuehrungszeichen. */
const entquotet = (wert) => String(wert ?? "").replace(/^"|"$/g, "")
const SCHEMA = "datametric360"
const iosApp = join(APP, "ios", "App")
const pbxPfad = join(iosApp, "App.xcodeproj", "project.pbxproj")
const quelleErw = join(APP, "native", "teilen")
const zielErw = join(iosApp, ZIEL_NAME)
function melden(text) {
console.log(" " + text)
}
function abbruch(text) {
console.error("")
console.error("FEHLER: " + text)
console.error("")
console.error("Die Share-Erweiterung ist damit NICHT eingerichtet. Die App selbst")
console.error("baut und läuft weiterhin - nur der Eintrag im Teilen-Blatt fehlt.")
console.error("Von Hand nachziehen: companion-app/docs/SHARE_EXTENSION.md")
process.exit(1)
}
if (!existsSync(pbxPfad)) {
abbruch(`${pbxPfad} nicht gefunden - erst 'npx cap sync ios' laufen lassen.`)
}
/* ---------------------------------------------------------------- Dateien */
mkdirSync(zielErw, { recursive: true })
for (const datei of ["ShareViewController.swift", "Info.plist", `${ZIEL_NAME}.entitlements`]) {
copyFileSync(join(quelleErw, datei), join(zielErw, datei))
}
copyFileSync(join(APP, "native", "App.entitlements"), join(iosApp, "App", "App.entitlements"))
for (const datei of PLUGIN_DATEIEN) {
copyFileSync(join(APP, "native", datei), join(iosApp, "App", datei))
}
melden(`Quellen nach ios/App/${ZIEL_NAME}/ kopiert`)
/* ------------------------------------------ Eigene Plugins registrieren */
/* Capacitor 8 sucht Plugin-Klassen NICHT im Programm. `registerPlugins()`
in CapacitorBridge.swift liest ausschliesslich `packageClassList` aus der
capacitor.config.json des Buendels und loest jeden Eintrag ueber
NSClassFromString auf - was dort nicht steht, existiert fuer die Bruecke
nicht, auch wenn die Klasse einkompiliert ist.
Genau daran sind beide eigenen Plugins gescheitert: `cap sync` erzeugt die
Liste aus den installierten npm-Paketen, und unsere Plugins sind keine.
In der ausgelieferten .ipa vom 04.09.2026 standen dort nur die sechs
Fremd-Plugins - die Zwischenablage meldete deshalb 'diese App-Fassung
enthaelt den nativen Teil noch nicht' (Nutzerbefund), obwohl
BelegZwischenablagePlugin nachweislich im Programm lag, und der
Kalendertermin fiel still auf das Teilen-Blatt zurueck, das den Kalender
gar nicht erreichen kann (siehe AGENTS.md Abschnitt BS).
Die Klassennamen kommen aus den Swift-Dateien selbst (@objc(...)), damit
sie nicht an zwei Stellen gepflegt werden muessen. */
const konfigPfad = join(iosApp, "App", "capacitor.config.json")
if (!existsSync(konfigPfad)) {
abbruch(`${konfigPfad} nicht gefunden - erst 'npx cap sync ios' laufen lassen.`)
}
const eigeneKlassen = PLUGIN_DATEIEN.map((datei) => {
const quelle = readFileSync(join(APP, "native", datei), "utf8")
const treffer = /@objc\(([A-Za-z0-9_]+)\)/.exec(quelle)
if (!treffer) abbruch(`${datei}: kein @objc(...)-Name gefunden`)
return treffer[1]
})
const konfig = JSON.parse(readFileSync(konfigPfad, "utf8"))
konfig.packageClassList = konfig.packageClassList ?? []
const fehlten = eigeneKlassen.filter((k) => !konfig.packageClassList.includes(k))
if (fehlten.length) {
konfig.packageClassList.push(...fehlten)
writeFileSync(konfigPfad, JSON.stringify(konfig, null, 2) + String.fromCharCode(10))
}
melden(`packageClassList: ${eigeneKlassen.join(", ")} eingetragen`)
/* ------------------------------------------------------------- URL-Schema */
/* Ohne dieses Schema kann die Erweiterung die App nicht holen - sie legt den
Beleg zwar ab, aber niemand merkt es, bis die App das nächste Mal von Hand
geöffnet wird. */
const appPlistPfad = join(iosApp, "App", "Info.plist")
let appPlist = readFileSync(appPlistPfad, "utf8")
if (!appPlist.includes("CFBundleURLTypes")) {
const eintrag = [
"\t<key>CFBundleURLTypes</key>",
"\t<array>",
"\t\t<dict>",
"\t\t\t<key>CFBundleURLName</key>",
`\t\t\t<string>app.datametric360</string>`,
"\t\t\t<key>CFBundleURLSchemes</key>",
"\t\t\t<array>",
`\t\t\t\t<string>${SCHEMA}</string>`,
"\t\t\t</array>",
"\t\t</dict>",
"\t</array>",
"</dict>",
].join("\n")
const letztes = appPlist.lastIndexOf("</dict>")
if (letztes < 0) abbruch("ios/App/App/Info.plist hat keine schließende <dict>-Marke.")
appPlist = appPlist.slice(0, letztes) + eintrag + appPlist.slice(letztes + "</dict>".length)
writeFileSync(appPlistPfad, appPlist, "utf8")
melden(`URL-Schema ${SCHEMA}:// eingetragen`)
} else {
melden("URL-Schema war bereits eingetragen")
}
/* ---------------------------------------------------------------- Projekt */
const projekt = xcode.project(pbxPfad)
projekt.parseSync()
/* ------------------------------------------------ Eigene Plugins (App-Ziel)
Steht VOR dem Ausstieg unten: die Erweiterung ist nach dem ersten Lauf
vorhanden, die Plugins müssen trotzdem bei jedem Bau geprüft werden - ios/
wird von `npx cap add ios` jederzeit neu erzeugt.
`addSourceFile` ohne Gruppe hängt die Datei an die ERSTE Sources-Phase des
Projekts, und das ist die des App-Ziels - genau die richtige. (Mit Gruppe
landete die Datei nur im Navigator; daran ist am 02.09.2026 die Erweiterung
ohne Programm entstanden, siehe unten.)
Ohne Gruppe geht `addSourceFile` intern über `addPluginFile`, und das legt
die Datei in eine Projektgruppe namens "Plugins". Ein Capacitor-Projekt hat
die nicht, `pbxGroupByName` liefert null, und das Skript stirbt mit
"Cannot read properties of null (reading 'path')" (03.09.2026). Die Gruppe
wird deshalb angelegt, falls sie fehlt - sie ist reine Ablage im Navigator
und ändert am Bau nichts. */
if (!projekt.pbxGroupByName("Plugins")) {
projekt.addPbxGroup([], "Plugins", '""')
melden("Projektgruppe Plugins angelegt (von addSourceFile vorausgesetzt)")
}
let pluginsGeaendert = false
for (const datei of PLUGIN_DATEIEN) {
if (JSON.stringify(projekt.pbxSourcesBuildPhaseObj()).includes(datei)) {
melden(`${datei} war bereits im App-Ziel`)
continue
}
projekt.addSourceFile(`App/${datei}`)
melden(`${datei} ins App-Ziel gehängt`)
pluginsGeaendert = true
}
if (pluginsGeaendert) {
writeFileSync(pbxPfad, projekt.writeSync())
// Nach dem Schreiben neu einlesen: der Rest des Skripts arbeitet sonst auf
// einem Stand, der die eigene Änderung nicht kennt.
projekt.parseSync()
}
const vorhanden = Object.values(projekt.pbxNativeTargetSection()).some(
(z) => z && typeof z === "object" && z.name === ZIEL_NAME,
)
if (vorhanden) {
melden(`Ziel ${ZIEL_NAME} war bereits im Projekt - nichts geändert`)
process.exit(0)
}
let ziel
try {
ziel = projekt.addTarget(ZIEL_NAME, "app_extension", ZIEL_NAME, BUNDLE_ID)
// Eigene Build-Phasen: ohne sie hat das Ziel keine Quelldateien und keine
// Ressourcen, und Xcode baut ein leeres Bündel.
//
// Die Quelle gehoert hier hinein und nicht in ein spaeteres addSourceFile:
// reicht man diesem eine Gruppe mit, legt es die Datei nur in die Gruppe
// (Navigator) und haengt sie an keine Sources-Phase. Das Ergebnis war ein
// .appex ganz ohne Programm - nur Info.plist und Signatur, obwohl
// CFBundleExecutable eines versprach (2026-09-02 an der fertigen .ipa
// gemessen). Xcode meldet dabei nichts; der Bau gilt als erfolgreich.
// Pfad relativ zum Projektordner (ios/App), nicht nur der Dateiname: ohne
// Gruppe loest Xcode den Namen gegen die Projektwurzel auf und sucht dann
// ios/App/ShareViewController.swift - "Build input file cannot be found".
projekt.addBuildPhase(
[`${ZIEL_NAME}/ShareViewController.swift`],
"PBXSourcesBuildPhase",
"Sources",
ziel.uuid,
)
projekt.addBuildPhase([], "PBXResourcesBuildPhase", "Resources", ziel.uuid)
projekt.addBuildPhase([], "PBXFrameworksBuildPhase", "Frameworks", ziel.uuid)
// Nur zur Anzeige in Xcode. Die Quelle ist oben bereits der Sources-Phase
// zugeordnet und steht deshalb hier nicht noch einmal.
const gruppe = projekt.addPbxGroup(
["Info.plist", `${ZIEL_NAME}.entitlements`],
ZIEL_NAME,
ZIEL_NAME,
)
// In die Projektwurzel einhängen, sonst taucht die Gruppe in Xcode nicht auf.
const wurzel = Object.keys(projekt.hash.project.objects.PBXGroup).find(
(k) => projekt.hash.project.objects.PBXGroup[k].name === "CustomTemplate",
)
if (wurzel) projekt.addToPbxGroup(gruppe.uuid, wurzel)
// Einstellungen, die `addTarget` nicht selbst setzt.
const konfigs = projekt.pbxXCBuildConfigurationSection()
for (const schluessel of Object.keys(konfigs)) {
const eintrag = konfigs[schluessel]
if (!eintrag || typeof eintrag !== "object" || !eintrag.buildSettings) continue
const s = eintrag.buildSettings
// Ohne Anfuehrungszeichen vergleichen: die pbxproj fuehrt Werte mal mit,
// mal ohne. Was `addTarget` schreibt, ist gequotet ("app.datametric360.
// teilen"), was Capacitor fuer die App erzeugt, steht nackt da
// (app.datametric360). Der Vergleich auf die gequotete Form traf deshalb
// die App nie - sie wurde ohne App-Gruppe signiert, waehrend die
// Erweiterung sie hatte. Ergebnis waere ein stiller Ausfall gewesen:
// teilen meldet Erfolg, die App findet nichts (2026-09-02 an der
// fertigen .ipa gemessen - Entitlements der App ohne
// application-groups).
if (entquotet(s.PRODUCT_BUNDLE_IDENTIFIER) === BUNDLE_ID || entquotet(s.PRODUCT_NAME) === ZIEL_NAME) {
s.INFOPLIST_FILE = `"${ZIEL_NAME}/Info.plist"`
s.CODE_SIGN_ENTITLEMENTS = `"${ZIEL_NAME}/${ZIEL_NAME}.entitlements"`
s.IPHONEOS_DEPLOYMENT_TARGET = "14.0"
s.SWIFT_VERSION = "5.0"
s.TARGETED_DEVICE_FAMILY = `"1,2"`
s.SKIP_INSTALL = "YES"
}
// Die App selbst braucht ihre neue Entitlements-Datei.
if (entquotet(s.PRODUCT_BUNDLE_IDENTIFIER) === "app.datametric360") {
s.CODE_SIGN_ENTITLEMENTS = `"App/App.entitlements"`
}
}
// Eingebettet wird die Erweiterung bereits von `addTarget`: die Bibliothek
// legt fuer den Typ "app_extension" von sich aus eine Copy-Phase nach
// PlugIns am ersten Ziel an. Hier noch eine zweite anzulegen, hat genau das
// erzeugt, wogegen Xcode sich wehrt (2026-09-02, erster Lauf ueberhaupt auf
// einem Mac):
// error: Unexpected duplicate tasks
// note: Target 'App': ValidateEmbeddedBinary .../DataMetric360Share.appex
// note: Target 'App': ValidateEmbeddedBinary .../DataMetric360Share.appex
// Beide Phasen zeigten auf dieselbe Datei und dasselbe Ziel - die .appex
// waere zweimal an denselben Ort kopiert worden.
writeFileSync(pbxPfad, projekt.writeSync(), "utf8")
} catch (fehler) {
abbruch(`Das Xcode-Projekt ließ sich nicht bearbeiten: ${fehler.message}`)
}
melden(`Ziel ${ZIEL_NAME} angelegt und eingebettet`)
melden(`App-Gruppe ${GRUPPE} auf beiden Zielen`)
console.log("")
console.log(" Einmalig im Apple-Entwicklerportal nötig, sonst schlägt das Signieren fehl:")
console.log(` - App-Gruppe ${GRUPPE} anlegen`)
console.log(` - App-ID ${BUNDLE_ID} anlegen und der Gruppe zuordnen`)
console.log(` - App-ID app.datametric360 ebenfalls der Gruppe zuordnen`)
console.log("")
+135
View File
@@ -0,0 +1,135 @@
/**
* Baut die drei App-Symbol-Vorlagen als SVG.
*
* Gewaehlt wurde Entwurf 3 bei 54 % Fahrzeuggroesse: DataMetric oben, das
* CI-Fahrzeug in der Mitte, 360 in Audi-Rot unten, auf Nacht-Grund.
*
* WARUM DIE SCHRIFTEN EINGEBETTET WERDEN
* --------------------------------------
* Eine SVG-Datei, die nur `font-family: "Audi Type"` nennt, sieht nur dort
* richtig aus, wo die Schrift installiert ist. Auf einem anderen Rechner - und
* in jedem Rasterisierer - faellt sie auf eine Ersatzschrift zurueck, und das
* faellt erst am fertigen Symbol auf. Die woff2-Dateien stecken deshalb als
* data:-URI in der Datei; sie ist damit fuer sich allein vollstaendig.
*
* DIE DREI AUSFUEHRUNGEN VON iOS 18
* ---------------------------------
* hell - die Kachel wie entworfen, mit eigenem Grund.
* dunkel - ohne Grund. iOS legt seinen eigenen dunklen Verlauf darunter; ein
* mitgeliefertes Schwarz wuerde darauf als Kasten sichtbar bleiben.
* getoent - ohne Grund und ohne Farbe. iOS faerbt das Bild selbst nach der
* Wahl des Nutzers ein und wertet dafuer die Helligkeit aus - das
* Rot der 360 wird deshalb zu einem helleren Grau als der Rest,
* damit die Zahl auch getoent noch abgesetzt bleibt.
*
* MASSE
* -----
* Alles als Anteil der Kantenlaenge, hergeleitet aus dem freigegebenen
* Entwurf (Flex-Spalte, Abstand 0,02 - siehe index.html):
*
* Wort 0,095 | Abstand 0,02 | Fahrzeug 0,54 | Abstand 0,02 | Zahl 0,17
* zusammen 0,845, der Rest verteilt sich gleichmaessig oben und unten.
*/
const fs = require("fs")
const path = require("path")
const SCHRIFTEN = path.join(__dirname, "..", "src", "assets", "audi", "schriften")
/* Das CI-Fahrzeug kommt aus dem Panel selbst - eine zweite Kopie des Pfades
waere eine zweite Quelle fuer dieselbe Form. */
const PANEL = path.join(
__dirname,
"..",
"..",
"custom_components",
"audi_dashboard",
"frontend",
"audi-dashboard-app.js",
)
const MARKE = "audi: '<path"
const roh = fs.readFileSync(PANEL, "utf8")
const von = roh.indexOf(MARKE)
if (von < 0) throw new Error("ICONS.audi nicht in audi-dashboard-app.js gefunden")
const dVon = roh.indexOf('d="', von) + 3
const dBis = roh.indexOf('"', dVon)
const AUTO = roh.slice(dVon, dBis)
if (!AUTO.startsWith("M")) throw new Error("Pfad sieht nicht wie erwartet aus: " + AUTO.slice(0, 30))
function schriftDaten(datei) {
return fs.readFileSync(path.join(SCHRIFTEN, datei)).toString("base64")
}
const TYPE = schriftDaten("audi-type-400.woff2")
const WIDE = schriftDaten("audi-type-wide-300.woff2")
/* Anteile der Kantenlaenge. */
const A = {
wort: 0.095,
fahrzeug: 0.54,
zahl: 0.17,
abstand: 0.02,
}
const S = 1024
const gesamt = (A.wort + A.abstand + A.fahrzeug + A.abstand + A.zahl) * S
const oben = (S - gesamt) / 2
const wortMitte = oben + (A.wort * S) / 2
const autoOben = oben + (A.wort + A.abstand) * S
const autoKante = A.fahrzeug * S
const zahlMitte = autoOben + autoKante + A.abstand * S + (A.zahl * S) / 2
/** Das CI-Fahrzeug aus dem 24er-Raster auf seine Kachelgroesse gebracht. */
function fahrzeug(farbe) {
const faktor = autoKante / 24
const links = (S - autoKante) / 2
return `<g transform="translate(${links.toFixed(2)} ${autoOben.toFixed(2)}) scale(${faktor.toFixed(5)})"><path d="${AUTO}" fill="${farbe}"/></g>`
}
/**
* @param {object} o
* @param {string|null} o.grund Hintergrund, oder null fuer keinen
* @param {string} o.vorn Wort und Fahrzeug
* @param {string} o.akzent die 360
*/
function symbol({ grund, vorn, akzent }) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${S}" height="${S}" viewBox="0 0 ${S} ${S}">
<style>
@font-face { font-family: "Audi Type"; font-weight: 400; src: url(data:font/woff2;base64,${TYPE}) format("woff2"); }
@font-face { font-family: "Audi Type Wide"; font-weight: 300; src: url(data:font/woff2;base64,${WIDE}) format("woff2"); }
.wort { font-family: "Audi Type"; font-weight: 400; letter-spacing: -0.01em; }
.zahl { font-family: "Audi Type Wide"; font-weight: 300; letter-spacing: -0.03em; }
</style>
${grund ? ` <rect width="${S}" height="${S}" fill="${grund}"/>\n` : ""}\
<text class="wort" x="${S / 2}" y="${wortMitte.toFixed(2)}" font-size="${(A.wort * S).toFixed(2)}" fill="${vorn}" text-anchor="middle" dominant-baseline="central">DataMetric</text>
${fahrzeug(vorn)}
<text class="zahl" x="${S / 2}" y="${zahlMitte.toFixed(2)}" font-size="${(A.zahl * S).toFixed(2)}" fill="${akzent}" text-anchor="middle" dominant-baseline="central">360</text>
</svg>
`
}
const AUSFUEHRUNGEN = {
/* Nacht-Canvas und Audi-Rot aus den Tokens des Projekts. */
hell: { grund: "#0C1014", vorn: "#FFFFFF", akzent: "#F50537" },
dunkel: { grund: null, vorn: "#FFFFFF", akzent: "#F50537" },
/* Getoent wertet iOS die Helligkeit aus: Weiss fuer Wort und Fahrzeug, ein
helles Grau fuer die Zahl - so bleibt sie abgesetzt, ohne eine Farbe zu
behaupten, die es getoent nicht gibt. */
getoent: { grund: null, vorn: "#FFFFFF", akzent: "#B4B4B8" },
}
const ZIEL = path.join(__dirname, "..", "native", "logo")
fs.mkdirSync(ZIEL, { recursive: true })
for (const [name, o] of Object.entries(AUSFUEHRUNGEN)) {
const datei = path.join(ZIEL, `AppIcon-${name}.svg`)
fs.writeFileSync(datei, symbol(o), "utf8")
console.log(`${path.basename(datei)} ${(fs.statSync(datei).size / 1024).toFixed(0)} KB`)
}
console.log("")
console.log("Masse bei 1024:")
console.log(` Wort Mitte y=${wortMitte.toFixed(1)} Groesse ${(A.wort * S).toFixed(1)}`)
console.log(` Fahrzeug oben y=${autoOben.toFixed(1)} Kante ${autoKante.toFixed(1)}`)
console.log(` Zahl Mitte y=${zahlMitte.toFixed(1)} Groesse ${(A.zahl * S).toFixed(1)}`)
+153
View File
@@ -0,0 +1,153 @@
<#
.SYNOPSIS
Packt die gebaute Companion-App als OTA-Bündel in die Integration.
.BESCHREIBUNG
Erzeugt aus companion-app/dist zwei Dateien:
custom_components/audi_dashboard/frontend/app/bundle.zip
custom_components/audi_dashboard/frontend/app/bundle.json
Die iOS-App lädt die Zip zur Laufzeit nach und tauscht ihre Oberfläche aus,
ohne dass Xcode gebraucht wird (siehe companion-app/src/daten/ota.ts).
WARUM DAS BÜNDEL IN DER INTEGRATION LIEGT
-----------------------------------------
Weil es dann von selbst dorthin kommt, wo die App es sucht. install.ps1
(der einzige Installationsweg - HACS scheidet aus, siehe VERSIONIERUNG.md)
kopiert den Ordner custom_components/audi_dashboard/ vollständig - das
Bündel reist bei jedem Lauf mit, ohne zweiten Auslieferungsweg. Ein
Ablageort unter /config/www/ hätte einen eigenen Weg dorthin gebraucht, den
man beim Ausliefern hätte vergessen können.
Der Nebeneffekt ist der eigentliche Gewinn: Bündel und Integration können
gar nicht auseinanderlaufen, weil sie dieselbe Datei sind.
DREI DINGE, DIE HIER BEWUSST SO SIND
------------------------------------
1. **Sourcemaps fliegen raus.** Sie machen rund zwei Drittel von dist/ aus
(1,4 von 2,0 MB) und werden auf dem Gerät nie gebraucht. Sie mitzuladen
hieße, bei jedem Update das Dreifache über die Leitung zu schicken.
2. **Die Einträge werden von Hand geschrieben, nicht von einer Bequem-API.**
Die Zip-Spezifikation verlangt "/" als Trennzeichen. Windows PowerShell
5.1 läuft auf dem .NET Framework, und dort schreiben SOWOHL
Compress-Archive ALS AUCH [IO.Compression.ZipFile]::CreateFromDirectory
Backslashes in die Einträge von Unterordnern (in .NET Core behoben, hier
aber nicht verfügbar). Nachgemessen: die erste Fassung dieses Skripts
erzeugte "assets\index-*.js" statt "assets/index-*.js" - iOS hätte daraus
Dateien mit Backslash im Namen gemacht statt einen Ordner, und die App
wäre mit fehlenden Assets gestartet.
Deshalb ZipFile::Open plus CreateEntryFromFile mit selbst gebautem
Eintragsnamen: dann steht genau das im Archiv, was hier steht. Der Inhalt
liegt ohne umschließenden Ordner in der Wurzel, index.html also direkt
dort - genau das erwartet das Plugin; fehlt es, wirft set() "no index.html
file inside the bundle folder".
3. **SHA-256 über die Zip, klein geschrieben.** Das Plugin rechnet auf dem
Gerät denselben Hash über die heruntergeladene Datei (calcChecksum in
CapgoUpdater.swift) und verwirft das Bündel bei Abweichung. Ein
abgeschnittener Download oder eine halb gespiegelte Datei fällt damit
auf, bevor die App sie startet.
.PARAMETER Bauen
Vorher "npm run build" ausführen. Ohne das wird der Inhalt von dist/
genommen, wie er gerade daliegt.
.BEISPIEL
.\ota-paket.ps1 -Bauen
npm run ota
#>
param(
[switch]$Bauen
)
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
$hier = $PSScriptRoot
$app = Split-Path $hier -Parent
$projekt = Split-Path $app -Parent
$dist = Join-Path $app "dist"
$manifest = Join-Path $projekt "custom_components\audi_dashboard\manifest.json"
$ziel = Join-Path $projekt "custom_components\audi_dashboard\frontend\app"
function Gut($t) { Write-Host " [ok] $t" -ForegroundColor Green }
function Info($t) { Write-Host " [info] $t" -ForegroundColor DarkGray }
Write-Host ""
Write-Host " OTA-Bündel bauen" -ForegroundColor White
Write-Host " ================" -ForegroundColor White
if ($Bauen) {
Info "npm run build ..."
Push-Location $app
try { npm run build; if ($LASTEXITCODE -ne 0) { throw "npm run build fehlgeschlagen" } }
finally { Pop-Location }
}
if (-not (Test-Path (Join-Path $dist "index.html"))) {
throw "$dist\index.html fehlt - erst 'npm run build' laufen lassen (oder -Bauen benutzen)."
}
$version = (Get-Content $manifest -Raw | ConvertFrom-Json).version
if (-not $version) { throw "Kein 'version'-Feld in $manifest" }
Gut "Version: $version"
# In einen Zwischenordner spiegeln, damit die Sourcemaps nicht mitwandern -
# dist/ selbst bleibt unangetastet (die Maps sind beim Entwickeln nützlich).
$tmp = Join-Path ([IO.Path]::GetTempPath()) ("dm360-ota-" + [Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
try {
Copy-Item "$dist\*" $tmp -Recurse -Force
$maps = Get-ChildItem $tmp -Recurse -File -Filter "*.map"
$maps | ForEach-Object { Remove-Item $_.FullName -Force }
Info "$($maps.Count) Sourcemap(s) ausgelassen"
if (-not (Test-Path (Join-Path $tmp "index.html"))) {
throw "index.html liegt nicht in der Wurzel des Bündels - das Plugin würde es ablehnen."
}
New-Item -ItemType Directory -Force -Path $ziel | Out-Null
$zip = Join-Path $ziel "bundle.zip"
if (Test-Path $zip) { Remove-Item $zip -Force }
# Eintragsnamen selbst bauen (siehe Punkt 2 im Kopfkommentar): relativer
# Pfad ab dem Zwischenordner, Backslashes durch "/" ersetzt.
$archiv = [IO.Compression.ZipFile]::Open($zip, [System.IO.Compression.ZipArchiveMode]::Create)
try {
foreach ($datei in Get-ChildItem $tmp -Recurse -File | Sort-Object FullName) {
$name = $datei.FullName.Substring($tmp.Length + 1).Replace("\", "/")
[void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
$archiv, $datei.FullName, $name, [IO.Compression.CompressionLevel]::Optimal)
}
}
finally { $archiv.Dispose() }
$hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
$groesse = (Get-Item $zip).Length
Gut ("bundle.zip {0:N0} Bytes" -f $groesse)
Gut "sha256 $hash"
# Kleingeschriebener Hex-Hash, weil das Plugin auf dem Geraet ebenfalls
# kleingeschrieben vergleicht.
$daten = [ordered]@{
version = $version
sha256 = $hash
bytes = $groesse
gebaut = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
}
$json = ($daten | ConvertTo-Json -Compress)
[IO.File]::WriteAllText((Join-Path $ziel "bundle.json"), $json, (New-Object Text.UTF8Encoding($false)))
Gut "bundle.json geschrieben"
}
finally {
Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue
}
Write-Host ""
Write-Host " Fertig. Das Bündel wird mit der Integration ausgeliefert -" -ForegroundColor Green
Write-Host " homeassistant\installationspaket\Installieren.cmd nimmt es automatisch mit."
Write-Host ""
+17 -8
View File
@@ -14,7 +14,7 @@ import { readFileSync } from "node:fs"
import { fileURLToPath } from "node:url"
import { dirname, resolve } from "node:path"
import { ENTITAETEN } from "../src/api/types.ts"
import { DIENST_DOMAIN, ENTITAETEN } from "../src/api/types.ts"
import { HassRest } from "../src/api/rest.ts"
import { Warteschlange } from "../src/api/warteschlange.ts"
import { ablageSetzen, websocketUrl, type Ablage } from "../src/api/umgebung.ts"
@@ -104,10 +104,19 @@ try {
"tankprozent" in status && "gesichert" in status && "sicherheitscheck" in status,
Object.keys(status).slice(0, 6).join(", "),
)
/* Wie viele Punkte es sind, hängt an der Sensor-Zuordnung der Instanz: vier
Türen, vier Fenster, Heckklappe, Motorhaube — unbelegte Rollen liefern gar
keinen Eintrag. Eine feste Zahl zu erwarten hieße, die Zuordnung der
Testinstanz zu prüfen statt das Backend; genau daran scheiterte diese
Prüfung seit dem Entfernen der Türschloss-Erkennung (16 → 10), ohne dass
irgendetwas kaputt gewesen wäre. Geprüft wird deshalb die vereinbarte
Form: label ist Text, ok ist true/false/null (null = Sensor unbekannt). */
const punkte = (status["sicherheitscheck"] ?? []) as { label?: unknown; ok?: unknown }[]
pruefe(
"Sicherheitscheck liefert alle 16 Einzelprüfungen",
Array.isArray(status["sicherheitscheck"]) && (status["sicherheitscheck"] as unknown[]).length === 16,
`${(status["sicherheitscheck"] as unknown[] | undefined)?.length ?? 0} Punkte`,
"Sicherheitscheck liefert Punkte in der vereinbarten Form",
Array.isArray(punkte) &&
punkte.every((p) => typeof p.label === "string" && (p.ok === null || typeof p.ok === "boolean")),
`${punkte.length} Punkte`,
)
} catch (e) {
pruefe("Fahrzeugstatus lesen", false, e instanceof Error ? e.message : String(e))
@@ -128,17 +137,17 @@ for (const [name, id] of [
/* -------------------------------------------------------- Dienstaufruf */
try {
await rest.dienstAufrufen("pyscript", "audi_dashboard_jetzt_aktualisieren")
pruefe("Dienstaufruf audi_dashboard_jetzt_aktualisieren", true)
await rest.dienstAufrufen(DIENST_DOMAIN, "jetzt_aktualisieren")
pruefe("Dienstaufruf audi_dashboard.jetzt_aktualisieren", true)
} catch (e) {
pruefe("Dienstaufruf audi_dashboard_jetzt_aktualisieren", false, e instanceof Error ? e.message : String(e))
pruefe("Dienstaufruf audi_dashboard.jetzt_aktualisieren", false, e instanceof Error ? e.message : String(e))
}
/* ------------------------------------------------------ Warteschlange */
try {
const warteschlange = new Warteschlange(rest)
await warteschlange.einreihen("pyscript", "audi_dashboard_jetzt_aktualisieren", {}, "Rauchtest")
await warteschlange.einreihen(DIENST_DOMAIN, "jetzt_aktualisieren", {}, "Rauchtest")
/* einreihen() stößt die Abarbeitung selbst an, ohne sie abzuwarten - ein
sofortiger eigener abarbeiten()-Aufruf liefe deshalb ins Leere (der Lauf
+83 -1
View File
@@ -1,12 +1,20 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { bilderVorladen } from "./screens/bilder"
import { App as CapacitorApp } from "@capacitor/app"
import { DataMetricApi, zugangLesen } from "./api"
import { DatenAnbieter, useDaten } from "./daten/DatenKontext"
import { geteiltenBelegLesen, geteiltenBelegSetzen } from "./daten/geteilterBeleg"
import { startklarMelden } from "./daten/ota"
import { Shell } from "./Shell"
import type { Route, SeitenName } from "./navigation"
import { useTabBeschriftung, useTheme } from "./theme"
import { Bestaetigungsblatt } from "./screens/bestaetigung"
import { Einrichtung } from "./screens/Einrichtung"
import { Hinweisleiste } from "./screens/Hinweisleiste"
import { Versionshinweis } from "./screens/Versionshinweis"
import { SeiteFuer } from "./screens/register"
import { zugangMerken } from "./screens/zugang"
@@ -38,6 +46,10 @@ export function App() {
return (
<div className="ads-root dm-wurzel" data-theme={theme}>
{/* Einmal fuer die ganze App: die Rueckfrage vor loeschenden Aktionen
(siehe screens/bestaetigung.tsx). Rendert nichts, solange nichts
gefragt ist. */}
<Bestaetigungsblatt />
{start === "pruefen" && <div className="dm-laden">Einen Moment </div>}
{start === "einrichten" && <Einrichtung fertig={() => setzeStart("bereit")} />}
{start === "bereit" && (
@@ -65,13 +77,76 @@ function AngemeldeteApp({ beiAbmeldung }: { beiAbmeldung: () => void }) {
if (daten.verbindung === "nicht_angemeldet") beiAbmeldung()
}, [daten.verbindung, beiAbmeldung])
// Meldet dem OTA-Plugin, dass diese Fassung hochgekommen ist (siehe
// daten/ota.ts). Bewusst hier und nicht in main.tsx: erst wenn diese
// Komponente rendert, hat React tatsächlich etwas auf den Schirm gebracht.
// Ein Absturz vor diesem Punkt bleibt unbestätigt, und das Plugin rollt
// nach appReadyTimeout von selbst auf das vorherige Bündel zurück.
useEffect(() => {
void startklarMelden()
}, [])
/* Fahrzeugfotos einmal vorab holen und dekodieren, sobald Daten da sind -
siehe bilderVorladen(). Damit erscheint beim Umschalten der Seiten sofort
das Bild statt kurz des Platzhalters. */
useEffect(() => {
if (daten.verbindung === "verbunden") bilderVorladen()
}, [daten.verbindung])
/* Belege aus dem Teilen-Blatt.
Die Erweiterung legt das PDF ab und oeffnet die App - beim ersten Start
kommt die App dadurch frisch hoch, bei einer schon laufenden App nur
nach vorn. Deshalb beide Faelle: einmal beim Aufbau, und danach bei
jeder Rueckkehr in den Vordergrund.
Der Beleg wandert in den geteilten Speicher und die App springt auf
Tanken - dort nimmt ihn Tanken.tsx auf und laedt ihn hoch, ueber
genau denselben Weg wie eine von Hand gewaehlte Datei. */
useEffect(() => {
let abgemeldet = false
/* Der Beleg kommt im Aufruf mit: die Teilen-Erweiterung schreibt ihn in
den Container der App-Gruppe und öffnet die App mit dem Pfad
(ShareViewController.swift). Zwei Wege hierher, weil iOS beide benutzt:
war die App zu, steht der Aufruf in getLaunchUrl(); lief sie schon,
kommt er als appUrlOpen.
Bis zum 02.09.2026 stand hier "resume" plus ein Griff in die
UserDefaults - der konnte nie etwas finden, die Begründung steht in
geteilterBeleg.ts. */
const holen = async (url: string | null | undefined) => {
const datei = await geteiltenBelegLesen(url)
if (abgemeldet || !datei) return
geteiltenBelegSetzen(datei)
geheZu("fuel")
}
void CapacitorApp.getLaunchUrl().then((start) => holen(start?.url))
const horcher = CapacitorApp.addListener("appUrlOpen", (ereignis) => {
void holen(ereignis.url)
})
return () => {
abgemeldet = true
void horcher.then((h) => h.remove())
}
}, [geheZu])
return (
<Shell
route={route}
geheZu={geheZu}
tabBeschriftung={tabBeschriftung}
statusStand={daten.statusStand}
// Ziehen zum Aktualisieren gibt es im Panel nur auf der Übersicht.
beiAktualisieren={route.name === "home" ? daten.jetztAktualisieren : undefined}
hinweis={
<Hinweisleiste verbindung={daten.verbindung} warteschlange={daten.warteschlange} />
<Hinweisleiste
verbindung={daten.verbindung}
warteschlange={daten.warteschlange}
versionsstand={daten.versionsstand}
/>
}
>
<SeiteFuer
@@ -81,6 +156,13 @@ function AngemeldeteApp({ beiAbmeldung }: { beiAbmeldung: () => void }) {
setzeTabBeschriftung={setzeTabBeschriftung}
beiAbmeldung={beiAbmeldung}
/>
{/* Erscheint von selbst, sobald der erste Datenabruf eine neuere
Fassung meldet - siehe screens/Versionshinweis.tsx. */}
<Versionshinweis
versionsstand={daten.versionsstand}
serverVersion={daten.serverVersion}
otaBuendel={daten.otaBuendel}
/>
</Shell>
)
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Auffangnetz für Abstürze irgendwo im Baum.
*
* Ohne diese Grenze unmountet React die gesamte App bei jedem unbehandelten
* Fehler beim Rendern - #wurzel bleibt leer, und übrig bleibt nur der
* Seitenhintergrund (im Nacht-Theme praktisch schwarz). Genau das wurde als
* "schwarze Seite nach Update prüfen" gemeldet: kein hängender Ladezustand,
* sondern ein stiller Absturz, dessen eigentliche Fehlermeldung nirgends
* ankam. Ein "zurück und wieder rein" half nur, weil das erneute Öffnen den
* Baum komplett neu aufbaut und der auslösende Zustand dabei verschwindet.
*
* Diese Grenze behebt nicht die eigentliche Ursache - sie sorgt nur dafür,
* dass ein Absturz ab jetzt sichtbar ist (Fehlertext, Neu-laden-Knopf) statt
* unsichtbar zu bleiben, und dass der Fehlertext in der Konsole landet, um
* die eigentliche Ursache beim nächsten Mal fassen zu können.
*/
import { Component, type ReactNode } from "react"
import { ActionButton } from "@audi-dash/ui"
export class ErrorGrenze extends Component<
{ children: ReactNode },
{ fehler: Error | null }
> {
state: { fehler: Error | null } = { fehler: null }
static getDerivedStateFromError(fehler: Error) {
return { fehler }
}
componentDidCatch(fehler: Error, info: { componentStack?: string | null }) {
console.error("ErrorGrenze: unbehandelter Fehler beim Rendern", fehler, info.componentStack)
}
render() {
if (!this.state.fehler) return this.props.children
return (
<div className="dm-leer" style={{ minHeight: "100vh", justifyContent: "center" }}>
<span className="dm-leer__titel">Etwas ist schiefgelaufen</span>
<p className="dm-leer__text">
Die Seite konnte nicht dargestellt werden. Ein Neuladen behebt das in der Regel.
</p>
<p className="dm-leer__text" style={{ fontFamily: "monospace", fontSize: "12px" }}>
{this.state.fehler.message}
</p>
<ActionButton onClick={() => window.location.reload()}>Neu laden</ActionButton>
</div>
)
}
}
+1 -1
View File
@@ -65,7 +65,7 @@ describe("Shell", () => {
expect(bereichVon("trip")).toBe("trips")
})
it("erreicht die Einstellungen über das Zahnrad, nicht über die Tab-Leiste", () => {
it("erreicht die Einstellungen über die Ringe, nicht über die Tab-Leiste", () => {
const geheZu = rahmen("home")
fireEvent.click(screen.getByLabelText("Einstellungen"))
expect(geheZu).toHaveBeenCalledWith("einst")
+318 -16
View File
@@ -7,21 +7,29 @@
* liegt bei 1000px.
*/
import type { ReactNode } from "react"
import { useEffect, useRef, useState } from "react"
import type {
MutableRefObject,
PointerEvent as ReactPointerEvent,
ReactNode,
RefObject,
} from "react"
import { TabBar } from "@audi-dash/ui"
import { standAlter } from "./format"
import { useBreiterBildschirm } from "./hooks/useBreiterBildschirm"
import { VierRinge } from "./screens/Typenschild"
import type { Hauptbereich, Route, SeitenName } from "./navigation"
import { TITEL, ZURUECK, bereichVon, istHauptbereich } from "./navigation"
import {
SymbolAktualisieren,
SymbolAudi,
SymbolFahrten,
SymbolStatistik,
SymbolTanken,
SymbolUebersicht,
SymbolZahnrad,
SymbolZahnradVoll,
SymbolZurueck,
} from "./symbole"
@@ -31,10 +39,206 @@ export interface ShellProps {
tabBeschriftung: boolean
/** Wird über der Kopfzeile eingeblendet (Offline-Hinweis, Warteschlange). */
hinweis?: ReactNode | undefined
/** Zeitpunkt des letzten Datenstands — steht als „vor 3 Std." unter dem Titel. */
statusStand?: string | null | undefined
/** Neuabfrage beim Fahrzeug, ausgelöst durch Ziehen zum Aktualisieren. */
beiAktualisieren?: (() => Promise<void>) | undefined
/** Heißt `children`, weil JSX-Verschachtelung genau diesen Namen erwartet. */
children: ReactNode
}
/** Ziehen zum Aktualisieren, 1:1 nach der ptr-Logik des Panels. */
const PTR_SCHWELLE = 56
/*
* Vom linken Rand nach rechts wischen = zurück.
*
* Warum von Hand: die App ist EINE WebView mit einer flachen Route (siehe
* navigation.ts), kein Verlaufsstapel. iOS' eigene Zurück-Geste hängt am
* Navigationsstapel eines UINavigationControllers - den gibt es hier nicht,
* also gibt es die Geste auch nicht geschenkt. Bis zum 02.09.2026 war der
* Pfeil oben links der einzige Weg zurück (gemeldet vom Eigentümer).
*
* RAND: die Geste muss am linken Rand beginnen. Das ist keine Nachahmung um
* ihrer selbst willen, sondern die einzige Art, sie von allem anderen
* Waagerechten sauber zu trennen - dem Wischen an einer Listenzeile
* (WischLoeschen), dem seitlichen Schieben in der Fotogalerie.
*
* SCHWELLE: erst ab dieser Strecke wird zurückgegangen. Darunter federt die
* Seite zurück - eine angefangene Geste ist damit widerrufbar, indem man den
* Finger zurückführt, genau wie bei iOS.
*/
/* Dieselben Zahlen wie randwischenVerdrahten() im Panel (26px Rand, 62px
Auslösung) - die Geste soll sich in beiden Oberflächen gleich anfühlen. */
const ZURUECK_RAND = 26
const ZURUECK_SCHWELLE = 62
const ZURUECK_WEG = 140
function useWischZurueck(
inhalt: RefObject<HTMLElement | null>,
zurueck: (() => void) | undefined,
belegt: MutableRefObject<boolean>,
) {
const [zug, setzeZug] = useState<number | null>(null)
const start = useRef<{ x: number; y: number } | null>(null)
useEffect(() => {
const el = inhalt.current
// Der Aussteiger sitzt IM Effekt, nicht davor - sonst hinge die
// Hook-Reihenfolge davon ab, ob es ein Zurück-Ziel gibt.
if (!el || !zurueck) return undefined
const beiStart = (e: TouchEvent) => {
if (e.touches.length !== 1) return
const t = e.touches[0]!
if (t.clientX > ZURUECK_RAND) return
start.current = { x: t.clientX, y: t.clientY }
}
const beiBewegung = (e: TouchEvent) => {
const s = start.current
if (!s || e.touches.length !== 1) return
const t = e.touches[0]!
const dx = t.clientX - s.x
const dy = t.clientY - s.y
if (dx <= 0 || Math.abs(dx) < Math.abs(dy) * 1.5) {
// Senkrecht oder rückwärts gemeint: die Geste gehört uns nicht.
if (Math.abs(dy) > 10) start.current = null
return
}
// Solange wir ziehen, hält sich das Ziehen-zum-Aktualisieren heraus.
belegt.current = true
setzeZug(Math.min(ZURUECK_WEG, dx))
e.preventDefault()
}
const beiEnde = () => {
const gezogen = zug ?? 0
start.current = null
belegt.current = false
setzeZug(null)
if (gezogen >= ZURUECK_SCHWELLE) zurueck()
}
el.addEventListener("touchstart", beiStart, { passive: true })
el.addEventListener("touchmove", beiBewegung, { passive: false })
el.addEventListener("touchend", beiEnde)
el.addEventListener("touchcancel", beiEnde)
return () => {
el.removeEventListener("touchstart", beiStart)
el.removeEventListener("touchmove", beiBewegung)
el.removeEventListener("touchend", beiEnde)
el.removeEventListener("touchcancel", beiEnde)
}
})
return zug
}
function useZiehenAktualisieren(
inhalt: RefObject<HTMLElement | null>,
beiAktualisieren: (() => Promise<void>) | undefined,
belegt: MutableRefObject<boolean>,
) {
const [zug, setzeZug] = useState(0)
const [laedt, setzeLaedt] = useState(false)
const start = useRef<number | null>(null)
const beenden = () => {
if (start.current === null || !beiAktualisieren) return
start.current = null
if (zug >= PTR_SCHWELLE) {
setzeLaedt(true)
void beiAktualisieren().finally(() => {
setzeLaedt(false)
setzeZug(0)
})
} else {
setzeZug(0)
}
}
const starten = (y: number) => {
if (laedt || belegt.current) return
if ((inhalt.current?.scrollTop ?? 0) > 0) return
start.current = y
setzeZug(0)
}
/* Gibt true zurück, wenn die Geste uns gehört — der Aufrufer muss dann
preventDefault() rufen. */
const bewegen = (y: number): boolean => {
if (start.current === null) return false
if (belegt.current) {
start.current = null
setzeZug(0)
return false
}
const dy = y - start.current
if (dy > 0 && (inhalt.current?.scrollTop ?? 0) <= 0) {
// Widerstand wie bei iOS: halbe Strecke, gedeckelt bei 90px.
setzeZug(Math.min(90, dy * 0.5))
return true
}
start.current = null
setzeZug(0)
return false
}
/* Auf dem Finger läuft die Geste über Touch-Ereignisse, nicht über Pointer.
Grund: preventDefault() auf einem Pointer-Ereignis verhindert laut
Spezifikation kein Scrollen — darüber entscheidet allein touch-action. Auf
einem echten Gerät übernimmt der Browser die senkrechte Geste deshalb
sofort, schickt pointercancel und stellt pointermove ein; der Zug wurde
zurückgesetzt, bevor er die Schwelle erreichte. Am Rechner fällt das nie
auf, eine Maus kennt keine Scroll-Übernahme. Der Listener muss von Hand
und mit passive:false hängen — React registriert touchmove passiv, dort
wäre preventDefault() wirkungslos. */
useEffect(() => {
const el = inhalt.current
// Der Aussteiger sitzt IM Effekt, nicht davor: ein vorgezogenes return
// wuerde diesen Hook je nach Bildschirmbreite mal aufrufen und mal nicht
// und damit die Hook-Reihenfolge brechen.
if (!el || !beiAktualisieren) return undefined
const beiStart = (e: TouchEvent) => {
if (e.touches.length === 1) starten(e.touches[0]!.clientY)
}
const beiBewegung = (e: TouchEvent) => {
if (e.touches.length !== 1) return
if (bewegen(e.touches[0]!.clientY)) e.preventDefault()
}
el.addEventListener("touchstart", beiStart, { passive: true })
el.addEventListener("touchmove", beiBewegung, { passive: false })
el.addEventListener("touchend", beenden)
el.addEventListener("touchcancel", beenden)
return () => {
el.removeEventListener("touchstart", beiStart)
el.removeEventListener("touchmove", beiBewegung)
el.removeEventListener("touchend", beenden)
el.removeEventListener("touchcancel", beenden)
}
})
if (!beiAktualisieren) {
return { zug: 0, laedt: false, handler: {} as Record<string, never> }
}
return {
zug,
laedt,
handler: {
// Nur Maus/Stift — der Finger läuft über die Touch-Ereignisse oben.
onPointerDown: (e: ReactPointerEvent) => {
if (!e.isPrimary || e.pointerType === "touch") return
starten(e.clientY)
},
onPointerMove: (e: ReactPointerEvent) => {
if (!e.isPrimary || e.pointerType === "touch") return
bewegen(e.clientY)
},
onPointerUp: beenden,
onPointerCancel: beenden,
},
}
}
const BEREICHE: { key: Hauptbereich; label: string; icon: ReactNode }[] = [
{ key: "home", label: "Übersicht", icon: <SymbolUebersicht /> },
{ key: "audi", label: "Mein Audi", icon: <SymbolAudi /> },
@@ -43,13 +247,46 @@ const BEREICHE: { key: Hauptbereich; label: string; icon: ReactNode }[] = [
{ key: "fuel", label: "Tanken", icon: <SymbolTanken /> },
]
export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: ShellProps) {
export function Shell({
route,
geheZu,
tabBeschriftung,
hinweis,
statusStand,
beiAktualisieren,
children,
}: ShellProps) {
const breit = useBreiterBildschirm()
const aktiverBereich = bereichVon(route.name)
const zurueckZiel = ZURUECK[route.name]
const unterseite = !istHauptbereich(route.name)
const inhaltRef = useRef<HTMLElement | null>(null)
// Eine Geste zur Zeit: läuft der Wisch nach rechts, hält sich das Ziehen
// zum Aktualisieren heraus. Ohne das zeigte eine schräge Geste am oberen
// Seitenrand beides gleichzeitig.
const gesteBelegt = useRef(false)
const zurueckZug = useWischZurueck(
inhaltRef,
zurueckZiel && !breit ? () => geheZu(zurueckZiel) : undefined,
gesteBelegt,
)
const ptr = useZiehenAktualisieren(
inhaltRef,
breit ? undefined : beiAktualisieren,
gesteBelegt,
)
// Der Text tickt mit, wie standAlterTicken() im Panel (Sekundentakt).
const [, neuZeichnen] = useState(0)
useEffect(() => {
if (route.name !== "home") return undefined
const uhr = window.setInterval(() => neuZeichnen((n) => n + 1), 1000)
return () => window.clearInterval(uhr)
}, [route.name])
const kopf = (
<header className="dm-kopf">
<header className={unterseite ? "dm-kopf dm-kopf--unterseite" : "dm-kopf"}>
{zurueckZiel && !breit ? (
<button
type="button"
@@ -60,19 +297,52 @@ export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: She
<SymbolZurueck groesse={22} />
</button>
) : null}
{/* Kein Eyebrow: das Panel blendet seinen (`.eyebrow{display:none}` in
audi-dashboard-ios.css) auf Telefonbreite vollständig aus - der feste
Text "DataMetric360" stand hier über jedem Bildschirm, im Panel steht
dort nichts. */}
<div className="dm-kopf__titel">
<span className="ads-eyebrow">DataMetric360</span>
<div className="ads-title">{TITEL[route.name]}</div>
{/* Datenstand nur auf der Übersicht, wie `#sync` im Panel. */}
{route.name === "home" && (
<div className="dm-stand">
<SymbolAktualisieren groesse={13} />
<span>{standAlter(statusStand)}</span>
</div>
)}
</div>
<button
type="button"
className="dm-kopf__aktion"
aria-label="Einstellungen"
aria-current={route.name === "einst" ? "page" : undefined}
onClick={() => geheZu("einst")}
>
<SymbolZahnrad groesse={22} />
</button>
{/* `kopf` wird nur in der schmalen Spalte gerendert (breit hat seinen
eigenen Seitenleisten-Kopf, siehe unten) - dort sind die Audi-Ringe
oben rechts der einzige Zugang zu Einstellungen, das Zahnrad
entfällt, "damit oben rechts nur ein Element sitzt" (Design-Audit
2026-08-13, siehe .rings/.profilbtn in audi-dashboard-ios.css).
Nur auf den Tab-Ebenen: auf Unterseiten blendet das Panel die Ringe
aus (`#marke`.style.display in render()), dort steht links der
Zurück-Pfeil. */}
{!unterseite ? (
<button
type="button"
className="dm-kopf__aktion"
aria-label="Einstellungen"
onClick={() => geheZu("einst")}
>
<VierRinge hoehe={17} />
</button>
) : (
/* Gegengewicht zum Zurueck-Pfeil.
Der Titel ist auf Unterseiten mittig gesetzt - aber nur innerhalb
seines eigenen Kastens, und der beginnt erst hinter dem
Zurueck-Pfeil. Ohne ein gleich breites Element auf der rechten
Seite sass die Ueberschrift deshalb um dessen halbe Breite zu
weit rechts: live gemessen 17px Versatz auf "Standort"
(2026-08-31), und zwar auf jeder Unterseite, nicht nur dort.
Ein leerer Platzhalter statt einer absoluten Positionierung: so
behaelt ein langer Titel weiterhin seinen eigenen Platz und
schiebt sich nicht unter die Knoepfe. */
<span className="dm-kopf__gegengewicht" aria-hidden="true" />
)}
</header>
)
@@ -110,7 +380,7 @@ export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: She
}
onClick={() => geheZu("einst")}
>
<SymbolZahnrad />
<SymbolZahnradVoll />
<span>Einstellungen</span>
</button>
</div>
@@ -135,7 +405,39 @@ export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: She
<div className="dm-spalte">
{hinweis}
{kopf}
<main className="dm-scroll dm-inhalt">{children}</main>
{/* Ziehen zum Aktualisieren wie im Panel (`.ptr`): das Band wächst
während der Geste aus der Höhe 0 heraus. */}
<div
className={ptr.laedt ? "dm-ptr dm-ptr--dreht" : "dm-ptr"}
style={{ height: ptr.laedt ? "56px" : `${ptr.zug}px` }}
aria-hidden={ptr.zug === 0 && !ptr.laedt}
>
<SymbolAktualisieren groesse={14} />
<span>
{ptr.laedt
? "Aktualisieren …"
: ptr.zug >= 56
? "Loslassen zum Aktualisieren"
: "Ziehen zum Aktualisieren"}
</span>
</div>
<main
className="dm-scroll dm-inhalt"
ref={inhaltRef}
{...ptr.handler}
/* Die Seite folgt dem Finger, damit die Geste sichtbar widerrufbar
bleibt: zurückführen unter die Schwelle, und sie federt zurück.
Ohne Zug steht hier kein transform - eine stehende Transformation
erzeugt einen eigenen Malkontext und hätte position:fixed im Inhalt
an sich gebunden. */
style={
zurueckZug === null
? undefined
: { transform: `translateX(${zurueckZug}px)`, transition: "none" }
}
>
{children}
</main>
<TabBar
items={BEREICHE}
activeKey={aktiverBereich}
+269 -20
View File
@@ -13,8 +13,16 @@ export * from "./live.ts";
export * from "./warteschlange.ts";
export * from "./ablageNativ.ts";
import { ENTITAETEN } from "./types.ts";
import type { Fahrt, Fahrzeugstatus, Profil, Tankvorgang } from "./types.ts";
import { DIENST_DOMAIN, ENTITAETEN } from "./types.ts";
import type {
AppVersionAngabe,
CsvImportErgebnis,
Fahrt,
Fahrzeugstatus,
ImportErgebnis,
Profil,
Tankvorgang,
} from "./types.ts";
import { HassRest } from "./rest.ts";
import { HassLive } from "./live.ts";
import { Warteschlange } from "./warteschlange.ts";
@@ -31,6 +39,39 @@ export interface TankvorgangFelder {
station?: string | null;
distanz?: number | null;
kraftstoff?: string | null;
/** Aus einem vorab gelesenen Beleg (Upload ohne tank_id) - so übergibt sie
auch das Panel beim Speichern eines neuen Tankvorgangs mit Beleg. */
receipt_key?: string | null;
receipt_file?: string | null;
}
/** Eingabefelder für Fahrten - Namen wie beim Backend (fahrterkennung.py:
fahrt_manuell_anlegen/fahrt_aktualisieren).
Alles außer den Zeitpunkten ist optional: leer bleibt es liegen, bis das
Kilometerstand-Screening (§7.2) oder eine spätere Bearbeitung es füllt. */
/** Eingabefelder für einen archivierten Reifensatz - Namen wie beim Backend
(reifen.py: reifen_archiv_aktualisieren). */
export interface ReifenArchivFelder {
ersetzt_am?: string;
km?: number | null;
marke?: string | null;
modell?: string | null;
dot?: string | null;
mass?: string | null;
druck_vorne?: string | null;
druck_hinten?: string | null;
kommentar?: string | null;
}
export interface FahrtFelder {
ts_start?: string;
ts_end?: string;
art?: "privat" | "arbeitsweg";
start_ort?: string | null;
ziel_ort?: string | null;
odo_start?: number | null;
odo_end?: number | null;
distanz?: number | null;
}
/** Bündelt die drei Bausteine und bildet die Fachvorgänge ab, die das alte
@@ -77,8 +118,8 @@ export class DataMetricApi {
profilSchreiben(profil: Profil): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
"audi_dashboard_profil_schreiben",
DIENST_DOMAIN,
"profil_schreiben",
{ profil_json: JSON.stringify(profil) },
"Fahrzeugdaten speichern",
);
@@ -86,27 +127,62 @@ export class DataMetricApi {
belegHochladen(pdfBase64: string, dateiname: string, tankId?: string): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
"audi_dashboard_beleg_hochladen",
DIENST_DOMAIN,
"beleg_hochladen",
{ pdf_base64: pdfBase64, dateiname, ...(tankId ? { tank_id: tankId } : {}) },
`Beleg „${dateiname}" hochladen`,
);
}
/** Legt eine Fahrt von Hand an — für Fahrten ohne automatische Erkennung. */
fahrtAnlegen(tsStart: string, tsEnde: string, art: "privat" | "arbeitsweg"): Promise<unknown> {
/**
* Fahrzeugfoto hochladen bzw. ersetzen. Dieselben Dienste, die das Panel
* über `data-bildupload` anspricht (`bild_hochladen`/`bild_loeschen` in
* services.yaml). Die App konnte Fotos bisher gar nicht ändern — sie
* verwies stattdessen auf die Verwaltung in Home Assistant.
*/
bildHochladen(dateiname: string, datenBase64: string): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
"audi_dashboard_fahrt_manuell_anlegen",
{ ts_start: tsStart, ts_end: tsEnde, art },
DIENST_DOMAIN,
"bild_hochladen",
{ dateiname, daten_base64: datenBase64 },
`Foto „${dateiname}" hochladen`,
)
}
bildLoeschen(dateiname: string): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"bild_loeschen",
{ dateiname },
`Foto „${dateiname}" löschen`,
)
}
/** Legt eine Fahrt von Hand an — für Fahrten ohne automatische Erkennung. */
fahrtAnlegen(felder: FahrtFelder): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"fahrt_manuell_anlegen",
{ ...felder },
"Fahrt eintragen",
);
}
/** Bearbeitet eine bestehende Fahrt, gleich ob automatisch erkannt oder von
Hand angelegt. */
fahrtAktualisieren(tripId: string, felder: FahrtFelder): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"fahrt_aktualisieren",
{ trip_id: tripId, ...felder },
"Fahrt ändern",
);
}
fahrtLoeschen(tripId: string): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
"audi_dashboard_fahrt_loeschen",
DIENST_DOMAIN,
"fahrt_loeschen",
{ trip_id: tripId },
"Fahrt löschen",
);
@@ -116,8 +192,8 @@ export class DataMetricApi {
Parameternamen — belegverarbeitung.py). */
tankvorgangAnlegen(felder: TankvorgangFelder): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
"audi_dashboard_tankvorgang_manuell",
DIENST_DOMAIN,
"tankvorgang_manuell",
{ ...felder },
"Tankvorgang eintragen",
);
@@ -125,8 +201,8 @@ export class DataMetricApi {
tankvorgangAktualisieren(tankId: string, felder: TankvorgangFelder): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
"audi_dashboard_tankvorgang_aktualisieren",
DIENST_DOMAIN,
"tankvorgang_aktualisieren",
{ tank_id: tankId, ...felder },
"Tankvorgang ändern",
);
@@ -134,15 +210,188 @@ export class DataMetricApi {
tankvorgangLoeschen(tankId: string): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
"audi_dashboard_tankvorgang_loeschen",
DIENST_DOMAIN,
"tankvorgang_loeschen",
{ tank_id: tankId },
"Tankvorgang löschen",
);
}
/** Löscht einen Tageseintrag aus der Batteriespannungs-Messwertliste. */
batterieverlaufEintragLoeschen(datum: string): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"batterieverlauf_loeschen",
{ datum },
"Messwert löschen",
);
}
/** "Neue Räder anlegen": archiviert den aktuellen Stand von `satz` und legt
einen frischen (km 0) an. */
reifenArchivieren(satz: "sommer" | "winter"): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"reifen_archivieren",
{ satz },
"Neue Räder anlegen",
);
}
/** Korrigiert nur den Kilometerzähler von `satz` - bewusst kein
profilSpeichern() (siehe reifenzaehler.py): ein eigener Dienst, der
referenz_odo_km unangetastet lässt, damit die nächste automatische
Fortschreibung ab dem neuen Stand weiterzählt statt ihn zu
überschreiben. Deckungsgleich mit data-kmspeichern im Panel. */
reifenKmSetzen(satz: "sommer" | "winter", km: number): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"reifen_km_setzen",
{ satz, km },
"Kilometerstand korrigieren",
);
}
reifenArchivAktualisieren(id: string, felder: ReifenArchivFelder): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"reifen_archiv_aktualisieren",
{ id, ...felder },
"Archivierten Reifensatz ändern",
);
}
reifenArchivLoeschen(id: string): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"reifen_archiv_loeschen",
{ id },
"Archivierten Reifensatz löschen",
);
}
/** Stößt eine sofortige Aktualisierung im Backend an (Pull-to-refresh). */
jetztAktualisieren(): Promise<unknown> {
return this.rest.dienstAufrufen("pyscript", "audi_dashboard_jetzt_aktualisieren");
return this.rest.dienstAufrufen(DIENST_DOMAIN, "jetzt_aktualisieren");
}
/** Nutzlast von sensor.audi_dashboard_app_version: welchen Stand das
Backend ausliefert (`app`, siehe VERSIONIERUNG.md) und ob ein
OTA-Bündel bereitliegt (`buendel`, siehe daten/ota.ts). Beides kommt
aus derselben Entität und wird deshalb in einem Aufruf gelesen statt in
zweien. `null` bei jedem Fehler — fehlende Entität (älteres Backend),
Netzproblem, was auch immer; dann wird nichts verglichen und nichts
gemeldet, statt einen Fehlalarm auszulösen. */
async appVersionAngabeLesen(): Promise<AppVersionAngabe | null> {
try {
const zustand = await this.rest.zustandLesen<{ daten?: AppVersionAngabe }>(
ENTITAETEN.appVersion,
);
return zustand.attributes?.daten ?? null;
} catch {
return null;
}
}
/* ------------------------------------------------- Selbst-Update der
Integration (aktualisierung.py) - Ersatz für install.ps1, das Windows
Smart App Control blockiert. Bewusst NICHT über die Warteschlange, wie
historieImportieren() oben: beides sind ausdrückliche, sofortige
Aktionen, kein Zustand, den man im Funkloch absetzt und später
nachgeholt haben will. Ergebnis kommt über
appVersionAngabeLesen()/daten.integration_update zurück, nicht über den
Rückgabewert dieser Aufrufe. */
/** Fragt nur nach, ob eine neuere Fassung vorliegt - lädt nichts herunter. */
updatePruefen(): Promise<unknown> {
return this.rest.dienstAufrufen(DIENST_DOMAIN, "update_pruefen");
}
/** Lädt die neueste Fassung und ersetzt den Integrationsordner. Home
Assistant muss danach neu gestartet werden. */
updateInstallieren(): Promise<unknown> {
return this.rest.dienstAufrufen(DIENST_DOMAIN, "update_installieren");
}
/** Startet Home Assistant komplett neu - der letzte Schritt nach
updateInstallieren(), damit die neue Fassung tatsächlich geladen wird
(ein reiner Reload reicht dafür nicht, siehe AGENTS.md Abschnitt J).
Kein audi_dashboard-Dienst, deshalb der Bereich "homeassistant" statt
DIENST_DOMAIN. */
homeAssistantNeuStarten(): Promise<unknown> {
return this.rest.dienstAufrufen("homeassistant", "restart");
}
/* ------------------------------------------- Import aus dem HA-Verlauf
Bewusst NICHT über die Warteschlange, anders als die übrigen
schreibenden Vorgänge: der Import ist keine Eingabe, die man im Funkloch
absetzt und später ausgeführt haben will. Er dauert lange, sein Ergebnis
ist der eigentliche Zweck des Aufrufs, und ein nachträglich aus der
Warteschlange abgefeuerter Lauf käme für den Nutzer aus dem Nichts.
Ohne Netz gehört er schlicht nicht angeboten. */
/** Startet den Import. Kehrt zurück, sobald das Backend den Auftrag
angenommen hat — nicht, wenn er fertig ist; dafür importStatusLesen(). */
/** Beendet die laufende Fahrt sofort, mit vorlaeufigem Ende.
Wie historieImportieren() bewusst NICHT ueber die Warteschlange: im
Funkloch abgesetzt und Stunden spaeter nachgeholt wuerde sie eine Fahrt
schliessen, die laengst regulaer zu Ende ist. */
fahrtJetztBeenden(): Promise<unknown> {
return this.rest.dienstAufrufen(DIENST_DOMAIN, "fahrt_jetzt_beenden", {});
}
/** Traegt einen Dienste-Token ein oder loescht ihn (leerer Text = loeschen).
Nicht ueber die Warteschlange: ein Token, der Stunden spaeter aus dem
Funkloch nachgereicht wird, ueberschriebe womoeglich einen inzwischen
eingetragenen neuen. Und der Wert kommt nie zurueck - was gesetzt ist,
steht in daten.zugaenge, der Token selbst nirgends. */
zugangSetzen(dienst: string, token: string): Promise<unknown> {
return this.rest.dienstAufrufen(DIENST_DOMAIN, "zugang_setzen", { dienst, token });
}
/** Liest die Konfiguration des Dongles - und schreibt dabei eine vorgemerkte
Aenderung, falls eine offen ist (siehe flespi.py).
Wie zugangSetzen bewusst NICHT ueber die Warteschlange: der Abruf gelingt
nur bei laufender Zuendung, und Stunden spaeter aus dem Funkloch nachgeholt
traefe er das Geraet garantiert im Schlaf an. */
dongleLesen(): Promise<unknown> {
return this.rest.dienstAufrufen(DIENST_DOMAIN, "dongle_lesen", {});
}
historieImportieren(start: string, ende: string): Promise<unknown> {
return this.rest.dienstAufrufen(DIENST_DOMAIN, "historie_importieren", {
start,
ende,
});
}
/** Stand des laufenden bzw. letzten Imports: "laeuft" | "fertig" |
"fehler", plus Zählwerte im Attribut "daten". */
async importStatusLesen(): Promise<{ zustand: string; daten: ImportErgebnis }> {
const zustand = await this.rest.zustandLesen<{ daten: ImportErgebnis }>(
ENTITAETEN.importStatus,
);
return { zustand: zustand.state, daten: zustand.attributes?.daten ?? {} };
}
/* --------------------------------------------------- CSV-Import
Wie historieImportieren() bewusst NICHT über die Warteschlange: eine
bewusste, einmalige Aktion, deren Ergebnis der eigentliche Zweck des
Aufrufs ist, kein Formularfeld, das man auch offline abschicken würde.
Anders als dort aber synchron im Backend (csv_import.py) - der
Dienstaufruf kehrt erst zurück, wenn sensor.audi_dashboard_import_status
den neuen Stand schon trägt, keine Wartezeit nötig. */
async csvImportieren(
art: "fahrten" | "tanken" | "service",
inhalt: string,
): Promise<CsvImportErgebnis> {
await this.rest.dienstAufrufen(DIENST_DOMAIN, "csv_importieren", { art, inhalt });
const zustand = await this.rest.zustandLesen<{ daten: CsvImportErgebnis }>(
ENTITAETEN.importStatus,
);
return { art, ...(zustand.attributes?.daten ?? {}) };
}
}
+18 -4
View File
@@ -5,8 +5,8 @@
hass-Objekt geleistet hat (SPECIFICATION.md §3 "Data flow"):
hass.states[id].attributes.daten -> zustandLesen()/datenLesen()
hass.callService(...) -> dienstAufrufen()
Dieselben Entitäten, dieselben pyscript-Dienste - nur über HTTP statt über
ein Objekt, das nur innerhalb des HA-Frontends existiert. */
Dieselben Entitäten, dieselben Dienste der Integration - nur über HTTP statt
über ein Objekt, das nur innerhalb des HA-Frontends existiert. */
import type { DatenEntity, HassState } from "./types.ts";
import { zugangLesen, type Zugang } from "./umgebung.ts";
@@ -36,6 +36,20 @@ export class ApiFehler extends Error {
get istNetzproblem(): boolean {
return this.status === null;
}
/** Vorübergehend nicht erreichbar: kein Netz, oder ein Server, der
gerade hochfährt.
502/503/504 sind genau das, was ein Neustart von Home Assistant
liefert - der Vorschaltserver antwortet schon, Home Assistant selbst
noch nicht. Das ist für die Oberfläche dasselbe wie kein Netz: warten
und den zwischengespeicherten Stand zeigen, nicht eine Fehlerzeile
stehen lassen, die niemand mehr wegbekommt. 500 gehört bewusst NICHT
dazu - das ist ein echter Fehler in der Anfrage, der sich durch
Warten nicht bessert. */
get istVoruebergehend(): boolean {
return this.istNetzproblem || this.status === 502 || this.status === 503 || this.status === 504;
}
}
export interface AnfrageOptionen {
@@ -124,8 +138,8 @@ export class HassRest {
return (zustand as DatenEntity<T>).attributes.daten;
}
/** Ruft einen Dienst auf, z. B. dienstAufrufen("pyscript",
"audi_dashboard_profil_schreiben", { profil_json }). */
/** Ruft einen Dienst auf, z. B. dienstAufrufen("audi_dashboard",
"profil_schreiben", { profil_json }). */
async dienstAufrufen(
bereich: string,
dienst: string,
+232 -18
View File
@@ -4,10 +4,10 @@
Die Fachtypen (Fahrt, Tankvorgang, ...) sind aus SPECIFICATION.md §5
"Data files" abgeleitet - also aus dem, was das Backend tatsächlich in
fahrten.jsonl / tankvorgaenge.jsonl schreibt, nicht aus Wunschdenken.
Felder, die laut §7 im Schema stehen, aber von keinem Codepfad je gefüllt
werden (start_lat, avg_speed_kmh, route, ...), sind hier bewusst als
optional markiert - sie können null/undefined sein und die Oberfläche darf
sich nie darauf verlassen. */
Mehrere Felder (start_lat/-lon, end_lat/-lon, route, avg_speed_kmh) sind
nur befüllt, wenn ein GPS-Sensor zugeordnet ist (screening.py/
historienimport.py) - ohne Zuordnung bleiben sie null. Deshalb hier bewusst
optional markiert; die Oberfläche darf sich nie auf ihre Existenz verlassen. */
/** Roher Zustand einer HA-Entität, wie ihn /api/states liefert. */
export interface HassState<A = Record<string, unknown>> {
@@ -19,7 +19,7 @@ export interface HassState<A = Record<string, unknown>> {
context?: { id: string; parent_id: string | null; user_id: string | null };
}
/** Die pyscript.*-Entitäten transportieren ihre Nutzlast im Attribut "daten". */
/** Die Entitäten der Integration transportieren ihre Nutzlast im Attribut "daten". */
export interface DatenAttribute<T> {
daten: T;
[weitere: string]: unknown;
@@ -40,7 +40,10 @@ export interface Fahrt {
duration_s: number;
distance_km: number | null;
/** "odometer" ist der einzige Wert, den das Backend je erzeugt (§7.1). */
km_quelle: "odometer" | "gps" | null;
/* "odometer+gnss": der Kilometerstand des Fahrzeugs als Anker, die
Nachkommastelle vom GNSS-Zaehler des Geraets - siehe screening.py.
"sensor"/"manuell" kommen aus dem Rueckblick bzw. von Hand. */
km_quelle: "odometer" | "odometer+gnss" | "gps" | "sensor" | "manuell" | null;
odo_start: number | null;
odo_end: number | null;
art: Fahrtart;
@@ -49,13 +52,29 @@ export interface Fahrt {
edited_fields?: string[];
/* Ab FMM003 erstmals real befüllbar - bis dahin durchgehend null (§7.2). */
avg_speed_kmh?: number | null;
vmax_kmh?: number | null;
start_lat?: number | null;
start_lon?: number | null;
end_lat?: number | null;
end_lon?: number | null;
start_address?: string | null;
end_address?: string | null;
route?: unknown | null;
/* Nur der Ortsname, vom Backend aufgeloest (geokodierung.py). Die Liste
hat je Fahrt eine Zeile - zwei volle Anschriften passen dort nicht. */
start_stadt?: string | null;
end_stadt?: string | null;
route?: [number, number][] | null;
/** Näherung aus der Literstand-Differenz (TANK_LITER_SENSOR) ÷ Distanz, kein
* vom Fahrzeug selbst für diese eine Fahrt gemeldeter Wert - siehe
* verbrauch_aus_literstaenden() im Backend. */
verbrauch_l_100km?: number | null;
/** Das Ende steht nur vorlaeufig fest (Knopf "Fahrt beenden", das Geraet
hat sein Zuendungs-Aus noch nicht gemeldet). */
ende_vorlaeufig?: boolean;
/** Kilometer am Anfang der Fahrt, die das Geraet verschlafen hat. */
luecke_km?: number | null;
/** Die Zuordnung dieser Luecke ist unsicher - siehe screening.py. */
luecke_unsicher?: boolean;
pausen?: unknown[];
}
@@ -70,6 +89,9 @@ export interface Tankvorgang {
/** Immer aus fuel_total_eur/liters berechnet, nie aus dem Beleg gelesen. */
price_per_l: number | null;
station_name?: string | null;
/** Kopfmarke des Belegs (shell_beleg_parser). Fehlt bei Vorgaengen ohne
Beleg und bei Belegen, deren Datei nicht mehr liegt. */
station_brand?: string | null;
station_id?: string | null;
station_address?: string | null;
fuel_type?: string | null;
@@ -88,14 +110,17 @@ export interface Tankvorgang {
edited_fields?: string[];
}
/** Einzelprüfung aus _sicherheitscheck: ok===null heißt "Sensor unbekannt". */
/** Einzelprüfung aus _sicherheitscheck: ok===null heißt "Sensor unbekannt".
"gruppe" fasst mehrere Punkte zu einer Sammelzeile zusammen (verriegelt /
tueren_klappen / fenster_dach / licht), siehe Fahrzeugstatus.tsx. */
export interface SicherheitsPunkt {
label: string;
ok: boolean | null;
gruppe: "verriegelt" | "tueren_klappen" | "fenster_dach" | "licht";
}
/** Feldnamen an der laufenden Instanz abgelesen, nicht aus der Spezifikation
abgeleitet: frontend_veroeffentlichung.py schreibt "tankprozent",
abgeleitet: veroeffentlichung.py schreibt "tankprozent",
"gesichert" und "sicherheitscheck" — ein früherer Entwurf dieser Datei
hatte "tank_prozent"/"sicher_abgestellt"/"sicherheit" angenommen, womit
die Oberfläche still überall undefined gelesen hätte. */
@@ -106,6 +131,18 @@ export interface Fahrzeugstatus {
batteriespannung?: number | null;
gesichert?: boolean | null;
sicherheitscheck?: SicherheitsPunkt[];
/** Diagnosecodes im Fehlerspeicher. Getrennt vom Sicherheitscheck, weil ein
Eintrag nichts darueber sagt, ob der Wagen verschlossen dasteht. */
fehlerspeicher?: { anzahl: number | null; ok: boolean | null } | null;
/** Zustand des Zündungssensors — ausdrücklich nicht „fährt": es gibt keinen
Bewegungssensor, und die Zündung meldet an auch bei Zubehörstellung oder
im Stand. `null`/fehlend heißt „kein Zündungssensor zugeordnet", also
unbekannt — ebenfalls nicht „steht". */
zuendung?: boolean | null;
standort_lat?: number | null;
standort_lon?: number | null;
standort_genauigkeit_m?: number | null;
standort_zeit?: string | null;
oelwechsel_faellig_ts?: string | null;
oelwechsel_faellig_km?: number | null;
inspektion_faellig_ts?: string | null;
@@ -131,19 +168,196 @@ export interface Profil {
[weitere: string]: unknown;
}
/** Ergebnis eines Historien-Imports (custom_components/audi_dashboard/historienimport.py). Die
Zählwerte fehlen, solange der Lauf noch nicht fertig ist; `meldung` steht
nur im Fehlerfall. */
export interface ImportErgebnis {
von?: string;
bis?: string;
/** Frühester Zeitpunkt mit Aufzeichnung im gewählten Fenster — erklärt ein
leeres Ergebnis („weiter zurück bewahrt HA nichts mehr auf"). */
ab_wann_daten?: string | null;
fahrten_angelegt?: number;
fahrten_uebersprungen?: number;
fahrten_zu_kurz?: number;
fahrten_nicht_gefahren?: number;
tankvorgaenge_angelegt?: number;
tankvorgaenge_uebersprungen?: number;
batterie_tage?: number;
meldung?: string;
}
/** Ergebnis eines CSV-Imports (custom_components/audi_dashboard/csv_import.py) -
dieselbe Entität wie ImportErgebnis (sensor.audi_dashboard_import_status),
aber eine andere Kennzahlform: Zeilenabgleich statt Fahrten/Tankvorgänge/
Batterie getrennt. */
export interface CsvImportErgebnis {
art?: string;
angelegt?: number;
aktualisiert?: number;
uebersprungen?: number;
}
/** Das OTA-Bündel, das die Integration mit ausliefert (siehe
custom_components/audi_dashboard/koordinator.py `_buendel_lesen()` und
companion-app/scripts/ota-paket.ps1). `null`, solange keine
bundle.json neben der Integration liegt — ein völlig normaler Zustand,
kein Fehler: wer nur das Panel nutzt, braucht kein App-Bündel. */
export interface Buendelangabe {
version: string;
sha256: string;
bytes: number;
gebaut: string;
/** Pfad relativ zur Home-Assistant-Basis-URL, z. B.
"/audi_dashboard_static/app/bundle.zip". Von der Integration
angehängt, steht nicht in bundle.json selbst. */
url: string;
}
/** Ergebnis der letzten "Auf Update prüfen"/"Update installieren"-Aktion
(custom_components/audi_dashboard/aktualisierung.py, dienste.py). `null`,
solange noch nie geprüft wurde - ein normaler Zustand, kein Fehler.
Ersetzt install.ps1 als Update-Weg, weil Windows Smart App Control es
zuverlässig blockiert. */
export interface IntegrationUpdateAngabe {
verfuegbar: boolean;
version: string | null;
geprueft_am: string;
/** true unmittelbar nach einer erfolgreichen Installation. */
installiert: boolean;
/** Ob nach der Installation wirklich ein Home-Assistant-Neustart nötig ist.
Der Updater entscheidet das am Datei-Vergleich: betrifft das Update nur
`frontend/`, reicht ein Neuladen der Seite - die Oberflächendateien
werden direkt von der Platte ausgeliefert. Nur geänderter Python-Code
braucht einen Neustart, weil Home Assistant seine Module einmal beim
Start importiert.
Fehlt das Feld (ältere Integration), gilt "Neustart nötig". */
neustart_noetig?: boolean;
fehler: string | null;
}
/** Ein Dienste-Token dieser Installation (zugaenge.py).
Der Token selbst ist hier BEWUSST nicht enthalten und kommt auch nie ueber
die Leitung: gezeigt wird nur, ob einer gesetzt ist, und seine letzten vier
Zeichen zum Wiedererkennen. */
export interface ZugangAngabe {
dienst: string;
titel: string;
zweck: string;
/** Laeuft die App ohne diesen Token gar nicht? Beide heutigen tun es. */
erforderlich: boolean;
gesetzt: boolean;
rest: string | null;
}
/** Was am Dongle steht und was noch aussteht (flespi.py).
`aktiv` ist false, solange kein flespi-Token hinterlegt ist - dann gibt es
nichts zu zeigen. `stand` traegt seinen eigenen Zeitstempel: eine Angabe von
gestern soll nicht wie eine frische aussehen. Was noch zum Geraet unterwegs
ist, steht je Wert in `offen` - flespi haelt es vor, wir nicht. */
export interface DongleWert {
schluessel: string;
/** Die Nummer im flespi-Configurator - danach sucht man dort. */
nummer: string;
titel: string;
einheit: string;
geraet: unknown;
/** Was noch zum Gerät unterwegs ist (flespis `pending`) - null, wenn nichts
aussteht. */
offen: unknown;
/** Unser eigener Wert, sofern es einen zu vergleichen gibt. */
unser: number | null;
/** Dreiwertig: true weicht ab, false stimmt überein, **null kein
Urteil** — der Wert wurde nie vom Gerät bestätigt, oder es ist
gerade eine Änderung zu ihm unterwegs. Bis zum 04.09.2026 war das
ein Bool, und `false` behauptete Übereinstimmung auch gegen einen
tagealten Wert. */
abweichung: boolean | null;
/** Wann das Gerät diesen Wert zuletzt bestätigt hat (flespis
`updated`) — null heißt: nie. */
gemeldet_am?: string | null;
}
export interface DongleStand {
geraet?: number;
gelesen_am: string;
anzahl?: number;
werte?: DongleWert[];
abweichungen?: number;
/** Zeilen ohne Urteil: nie bestätigt oder gerade unterwegs. */
unbekannt?: number;
/** Welche Zwischenspeicher dieser Abruf geleert hat. */
geleert?: string[];
/** Wie viele der gezeigten Werte noch zum Gerät unterwegs sind. */
offen?: number;
fehler: string | null;
}
export interface DongleAngabe {
aktiv: boolean;
stand: DongleStand | null;
}
/** Ein einzelner Wert aus der letzten Meldung des Dongles (geraet.py). */
export interface DatensatzWert {
name: string;
wert: string;
einheit: string | null;
}
/** Die letzte Meldung des Dongles, so wie sie in Home Assistant ankam.
"angekommen" ist die Ankunftszeit, "meldezeit" die Zeit des Geraets -
der Abstand zwischen beiden ist der Puffer-Rueckstand. */
export interface LetzterDatensatz {
geraet: string;
angekommen: string;
meldezeit: string | null;
werte: DatensatzWert[];
gesamt: number;
}
/** Nutzlast von sensor.audi_dashboard_app_version. */
export interface AppVersionAngabe {
app: string | null;
buendel: Buendelangabe | null;
integration_update: IntegrationUpdateAngabe | null;
zugaenge?: ZugangAngabe[];
dongle?: DongleAngabe;
letzter_datensatz?: LetzterDatensatz | null;
/** Zeitstempel je vorhandenem Fahrzeugfoto - siehe bilder.staende(). */
bildstaende?: Record<string, number> | null;
}
/* -------------------------------------------------- Entitäts-Verzeichnis */
/** Alle vom Backend veröffentlichten pyscript-Entitäten an einer Stelle.
/** Alle vom Backend veröffentlichten Entitäten an einer Stelle.
Kein Hardcoding über die App verstreut: wer eine Entität umbenennt,
ändert genau diese Tabelle. */
ändert genau diese Tabelle.
Die Namen sind ein Vertrag mit custom_components/audi_dashboard/const.py;
das HA-Panel führt dieselbe Tabelle in audi-dashboard-app.js. Bis
2026-08-23 hießen sie pyscript.audi_dashboard_*, weil pyscript sie
bereitstellte und die Domain besaß. Seit dem Umbau zur eigenen Integration
sind es echte Entitäten dieser Integration. */
export const ENTITAETEN = {
profil: "pyscript.audi_dashboard_profil",
fahrten: "pyscript.audi_dashboard_fahrten",
tankvorgaenge: "pyscript.audi_dashboard_tankvorgaenge",
fahrzeugstatus: "pyscript.audi_dashboard_fahrzeugstatus",
batterieverlauf: "pyscript.audi_dashboard_batterieverlauf",
belegErgebnis: "pyscript.audi_dashboard_beleg_ergebnis",
updateStatus: "pyscript.audi_dashboard_update_status",
profil: "sensor.audi_dashboard_profil",
fahrten: "sensor.audi_dashboard_fahrten",
tankvorgaenge: "sensor.audi_dashboard_tankvorgaenge",
fahrzeugstatus: "sensor.audi_dashboard_fahrzeugstatus",
batterieverlauf: "sensor.audi_dashboard_batterieverlauf",
belegErgebnis: "sensor.audi_dashboard_beleg_ergebnis",
importStatus: "sensor.audi_dashboard_import_status",
appVersion: "sensor.audi_dashboard_app_version",
} as const;
export type EntitaetsSchluessel = keyof typeof ENTITAETEN;
/** Die Domain, unter der das Backend seine Dienste anbietet:
`audi_dashboard.<name>`. Früher `pyscript.audi_dashboard_<name>` - der
doppelte Präfix war nur nötig, weil sich alle pyscript-Dienste eine Domain
teilten. Siehe custom_components/audi_dashboard/dienste.py. */
export const DIENST_DOMAIN = "audi_dashboard";
+48
View File
@@ -0,0 +1,48 @@
/**
* Das Schema der Server-Adresse.
*
* Anlass: beim Neuverbinden stand „localhost:5173" im Feld — die App machte
* daraus `https://localhost:5173` und meldete „Der Server ist unter dieser
* Adresse nicht erreichbar". Das Beispiel im Feld selbst
* (`192.168.1.20:8123`) hatte dasselbe Problem: im Heimnetz gibt es kein
* Zertifikat, Home Assistant spricht dort `http`.
*/
import { describe, expect, it } from "vitest"
import { basisUrlNormalisieren } from "./umgebung"
describe("basisUrlNormalisieren", () => {
it("nimmt http fuer das eigene Netz", () => {
// Genau die Vorlage aus dem Eingabefeld, und der gemeldete Fall.
expect(basisUrlNormalisieren("192.168.1.20:8123")).toBe("http://192.168.1.20:8123")
expect(basisUrlNormalisieren("localhost:5173")).toBe("http://localhost:5173")
expect(basisUrlNormalisieren("127.0.0.1:8123")).toBe("http://127.0.0.1:8123")
expect(basisUrlNormalisieren("10.0.0.5:8123")).toBe("http://10.0.0.5:8123")
expect(basisUrlNormalisieren("172.16.3.4:8123")).toBe("http://172.16.3.4:8123")
expect(basisUrlNormalisieren("homeassistant.local:8123")).toBe(
"http://homeassistant.local:8123",
)
})
it("bleibt bei https fuer alles von aussen Erreichbare", () => {
// Der echte Betriebsfall: die Domain laeuft ueber Cloudflare.
expect(basisUrlNormalisieren("datametric360.de")).toBe("https://datametric360.de")
expect(basisUrlNormalisieren("ha.example.com:8123")).toBe("https://ha.example.com:8123")
// 172.32 liegt bereits AUSSERHALB des privaten Bereichs (nur 16-31).
expect(basisUrlNormalisieren("172.32.0.1")).toBe("https://172.32.0.1")
})
it("raet gar nicht, wenn ein Schema dasteht", () => {
expect(basisUrlNormalisieren("https://192.168.1.20:8123")).toBe("https://192.168.1.20:8123")
expect(basisUrlNormalisieren("http://datametric360.de")).toBe("http://datametric360.de")
})
it("schneidet abschliessende Schraegstriche ab", () => {
// Sonst entstehen Adressen wie "https://host//api/states".
expect(basisUrlNormalisieren(" https://datametric360.de/// ")).toBe(
"https://datametric360.de",
)
expect(basisUrlNormalisieren("192.168.1.20:8123/")).toBe("http://192.168.1.20:8123")
})
})
+35 -2
View File
@@ -101,7 +101,7 @@ const SCHLUESSEL_BASIS = "dm360.basis_url";
const SCHLUESSEL_TOKEN = "dm360.token";
export interface Zugang {
/** Basis-URL ohne abschließenden Schrägstrich, z. B. https://audi.datametric360.app */
/** Basis-URL ohne abschließenden Schrägstrich, z. B. https://audi.datametric360.de */
basisUrl: string;
/** Long-Lived Access Token aus dem HA-Benutzerprofil. */
token: string;
@@ -129,9 +129,42 @@ export async function zugangVerwerfen(): Promise<void> {
/** Schneidet abschließende Schrägstriche ab und ergänzt fehlendes Schema.
Ohne das entstehen sonst Adressen wie "https://host//api/states". */
/**
* Adressen, die per Definition im eigenen Netz liegen - dort gibt es kein
* Zertifikat, also spricht der Server http.
*
* Home Assistant im Heimnetz ist genau dieser Fall: "192.168.1.20:8123" steht
* sogar als Beispiel im Eingabefeld. Bis zum 04.09.2026 setzte die App davor
* blind https:// - die eigene Vorlage, so eingetippt, ergab damit eine
* Adresse, unter der nichts antwortet ("Der Server ist unter dieser Adresse
* nicht erreichbar", gemeldet beim Neuverbinden).
*
* Umgekehrt bleibt https richtig fuer alles, was von aussen erreichbar ist -
* datametric360.de laeuft ueber Cloudflare, dort waere http ein Rueckschritt.
* Deshalb nicht generell umgestellt, sondern nach dem Ziel unterschieden.
*/
function imEigenenNetz(host: string): boolean {
const h = host.toLowerCase().replace(/:[0-9]+$/, "").replace(/^\[|\]$/g, "");
if (h === "localhost" || h === "::1") return true;
if (h.endsWith(".local") || h.endsWith(".home.arpa") || h.endsWith(".localhost")) return true;
const v4 = /^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/.exec(h);
if (!v4) return false;
const a = Number(v4[1]);
const b = Number(v4[2]);
return a === 127 || a === 10 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31);
}
/** Schneidet abschliessende Schraegstriche ab und ergaenzt fehlendes Schema.
Ohne das entstehen sonst Adressen wie "https://host//api/states".
Geraten wird nur, wenn kein Schema dasteht - wer http:// oder https://
tippt, bekommt genau das. */
export function basisUrlNormalisieren(eingabe: string): string {
let url = eingabe.trim();
if (!/^https?:\/\//i.test(url)) url = `https://${url}`;
if (!/^https?:\/\//i.test(url)) {
const ziel = url.split("/")[0] ?? "";
url = `${imEigenenNetz(ziel) ? "http" : "https"}://${url}`;
}
return url.replace(/\/+$/, "");
}
@@ -0,0 +1,134 @@
/**
* Was passiert, wenn Home Assistant neu startet.
*
* Gemeldet vom Eigentümer (2026-08-31): während eines Neustarts zeigte die App
* „502 Error“, und die Meldung blieb stehen, nachdem Home Assistant längst
* wieder lief — erst ein Beenden der App half. Zwei Ursachen, beide hier
* abgesichert:
*
* 1. 502 galt als harter Ladefehler statt als „Server kommt gerade hoch“.
* 2. Nach dem Wiederverbinden wurde genau einmal nachgeladen. Schlug das
* fehl (die Websocket-Verbindung steht regelmäßig ein paar Sekunden vor
* der REST-Schnittstelle), kam nie wieder ein „verbunden“ — und damit nie
* wieder ein Versuch.
*/
import { render, screen, waitFor } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import { ApiFehler, type DataMetricApi } from "../api"
import { DatenAnbieter, useDaten } from "./DatenKontext"
import { beispielApi } from "../tests/beispieldaten"
function Anzeige() {
const { bereit, ladefehler } = useDaten()
return (
<div>
<span data-testid="bereit">{bereit ? "ja" : "nein"}</span>
<span data-testid="fehler">{ladefehler ?? "-"}</span>
</div>
)
}
/** Eine API, deren `profilLesen` sich auf Kommando wie ein gerade startender
Home Assistant verhält: die nächsten n Aufrufe mit 502 abweisen, danach
wieder normal antworten.
Die Fehlschläge werden bewusst gezählt und nicht an der Aufrufnummer
festgemacht: der Horcher meldet beim Abonnieren sofort "verbunden", die
Zählung liefe sonst schon beim Aufbau los. */
function apiMitNeustart() {
const echt = beispielApi()
let verbindungHorcher: ((z: string) => void) | null = null
let offeneFehlschlaege = 0
const profilLesen = vi.fn(async () => {
if (offeneFehlschlaege > 0) {
offeneFehlschlaege -= 1
throw new ApiFehler("Bad Gateway", 502)
}
return echt.profilLesen()
})
const api = {
...echt,
profilLesen,
live: {
...echt.live,
aufVerbindung: (h: (z: string) => void) => {
verbindungHorcher = h
h("verbunden")
return () => {}
},
},
} as unknown as DataMetricApi
return {
api,
profilLesen,
/** Home Assistant startet neu: die nächsten n Ladeversuche laufen ins
502, dann meldet sich die Websocket-Verbindung zurück. */
neustartMelden: (fehlschlaege: number) => {
offeneFehlschlaege = fehlschlaege
verbindungHorcher?.("verbunden")
},
}
}
describe("Neustart von Home Assistant", () => {
it("meldet 502 nicht als Ladefehler, solange Daten da sind", async () => {
const { api, neustartMelden } = apiMitNeustart()
render(
<DatenAnbieter api={api}>
<Anzeige />
</DatenAnbieter>,
)
await waitFor(() => expect(screen.getByTestId("bereit").textContent).toBe("ja"))
neustartMelden(1)
// Der fehlgeschlagene Versuch darf keine Fehlerzeile hinterlassen.
await waitFor(() => expect(screen.getByTestId("fehler").textContent).toBe("-"))
expect(screen.getByTestId("bereit").textContent).toBe("ja")
})
it("versucht es nach einem fehlgeschlagenen Nachladen erneut", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
const { api, profilLesen, neustartMelden } = apiMitNeustart()
render(
<DatenAnbieter api={api}>
<Anzeige />
</DatenAnbieter>,
)
await waitFor(() => expect(screen.getByTestId("bereit").textContent).toBe("ja"))
const vorher = profilLesen.mock.calls.length
neustartMelden(2)
await waitFor(() => expect(profilLesen.mock.calls.length).toBe(vorher + 1))
// Ohne Wiederholung bliebe es bei diesem einen Versuch — und die App
// auf dem Stand von vor dem Neustart, bis jemand sie beendet.
await vi.advanceTimersByTimeAsync(3000)
await waitFor(() => expect(profilLesen.mock.calls.length).toBe(vorher + 2))
await vi.advanceTimersByTimeAsync(5000)
await waitFor(() => expect(profilLesen.mock.calls.length).toBe(vorher + 3))
expect(screen.getByTestId("fehler").textContent).toBe("-")
} finally {
vi.useRealTimers()
}
})
})
describe("ApiFehler.istVoruebergehend", () => {
it("gilt für kein Netz und für einen startenden Server", () => {
expect(new ApiFehler("offline", null).istVoruebergehend).toBe(true)
expect(new ApiFehler("bad gateway", 502).istVoruebergehend).toBe(true)
expect(new ApiFehler("unavailable", 503).istVoruebergehend).toBe(true)
expect(new ApiFehler("timeout", 504).istVoruebergehend).toBe(true)
})
it("gilt nicht für einen echten Serverfehler", () => {
// 500 bessert sich durch Warten nicht — das muss sichtbar bleiben.
expect(new ApiFehler("kaputt", 500).istVoruebergehend).toBe(false)
expect(new ApiFehler("nicht da", 404).istVoruebergehend).toBe(false)
})
})
+121 -8
View File
@@ -22,16 +22,32 @@ import {
ApiFehler,
DataMetricApi,
ENTITAETEN,
type Buendelangabe,
type Fahrt,
type Fahrzeugstatus,
type IntegrationUpdateAngabe,
type DongleAngabe,
type LetzterDatensatz,
type ZugangAngabe,
type Profil,
type Tankvorgang,
type Verbindungszustand,
type WartenderAuftrag,
} from "../api"
import { bildStaendeSetzen } from "../screens/bilder"
import type { Versionsstand } from "./appVersion"
import { eigeneVersion, versionVergleichen } from "./appVersion"
import type { Einstellungen, Fahrzeug } from "./profilAdapter"
import { profilZuEinstellungen, profilZuFahrzeug, zusammenfuehren } from "./profilAdapter"
/** Abstaende zwischen zwei Ladeversuchen nach einem Wiederverbinden.
Wachsend, in Millisekunden, zusammen gut zwei Minuten. Der erste
Abstand ist kurz, weil Home Assistant nach einem Neustart meist binnen
Sekunden antwortet; die spaeteren sind lang, damit ein wirklich
abgestuerzter Server nicht dauernd angefragt wird. */
const WIEDERHOLABSTAENDE_MS = [2000, 4000, 8000, 15000, 30000, 60000]
export interface DatenWert {
bereit: boolean
/** Erste Ladung fehlgeschlagen und noch nie Daten gesehen. */
@@ -40,6 +56,28 @@ export interface DatenWert {
warteschlange: readonly WartenderAuftrag[]
/** Zeitpunkt der letzten Statusmeldung — treibt die Veraltet-Anzeige. */
statusStand: string | null
/** Ob diese App noch dem Stand entspricht, den das Backend ausliefert.
Siehe appVersion.ts und VERSIONIERUNG.md. */
versionsstand: Versionsstand
/** Die vom Backend gemeldete Integrationsversion (`APP_VERSION`,
`sensor.audi_dashboard_app_version`'s `app`-Feld), oder null vor der
ersten Antwort. Panel-Gegenstück: die "Version"-Kachel in vEinst()
("Installiert: ..."), die hier bislang fehlte, weil nur das
Vergleichsergebnis (`versionsstand`), nie der Rohwert selbst,
weitergereicht wurde. */
serverVersion: string | null
/** Das OTA-Bündel, das die Integration mitliefert (siehe daten/ota.ts),
oder null — kein Fehler, sondern der Zustand ohne Bündel oder ohne
Verbindung. */
otaBuendel: Buendelangabe | null
/** Ergebnis der letzten Selbst-Update-Prüfung/-Installation der
Integration (aktualisierung.py), oder null vor der ersten Prüfung. */
integrationUpdate: IntegrationUpdateAngabe | null
zugaenge: ZugangAngabe[]
/** Konfiguration des Dongles und eine ggf. vorgemerkte Aenderung
(flespi.py). null, solange kein flespi-Token hinterlegt ist. */
dongle: DongleAngabe | null
letzterDatensatz: LetzterDatensatz | null
einstellungen: Einstellungen | null
fahrzeug: Fahrzeug | null
@@ -48,7 +86,10 @@ export interface DatenWert {
rohprofil: Profil | null
api: DataMetricApi
neuLaden: () => Promise<void>
/** Laedt alles neu; `true`, wenn es geklappt hat. Aufrufer duerfen den
Rueckgabewert ignorieren - er ist fuer die Wiederholung nach einem
Wiederverbinden da. */
neuLaden: () => Promise<boolean>
jetztAktualisieren: () => Promise<void>
/** Speichert geänderte Einstellungen/Fahrzeugdaten als ganzes Profil. */
profilSpeichern: (aenderung: {
@@ -81,13 +122,32 @@ export function DatenAnbieter({
const [ladefehler, setzeLadefehler] = useState<string | null>(null)
const [verbindung, setzeVerbindung] = useState<Verbindungszustand>("getrennt")
const [warteschlange, setzeWarteschlange] = useState<readonly WartenderAuftrag[]>([])
const [serverVersion, setzeServerVersion] = useState<string | null>(null)
const [otaBuendel, setzeOtaBuendel] = useState<Buendelangabe | null>(null)
const [zugaenge, setzeZugaenge] = useState<ZugangAngabe[]>([])
const [dongle, setzeDongle] = useState<DongleAngabe | null>(null)
const [letzterDatensatz, setzeLetzterDatensatz] = useState<LetzterDatensatz | null>(null)
const [integrationUpdate, setzeIntegrationUpdate] = useState<IntegrationUpdateAngabe | null>(
null,
)
// Damit profilSpeichern immer gegen den neuesten Rohstand arbeitet, auch
// wenn zwischendurch ein Push hereinkam.
const rohprofilRef = useRef<Profil | null>(null)
rohprofilRef.current = rohprofil
const neuLaden = useCallback(async () => {
// Dasselbe fuer `bereit`: neuLaden liest den Wert nur, darf aber nicht
// davon abhaengen. Stand er in den Abhaengigkeiten, bekaeme der Effekt
// unten (der bewusst nur an `api` haengt) fuer immer die Fassung vom
// ersten Rendern mit `bereit === false` - und wuerde nach einem
// Wiederverbinden jeden Aussetzer als harten Ladefehler melden, obwohl
// laengst Daten da sind.
const bereitRef = useRef(false)
bereitRef.current = bereit
/** Laedt alles neu. Gibt zurueck, ob es geklappt hat - der Aufrufer
entscheidet, ob er es noch einmal versucht. */
const neuLaden = useCallback(async (): Promise<boolean> => {
try {
const [p, f, t, s] = await Promise.all([
api.profilLesen(),
@@ -102,18 +162,54 @@ export function DatenAnbieter({
setzeStatusStand(s.last_updated)
setzeLadefehler(null)
setzeBereit(true)
// Bewusst außerhalb des Promise.all oben und ohne eigenes catch hier:
// appVersionAngabeLesen() schluckt seine Fehler selbst und liefert dann
// null. Ein fehlender Versionsvergleich darf die Erstladung nie
// aufhalten - er ist ein Hinweis, keine Betriebsvoraussetzung.
const versionsangabe = await api.appVersionAngabeLesen()
setzeServerVersion(versionsangabe?.app ?? null)
setzeOtaBuendel(versionsangabe?.buendel ?? null)
setzeIntegrationUpdate(versionsangabe?.integration_update ?? null)
setzeZugaenge(versionsangabe?.zugaenge ?? [])
setzeDongle(versionsangabe?.dongle ?? null)
setzeLetzterDatensatz(versionsangabe?.letzter_datensatz ?? null)
// Kein React-Zustand: die Bildadressen werden beim Rendern gebraucht,
// teils ausserhalb von Komponenten (bilderVorladen) - dieselbe
// Ueberlegung wie bei zugangMerken().
bildStaendeSetzen(versionsangabe?.bildstaende ?? null)
return true
} catch (fehler) {
// Kein Netz ist kein Ladefehler, solange schon Daten da sind — dann
// zeigt die App den zwischengespeicherten Stand mit Offline-Hinweis.
const netzproblem = fehler instanceof ApiFehler && fehler.istNetzproblem
if (!netzproblem || !bereit) {
// Ein voruebergehender Ausfall ist kein Ladefehler, solange schon
// Daten da sind — dann zeigt die App den zwischengespeicherten Stand
// mit Offline-Hinweis. Dazu zaehlt auch ein Home Assistant, der
// gerade neu startet (502/503/504), siehe istVoruebergehend.
const wartet = fehler instanceof ApiFehler && fehler.istVoruebergehend
if (!wartet || !bereitRef.current) {
setzeLadefehler(fehler instanceof Error ? fehler.message : String(fehler))
}
return false
}
}, [api, bereit])
}, [api])
// Erstladung plus Live-Verbindung.
useEffect(() => {
let abgemeldet = false
let nachladeUhr: ReturnType<typeof setTimeout> | null = null
/** Versucht es so lange, bis eine Ladung durchgeht - mit wachsendem
Abstand, damit ein dauerhaft nicht erreichbarer Server nicht im
Sekundentakt angefragt wird. Nach gut zwei Minuten ist Schluss;
dann hilft nur noch das naechste "verbunden" oder ein Zug nach
unten auf der Uebersicht. */
const nachladenBisEsKlappt = async (versuch = 0): Promise<void> => {
if (abgemeldet) return
if (await neuLaden()) return
if (abgemeldet || versuch >= WIEDERHOLABSTAENDE_MS.length) return
nachladeUhr = setTimeout(() => {
void nachladenBisEsKlappt(versuch + 1)
}, WIEDERHOLABSTAENDE_MS[versuch])
}
void neuLaden()
void api.warteschlange.laden()
@@ -121,7 +217,15 @@ export function DatenAnbieter({
setzeVerbindung(zustand)
// Nach einem Wiederverbinden kann während der Trennung etwas passiert
// sein, das kein Ereignis mehr ausgelöst hat.
if (zustand === "verbunden") void neuLaden()
//
// Warum das nicht ein einzelner Aufruf sein darf: nach einem Neustart
// von Home Assistant steht die Websocket-Verbindung regelmaessig ein
// paar Sekunden, bevor die REST-Schnittstelle antwortet - dazwischen
// kommt 502. Ein einziger Versuch schlug dann fehl, und weil danach
// kein weiteres "verbunden" mehr folgt, blieb die App bis zum
// Beenden auf dem Stand von vor dem Neustart stehen, mitsamt der
// Fehlerzeile (vom Eigentuemer gemeldet, 2026-08-31).
if (zustand === "verbunden") void nachladenBisEsKlappt()
})
const abZustand = api.live.aufZustand(({ entity_id, new_state }) => {
@@ -150,6 +254,8 @@ export function DatenAnbieter({
void api.live.starten()
return () => {
abgemeldet = true
if (nachladeUhr) clearTimeout(nachladeUhr)
abVerbindung()
abZustand()
abWarteschlange()
@@ -198,6 +304,13 @@ export function DatenAnbieter({
verbindung,
warteschlange,
statusStand,
versionsstand: versionVergleichen(eigeneVersion(), serverVersion).stand,
serverVersion,
otaBuendel,
integrationUpdate,
zugaenge,
dongle,
letzterDatensatz,
einstellungen,
fahrzeug,
fahrten,
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest"
import { versionOrdnung, versionVergleichen } from "./appVersion"
/* Der Vergleich entscheidet, ob die App dem Nutzer sagt „ich bin älter als der
Server". Beide Fehlrichtungen sind teuer: ein Fehlalarm schickt ihn grundlos
an Xcode, ein verschluckter Hinweis lässt genau die stille Veraltung zu, die
die ganze Mechanik verhindern soll. */
describe("versionVergleichen", () => {
it("meldet Gleichstand bei identischer Version", () => {
const v = versionVergleichen("2026.08.23.1", "2026.08.23.1")
expect(v.stand).toBe("gleich")
})
it("nennt die Richtung, statt nur eine Abweichung zu melden", () => {
expect(versionVergleichen("2026.08.23.1", "2026.08.24.1").stand).toBe("aelter")
expect(versionVergleichen("2026.08.23.2", "2026.08.23.1").stand).toBe("neuer")
})
it("meldet ohne erkennbares Format eine Abweichung ohne Richtung", () => {
// Der Einwand, der bis zum 04.09.2026 GEGEN jede Sortierung sprach: bei
// einem Formatwechsel darf nichts erfunden werden.
expect(versionVergleichen("2026.8.23.1", "fruehling-2").stand).toBe("abweichend")
})
it("vergleicht nicht, wenn das Backend nichts meldet", () => {
// Altes Backend ohne die Versions-Entität, oder offline. Ein Hinweis waere
// hier geraten, nicht gewusst.
expect(versionVergleichen("2026.08.23.1", null).stand).toBe("unbekannt")
})
it("vergleicht nicht, wenn die App ihre eigene Version nicht kennt", () => {
expect(versionVergleichen(null, "2026.08.23.1").stand).toBe("unbekannt")
})
it("behandelt eine leere Zeichenkette wie fehlend, nicht wie einen Wert", () => {
expect(versionVergleichen("", "2026.08.23.1").stand).toBe("unbekannt")
expect(versionVergleichen("2026.08.23.1", "").stand).toBe("unbekannt")
})
it("gibt beide Staende zur Anzeige zurueck", () => {
const v = versionVergleichen("2026.08.23.1", "2026.08.24.1")
expect(v.app).toBe("2026.08.23.1")
expect(v.server).toBe("2026.08.24.1")
})
})
/* versionOrdnung() ist die Stelle, an der aus zwei Kennungen ein Vorher und
ein Nachher wird. Sie entscheidet, ob der Update-Knopf erscheint - und ein
Vorzeichenfehler hier böte einen Rückschritt an, wie am 04.09.2026 real
geschehen (App .17, Instanz .5). */
describe("versionOrdnung", () => {
it("erkennt die letzte Stelle, auch zweistellig", () => {
// Genau der gemeldete Fall: 5 gegen 17. Zeichenweise verglichen stuende
// "17" vor "5" - deshalb wird je Stelle als Zahl verglichen.
expect(versionOrdnung("2026.9.4.5", "2026.9.4.17")).toBe(-1)
expect(versionOrdnung("2026.9.4.17", "2026.9.4.5")).toBe(1)
})
it("vergleicht Stelle für Stelle von links", () => {
expect(versionOrdnung("2026.8.31.9", "2026.9.1.1")).toBe(-1)
expect(versionOrdnung("2026.9.1.1", "2025.12.31.9")).toBe(1)
})
it("behandelt fehlende Stellen als Null", () => {
expect(versionOrdnung("2026.9.4", "2026.9.4.0")).toBe(0)
expect(versionOrdnung("2026.9.4", "2026.9.4.1")).toBe(-1)
})
it("stoert sich nicht an fuehrenden Nullen", () => {
expect(versionOrdnung("2026.08.23.1", "2026.8.23.1")).toBe(0)
})
it("sortiert nicht, was nicht ins Format passt", () => {
// parseInt haette "4b" klaglos als 4 gelesen - genau die scheingenaue
// Antwort, die hier nicht entstehen darf.
expect(versionOrdnung("2026.9.4b", "2026.9.5")).toBeNull()
expect(versionOrdnung("v2026.9.4", "2026.9.5")).toBeNull()
expect(versionOrdnung("2026..4", "2026.9.4")).toBeNull()
})
it("nennt zeichengleiche Kennungen gleich, auch ohne lesbares Format", () => {
expect(versionOrdnung("fruehling-2", "fruehling-2")).toBe(0)
})
it("entscheidet nichts, wenn eine Seite fehlt", () => {
expect(versionOrdnung(null, "2026.9.4.1")).toBeNull()
expect(versionOrdnung("2026.9.4.1", "")).toBeNull()
})
})
+99
View File
@@ -0,0 +1,99 @@
/**
* Vergleich zwischen dem Stand, den diese App mitbringt, und dem, den das
* Backend ausliefert. Hintergrund: ../../VERSIONIERUNG.md
*
* Warum es das braucht: Das Panel lädt seine Dateien bei jedem Seitenaufruf
* frisch und kann gar nicht veralten. Die iOS-App trägt ihre Dateien fest
* gebündelt und bleibt auf dem Stand vom Signieren — unbegrenzt und bisher
* unbemerkt. Diese Datei macht daraus ein sichtbares Signal.
*
* ENTSCHEIDUNG GEÄNDERT AM 04.09.2026 — vorher stand hier ausdrücklich
* „bewusst kein größer/kleiner-Vergleich: die Version ist eine Kennung, keine
* Zahl". Der Anlass war ein echter Fall: nach einem Xcode-Lauf lief die App
* auf `2026.9.4.17`, die Instanz stand noch auf `2026.9.4.5` — und die App bot
* an, sich auf `.5` zu „erneuern". Ein Rückschritt, den die Gleichheitsprüfung
* gar nicht sehen konnte. Vorgabe des Eigentümers: erkannt werden soll eine
* NEUERE Fassung und keine andere.
*
* Der damalige Einwand bleibt gültig und ist deshalb eingebaut, statt
* verworfen: sortiert wird nur, was dem Format `JJJJ.M.T.N` entspricht. Passt
* eine der beiden Zeichenketten nicht ins Schema (Formatwechsel), liefert der
* Vergleich „abweichend" statt einer erfundenen Reihenfolge — dann sagt die
* App, dass etwas nicht übereinstimmt, aber nicht, in welche Richtung.
*/
export type Versionsstand =
/** App und Backend melden denselben Stand. */
| "gleich"
/** Die App ist nachweislich älter — nur hier gibt es etwas zu holen. */
| "aelter"
/** Die App ist nachweislich neuer, etwa direkt nach einem Xcode-Lauf,
solange die Instanz noch nicht nachgezogen hat. Kein Handlungsbedarf. */
| "neuer"
/** Verschieden, aber nicht in eine Reihenfolge zu bringen (Formatwechsel). */
| "abweichend"
/** Kein Vergleich möglich (Backend meldet nichts, offline, altes Backend). */
| "unbekannt"
export interface Versionsvergleich {
stand: Versionsstand
app: string | null
server: string | null
}
/** Die in diese App einkompilierte Version (vite, siehe vite.config.ts). */
export function eigeneVersion(): string | null {
// Der Zugriff ist abgesichert, weil die Konstante in Testläufen und im
// Dev-Server nicht zwingend gesetzt ist. Fehlt sie, wird nicht verglichen,
// statt eine Abweichung zu behaupten.
return typeof __APP_VERSION__ === "string" && __APP_VERSION__ ? __APP_VERSION__ : null
}
/** Zerlegt `2026.9.4.17` in [2026, 9, 4, 17]. `null`, sobald ein Teil keine
reine Ziffernfolge ist — dann wird nicht sortiert (siehe Modulkopf). */
function teile(version: string): number[] | null {
const stuecke = version.split(".")
if (stuecke.length === 0) return null
const zahlen: number[] = []
for (const stueck of stuecke) {
// Bewusst kein parseInt: das würde "4b" klaglos als 4 lesen und damit
// genau die scheingenaue Sortierung erzeugen, vor der der Modulkopf warnt.
if (!/^[0-9]+$/.test(stueck)) return null
zahlen.push(Number(stueck))
}
return zahlen
}
/**
* `-1` wenn `a` älter ist als `b`, `0` bei Gleichstand, `+1` wenn `a` neuer
* ist — und `null`, wenn sich das nicht entscheiden lässt.
*
* Fehlende Stellen zählen als 0, damit `2026.9.4` und `2026.9.4.0` denselben
* Stand bezeichnen.
*/
export function versionOrdnung(a: string | null, b: string | null): number | null {
if (!a || !b) return null
// Zeichengleich ist gleich, auch wenn das Format unbekannt ist.
if (a === b) return 0
const links = teile(a)
const rechts = teile(b)
if (!links || !rechts) return null
const laenge = Math.max(links.length, rechts.length)
for (let i = 0; i < laenge; i++) {
const l = links[i] ?? 0
const r = rechts[i] ?? 0
if (l !== r) return l < r ? -1 : 1
}
return 0
}
export function versionVergleichen(
app: string | null,
server: string | null,
): Versionsvergleich {
if (!app || !server) return { stand: "unbekannt", app, server }
const ordnung = versionOrdnung(app, server)
if (ordnung === null) return { stand: "abweichend", app, server }
if (ordnung === 0) return { stand: "gleich", app, server }
return { stand: ordnung < 0 ? "aelter" : "neuer", app, server }
}
@@ -0,0 +1,93 @@
/**
* Tests zur Uebernahme eines eingelesenen Belegs ins Tankformular.
*
* Die Nutzlast unten ist KEIN erfundenes Beispiel: sie ist die Ausgabe von
* shell_beleg_parser._parsen() fuer den Beleg, den der Eigentuemer am
* 04.09.2026 ueber das Teilen-Blatt hereingereicht hat und der in der App nur
* mit Datum und Uhrzeit ankam (Shell, Richard-Wagner-Str. 9, Ingolstadt,
* 43,87 l fuer 95,60 EUR). Genau dieser Fall gehoert in einen Test.
*/
import { describe, expect, it } from "vitest"
import { belegFormularwerte } from "./belegentwurf"
/* Wortgleich mit dem, was belege._beleg_felder() fuer diesen Beleg
veroeffentlicht - die Feldnamen sind der Punkt dieser Suite. */
const GETEILT = {
receipt_key: "0000000459_3368-082-00082_2026-04-03T00:55",
receipt_no: "3368/082/00082",
tse_beleg_nr: "73912",
ts: "2026-04-03T00:55:00",
ts_payment: "2026-04-03T00:52:43",
ts_tse: "2026-04-03T00:55:01",
station_id: "0000000459",
station_brand: "Shell",
station_name: "Shell, Richard-Wagner-Str. 9, Ingolstadt",
station_address: "Richard-Wagner-Str. 9, 85057 Ingolstadt",
article_no: "000013",
product_name: "V-Power Racing",
fuel_type: "V-Power Racing",
liters: 43.87,
fuel_total_eur: 95.6,
price_per_l: 2.1792,
discount: 11.84,
discount_per_l: 0.27,
list_price_per_l: 2.449,
receipt_total_eur: 95.6,
net_eur: 80.34,
vat_eur: 15.26,
receipt_file: "/config/audi_dashboard/belege/e-receipt (7).pdf",
}
describe("belegFormularwerte", () => {
it("fuellt das ganze Formular, nicht nur Datum und Uhrzeit", () => {
// Der gemeldete Fehler: alles ausser zeitpunkt blieb leer, weil die App
// liter/kosten/station/ersparnis/kraftstoff abfragte statt der
// Belegfelder.
expect(belegFormularwerte(GETEILT)).toEqual({
zeitpunkt: "2026-04-03T00:55",
liter: "43.87",
kosten: "95.6",
ersparnis: "11.84",
station: "Shell, Richard-Wagner-Str. 9, Ingolstadt",
kraftstoff: "V-Power Racing",
})
})
it("nimmt den realen Betrag, nicht den Listenpreis", () => {
// fuel_total_eur ist bereits rabattiert; receipt_total_eur waere hier
// zufaellig gleich, list_price_per_l dagegen ein Preis je Liter.
expect(belegFormularwerte(GETEILT).kosten).toBe(String(GETEILT.fuel_total_eur))
})
it("laesst Felder leer, die der Beleg nicht nennt", () => {
// Ein Beleg ohne Rabatt ist normal (BelegOhneRabatt im Parsertest) - das
// Formularfeld soll dann nicht auf "null" stehen.
expect(belegFormularwerte({ ...GETEILT, discount: null })).toMatchObject({ ersparnis: "" })
expect(belegFormularwerte({ ts: "2026-04-03T00:55:00" })).toEqual({
zeitpunkt: "2026-04-03T00:55",
liter: "",
kosten: "",
ersparnis: "",
station: "",
kraftstoff: "",
})
})
it("laesst eine echte Null durch", () => {
// 0 ist ein Wert, kein fehlendes Feld - ein Beleg mit 0,00 EUR Rabatt
// darf nicht wie "kein Rabatt angegeben" aussehen.
expect(belegFormularwerte({ ...GETEILT, discount: 0 }).ersparnis).toBe("0")
})
it("kommt mit gar keinem Beleg zurecht", () => {
expect(belegFormularwerte(null).zeitpunkt).toBe("")
expect(belegFormularwerte(undefined).liter).toBe("")
})
it("uebernimmt keinen Kilometerstand", () => {
// SPECIFICATION.md §7.7 Regel 2: der kommt immer vom Fahrzeug.
expect(Object.keys(belegFormularwerte(GETEILT))).not.toContain("km")
})
})
+68
View File
@@ -0,0 +1,68 @@
/**
* Was ein eingelesener Beleg ins Tankformular traegt.
*
* WARUM DAS HIER STEHT UND NICHT IM BILDSCHIRM
* -------------------------------------------
* Bis zum 04.09.2026 stand die Uebernahme als Folge von `if`-Zeilen in
* `Tanken.tsx` - und fragte `liter`/`kosten`/`station`/`ersparnis`/
* `kraftstoff` ab, also die Namen der FORMULARFELDER. Veroeffentlicht werden
* aber die Namen des BELEGS (`_beleg_felder` in belege.py, dieselben, die auch
* im Tankvorgang landen). Uebereingestimmt hat allein `ts`.
*
* Sichtbar war das als: ein eingelesener Beleg fuellte Datum und Uhrzeit,
* sonst nichts (Befund des Eigentuemers, 04.09.2026 - gemeldet fuer einen
* ueber das Teilen-Blatt hereingereichten Beleg, betraf aber jeden Weg
* gleichermassen, weil beide durch `belegLesen()` laufen). Als eigene
* Funktion laesst sich die Zuordnung gegen eine echte Nutzlast pruefen; in
* den `if`-Zeilen des Bildschirms konnte sie es nicht.
*
* KEIN KILOMETERSTAND
* -------------------
* Er kommt immer vom Fahrzeug, nie vom Beleg (SPECIFICATION.md §7.7 Regel 2).
* Das Panel haelt es in `tankFelder()` genauso.
*/
/** Die Felder, die belege.py nach dem Lesen eines Belegs veroeffentlicht. */
export interface BelegEntwurf {
ts?: string
liters?: number | null
/** Der reale, bereits rabattierte Betrag. */
fuel_total_eur?: number | null
/** SmartDeal-Ersparnis. */
discount?: number | null
station_name?: string | null
fuel_type?: string | null
receipt_key?: string | null
receipt_file?: string | null
}
/** Was davon in welches Formularfeld geht - leere Zeichenkette heisst: nicht
setzen, das Feld behaelt seinen bisherigen Wert. */
export interface Formularwerte {
zeitpunkt: string
liter: string
kosten: string
ersparnis: string
station: string
kraftstoff: string
}
/**
* Zahl in die Zeichenkette eines Zahlenfelds. `0` ist ein gueltiger Wert und
* muss durchkommen - nur `null`/`undefined` bedeuten "steht nicht im Beleg".
*/
function zahl(wert: number | null | undefined): string {
return wert == null ? "" : String(wert)
}
export function belegFormularwerte(e: BelegEntwurf | null | undefined): Formularwerte {
return {
// Das Eingabefeld will "JJJJ-MM-TTThh:mm", der Beleg liefert Sekunden mit.
zeitpunkt: e?.ts ? e.ts.slice(0, 16) : "",
liter: zahl(e?.liters),
kosten: zahl(e?.fuel_total_eur),
ersparnis: zahl(e?.discount),
station: e?.station_name ?? "",
kraftstoff: e?.fuel_type ?? "",
}
}
@@ -0,0 +1,27 @@
/**
* Das OTA-Bündel muss dieselbe Fassung tragen wie das Manifest der
* Integration.
*
* Am 05.09.2026 am iPhone aufgefallen: der Server meldete 2026.9.5.29, das
* mitgelieferte Bündel war noch 2026.9.5.28. `buendelPasst()` verlangt
* Gleichheit — die App hielt sich deshalb für nicht erneuerbar und schickte
* den Eigentümer zu Xcode, obwohl nur die Veröffentlichung unvollständig war:
* die Versionsnummer war erhöht worden, ohne `npm run ota` laufen zu lassen.
*
* Dieser Test macht daraus einen Fehlschlag vor der Auslieferung statt einer
* irreführenden Meldung auf dem Telefon.
*/
import { readFileSync } from "node:fs"
import { describe, expect, it } from "vitest"
const lies = (pfad: string) => JSON.parse(readFileSync(pfad, "utf8")) as { version?: string }
describe("OTA-Bündel und Manifest", () => {
it("tragen dieselbe Versionsnummer", () => {
const manifest = lies("../custom_components/audi_dashboard/manifest.json")
const buendel = lies("../custom_components/audi_dashboard/frontend/app/bundle.json")
expect(buendel.version).toBe(manifest.version)
})
})
@@ -0,0 +1,42 @@
/**
* Die Modellliste der App muss der des Panels entsprechen.
*
* Bis zum 05.09.2026 war Modell in der App ein freies Textfeld, im Panel ein
* Auswahlfeld - vom Eigentümer bemerkt. Beim Nachziehen entstand eine
* Zweitschrift der Liste; dieser Test schneidet die Panel-Fassung aus der
* Quelle heraus und vergleicht sie, damit die beiden nicht auseinanderlaufen.
*/
import { readFileSync } from "node:fs"
import { describe, expect, it } from "vitest"
import { FAHRZEUG_MODELLE, modellauswahl } from "./fahrzeugmodelle"
function panelListe(): string[] {
const quelle = readFileSync(
"../custom_components/audi_dashboard/frontend/audi-dashboard-app.js",
"utf8",
)
const start = quelle.indexOf("const FAHRZEUG_MODELLE = [")
if (start < 0) throw new Error("FAHRZEUG_MODELLE nicht in der Panel-Quelle gefunden")
const ende = quelle.indexOf("];", start)
const roh = quelle.slice(start, ende)
return [...roh.matchAll(/"([^"]+)"/g)].map((m) => m[1] as string)
}
describe("Modellliste", () => {
it("stimmt mit der des Panels überein", () => {
expect([...FAHRZEUG_MODELLE]).toEqual(panelListe())
})
it("stellt einen unbekannten gespeicherten Titel voran", () => {
const auswahl = modellauswahl("RS 6 Avant performance")
expect(auswahl[0]).toBe("RS 6 Avant performance")
expect(auswahl).toHaveLength(FAHRZEUG_MODELLE.length + 1)
})
it("laesst die Liste unveraendert, wenn der Titel darin steht", () => {
expect(modellauswahl("RS 4 Avant")).toEqual(FAHRZEUG_MODELLE)
})
})
@@ -0,0 +1,38 @@
/**
* Die Modelle, zu denen ein echtes Badge vorliegt.
*
* Zweitschrift von `FAHRZEUG_MODELLE` im Panel
* (custom_components/audi_dashboard/frontend/audi-dashboard-app.js). Zwei
* Codebasen, eine Liste - dass sie gleich bleiben, sichert
* `fahrzeugmodelle.test.ts`, der die Panel-Quelle einliest und Eintrag für
* Eintrag vergleicht.
*
* Beschränkt auf Modelle mit Badge: für alles andere stünde ein falsches oder
* gar kein Badge auf der Übersicht. Die Ausführung („competition",
* „performance") gehört nicht hierher, sondern in das Feld „Ausführung" -
* Entscheidung des Eigentümers vom 05.09.2026.
*/
export const FAHRZEUG_MODELLE = [
"RS 3 Sportback",
"RS 3 Limousine",
"RS 4 Avant",
"RS 5 Coupé",
"RS 5 Sportback",
"RS 6 Avant",
"RS 7 Sportback",
"S3 Sportback",
"S3 Limousine",
"SQ7",
] as const
/** Die Liste, wie sie im Auswahlfeld stehen muss.
*
* Ein gespeicherter Titel, der nicht (mehr) in der Liste steht, wird
* vorangestellt statt verschluckt - sonst fiele die Auswahl still auf den
* ersten Eintrag zurück und änderte das Fahrzeug des Nutzers. Wortgleich mit
* dem Panel. */
export function modellauswahl(titel: string): readonly string[] {
return FAHRZEUG_MODELLE.includes(titel as (typeof FAHRZEUG_MODELLE)[number])
? FAHRZEUG_MODELLE
: [titel, ...FAHRZEUG_MODELLE]
}
+162
View File
@@ -0,0 +1,162 @@
/**
* Adresse → Koordinaten (Vorwärts-Geokodierung über Nominatim), mit Cache im
* localStorage. Gegenstück zur Rückwärts-Auflösung in `Standort.tsx`.
*
* Gebraucht wird sie für die Tankstellennadel auf dem Einzelbeleg: der Beleg
* trägt nur eine Anschrift, und das Backend füllt `station_lat`/`station_lon`
* nie — ohne diesen Schritt bliebe die Karte dort dauerhaft ohne Nadel. Das
* Panel macht es seit jeher genau so (`adresseAufloesen()`); der App fehlte
* beides, Karte wie Auflösung.
*
* Der Cache liegt bewusst im localStorage und nicht nur im Speicher: eine
* Tankstellenadresse ändert ihre Lage nicht, und Nominatim bittet in seinen
* Nutzungsbedingungen ausdrücklich darum, Ergebnisse zu behalten. Auch ein
* erfolgloser Treffer wird als `null` vermerkt, damit eine Adresse, die
* Nominatim nicht kennt, nicht bei jedem Öffnen erneut abgefragt wird — ein
* Netzfehler dagegen nicht, der ist kein „gibt es nicht".
*/
export interface Koordinate {
lat: number
lon: number
}
const CACHE_SCHLUESSEL = "dm360.geocache"
/* Arten, bei denen der Eigenname aussagekräftiger ist als die Anschrift
(Feld type der Nominatim-Antwort). */
const PLATZ_ARTEN = new Set([
"parking",
"parking_space",
"parking_entrance",
"bicycle_parking",
"motorcycle_parking",
"rest_area",
"services",
"fuel",
"charging_station",
])
function cacheLesen(): Record<string, Koordinate | string | null> {
try {
const roh = localStorage.getItem(CACHE_SCHLUESSEL)
return roh ? (JSON.parse(roh) as Record<string, Koordinate | string | null>) : {}
} catch {
return {}
}
}
/** Adresstext im selben Ablagefach merken (Schlüssel mit "rev:"-Vorsatz). */
function adresseMerken(schluessel: string, adresse: string): void {
try {
const roh = localStorage.getItem(CACHE_SCHLUESSEL)
const cache = roh ? (JSON.parse(roh) as Record<string, unknown>) : {}
cache[schluessel] = adresse
localStorage.setItem(CACHE_SCHLUESSEL, JSON.stringify(cache))
} catch {
// Privatmodus oder volle Ablage — dann eben ohne Cache.
}
}
function cacheSchreiben(suche: string, treffer: Koordinate | null): void {
try {
const cache = cacheLesen()
cache[suche] = treffer
localStorage.setItem(CACHE_SCHLUESSEL, JSON.stringify(cache))
} catch {
// Privatmodus oder volle Ablage — dann eben ohne Cache.
}
}
/**
* Koordinaten → Adresse. Der Cache ist hier genauso wichtig wie bei der
* Vorwärtsrichtung, aus einem anderen Grund: Nominatim drosselt und sperrt
* bei häufigen Abrufen, und die Antwort kommt dann ohne CORS-Kopf zurück —
* im Browser ununterscheidbar von einem Netzfehler. Ohne dauerhaften Cache
* stand an einer längst aufgelösten Stelle plötzlich wieder nur ein
* Koordinatenpaar. Auf drei Nachkommastellen gerundet (~110 m), damit ein
* paar Meter Abweichung keinen neuen Abruf auslösen.
*/
/** Ein bereits aufgeloester Ort aus dem Zwischenspeicher - OHNE Netzabruf.
*
* Die Einzelfahrt loest Koordinaten bei Bedarf auf und legt das Ergebnis im
* selben Fach ab wie die Standortansicht. Was einmal dort steht, kann die
* Liste umsonst mitbenutzen: kein zusaetzlicher Abruf, keine Wartezeit, kein
* Flackern beim Zeichnen.
*
* Bewusst NUR aus dem Speicher. Ein Abruf je Zeile waere genau der
* Vorratsabruf, um dessen Unterlassung Nominatim in seinen
* Nutzungsbedingungen bittet - bei zweihundert Fahrten waeren das
* vierhundert Anfragen fuer einen Blick auf die Liste.
*
* Schluessel wie bei der Aufloesung: drei Nachkommastellen (~110 m). */
export async function koordinatenAufloesen(lat: number, lon: number): Promise<string | null> {
const schluessel = `rev:${lat.toFixed(3)},${lon.toFixed(3)}`
const cache = cacheLesen()
const gemerkt = cache[schluessel]
if (typeof gemerkt === "string") return gemerkt
try {
const antwort = await fetch(
`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lon}&zoom=18&addressdetails=1`,
{ headers: { "Accept-Language": "de" } },
)
const daten = (await antwort.json()) as {
name?: string
type?: string
address?: Record<string, string>
display_name?: string
}
const a = daten.address ?? {}
const strasse = [a["road"], a["house_number"]].filter(Boolean).join(" ")
const stadt = (a["city"] ?? a["town"] ?? a["village"] ?? "").trim()
const ort = [a["postcode"], stadt].filter(Boolean).join(" ")
/* Steht das Auto auf einem benannten Platz — Parkplatz, Parkhaus,
Rastplatz, Ladesäule —, sagt dessen Name mehr als die nächstgelegene
Hausnummer: "Parkhaus Westentor" statt "Rathausstraße 4". Nominatim
liefert ihn im Feld name, zusammen mit der Art der getroffenen Fläche;
ohne die Prüfung auf diese Art würde auch jedes beliebige Gebäude, das
zufällig einen Namen trägt, die Anschrift verdrängen. Der Ort bleibt
angehängt, damit erkennbar ist, wo der Platz liegt. */
const platz = PLATZ_ARTEN.has(daten.type ?? "") ? (daten.name ?? "").trim() : ""
const adresse = platz
? [platz, ort].filter(Boolean).join(", ")
: [strasse, ort].filter(Boolean).join(", ") || daten.display_name || null
// Nur echte Treffer merken: eine gedrosselte Antwort ist kein "hier gibt
// es keine Adresse", die soll beim nächsten Mal erneut versucht werden.
if (adresse) adresseMerken(schluessel, adresse)
return adresse
} catch {
return null
}
}
export async function adresseAufloesen(adresse: string | null | undefined): Promise<Koordinate | null> {
const suche = (adresse ?? "").trim()
if (!suche) return null
const cache = cacheLesen()
if (Object.prototype.hasOwnProperty.call(cache, suche)) {
const gemerkt = cache[suche]
return typeof gemerkt === "object" ? gemerkt : null
}
let treffer: Koordinate | null = null
try {
const antwort = await fetch(
`https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=${encodeURIComponent(suche)}`,
{ headers: { "Accept-Language": "de" } },
)
const daten: unknown = await antwort.json()
if (Array.isArray(daten) && daten.length > 0) {
const erster = daten[0] as { lat?: string; lon?: string }
const lat = Number.parseFloat(erster.lat ?? "")
const lon = Number.parseFloat(erster.lon ?? "")
if (Number.isFinite(lat) && Number.isFinite(lon)) treffer = { lat, lon }
}
} catch {
// Netzfehler: nichts merken, damit der nächste Aufruf es erneut versucht.
return null
}
cacheSchreiben(suche, treffer)
return treffer
}
@@ -0,0 +1,119 @@
/**
* Die Auswertung eines geteilten Belegs — der Teil, der ohne Gerät prüfbar
* ist. Das Ablegen selbst passiert nativ (ShareViewController.swift) und kann
* hier nicht nachgestellt werden; geprüft wird, was die App mit dem Aufruf und
* der abgelegten Datei macht.
*
* `@capacitor/core` und `@capacitor/filesystem` werden ersetzt: das eine
* meldet sonst „kein natives Gerät" und das Lesen steigt sofort aus, das
* andere greift auf einen Dateibereich zu, den es hier nicht gibt.
*/
import { beforeEach, describe, expect, it, vi } from "vitest"
const dateien = new Map<string, string>()
vi.mock("@capacitor/core", () => ({
Capacitor: { isNativePlatform: () => true },
}))
vi.mock("@capacitor/filesystem", () => ({
Filesystem: {
readFile: vi.fn(async ({ path }: { path: string }) => {
const inhalt = dateien.get(path)
if (inhalt === undefined) throw new Error("nicht gefunden")
return { data: inhalt }
}),
deleteFile: vi.fn(async ({ path }: { path: string }) => {
dateien.delete(path)
}),
},
}))
/** Ein winziges, gültiges PDF als Base64 — der Inhalt ist hier gleichgültig. */
const PDF_BASE64 = btoa("%PDF-1.4\n%%EOF\n")
const PFAD = "file:///var/mobile/Shared/AppGroup/abc/geteilte-belege/1756832400-beleg.pdf"
const AUFRUF = `datametric360://beleg?pfad=${encodeURIComponent(PFAD)}`
describe("Belegpfad aus dem Aufruf", () => {
it("erkennt den Pfad", async () => {
const { belegpfadAusUrl } = await import("./geteilterBeleg")
expect(belegpfadAusUrl(AUFRUF)).toBe(PFAD)
})
it("lässt fremde Aufrufe in Ruhe", async () => {
const { belegpfadAusUrl } = await import("./geteilterBeleg")
expect(belegpfadAusUrl("datametric360://einstellungen")).toBeNull()
expect(belegpfadAusUrl("https://example.org/?pfad=x")).toBeNull()
expect(belegpfadAusUrl(null)).toBeNull()
})
it("verträgt einen Aufruf ohne Pfad", async () => {
const { belegpfadAusUrl } = await import("./geteilterBeleg")
expect(belegpfadAusUrl("datametric360://beleg")).toBeNull()
expect(belegpfadAusUrl("datametric360://beleg?anderes=1")).toBeNull()
})
})
describe("geteilten Beleg lesen", () => {
beforeEach(() => {
dateien.clear()
vi.resetModules()
})
it("liefert nichts ohne Aufruf", async () => {
const { geteiltenBelegLesen } = await import("./geteilterBeleg")
expect(await geteiltenBelegLesen(null)).toBeNull()
})
it("liest die Datei und gibt sie als PDF zurück", async () => {
dateien.set(PFAD, PDF_BASE64)
const { geteiltenBelegLesen } = await import("./geteilterBeleg")
const datei = await geteiltenBelegLesen(AUFRUF)
expect(datei).not.toBeNull()
expect(datei?.type).toBe("application/pdf")
expect(datei?.name).toBe("1756832400-beleg.pdf")
expect(datei?.size).toBeGreaterThan(0)
})
/* Der Grund, warum überhaupt gelöscht wird: sonst käme derselbe Beleg bei
jedem Öffnen erneut, und weil der Upload im Backend einen Vorgang anlegt,
wären das echte Doppeleinträge. */
it("räumt die Datei weg, holt sie also genau einmal", async () => {
dateien.set(PFAD, PDF_BASE64)
const { geteiltenBelegLesen } = await import("./geteilterBeleg")
expect(await geteiltenBelegLesen(AUFRUF)).not.toBeNull()
expect(dateien.has(PFAD)).toBe(false)
expect(await geteiltenBelegLesen(AUFRUF)).toBeNull()
})
it("verschluckt sich nicht an einer fehlenden Datei", async () => {
const { geteiltenBelegLesen } = await import("./geteilterBeleg")
expect(await geteiltenBelegLesen(AUFRUF)).toBeNull()
})
})
describe("Übergabe an die Tanken-Seite", () => {
beforeEach(() => vi.resetModules())
it("meldet jeden Wechsel an die Horcher", async () => {
const { geteiltenBelegSetzen, geteiltenBelegStand, geteiltenBelegHorchen } =
await import("./geteilterBeleg")
let gerufen = 0
const abmelden = geteiltenBelegHorchen(() => gerufen++)
const datei = new File([new Uint8Array([1])], "beleg.pdf", { type: "application/pdf" })
geteiltenBelegSetzen(datei)
expect(geteiltenBelegStand()).toBe(datei)
expect(gerufen).toBe(1)
geteiltenBelegSetzen(null)
expect(geteiltenBelegStand()).toBeNull()
expect(gerufen).toBe(2)
abmelden()
geteiltenBelegSetzen(datei)
expect(gerufen).toBe(2)
})
})
+124
View File
@@ -0,0 +1,124 @@
/**
* Belege, die über das iOS-Teilen-Blatt hereinkommen.
*
* Die Gegenseite ist `native/teilen/ShareViewController.swift`: sie schreibt
* das geteilte PDF in den Container der App-Gruppe `group.app.datametric360`
* und öffnet danach die App mit dem Pfad im Aufruf:
*
* datametric360://beleg?pfad=file:///…/geteilte-belege/1756832400-beleg.pdf
*
* Diese Datei liest ihn über `@capacitor/filesystem`, das volle `file://`-Pfade
* ausdrücklich unterstützt (README: „Simply leave out the `directory` param to
* use a full file path"), und löscht die Datei danach.
*
* DER ERSTE ENTWURF GING ÜBER USERDEFAULTS UND HAT NIE FUNKTIONIERT
* ----------------------------------------------------------------
* Er legte das PDF base64-kodiert unter `dm360_geteilter_beleg` in
* `UserDefaults(suiteName: gruppe)` ab und holte es hier mit
* `@capacitor/preferences` und `configure({ group })` wieder heraus — in der
* Annahme, das Plugin lese damit die UserDefaults der Gruppe. Das tut es
* nicht. Sein iOS-Code (8.0.1, `ios/Sources/PreferencesPlugin/Preferences.swift`)
* liest **immer** `UserDefaults.standard` der App; `group` setzt lediglich ein
* Schlüssel-Präfix. Gesucht wurde also
* `group.app.datametric360.dm360_geteilter_beleg` im eigenen Bereich der App,
* während der Eintrag unter dem blanken Schlüssel im Gruppen-Container lag:
* falscher Behälter und falscher Schlüssel.
*
* Sichtbar war davon nur, dass die App aufging und nichts passierte — gemeldet
* am 02.09.2026 („es wird kein Tankvorgang angelegt beim Teilen mit der App").
*
* ABGEHOLT WIRD GENAU EINMAL
* --------------------------
* Die Datei wird nach dem Lesen gelöscht. Bliebe sie liegen, käme derselbe
* Beleg bei jedem Öffnen erneut — und weil der Upload beim Backend einen
* Vorgang anlegt, wären das echte Doppeleinträge.
*/
import { Capacitor } from "@capacitor/core"
import { Filesystem } from "@capacitor/filesystem"
/** Muss zum Schema in ShareViewController.swift passen. */
const SCHEMA = "datametric360://beleg"
function base64ZuDatei(base64: string, name: string): File {
const roh = atob(base64)
const bytes = new Uint8Array(roh.length)
for (let i = 0; i < roh.length; i++) bytes[i] = roh.charCodeAt(i)
return new File([bytes], name, { type: "application/pdf" })
}
/**
* Zieht den Belegpfad aus einem Aufruf der App. `null`, wenn der Aufruf ein
* anderer war (die App kann über dasselbe Schema auch anders geöffnet werden).
*/
export function belegpfadAusUrl(url: string | null | undefined): string | null {
if (!url || !url.startsWith(SCHEMA)) return null
try {
const frage = url.indexOf("?")
if (frage < 0) return null
const pfad = new URLSearchParams(url.slice(frage + 1)).get("pfad")
return pfad || null
} catch {
return null
}
}
/**
* Liest den geteilten Beleg und räumt ihn weg. `null`, wenn der Aufruf keinen
* Pfad trug oder die Datei nicht lesbar war.
*/
export async function geteiltenBelegLesen(url: string | null | undefined): Promise<File | null> {
if (!Capacitor.isNativePlatform()) return null
const pfad = belegpfadAusUrl(url)
if (!pfad) return null
let base64: string
try {
const gelesen = await Filesystem.readFile({ path: pfad })
base64 = typeof gelesen.data === "string" ? gelesen.data : ""
} catch (fehler) {
console.warn("Geteilter Beleg: Datei nicht lesbar", fehler)
return null
}
if (!base64) return null
// Erst wegräumen, dann zurückgeben: schlägt das Hochladen später fehl, ist
// die Datei zwar weg - aber ein liegengebliebener Beleg, der bei jedem
// Öffnen erneut hochlädt, wäre der schlimmere Fehler.
try {
await Filesystem.deleteFile({ path: pfad })
} catch {
// Halb so wild: der Name trägt einen Zeitstempel, ein Rest überschreibt
// nichts und wird nie wieder gelesen - die App kommt nur über einen
// frischen Aufruf an einen Pfad.
}
const name = decodeURIComponent(pfad.split("/").pop() || "beleg.pdf")
return base64ZuDatei(base64, name)
}
/* ------------------------------------------------------------- Übergabe
Zwischen dem Abholen (App.tsx, beim Start und bei jedem Aufruf) und dem
Verarbeiten (Tanken.tsx) liegt ein Bildschirmwechsel. Ein kleiner geteilter
Speicher nach dem Muster von theme.ts überbrückt ihn — ein Zustand, der
außerhalb des Komponentenbaums lebt und den beide Seiten sehen. */
let wartend: File | null = null
const horcher = new Set<() => void>()
export function geteiltenBelegSetzen(datei: File | null): void {
wartend = datei
for (const bei of horcher) bei()
}
export function geteiltenBelegStand(): File | null {
return wartend
}
export function geteiltenBelegHorchen(bei: () => void): () => void {
horcher.add(bei)
return () => {
horcher.delete(bei)
}
}
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest"
import { buendelPasst } from "./ota"
import type { Buendelangabe } from "../api"
const buendel = (teil: Partial<Buendelangabe> = {}): Buendelangabe => ({
version: "2026.8.24.1",
sha256: "a".repeat(64),
bytes: 224186,
gebaut: "2026-08-24T00:00:00Z",
url: "/audi_dashboard_static/app/bundle.zip",
...teil,
})
/* buendelPasst() entscheidet, ob der Update-Knopf überhaupt erscheint - ein
Fehler hier zeigt entweder einen Knopf, der ins Leere läuft (kein Bündel
da), oder verbirgt ein echtes Update dauerhaft. */
describe("buendelPasst", () => {
it("lehnt ab, wenn kein Bündel vorliegt", () => {
expect(buendelPasst(null, "2026.8.23.2")).toBe(false)
})
it("lehnt ab, wenn Bündel- und eigene Version übereinstimmen", () => {
expect(buendelPasst(buendel({ version: "2026.8.24.1" }), "2026.8.24.1")).toBe(false)
})
it("akzeptiert bei abweichender Version", () => {
expect(buendelPasst(buendel({ version: "2026.8.24.2" }), "2026.8.24.1")).toBe(true)
})
it("lehnt ab, wenn die eigene Version unbekannt ist", () => {
// null heißt "diese App weiß nicht, welche Version sie ist" (z. B. im
// Dev-Server ohne eingebautes __APP_VERSION__) - dann lässt sich ein
// Update nicht sinnvoll anbieten, weil "abweichend" nicht feststellbar ist.
expect(buendelPasst(buendel(), null)).toBe(false)
})
it("lehnt ab, wenn dem Bündel Pflichtfelder fehlen", () => {
expect(buendelPasst(buendel({ url: "" }), "2026.8.23.2")).toBe(false)
expect(buendelPasst(buendel({ sha256: "" }), "2026.8.23.2")).toBe(false)
expect(buendelPasst(buendel({ version: "" }), "2026.8.23.2")).toBe(false)
})
/* Der eigentliche Zweck, und bis zum 03.09.2026 nur ein Kommentar: das
Bündel muss zu der Fassung gehören, die der Server ausliefert. Ohne
diese Prüfung meldete die App "diese Fassung ändert auch Natives",
obwohl nur das Bündel nach einem Versionssprung nicht neu gebaut war. */
it("akzeptiert, wenn das Bündel die Fassung des Servers ist", () => {
expect(buendelPasst(buendel({ version: "2026.9.3.11" }), "2026.9.3.10", "2026.9.3.11")).toBe(
true,
)
})
it("lehnt ab, wenn das Bündel hinter dem Server zurückliegt", () => {
expect(buendelPasst(buendel({ version: "2026.9.3.10" }), "2026.9.3.9", "2026.9.3.11")).toBe(
false,
)
})
it("prüft ohne bekannte Serverfassung nur gegen die eigene", () => {
// Offline oder altes Backend: einen Vergleich, den man nicht anstellen
// kann, darf der Knopf nicht ausbaden.
expect(buendelPasst(buendel({ version: "2026.9.3.11" }), "2026.9.3.10", null)).toBe(true)
})
})
/* Der gemeldete Fall vom 04.09.2026: nach einem Xcode-Lauf lief die App auf
2026.9.4.17, die Instanz stand noch auf 2026.9.4.5 - und bot an, sich auf
.5 zu "erneuern". Das Bündel passte zur Serverfassung, war also nach der
damaligen Prüfung in Ordnung; dass es ein Rückschritt ist, konnte sie nicht
sehen. Vorgabe des Eigentümers: erkannt wird eine NEUERE Fassung, sonst
keine. */
describe("buendelPasst geht nur vorwaerts", () => {
it("lehnt den gemeldeten Rueckschritt ab, obwohl das Buendel zum Server passt", () => {
expect(
buendelPasst(buendel({ version: "2026.9.4.5" }), "2026.9.4.17", "2026.9.4.5"),
).toBe(false)
})
it("nimmt dieselbe Lage nach dem Nachziehen der Instanz an", () => {
// Sobald die Integration auf .18 steht, geht es wieder vorwaerts.
expect(
buendelPasst(buendel({ version: "2026.9.4.18" }), "2026.9.4.17", "2026.9.4.18"),
).toBe(true)
})
it("lehnt einen Rueckschritt auch ohne bekannte Serverfassung ab", () => {
expect(buendelPasst(buendel({ version: "2026.9.4.5" }), "2026.9.4.17", null)).toBe(false)
})
it("vergleicht die letzte Stelle als Zahl, nicht zeichenweise", () => {
// "9" gegen "10": zeichenweise stuende "10" davor.
expect(buendelPasst(buendel({ version: "2026.9.4.10" }), "2026.9.4.9", null)).toBe(true)
expect(buendelPasst(buendel({ version: "2026.9.4.9" }), "2026.9.4.10", null)).toBe(false)
})
it("bleibt beim alten Verhalten, wenn sich die Reihenfolge nicht bestimmen laesst", () => {
// Formatwechsel: dann entscheidet weiterhin allein die Serverfassung -
// eine Reihenfolge zu erfinden waere schlechter als die alte Regel.
expect(buendelPasst(buendel({ version: "fruehling-3" }), "2026.9.4.17", "fruehling-3")).toBe(
true,
)
})
})
+179
View File
@@ -0,0 +1,179 @@
/**
* Oberflächen-Updates ohne Xcode („OTA").
*
* Die native Hülle trägt ihre Oberfläche fest gebündelt — sie bleibt auf dem
* Stand vom Signieren, unbegrenzt. Diese Datei ist der Ausweg: die App lädt
* ein neues Bündel von der eigenen Home-Assistant-Instanz und tauscht es aus.
*
* WOHER DAS BÜNDEL KOMMT
* ----------------------
* Aus der Integration selbst: `custom_components/audi_dashboard/frontend/app/`
* enthält `bundle.zip` und `bundle.json`, ausgeliefert unter
* `/audi_dashboard_static/app/`. Damit reist das Bündel bei jedem
* HACS-Update mit, und es kann gar nicht zur Integration unpassend sein — es
* ist dieselbe Lieferung. Gebaut wird es von
* `companion-app/scripts/ota-paket.ps1`.
*
* Welche Fassung bereitliegt, sagt `sensor.audi_dashboard_app_version` im
* Attribut `daten.buendel`. Kein zweiter Abruf, keine zweite Quelle.
*
* WAS OTA NICHT KANN
* ------------------
* Nur Weboberfläche — HTML, CSS, JavaScript, Bilder. Alles Native (Plugins,
* Capacitor selbst, Berechtigungen, iOS-Einstellungen) braucht weiterhin
* Xcode. Deshalb setzt `resetWhenUpdate` in capacitor.config.ts nach einer
* frischen Xcode-Installation wieder auf das eingebaute Bündel zurück: sonst
* überdeckte ein älteres OTA-Bündel genau die native Änderung, für die man
* Xcode gebraucht hat.
*
* DIE RÜCKFALLEBENE
* -----------------
* `startklarMelden()` unten muss nach jedem Start laufen. Bleibt die Meldung
* aus (weil das neue Bündel gar nicht erst hochkommt), kehrt das Plugin nach
* `appReadyTimeout` von selbst zum vorherigen Bündel zurück. Ein kaputtes
* Bündel kann das Gerät deshalb nicht dauerhaft unbrauchbar machen — man
* landet wieder da, wo man vorher war.
*/
import { Capacitor } from "@capacitor/core"
import { CapacitorUpdater } from "@capgo/capacitor-updater"
import type { Buendelangabe } from "../api"
import { versionOrdnung } from "./appVersion"
/** Läuft die App in der nativen Hülle? Nur dort gibt es etwas auszutauschen —
im Browser und im HA-Panel lädt ohnehin jeder Seitenaufruf den aktuellen
Stand. */
export function otaMoeglich(): boolean {
return Capacitor.isNativePlatform()
}
/**
* Meldet dem Plugin, dass dieses Bündel hochgekommen ist.
*
* Ohne diesen Aufruf rollt das Plugin nach kurzer Zeit auf das vorherige
* Bündel zurück — was genau richtig ist, wenn ein Update die App zerlegt hat.
* Deshalb steht der Aufruf bewusst dort, wo React nachweislich gerendert hat
* (App.tsx), und nicht schon in main.tsx: ein Absturz beim ersten Rendern
* soll als Fehlschlag gelten und den Rückfall auslösen.
*/
export async function startklarMelden(): Promise<void> {
if (!otaMoeglich()) return
try {
await CapacitorUpdater.notifyAppReady()
} catch (fehler) {
// Nur protokollieren: Scheitert die Meldung, greift der Rückfall — das
// ist unangenehm, aber sicher. Ein Absturz an dieser Stelle wäre schlimmer.
console.warn("OTA: notifyAppReady fehlgeschlagen", fehler)
}
}
/** Ist ein anderes Bündel verfügbar als das gerade laufende?
Dieselbe Vorsicht wie in versionVergleichen() (appVersion.ts): kennt die
App ihre eigene Version nicht (eigene === null — praktisch nur im
Dev-Server, ein natives Release baut nie ohne __APP_VERSION__), gilt das
als "nicht vergleichbar", nicht als "abweichend". Alles andere böte einen
Update-Knopf an, dessen Ziel man mit der laufenden Fassung gar nicht
abgleichen konnte. */
export function buendelPasst(
buendel: Buendelangabe | null,
eigene: string | null,
serverVersion?: string | null,
): boolean {
if (!buendel?.version || !buendel.url || !buendel.sha256 || !eigene) return false
// Es muss etwas ANDERES sein als das, was gerade läuft - sonst gibt es
// nichts zu holen.
if (buendel.version === eigene) return false
// Und es muss VORWAERTS gehen. Am 04.09.2026 lief die App nach einem
// Xcode-Lauf auf 2026.9.4.17, während die Instanz noch 2026.9.4.5
// auslieferte - angeboten wurde ein Rückschritt auf .5, der die
// Oberfläche still um zwölf Fassungen zurückgedreht hätte. Die
// Gleichheitsprüfung konnte das nicht sehen; siehe appVersion.ts.
const ordnung = versionOrdnung(buendel.version, eigene)
if (ordnung !== null && ordnung <= 0) return false
// Und es muss die Fassung sein, die diese Installation ausliefert. Ohne
// bekannte Serverfassung bleibt es beim alten Verhalten - ein Vergleich,
// den man nicht anstellen kann, darf den Knopf nicht wegnehmen.
if (serverVersion == null) return true
return buendel.version === serverVersion
}
export class OtaFehler extends Error {}
/**
* Erklärt einen fehlgeschlagenen Download, statt die Meldung des Plugins
* durchzureichen.
*
* „Checksum" heißt: die geladenen Bytes sind nicht die erwarteten. In diesem
* Projekt war die Ursache bisher nie ein kaputtes Bündel (Ablage, Auslieferung
* und Prüfsumme sind nachweislich deckungsgleich), sondern ein Download, der
* mitten in einen Neustart von Home Assistant lief. Das ist keine Sackgasse,
* sondern ein Grund, es gleich noch einmal zu versuchen - und genau das soll
* dort stehen, wo der Fehler steht.
*/
function ladefehlerText(fehler: unknown): string {
const meldung = fehler instanceof Error ? fehler.message : String(fehler)
if (/checksum|prüfsumme|pruefsumme/i.test(meldung)) {
return (
"Das Update kam unvollständig an. Das passiert, wenn Home Assistant " +
"während des Ladens neu startet. Sobald es wieder läuft, noch einmal " +
`versuchen. (${meldung})`
)
}
return `Das Update konnte nicht geladen werden (${meldung}).`
}
/**
* Lädt das Bündel und schaltet darauf um.
*
* Kehrt im Erfolgsfall **nie** zurück: `set()` zerstört den JavaScript-Kontext
* und lädt die App neu. Alles, was danach stünde, liefe nicht mehr — deshalb
* steht hier auch keine Erfolgsmeldung. Der Erfolg ist, dass die App neu
* startet.
*
* @param basisUrl Adresse der Home-Assistant-Instanz, wie beim Einrichten
* hinterlegt. Die Bündel-URL aus der Entität ist ein Pfad.
*/
export async function buendelAnwenden(
buendel: Buendelangabe,
basisUrl: string,
): Promise<never> {
if (!otaMoeglich()) throw new OtaFehler("Updates gibt es nur in der App auf dem Gerät.")
// Die Fassung hängt als Abfrageteil an der Adresse.
//
// Home Assistant liefert /audi_dashboard_static/ ohne Cache-Control, aber
// mit ETag und Last-Modified (nachgemessen). Genau dann darf jeder Zwischen-
// speicher - der des Geräts wie einer im Weg - die Datei nach eigenem
// Ermessen eine Weile behalten. Käme dabei die vorherige Zip zurück, während
// die Prüfsumme aus der Entität längst die neue nennt, scheiterte jedes
// Update mit einem Prüfsummenfehler, ohne dass irgendetwas kaputt wäre.
// Dieselbe Vorsorge trifft das Panel seit jeher für sein eigenes Bündel.
const adresse = new URL(buendel.url, basisUrl)
adresse.searchParams.set("v", buendel.version)
let geladen
try {
geladen = await CapacitorUpdater.download({
url: adresse.href,
version: buendel.version,
// Das Plugin rechnet auf dem Gerät SHA-256 über die heruntergeladene
// Zip und verwirft sie bei Abweichung (calcChecksum in
// CapgoUpdater.swift). Ein abgebrochener Download oder eine halb
// gespiegelte Datei fällt damit auf, bevor die App sie startet.
checksum: buendel.sha256,
})
} catch (fehler) {
throw new OtaFehler(ladefehlerText(fehler))
}
try {
await CapacitorUpdater.set({ id: geladen.id })
} catch (fehler) {
throw new OtaFehler(
`Das Update wurde geladen, ließ sich aber nicht starten (${fehler instanceof Error ? fehler.message : String(fehler)}).`,
)
}
// Unerreichbar: set() lädt die App neu. Steht hier, damit der Rückgabetyp
// ehrlich bleibt.
throw new OtaFehler("Die App hätte an dieser Stelle neu starten müssen.")
}
@@ -0,0 +1,74 @@
import { readFileSync } from "node:fs"
import { describe, expect, it } from "vitest"
import { hauptuntersuchungFaellig } from "./service"
/* Vergleicht die Ableitung der Hauptuntersuchung Zeile für Zeile gegen die
ECHTEN Panel-Funktionen, aus der Panel-Quelle herausgeschnitten. */
const quelle = readFileSync(
"../custom_components/audi_dashboard/frontend/audi-dashboard-app.js", "utf8")
const block = (kopf: string) => {
const i = quelle.indexOf(kopf)
if (i < 0) throw new Error("fehlt: " + kopf)
let tiefe = 0
for (let k = quelle.indexOf("{", i); k < quelle.length; k++) {
if (quelle[k] === "{") tiefe++
else if (quelle[k] === "}" && !--tiefe) return quelle.slice(i, k + 1)
}
throw new Error("unvollstaendig: " + kopf)
}
const zeile = (muster: RegExp) => {
const m = muster.exec(quelle)
if (!m) throw new Error("fehlt: " + muster)
return m[0]
}
const panel = new Function(`
${zeile(/^const DATUM_ISO = .*$/m)}
${zeile(/^const DATUM_TAG = .*$/m)}
${zeile(/^const DATUM_MONAT = .*$/m)}
${zeile(/^const DATUM_ISO_MONAT = .*$/m)}
${block("function datumBauen")}
${zeile(/^const gueltigesDatum = .*$/m)}
${block("function datumAusText")}
${block("function dat(")}
${zeile(/^function monatePlus.*$/m)}
return { datumAusText, monatePlus };
`)() as {
datumAusText: (w: string) => { zeit: Date; genau: string } | null
monatePlus: (d: string | Date, m: number) => Date
}
const iso = (d: Date | null) => d === null ? null
: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
/* Wie hauptuntersuchungsTermin() im Panel, nur ohne dessen CAR-Zugriff.
Beide Seiten führen die Genauigkeit mit, nur in verschiedener Form: das
Panel als Merkmal `genau` am Termin, die App als ISO-Monat im Text
("2028-03"). Verglichen wird deshalb die normalisierte Form - dieselbe
Aussage, nicht dieselbe Darstellung. */
const panelHU = (erst: string, eintragDe?: string) => {
if (eintragDe) return iso(panel.monatePlus(eintragDe, 24))
const z = panel.datumAusText(erst)
if (!z) return null
const ziel = iso(panel.monatePlus(z.zeit, 36))
return z.genau === "monat" ? ziel!.slice(0, 7) : ziel
}
describe("Hauptuntersuchung: Panel und App rechnen gleich", () => {
for (const erst of ["01.03.2025", "2025-03-01", "15.01.2025", "20.11.2025",
"10.02.2025", "29.02.2024", "31.12.2023", "03/2025",
"31.02.2025", "", "Unsinn"]) {
it(`Erstzulassung ${JSON.stringify(erst)}`, () => {
expect(hauptuntersuchungFaellig([], erst)).toBe(panelHU(erst))
})
}
const paare: [string, string][] = [["15.01.2025", "2025-01-15"], ["20.11.2025", "2025-11-20"],
["10.02.2025", "2025-02-10"], ["29.02.2024", "2024-02-29"]]
for (const [de, isoDatum] of paare) {
it(`Wartungsplan ${de}`, () => {
expect(hauptuntersuchungFaellig([{ art: "Hauptuntersuchung", datum: isoDatum }], "01.03.2025"))
.toBe(panelHU("01.03.2025", de))
})
}
})
@@ -0,0 +1,70 @@
import { readFileSync } from "node:fs"
import { describe, expect, it } from "vitest"
import { terminUeberschritten } from "./service"
/* Beide Codebasen müssen denselben Termin gleich beurteilen - sonst steht auf
der Übersicht "überschritten" und auf derselben Seite im Panel nicht. Geprüft
wird gegen die ECHTE Panel-Funktion, aus der Panel-Quelle herausgeschnitten,
nicht gegen einen Nachbau. Muster wie in paritaet_hu.test.ts. */
const quelle = readFileSync(
"../custom_components/audi_dashboard/frontend/audi-dashboard-app.js", "utf8")
const block = (kopf: string) => {
const i = quelle.indexOf(kopf)
if (i < 0) throw new Error("fehlt: " + kopf)
let tiefe = 0
for (let k = quelle.indexOf("{", i); k < quelle.length; k++) {
if (quelle[k] === "{") tiefe++
else if (quelle[k] === "}" && !--tiefe) return quelle.slice(i, k + 1)
}
throw new Error("unvollstaendig: " + kopf)
}
const zeile = (muster: RegExp) => {
const m = muster.exec(quelle)
if (!m) throw new Error("fehlt: " + muster)
return m[0]
}
const panel = new Function(`
${zeile(/^const DATUM_ISO = .*$/m)}
${zeile(/^const DATUM_TAG = .*$/m)}
${zeile(/^const DATUM_MONAT = .*$/m)}
${zeile(/^const DATUM_ISO_MONAT = .*$/m)}
${zeile(/^const DATUM_ZEITSTEMPEL = .*$/m)}
${zeile(/^const gueltigesDatum = .*$/m)}
${block("function datumBauen")}
${block("function datumAusText")}
${block("function zeitstempelAlsDatum")}
${block("function terminUeberschritten")}
return { terminUeberschritten };
`)() as { terminUeberschritten: (...k: (string | Date | null)[]) => boolean }
const tag = (versatz: number) => {
const x = new Date()
x.setDate(x.getDate() + versatz)
return `${String(x.getDate()).padStart(2, "0")}.${String(x.getMonth() + 1).padStart(2, "0")}.${x.getFullYear()}`
}
describe("terminUeberschritten: Panel und App urteilen gleich", () => {
const faelle: { name: string; args: (string | null)[] }[] = [
{ name: "gestern", args: [tag(-1)] },
{ name: "heute", args: [tag(0)] },
{ name: "morgen", args: [tag(1)] },
{ name: "ISO vorbei", args: ["2024-03-01"] },
{ name: "ISO Zukunft", args: ["2028-07-23"] },
{ name: "Fahrzeug-Zeitstempel vorbei", args: ["2025-10-21T00:00:00+00:00"] },
{ name: "Fahrzeug-Zeitstempel Zukunft", args: ["2027-10-21T00:00:00+00:00"] },
{ name: "monatsgenau vorbei", args: ["03/2025"] },
{ name: "monatsgenau Zukunft", args: ["03/2099"] },
{ name: "kein Datum", args: [null] },
{ name: "leer", args: [""] },
{ name: "unlesbar", args: ["irgendwas"] },
{ name: "erstes lesbares: null, dann gestern", args: [null, tag(-1)] },
{ name: "erstes lesbares: morgen vor gestern", args: [tag(1), tag(-1)] },
]
for (const f of faelle) {
it(f.name, () => {
expect(terminUeberschritten(...f.args)).toBe(panel.terminUeberschritten(...f.args))
})
}
})
@@ -19,12 +19,10 @@ function beispielprofil(aenderung: Partial<Profil> = {}): Profil {
untertitel: "quattro · 331 kW",
kennzeichen: "M AB 1234",
tankvolumen_liter: 58,
wlan_name: "Audi_MMI",
},
einstellungen: {
uebersichtsbild: "seitenansicht.webp",
fahrten_pausenzeit_min: 15,
smartdeal: { aktiv: true, laeuft_ab: null },
smartdeal: { aktiv: true, laeuft_ab: null },
oelwechsel_intervall: { modus: "hersteller", hersteller_km: 30000, hersteller_monate: 24 },
},
reifen: {
@@ -43,13 +41,11 @@ describe("profilZuEinstellungen", () => {
expect(e.fahrzeugtitel).toBe("RS 4 Avant")
expect(e.kennzeichen).toBe("M AB 1234")
expect(e.tankvolumen).toBe(58)
expect(e.pauseMin).toBe(15)
})
it("fällt auf sinnvolle Werte zurück, wenn Abschnitte fehlen", () => {
const e = profilZuEinstellungen({})
expect(e.tankvolumen).toBe(58)
expect(e.pauseMin).toBe(15)
expect(e.backupIntervall).toBe("aus")
})
})
@@ -95,7 +91,7 @@ describe("profilZuFahrzeug mit Fahrzeugstatus", () => {
tankprozent: 62,
reichweite_km: 385,
gesichert: true,
sicherheitscheck: [{ label: "Tür vorne links", ok: true }],
sicherheitscheck: [{ label: "Tür vorne links", ok: true, gruppe: "tueren_klappen" }],
})
expect(f.odo).toBe(48250)
expect(f.odoBekannt).toBe(true)
+57 -10
View File
@@ -19,12 +19,13 @@ export interface Einstellungen {
tankvolumen: number
nachtVon: string
nachtBis: string
pauseMin: number
markenname: string
wlanName: string
backupIntervall: string
letztesBackup: string | null
ausfuehrung: string
/** Minuten nach dem Zuendungs-Aus, bis die Fahrt automatisch endet.
0 heisst: sofort. Siehe fahrterkennung._pausenzeit_s im Backend. */
pausenzeitMin: number
}
export interface Reifensatz {
@@ -37,18 +38,49 @@ export interface Reifensatz {
[weitere: string]: unknown
}
/** Ein per "Neue Räder anlegen" archivierter Reifensatz (reifen.py
archivieren()) - der vollständige Stand zum Zeitpunkt des Austauschs. */
export interface ReifenArchivEintrag {
id: string
satz: "sommer" | "winter"
ersetzt_am: string
km: number | null
marke: string | null
modell: string | null
dot: string | null
mass: string | null
druck_vorne: string | null
druck_hinten: string | null
kommentar: string | null
}
export interface Fahrzeug {
fin: string
details: string
erstzulassung: string
hu: string
odo: number
odoBekannt: boolean
tankPct: number
tankBekannt: boolean
reichweite: number | null
batteriespannung: number | null
gesichert: boolean | null
sicherheitscheck: SicherheitsPunkt[]
/** Diagnosecodes im Fehlerspeicher; `null` heisst "kein Sensor zugeordnet
oder er meldet nichts" - nie als "keine Fehler" deuten. */
fehlerspeicher: { anzahl: number | null; ok: boolean | null } | null
/** Genauigkeit der Position in Metern; `null` heisst unbekannt. */
standortGenauigkeitM: number | null
/** Zustand des Zündungssensors — nicht „fährt": ohne Bewegungssensor sagt
an nur, dass die Zündung an ist. `null` heißt „kein Zündungssensor
zugeordnet", also unbekannt — nicht „steht". */
zuendung: boolean | null
geparktSeit: string | null
/** Beginn der laufenden Fahrt, sonst null - siehe faehrt_seit im Backend. */
faehrtSeit: string | null
standortLat: number | null
standortLon: number | null
standortZeit: string | null
oelwechselFaelligTs: string | null
oelwechselFaelligKm: number | null
inspektionFaelligTs: string | null
@@ -69,6 +101,8 @@ export interface Fahrzeug {
wechsel: Record<string, unknown>
sommer: Reifensatz
winter: Reifensatz
/** Neueste zuerst - schon so im Profil (reifen.py archivieren()). */
archiv: ReifenArchivEintrag[]
}
service: Record<string, unknown>
technik: Record<string, unknown>
@@ -108,16 +142,22 @@ export function profilZuEinstellungen(profil: Profil): Einstellungen {
fahrzeugzusatz: text(fahrzeug, "zusatz"),
fahrzeuguntertitel: text(fahrzeug, "untertitel"),
kennzeichen: text(fahrzeug, "kennzeichen"),
startbild: text(einst, "uebersichtsbild", "seitenansicht.webp"),
// Der Name der Ansicht, nicht der Dateiname - so speichert es das Panel.
startbild: text(einst, "uebersichtsbild", "Seitenansicht"),
tankvolumen: zahl(fahrzeug, "tankvolumen_liter") ?? 58,
nachtVon: text(einst, "nacht_von", "22:00"),
nachtBis: text(einst, "nacht_bis", "06:00"),
pauseMin: zahl(einst, "fahrten_pausenzeit_min") ?? 15,
markenname: text(einst, "kraftstoffanbieter", "Shell"),
wlanName: text(fahrzeug, "wlan_name"),
backupIntervall: text(einst, "backup_intervall", "aus"),
letztesBackup: text(einst, "letztes_backup") || null,
ausfuehrung: text(fahrzeug, "ausfuehrung"),
// Der Wert hat die Zeit ueberlebt, in der die Einstellung nicht angeboten
// wurde (31.08. bis 03.09.2026) - deshalb steht er in vielen Profilen
// schon, und der Standard greift nur bei neuen.
// Auf den Bereich des Reglers geklemmt - siehe profilZuConfig() im Panel:
// vor dem 03.09.2026 war 0 ("sofort") moeglich, das Geraet kennt aber kein
// Schlaf-Timeout unter einer Minute.
pausenzeitMin: Math.min(60, Math.max(1, zahl(einst, "fahrten_pausenzeit_min") ?? 15)),
}
}
@@ -138,14 +178,22 @@ export function profilZuFahrzeug(profil: Profil, status: Fahrzeugstatus): Fahrze
fin: text(fahrzeug, "fin"),
details: text(fahrzeug, "details"),
erstzulassung: text(fahrzeug, "erstzulassung"),
hu: text(fahrzeug, "hauptuntersuchung_faellig"),
odo: status.km ?? 0,
odoBekannt: status.km !== null && status.km !== undefined,
tankPct: status.tankprozent ?? 0,
tankBekannt: status.tankprozent !== null && status.tankprozent !== undefined,
reichweite: status.reichweite_km ?? null,
batteriespannung: status.batteriespannung ?? null,
gesichert: status.gesichert ?? null,
sicherheitscheck: status.sicherheitscheck ?? [],
fehlerspeicher: status.fehlerspeicher ?? null,
standortGenauigkeitM: status.standort_genauigkeit_m ?? null,
zuendung: status.zuendung ?? null,
geparktSeit: (status.geparkt_seit as string | null) ?? null,
faehrtSeit: (status.faehrt_seit as string | null) ?? null,
standortLat: status.standort_lat ?? null,
standortLon: status.standort_lon ?? null,
standortZeit: status.standort_zeit ?? null,
oelwechselFaelligTs: status.oelwechsel_faellig_ts ?? null,
oelwechselFaelligKm: status.oelwechsel_faellig_km ?? null,
inspektionFaelligTs: status.inspektion_faellig_ts ?? null,
@@ -171,6 +219,7 @@ export function profilZuFahrzeug(profil: Profil, status: Fahrzeugstatus): Fahrze
wechsel: (reifen["wechsel"] ?? {}) as Record<string, unknown>,
sommer: saetze["sommer"] ?? {},
winter: saetze["winter"] ?? {},
archiv: (reifen["archiv"] as ReifenArchivEintrag[] | undefined) ?? [],
},
service: abschnitt(profil, "service"),
technik: abschnitt(profil, "technik"),
@@ -199,18 +248,16 @@ export function zusammenfuehren(
f["zusatz"] = einstellungen.fahrzeugzusatz
f["untertitel"] = einstellungen.fahrzeuguntertitel
f["kennzeichen"] = einstellungen.kennzeichen
f["wlan_name"] = einstellungen.wlanName
f["ausfuehrung"] = einstellungen.ausfuehrung
f["tankvolumen_liter"] = einstellungen.tankvolumen
f["fin"] = fahrzeug.fin
f["erstzulassung"] = fahrzeug.erstzulassung
f["hauptuntersuchung_faellig"] = fahrzeug.hu
e["uebersichtsbild"] = einstellungen.startbild
e["fahrten_pausenzeit_min"] = einstellungen.pauseMin
e["backup_intervall"] = einstellungen.backupIntervall
e["nacht_von"] = einstellungen.nachtVon
e["nacht_bis"] = einstellungen.nachtBis
e["fahrten_pausenzeit_min"] = einstellungen.pausenzeitMin
e["smartdeal"] = { aktiv: fahrzeug.smartdeal.aktiv, laeuft_ab: fahrzeug.smartdeal.laeuftAb }
e["oelwechsel_intervall"] = {
modus: fahrzeug.oel.modus,
+348 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"
import { letzterOelwechsel, oelwechselPrognose } from "./service"
import { hauptuntersuchungFaellig, inspektionPrognose, kmProTagAusFahrten, letzterOelwechsel, meldungsPrognose, meldungsPrognoseEigenesIntervall, naechsterService, oelMeldungsPrognose, oelwechselPrognose, terminUeberschritten } from "./service"
import type { Fahrzeug } from "./profilAdapter"
function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
@@ -8,14 +8,22 @@ function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
fin: "",
details: "",
erstzulassung: "",
hu: "",
odo,
odoBekannt: true,
tankPct: 0,
tankBekannt: true,
reichweite: null,
batteriespannung: null,
gesichert: null,
sicherheitscheck: [],
fehlerspeicher: null,
standortGenauigkeitM: null,
zuendung: null,
geparktSeit: null,
faehrtSeit: null,
standortLat: null,
standortLon: null,
standortZeit: null,
oelwechselFaelligTs: null,
oelwechselFaelligKm: null,
inspektionFaelligTs: null,
@@ -24,7 +32,7 @@ function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
oel: { modus, km: 15000, monate: 12, herstellerKm: 30000, herstellerMonate: 24 },
versicherung: {},
steuer: { faellig: "" },
reifen: { aktiv: "Sommer", anzugsmoment: null, wechsel: {}, sommer: {}, winter: {} },
reifen: { aktiv: "Sommer", anzugsmoment: null, wechsel: {}, sommer: {}, winter: {}, archiv: [] },
service: {},
technik: {},
ausstattung: [],
@@ -92,3 +100,340 @@ describe("oelwechselPrognose", () => {
expect(oelwechselPrognose(fahrzeug(46000), [], jetzt)).toBeNull()
})
})
describe("inspektionPrognose", () => {
const jetzt = new Date("2026-08-11T12:00:00")
it("rechnet mit dem festen Intervall 30.000 km / 24 Monate, nicht dem Ölwechsel-Intervall", () => {
// Letzte Inspektion 05.03.2025 bei 25000 km -> Ziel 55000. Bei 46000 km
// sind das 9000 km offen, unabhängig vom oel-Feld des Fahrzeugs (15000/12).
const p = inspektionPrognose(fahrzeug(46000), BUCH, jetzt)
expect(p).not.toBeNull()
expect(p!.restKm).toBe(9000)
})
it("ignoriert den Ölwechsel-Eintrag bei der Suche nach der letzten Inspektion", () => {
expect(inspektionPrognose(fahrzeug(46000), [BUCH[0]!], jetzt)).toBeNull()
})
it("liefert nichts, wenn nie eine Inspektion eingetragen wurde", () => {
expect(inspektionPrognose(fahrzeug(46000), [], jetzt)).toBeNull()
})
})
describe("naechsterService", () => {
const jetzt = new Date("2026-08-11T12:00:00")
it("wählt über Ölwechsel und Inspektion hinweg das zeitlich frühere Datum", () => {
// Beide unabhängig mit derselben Formel berechnet, statt ein erwartetes
// Datum von Hand vorzurechnen - so bleibt der Test robust gegen spätere
// Änderungen an den Intervallen, prüft aber trotzdem die Auswahllogik.
const oel = oelwechselPrognose(fahrzeug(46000), BUCH, jetzt)
const insp = inspektionPrognose(fahrzeug(46000), BUCH, jetzt)
const erwarteteArt = oel!.datum <= insp!.datum ? "Ölwechsel" : "Inspektion"
const s = naechsterService(fahrzeug(46000), BUCH, [], jetzt)
expect(s).not.toBeNull()
expect(s!.art).toBe(erwarteteArt)
expect(s!.datum).toBe(erwarteteArt === "Ölwechsel" ? oel!.datum : insp!.datum)
})
it("nimmt die Hauptuntersuchung auf, obwohl sie keine eigene Prognose hat", () => {
const f = { ...fahrzeug(46000), erstzulassung: "01.03.2025" }
const s = naechsterService(f, [], [], jetzt)
expect(s).not.toBeNull()
expect(s!.art).toBe("Hauptuntersuchung")
expect(s!.restKm).toBeNull()
expect(s!.datum).toBe("2028-03-01")
})
it("liefert nichts ohne Servicebuch und ohne Erstzulassung", () => {
expect(naechsterService(fahrzeug(46000), [], [], jetzt)).toBeNull()
})
it("fällt ohne Servicebucheintrag auf die vom Fahrzeug gemeldete Ölwechsel-Fälligkeit zurück", () => {
// Ein frisch eingerichtetes Fahrzeug mit zugeordnetem Sensor, aber noch
// ganz ohne Servicebuch, muss die Meldung trotzdem zeigen - siehe
// AGENTS.md, Abschnitt H, "no inspektion and ölwechsel shown ...".
const f = { ...fahrzeug(46000), oelwechselFaelligTs: "2027-03-14T00:00:00+00:00", oelwechselFaelligKm: 11750 }
const s = naechsterService(f, [], [], jetzt)
expect(s).not.toBeNull()
expect(s!.art).toBe("Ölwechsel")
expect(s!.datum).toBe("2027-03-14")
expect(s!.restKm).toBe(11750)
})
it("fällt ohne Servicebucheintrag auf die vom Fahrzeug gemeldete Inspektions-Fälligkeit zurück", () => {
const f = { ...fahrzeug(46000), inspektionFaelligTs: "2027-01-01T00:00:00+00:00", inspektionFaelligKm: 5000 }
const s = naechsterService(f, [], [], jetzt)
expect(s).not.toBeNull()
expect(s!.art).toBe("Inspektion")
expect(s!.datum).toBe("2027-01-01")
expect(s!.restKm).toBe(5000)
})
it("bevorzugt die eigene Prognose gegenüber der Fahrzeugmeldung, wenn beide vorliegen", () => {
// Servicebucheintrag vorhanden -> oelwechselPrognose() greift; die
// Meldung (weit in der Zukunft) darf sie nicht verdrängen.
const f = { ...fahrzeug(46000), oelwechselFaelligTs: "2099-01-01T00:00:00+00:00", oelwechselFaelligKm: 99999 }
const oel = oelwechselPrognose(f, BUCH, jetzt)
expect(oel).not.toBeNull()
const s = naechsterService(f, BUCH, [], jetzt)
expect(s!.datum).not.toBe("2099-01-01")
})
})
describe("kmProTagAusFahrten", () => {
const jetzt = new Date("2026-08-11T12:00:00")
it("rechnet aus abgeschlossenen Fahrten seit der ältesten die km/Tag-Rate", () => {
// Exakt 30 Tage vor jetzt (gleiche Uhrzeit), 900 km insgesamt -> 30 km/Tag.
const fahrten = [
{ ts_start: "2026-07-12T12:00:00", distance_km: 400, status: "abgeschlossen" },
{ ts_start: "2026-08-01T08:00:00", distance_km: 500, status: "abgeschlossen" },
]
expect(kmProTagAusFahrten(fahrten, jetzt)).toBeCloseTo(30, 5)
})
it("ignoriert offene Fahrten und Fahrten ohne bekannte Distanz", () => {
const fahrten = [
{ ts_start: "2026-07-12T08:00:00", distance_km: 400, status: "abgeschlossen" },
{ ts_start: "2026-08-10T08:00:00", distance_km: null, status: "abgeschlossen" },
{ ts_start: "2026-08-11T08:00:00", distance_km: 9999, status: "offen" },
]
// Nur die erste Fahrt zählt -> dieselbe Rate wie ohne die beiden anderen.
expect(kmProTagAusFahrten(fahrten, jetzt)).toBe(
kmProTagAusFahrten([fahrten[0]!], jetzt),
)
})
it("liefert nichts ohne Fahrten oder ohne belastbaren Zeitraum", () => {
expect(kmProTagAusFahrten([], jetzt)).toBeNull()
expect(kmProTagAusFahrten([{ ts_start: jetzt.toISOString(), distance_km: 5, status: "abgeschlossen" }], jetzt))
.toBeNull()
})
})
describe("meldungsPrognose", () => {
const jetzt = new Date("2026-08-11T12:00:00")
// 30 km/Tag, siehe kmProTagAusFahrten-Tests oben.
const fahrten = [
{ ts_start: "2026-07-12T12:00:00", distance_km: 400, status: "abgeschlossen" },
{ ts_start: "2026-08-01T08:00:00", distance_km: 500, status: "abgeschlossen" },
]
it("rechnet die Restkilometer mit der Fahrleistung aus dem Fahrtenlog auf ein Datum hoch", () => {
// 300 km Rest bei 30 km/Tag -> 10 Tage.
const p = meldungsPrognose("2099-01-01T00:00:00+00:00", 300, fahrten, jetzt)
expect(p).not.toBeNull()
expect(p!.datum).toBe("2026-08-21")
expect(p!.durchZeitlimit).toBe(false)
})
it("deckelt auf die vom Fahrzeug gemeldete Zeitgrenze, wenn zu wenig gefahren wird", () => {
// 30000 km Rest bei 30 km/Tag läge Jahre in der Zukunft - die gemeldete
// Zeitgrenze (fünf Tage später) greift zuerst.
const p = meldungsPrognose("2026-08-16T00:00:00+00:00", 30000, fahrten, jetzt)
expect(p).not.toBeNull()
expect(p!.datum).toBe("2026-08-16")
expect(p!.durchZeitlimit).toBe(true)
})
it("fällt ohne Fahrtenlog auf die reine Zeitgrenze der Meldung zurück", () => {
const p = meldungsPrognose("2027-01-01T00:00:00+00:00", 5000, [], jetzt)
expect(p).not.toBeNull()
expect(p!.datum).toBe("2027-01-01")
expect(p!.durchZeitlimit).toBe(true)
})
it("liefert nichts ohne jede brauchbare Angabe", () => {
expect(meldungsPrognose(null, null, fahrten, jetzt)).toBeNull()
})
it("gibt die Restkilometer mit zurück", () => {
const p = meldungsPrognose("2027-01-01T00:00:00+00:00", 5000, [], jetzt)
expect(p!.restKm).toBe(5000)
})
})
describe("meldungsPrognoseEigenesIntervall", () => {
const jetzt = new Date("2026-08-11T12:00:00")
// 30 km/Tag, siehe kmProTagAusFahrten-Tests oben.
const fahrten = [
{ ts_start: "2026-07-12T12:00:00", distance_km: 400, status: "abgeschlossen" },
{ ts_start: "2026-08-01T08:00:00", distance_km: 500, status: "abgeschlossen" },
]
it("rechnet Restkilometer und Zeitgrenze auf das eigene, kürzere Intervall um", () => {
// Herstellermeldung: 25.000 km / 24 Monate Rest bis zum 2027-08-11 -
// daraus ergibt sich ein angenommener letzter Service vor 2025-08-11.
// Eigenes Intervall (15.000 km / 12 Monate) ab demselben Punkt: Zeit-
// grenze 2026-08-11 (heute), Restkilometer 25000 - 30000 + 15000 = 10000.
const p = meldungsPrognoseEigenesIntervall(
"2027-08-11T00:00:00+00:00", 25000, 30000, 24, 15000, 12, fahrten, jetzt,
)
expect(p).not.toBeNull()
expect(p!.restKm).toBe(10000)
// 10.000 km bei 30 km/Tag läge weit in der Zukunft - die zurückgerechnete
// eigene Zeitgrenze (heute) greift zuerst.
expect(p!.durchZeitlimit).toBe(true)
expect(p!.datum).toBe("2026-08-11")
})
it("deckelt die Restkilometer bei null, wenn das eigene Intervall rechnerisch schon überschritten ist", () => {
// Erst 10.000 von 30.000 km seit der Herstellerfälligkeit gefahren -
// das kürzere eigene Intervall (15.000 km) ist rechnerisch längst fällig.
const p = meldungsPrognoseEigenesIntervall(
"2027-08-11T00:00:00+00:00", 10000, 30000, 24, 15000, 12, fahrten, jetzt,
)
expect(p).not.toBeNull()
expect(p!.restKm).toBe(0)
expect(p!.durchZeitlimit).toBe(false)
})
it("fällt ohne Fahrtenlog auf die zurückgerechnete eigene Zeitgrenze zurück", () => {
const p = meldungsPrognoseEigenesIntervall(
"2027-08-11T00:00:00+00:00", 25000, 30000, 24, 15000, 12, [], jetzt,
)
expect(p).not.toBeNull()
expect(p!.datum).toBe("2026-08-11")
expect(p!.durchZeitlimit).toBe(true)
})
it("liefert nichts ohne bekannte Herstellerintervall-Länge", () => {
expect(
meldungsPrognoseEigenesIntervall("2027-08-11T00:00:00+00:00", 25000, null, 24, 15000, 12, fahrten, jetzt),
).toBeNull()
})
})
describe("oelMeldungsPrognose", () => {
const jetzt = new Date("2026-08-11T12:00:00")
const fahrten = [
{ ts_start: "2026-07-12T12:00:00", distance_km: 400, status: "abgeschlossen" },
{ ts_start: "2026-08-01T08:00:00", distance_km: 500, status: "abgeschlossen" },
]
it("übernimmt bei Herstellervorgabe unverändert die reine Fahrzeugmeldung", () => {
const f = fahrzeug(10000, "hersteller")
f.oelwechselFaelligTs = "2027-08-11T00:00:00+00:00"
f.oelwechselFaelligKm = 25000
const p = oelMeldungsPrognose(f, fahrten, jetzt)
expect(p!.restKm).toBe(25000)
expect(p!.datum).toBe("2027-08-11")
})
it("rechnet bei eigenem Intervall auf dessen kürzere Vorgabe um", () => {
const f = fahrzeug(10000, "eigen")
f.oelwechselFaelligTs = "2027-08-11T00:00:00+00:00"
f.oelwechselFaelligKm = 25000
const p = oelMeldungsPrognose(f, fahrten, jetzt)
expect(p!.restKm).toBe(10000)
expect(p!.datum).toBe("2026-08-11")
})
it("fällt bei eigenem Intervall ohne Herstellerwerte auf die reine Meldung zurück", () => {
const f = fahrzeug(10000, "eigen")
f.oel.herstellerKm = null
f.oelwechselFaelligTs = "2027-08-11T00:00:00+00:00"
f.oelwechselFaelligKm = 25000
const p = oelMeldungsPrognose(f, fahrten, jetzt)
expect(p!.restKm).toBe(25000)
expect(p!.datum).toBe("2027-08-11")
})
})
/* Gemeldet vom Eigentümer am 05.09.2026: Wartungsplan leer, Erstzulassung
01.03.2025 - erwartet 01.03.2028. Angezeigt wurden stattdessen DREI
verschiedene Antworten: Übersicht "01.08.2026", Mein Audi "August 2026",
Service "kein Eintrag im Wartungsplan". Ursache war ein Profilfeld
`hauptuntersuchung_faellig` ("08/2026"), das die Ableitung überstimmte,
selbst aber in keiner Oberfläche ein Eingabefeld hatte. Vorgabe seither:
"die eingabe reicht fürs erste mal in der Erstzulassung und danach über den
Wartungsplan" - zwei Quellen, keine dritte. */
describe("Hauptuntersuchung", () => {
const buchMit = (datum: string) => [{ art: "Hauptuntersuchung", datum, km: 40000 }]
it("leitet die erste aus der Erstzulassung ab: plus 36 Monate", () => {
expect(hauptuntersuchungFaellig([], "01.03.2025")).toBe("2028-03-01")
})
it("liest die Erstzulassung in jeder Schreibweise gleich", () => {
// Vor dem 05.09.2026 ergab dieselbe Erstzulassung je nach Schreibweise
// 2028-02-28 oder 2028-03-01 - toISOString() rechnete nach UTC.
for (const schreibweise of ["01.03.2025", "2025-03-01"]) {
expect(hauptuntersuchungFaellig([], schreibweise)).toBe("2028-03-01")
}
})
it("rechnet ab dem Wartungsplan-Eintrag mit 24 Monaten statt 36", () => {
expect(hauptuntersuchungFaellig(buchMit("2025-03-01"), "01.03.2025")).toBe("2027-03-01")
})
it("lässt den Wartungsplan die Erstzulassung schlagen", () => {
// Die Erstzulassung zählt nur bis zur ersten eingetragenen Untersuchung.
const spaeter = hauptuntersuchungFaellig(buchMit("2026-06-15"), "01.03.2025")
expect(spaeter).toBe("2028-06-15")
})
it("verliert keinen Tag über die Zeitumstellung", () => {
// monatePlus() rechnete früher über UTC und verlor dabei einen Tag,
// sobald Start und Ziel in verschiedenen Zeitzonen-Halbjahren lagen.
expect(hauptuntersuchungFaellig(buchMit("2025-01-15"), "")).toBe("2027-01-15")
expect(hauptuntersuchungFaellig(buchMit("2025-11-20"), "")).toBe("2027-11-20")
expect(hauptuntersuchungFaellig(buchMit("2025-02-10"), "")).toBe("2027-02-10")
})
it("sagt nichts, wenn es weder Eintrag noch Erstzulassung gibt", () => {
expect(hauptuntersuchungFaellig([], "")).toBeNull()
expect(hauptuntersuchungFaellig([], null)).toBeNull()
expect(hauptuntersuchungFaellig([], "keine Ahnung")).toBeNull()
})
})
describe("terminUeberschritten", () => {
const tag = (versatz: number) => {
const x = new Date()
x.setDate(x.getDate() + versatz)
return `${String(x.getDate()).padStart(2, "0")}.${String(x.getMonth() + 1).padStart(2, "0")}.${x.getFullYear()}`
}
it("gestern ist überschritten", () => {
expect(terminUeberschritten(tag(-1))).toBe(true)
})
it("heute zählt mit - 'bereits fällig' ist gemeint", () => {
expect(terminUeberschritten(tag(0))).toBe(true)
})
it("morgen nicht", () => {
expect(terminUeberschritten(tag(1))).toBe(false)
})
it("erkennt den vollen Zeitstempel des Fahrzeugs", () => {
// Genau die Form, die oelwechselFaelligTs/inspektionFaelligTs tragen. Im
// Panel war das die Lücke: dessen Datumsleser ist verankert und nahm sie
// nicht an, die Fahrzeugmeldung wäre dort still nie überschritten gewesen.
expect(terminUeberschritten("2025-10-21T00:00:00+00:00")).toBe(true)
expect(terminUeberschritten("2027-10-21T00:00:00+00:00")).toBe(false)
})
it("versteht monatsgenaue Angaben (Hauptuntersuchung)", () => {
expect(terminUeberschritten("03/2025")).toBe(true)
expect(terminUeberschritten("03/2099")).toBe(false)
})
it("ohne lesbares Datum wird nichts behauptet", () => {
expect(terminUeberschritten(null)).toBe(false)
expect(terminUeberschritten("")).toBe(false)
expect(terminUeberschritten("irgendwas")).toBe(false)
expect(terminUeberschritten(undefined)).toBe(false)
})
it("nimmt das erste LESBARE Datum, nicht das erste beliebige", () => {
// So kann die Markierung nicht von dem abweichen, was daneben steht.
expect(terminUeberschritten(null, tag(-1))).toBe(true)
expect(terminUeberschritten(tag(1), tag(-1))).toBe(false)
})
})
+372 -29
View File
@@ -1,15 +1,17 @@
/**
* Eigene Ölwechsel-Prognose. Portiert aus `oelwechselPrognose()` im alten
* Panel.
* Eigene Service-Prognosen (Ölwechsel, Inspektion). Portiert aus
* `oelwechselPrognose()` im alten Panel; die Inspektion kam am 2026-08-17
* hinzu (§ AGENTS.md, "Inspektion: voraussichtliches Datum ergänzen").
*
* Die Rechnung beginnt bewusst neu ab dem letzten tatsächlich durchgeführten
* Ölwechsel — der allgemeine Langzeitdurchschnitt seit dem allerersten
* Servicebucheintrag wäre dafür zu träge. Aus km und Tagen seit dem letzten
* Wechsel ergibt sich die aktuelle Fahrleistung pro Tag; hochgerechnet auf die
* verbleibenden km bis zum eingestellten Intervall ergibt das ein Datum,
* gedeckelt auf spätestens das Zeitlimit des Intervalls.
* Eintrag dieser Art — der allgemeine Langzeitdurchschnitt seit dem
* allerersten Servicebucheintrag wäre dafür zu träge. Aus km und Tagen seit
* dem letzten Eintrag ergibt sich die aktuelle Fahrleistung pro Tag;
* hochgerechnet auf die verbleibenden km bis zum Intervall ergibt das ein
* Datum, gedeckelt auf spätestens das Zeitlimit des Intervalls.
*/
import { alsZeitpunkt, isoTag } from "../format"
import type { Fahrzeug } from "./profilAdapter"
const TAG_MS = 86_400_000
@@ -29,40 +31,60 @@ interface Buchhaltung {
art?: string
}
/* Durchgehend in ORTSZEIT gerechnet, ohne Umweg über UTC.
Vorher stand hier `new Date(isoDatum)` (das liest einen reinen Tag als
UTC-Mitternacht) und am Ende `toISOString()` (das rechnet zurück). Fällt
Start und Ziel in verschiedene Zeitumstellungen, verschiebt das um eine
Stunde - und die reicht über Mitternacht. Am 05.09.2026 gemessen, drei
von fünf Fällen daneben:
2025-01-15 + 6 Monate -> 2025-07-14 statt 2025-07-15
2025-11-20 + 6 Monate -> 2026-05-19 statt 2026-05-20
2025-02-10 + 4 Monate -> 2025-06-09 statt 2025-06-10
Betraf Ölwechsel- und Inspektionsprognose gleichermaßen. Das Panel
rechnet in `monatePlus()` von jeher in Ortszeit und war damit richtig -
eine stille Paritätsabweichung. */
function monatePlus(isoDatum: string, monate: number): string {
const d = new Date(isoDatum)
const teile = /^(\d{4})-(\d{2})-(\d{2})/.exec(isoDatum)
if (!teile) return isoDatum
const d = new Date(Number(teile[1]), Number(teile[2]) - 1 + monate, Number(teile[3]))
if (Number.isNaN(d.getTime())) return isoDatum
d.setMonth(d.getMonth() + monate)
return d.toISOString().slice(0, 10)
const zwei = (n: number) => String(n).padStart(2, "0")
return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}`
}
/** Der jüngste Servicebucheintrag, dessen Art auf einen Ölwechsel hindeutet. */
export function letzterOelwechsel(buch: readonly Buchhaltung[]): Buchhaltung | null {
/** Der jüngste Servicebucheintrag, dessen Art auf das gesuchte Stichwort
passt (z. B. "öl"/"oel" oder "inspektion"). */
function letzterEintrag(buch: readonly Buchhaltung[], stichwort: RegExp): Buchhaltung | null {
const treffer = buch
.filter((e) => typeof e.art === "string" && /öl|oel/i.test(e.art) && e.datum && e.km != null)
.filter((e) => typeof e.art === "string" && stichwort.test(e.art) && e.datum && e.km != null)
.sort((a, b) => String(b.datum).localeCompare(String(a.datum)))
return treffer[0] ?? null
}
export function oelwechselPrognose(
/** Der jüngste Servicebucheintrag, dessen Art auf einen Ölwechsel hindeutet. */
export function letzterOelwechsel(buch: readonly Buchhaltung[]): Buchhaltung | null {
return letzterEintrag(buch, /öl|oel/i)
}
/** Der jüngste Servicebucheintrag, dessen Art auf eine Inspektion hindeutet. */
export function letzterInspektion(buch: readonly Buchhaltung[]): Buchhaltung | null {
return letzterEintrag(buch, /inspektion/i)
}
/** Gemeinsamer Kern für Ölwechsel- und Inspektionsprognose - beide rechnen
identisch, nur mit anderem Intervall und anderer Suche im Servicebuch. */
function servicePrognose(
fahrzeug: Fahrzeug,
buch: readonly Buchhaltung[],
jetzt = new Date(),
letzter: Buchhaltung | null,
intervallKm: number,
intervallMonate: number,
jetzt: Date,
): Oelprognose | null {
if (!fahrzeug.odoBekannt) return null
const letzter = letzterOelwechsel(buch)
if (!letzter?.datum || letzter.km == null) return null
const oel = fahrzeug.oel
const intervallKm =
oel.modus === "hersteller"
? (oel.herstellerKm ?? 0)
: Math.min(oel.km ?? Infinity, oel.herstellerKm ?? Infinity)
const intervallMonate =
oel.modus === "hersteller"
? (oel.herstellerMonate ?? 0)
: Math.min(oel.monate ?? Infinity, oel.herstellerMonate ?? Infinity)
if (!Number.isFinite(intervallKm) || intervallKm <= 0) return null
const zielKm = letzter.km + intervallKm
@@ -76,7 +98,7 @@ export function oelwechselPrognose(
const tageSeit = (jetzt.getTime() - new Date(letzter.datum).getTime()) / TAG_MS
const kmSeit = fahrzeug.odo - letzter.km
// Ohne belastbare Fahrleistung (direkt nach dem Wechsel) gibt es keine
// Ohne belastbare Fahrleistung (direkt nach dem Eintrag) gibt es keine
// seriöse Kilometerprognose — dann gilt das Zeitlimit.
if (tageSeit <= 0 || kmSeit <= 0) {
return { datum: kappDatum, restKm, kmProTag: 0, durchZeitlimit: true }
@@ -94,3 +116,324 @@ export function oelwechselPrognose(
durchZeitlimit,
}
}
export function oelwechselPrognose(
fahrzeug: Fahrzeug,
buch: readonly Buchhaltung[],
jetzt = new Date(),
): Oelprognose | null {
const oel = fahrzeug.oel
const intervallKm =
oel.modus === "hersteller"
? (oel.herstellerKm ?? 0)
: Math.min(oel.km ?? Infinity, oel.herstellerKm ?? Infinity)
const intervallMonate =
oel.modus === "hersteller"
? (oel.herstellerMonate ?? 0)
: Math.min(oel.monate ?? Infinity, oel.herstellerMonate ?? Infinity)
return servicePrognose(fahrzeug, letzterOelwechsel(buch), intervallKm, intervallMonate, jetzt)
}
/** Inspektion: feste 30.000 km / 24 Monate, wie auch das Fahrzeug selbst und
das alte Panel rechnen - kein einstellbares Intervall wie beim Ölwechsel. */
export function inspektionPrognose(
fahrzeug: Fahrzeug,
buch: readonly Buchhaltung[],
jetzt = new Date(),
): Oelprognose | null {
return servicePrognose(fahrzeug, letzterEintrag(buch, /inspektion/i), 30000, 24, jetzt)
}
interface Fahrtdaten {
ts_start: string
distance_km: number | null
status?: string
}
/** Fahrleistung pro Tag aus dem tatsächlichen Fahrtenlog, unabhängig vom
Servicebuch - Grundlage für meldungsPrognose() unten, die auch ganz ohne
Servicebucheintrag funktionieren soll. Nur abgeschlossene Fahrten mit
bekannter Distanz zählen; die Rate ist der Durchschnitt seit der ältesten
erfassten Fahrt, kein kurzes Zeitfenster - damit bleibt sie robust gegen
einzelne fahrtenarme Tage. null ohne belastbare Datenlage. */
export function kmProTagAusFahrten(fahrten: readonly Fahrtdaten[], jetzt = new Date()): number | null {
const gueltig = fahrten.filter((f) => f.status !== "offen" && f.distance_km != null)
if (!gueltig.length) return null
const km = gueltig.reduce((s, f) => s + (f.distance_km ?? 0), 0)
const aeltesteZeit = Math.min(...gueltig.map((f) => new Date(f.ts_start).getTime()))
const tage = (jetzt.getTime() - aeltesteZeit) / TAG_MS
return tage >= 1 ? km / tage : null
}
export interface Meldungsprognose {
/** ISO-Tagesdatum. */
datum: string
/** Die vom Fahrzeug gemeldete Zeitgrenze hat gegriffen, nicht die
hochgerechnete Kilometergrenze - der Nutzer fährt zu wenig, um die
km-Grenze rechtzeitig zu erreichen. */
durchZeitlimit: boolean
/** Restkilometer bis zur Fälligkeit, falls bekannt. */
restKm: number | null
}
/** Prognose rein aus der Fahrzeugmeldung, für den Fall ganz ohne
Servicebucheintrag (siehe Aufrufer unten und in Service.tsx) - ein echter
Servicebucheintrag bleibt vorrangig, näher am tatsächlichen letzten
Service. Restkilometer aus der Meldung, hochgerechnet mit der echten
Fahrleistung aus dem Fahrtenlog statt einer Servicebuch-Rate; gedeckelt
auf das vom Fahrzeug selbst gemeldete Datum als Zeitgrenze. */
export function meldungsPrognose(
faelligTs: string | null,
faelligKm: number | null,
fahrten: readonly Fahrtdaten[],
jetzt = new Date(),
): Meldungsprognose | null {
const zeitDatum = faelligTs ? faelligTs.slice(0, 10) : null
if (faelligKm == null) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm: null } : null
const restKm = Math.abs(faelligKm)
if (restKm <= 0) return { datum: jetzt.toISOString().slice(0, 10), durchZeitlimit: false, restKm }
const rate = kmProTagAusFahrten(fahrten, jetzt)
if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm } : null
const kmDatum = new Date(jetzt.getTime() + (restKm / rate) * TAG_MS).toISOString().slice(0, 10)
const durchZeitlimit = !!zeitDatum && zeitDatum < kmDatum
return { datum: durchZeitlimit ? (zeitDatum as string) : kmDatum, durchZeitlimit, restKm }
}
/** Eigenes (kürzeres) Intervall ganz ohne Servicebucheintrag: die
Fahrzeugmeldung kennt nur das Herstellerintervall, nicht die in
"Einrichten" gewählte kürzere Vorgabe. Ein angenommener letzter Service
wird aus der gemeldeten Fälligkeit und der bekannten Herstellerintervall-
Länge zurückgerechnet ("Fälligkeitspunkt minus Herstellerintervall"), und
das eigene Intervall ab demselben angenommenen Punkt neu hochgerechnet -
eine Näherung (der wahre letzte Service kann näher oder ferner liegen),
aber ohne sie würde die eigene Intervallwahl bis zum ersten
Servicebucheintrag komplett ignoriert (siehe oelMeldungsPrognose()). */
export function meldungsPrognoseEigenesIntervall(
faelligTs: string | null,
faelligKm: number | null,
herstellerKm: number | null,
herstellerMonate: number | null,
eigenKm: number | null,
eigenMonate: number | null,
fahrten: readonly Fahrtdaten[],
jetzt = new Date(),
): Meldungsprognose | null {
if (faelligKm == null || !herstellerKm || eigenKm == null) return null
const restKm = Math.max(0, Math.abs(faelligKm) - herstellerKm + eigenKm)
const zeitDatum =
faelligTs && herstellerMonate != null && eigenMonate != null
? monatePlus(faelligTs, eigenMonate - herstellerMonate)
: null
if (restKm <= 0) return { datum: jetzt.toISOString().slice(0, 10), durchZeitlimit: false, restKm }
const rate = kmProTagAusFahrten(fahrten, jetzt)
if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm } : null
const kmDatum = new Date(jetzt.getTime() + (restKm / rate) * TAG_MS).toISOString().slice(0, 10)
const durchZeitlimit = !!zeitDatum && zeitDatum < kmDatum
return { datum: durchZeitlimit ? (zeitDatum as string) : kmDatum, durchZeitlimit, restKm }
}
/** Fällt ganz ohne Servicebucheintrag auf die Fahrzeugmeldung zurück - bei
Herstellervorgabe unverändert (die Meldung IST die Herstellervorgabe),
bei eigenem (kürzerem) Intervall über meldungsPrognoseEigenesIntervall()
hochgerechnet, damit die Intervallwahl in "Einrichten" auch ganz ohne
Servicebucheintrag etwas bewirkt. Fehlen die Herstellerwerte selbst
(kein Sensor dafür), fällt das sanft auf die reine Meldung zurück. */
export function oelMeldungsPrognose(
fahrzeug: Fahrzeug,
fahrten: readonly Fahrtdaten[],
jetzt = new Date(),
): Meldungsprognose | null {
const oel = fahrzeug.oel
const roh = () => meldungsPrognose(fahrzeug.oelwechselFaelligTs, fahrzeug.oelwechselFaelligKm, fahrten, jetzt)
if (oel.modus === "hersteller") return roh()
const eigen = meldungsPrognoseEigenesIntervall(
fahrzeug.oelwechselFaelligTs,
fahrzeug.oelwechselFaelligKm,
oel.herstellerKm,
oel.herstellerMonate,
oel.km,
oel.monate,
fahrten,
jetzt,
)
return eigen ?? roh()
}
/* ------------------------------------------------------ Nächster Service */
export type Serviceart = "Ölwechsel" | "Inspektion" | "Hauptuntersuchung"
export interface NaechsterService {
art: Serviceart
/** Restkilometer bis zur Fälligkeit. null bei der Hauptuntersuchung — die
hat kein Kilometerziel, nur einen Termin. */
restKm: number | null
/** ISO-Tagesdatum der voraussichtlichen Fälligkeit. */
datum: string
}
/**
* Der zeitlich nächste anstehende Service über alle drei Arten hinweg —
* Gegenstück zu `naechsterTermin()` im Panel.
*
* Ölwechsel und Inspektion kommen vorrangig aus der eigenen Prognose
* (Servicebuch plus tatsächliche Fahrleistung); ohne Servicebucheintrag dazu
* fällt jede der beiden einzeln auf meldungsPrognose() zurück - die vom
* Fahrzeug selbst gemeldete Fälligkeit, hochgerechnet mit der Fahrleistung
* aus dem Fahrtenlog. Ein frisch eingerichtetes Fahrzeug mit zugeordnetem
* Sensor, aber noch ganz ohne Servicebuch, zeigte hier vorher "kein Eintrag
* im Servicebuch", obwohl die Fälligkeit längst bekannt war. Die
* Hauptuntersuchung kommt weiterhin nur aus dem im Profil gepflegten
* Termin: sie hat kein Kilometerziel, das Fahrzeug meldet sie nicht, und
* hochrechnen lässt sie sich auch nicht.
*/
export function naechsterService(
fahrzeug: Fahrzeug,
buch: readonly Buchhaltung[],
fahrten: readonly Fahrtdaten[] = [],
jetzt = new Date(),
): NaechsterService | null {
const kandidaten = serviceKandidaten(fahrzeug, buch, fahrten, jetzt)
if (kandidaten.length === 0) return null
// ISO-Tagesdaten, deshalb reicht der Zeichenkettenvergleich.
return kandidaten.slice().sort((a, b) => a.datum.localeCompare(b.datum))[0] ?? null
}
/**
* Alle drei Arten mit ihrer voraussichtlichen Fälligkeit, ungeordnet.
*
* `naechsterService()` nimmt davon die früheste. Getrennt herausgezogen, weil
* das Terminfeld auf der Serviceseite die Fälligkeit **je gewählter Art**
* vorschlägt - so wie `termine()` im Panel, das dort dieselbe Liste liefert.
* Eine Art ohne Grundlage (Hauptuntersuchung ohne gepflegtes HU-Datum) fehlt
* in der Liste; der Aufrufer entscheidet, was er stattdessen anbietet.
*/
export function serviceKandidaten(
fahrzeug: Fahrzeug,
buch: readonly Buchhaltung[],
fahrten: readonly Fahrtdaten[] = [],
jetzt = new Date(),
): NaechsterService[] {
const kandidaten: NaechsterService[] = []
const oel = oelwechselPrognose(fahrzeug, buch, jetzt)
if (oel) {
kandidaten.push({ art: "Ölwechsel", restKm: oel.restKm, datum: oel.datum })
} else {
const m = oelMeldungsPrognose(fahrzeug, fahrten, jetzt)
if (m) kandidaten.push({ art: "Ölwechsel", restKm: m.restKm, datum: m.datum })
}
const insp = inspektionPrognose(fahrzeug, buch, jetzt)
if (insp) {
kandidaten.push({ art: "Inspektion", restKm: insp.restKm, datum: insp.datum })
} else {
const m = meldungsPrognose(fahrzeug.inspektionFaelligTs, fahrzeug.inspektionFaelligKm, fahrten, jetzt)
if (m) kandidaten.push({ art: "Inspektion", restKm: m.restKm, datum: m.datum })
}
/* Über dieselbe Ableitung wie Service und Mein Audi. Vorher stand hier
`new Date(fahrzeug.hu)` auf einem Profilfeld, das die Ableitung gar nicht
erst befragt hat - daher zeigte die Übersicht am 05.09.2026 den 01.08.2026,
während Service "kein Eintrag im Wartungsplan" meldete. Drei Bildschirme,
drei Antworten auf dieselbe Frage. */
const hu = hauptuntersuchungFaellig(buch, fahrzeug.erstzulassung)
if (hu) {
kandidaten.push({ art: "Hauptuntersuchung", restKm: null, datum: hu })
}
return kandidaten
}
/* Die Hauptuntersuchung hat ZWEI Quellen — wortgleich mit
hauptuntersuchungsTermin() im Panel:
1. der letzte Servicebuch-Eintrag plus **24** Monate.
2. die Erstzulassung plus **36** Monate.
Die 36 gegen 24 sind kein Versehen: die erste Hauptuntersuchung eines
Neuwagens ist nach drei Jahren fällig, jede weitere nach zwei (Vorgabe des
Eigentümers, 05.09.2026).
Ein drittes, überstimmendes Profilfeld gibt es nicht mehr — Begründung im
Panel bei hauptuntersuchungsTermin(). */
export const HU_MONATE_FOLGE = 24
export const HU_MONATE_ERSTE = 36
/** Der jüngste Servicebucheintrag, dessen Art auf eine Hauptuntersuchung
hindeutet.
Verlangt bewusst KEINE Kilometerangabe, anders als letzterEintrag(): eine
Hauptuntersuchung ist rein datumsbasiert, ihr Kilometerstand geht in keine
Rechnung ein. Bis zum 05.09.2026 lief sie über letzterEintrag() und wurde
damit stillschweigend übergangen, sobald der Eintrag kein km-Feld hatte -
die App wäre dann auf die Erstzulassung zurückgefallen und hätte drei
Jahre statt zwei gerechnet, während das Panel den Eintrag benutzte. */
export function letzteHauptuntersuchung(buch: readonly Buchhaltung[]): Buchhaltung | null {
const treffer = buch
.filter((e) => typeof e.art === "string" && /hauptunter/i.test(e.art) && e.datum)
.sort((a, b) => String(b.datum).localeCompare(String(a.datum)))
return treffer[0] ?? null
}
/**
* Die Fälligkeit der Hauptuntersuchung, oder null wenn sich keine bestimmen
* lässt.
*
* `null` heißt wirklich "unbekannt": es gibt weder einen Servicebuch-Eintrag
* noch eine lesbare Erstzulassung. Dann steht in der Oberfläche ein Strich -
* eine erfundene Fälligkeit wäre schlechter.
*/
export function hauptuntersuchungFaellig(
buch: readonly Buchhaltung[],
erstzulassung: string | null | undefined,
): string | null {
const eintrag = letzteHauptuntersuchung(buch)
if (eintrag?.datum) return monatePlus(eintrag.datum, HU_MONATE_FOLGE)
/* alsZeitpunkt() ist derselbe Parser, den auch die Anzeige benutzt, und
liefert die Genauigkeit gleich mit: eine Erstzulassung "03/2025" nennt
keinen Tag, also nennt der abgeleitete Termin auch keinen - "01.03.2028"
wäre eine Erfindung. Das Ergebnis ist dann ein ISO-Monat ("2028-03"),
den datum() als "März 2028" ausgibt. isoTag() rechnet den
Zeitzonenversatz heraus; toISOString() allein verschöbe den Tag. */
const z = alsZeitpunkt(erstzulassung)
if (!z) return null
const ziel = monatePlus(isoTag(z.zeit), HU_MONATE_ERSTE)
return z.genau === "monat" ? ziel.slice(0, 7) : ziel
}
/** Ist dieser Servicetermin bereits fällig oder überschritten?
*
* Der Fälligkeitstag selbst zählt mit („bereits fällig bzw. überschritten",
* Ansage des Eigentümers 07.09.2026).
*
* Entschieden wird am DATUM, nicht an den Restkilometern - und das ist keine
* Bequemlichkeit. Die Kilometerseite trägt je nach Quelle ein anderes
* Vorzeichen: die Sensoren des Fahrzeugs melden negativ, solange noch Strecke
* bleibt (am 07.09.2026 gemessen: oil_change_distance -28600 bei Fälligkeit
* 2028), und servicePrognose() klemmt ihre Restkilometer ohnehin mit
* Math.max(0, ...). Welches Vorzeichen ein überschrittener Fahrzeugwert trägt,
* lässt sich an den vorliegenden Daten nicht beobachten - geraten wird es
* nicht. Der km-Fall fällt ohnehin unter die Datumsregel: servicePrognose()
* setzt datum auf heute, sobald restKm auf 0 läuft.
*
* Genommen wird das ERSTE lesbare Datum der übergebenen Kandidaten - also das,
* das an dieser Stelle auch angezeigt wird. So kann die Markierung nicht von
* dem abweichen, was daneben steht. Wortgleich mit terminUeberschritten() im
* Panel. */
export function terminUeberschritten(
...kandidaten: (string | Date | null | undefined)[]
): boolean {
for (const kandidat of kandidaten) {
if (kandidat === null || kandidat === undefined || kandidat === "") continue
const z = alsZeitpunkt(kandidat)
if (!z) continue
const d = z.zeit
const h = new Date()
return (
new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() <=
new Date(h.getFullYear(), h.getMonth(), h.getDate()).getTime()
)
}
return false
}
+72 -19
View File
@@ -46,25 +46,46 @@ function alsTag(d: Date): string {
}
describe("zeitraumStart", () => {
it("beginnt das Jahr am 1. Januar", () => {
expect(alsTag(zeitraumStart("jahr", JETZT))).toBe("2026-01-01")
it("rechnet das Jahr 365 Tage zurück", () => {
expect(alsTag(zeitraumStart("jahr", JETZT))).toBe("2025-08-11")
})
it("beginnt den Monat am Ersten", () => {
expect(alsTag(zeitraumStart("monat", JETZT))).toBe("2026-08-01")
it("rechnet den Monat 30 Tage zurück", () => {
expect(alsTag(zeitraumStart("monat", JETZT))).toBe("2026-07-12")
})
it("beginnt die Woche am Montag, nicht am Sonntag", () => {
// Der 11.08.2026 ist ein Dienstag, die Woche beginnt am 10.08.
const start = zeitraumStart("woche", JETZT)
expect(start.getDay()).toBe(1)
expect(start.getDate()).toBe(10)
it("rechnet die Woche 7 Tage zurück", () => {
expect(alsTag(zeitraumStart("woche", JETZT))).toBe("2026-08-04")
})
it("beginnt den Tag um Mitternacht", () => {
it("rechnet den Tag 24 Stunden zurück", () => {
const start = zeitraumStart("tag", JETZT)
expect(start.getHours()).toBe(0)
expect(start.getDate()).toBe(11)
expect(alsTag(start)).toBe("2026-08-10")
expect(start.getHours()).toBe(12)
})
/* Der Grund für die Umstellung, als Test: am 02.09.2026 begann die
Kalenderwoche am Montag, dem 31.08., der Kalendermonat aber erst am
01.09. Eine Fahrt vom 31.08. zählte damit zur Woche und nicht zum
Monat - "Monat kleiner als Woche", vom Eigentümer an der realen Instanz
gemeldet. Rollende Fenster sind ineinander enthalten, immer. */
it("hält Tag, Woche, Monat und Jahr ineinander verschachtelt", () => {
for (const tag of ["2026-09-02T09:00:00", "2026-01-01T09:00:00", "2026-03-01T00:30:00"]) {
const jetzt = new Date(tag)
const [j, m, w, t] = (["jahr", "monat", "woche", "tag"] as const).map((art) =>
zeitraumStart(art, jetzt).getTime(),
)
expect(j!).toBeLessThanOrEqual(m!)
expect(m!).toBeLessThanOrEqual(w!)
expect(w!).toBeLessThanOrEqual(t!)
}
})
it("zählt eine Fahrt vom Monatsletzten in Woche UND Monat", () => {
const jetzt = new Date("2026-09-02T09:00:00")
const fahrten = [fahrt({ trip_id: "a", ts_start: "2026-08-31T08:00:00" })]
expect(fahrtenSeit(fahrten, "woche", jetzt)).toHaveLength(1)
expect(fahrtenSeit(fahrten, "monat", jetzt)).toHaveLength(1)
})
})
@@ -91,7 +112,9 @@ describe("fahrtenSeit und tankSeit", () => {
fahrt({ trip_id: "heute", ts_start: "2026-08-11T09:00:00" }),
fahrt({ trip_id: "diesewoche", ts_start: "2026-08-10T09:00:00" }),
fahrt({ trip_id: "diesenmonat", ts_start: "2026-08-02T09:00:00" }),
fahrt({ trip_id: "letztesjahr", ts_start: "2025-12-30T09:00:00" }),
// Im Kalenderjahr 2026 nicht enthalten, in den letzten 365 Tagen schon -
// seit der Umstellung auf rollende Fenster zählt sie deshalb mit.
fahrt({ trip_id: "dezember", ts_start: "2025-12-30T09:00:00" }),
fahrt({ trip_id: "ohnestrecke", ts_start: "2026-08-11T10:00:00", distance_km: null }),
]
@@ -101,7 +124,7 @@ describe("fahrtenSeit und tankSeit", () => {
"heute",
"diesewoche",
])
expect(fahrtenSeit(fahrten, "jahr", JETZT)).toHaveLength(3)
expect(fahrtenSeit(fahrten, "jahr", JETZT)).toHaveLength(4)
})
it("lässt Fahrten ohne Strecke außen vor — sie würden jede Summe verfälschen", () => {
@@ -118,19 +141,48 @@ describe("fahrtenSeit und tankSeit", () => {
})
describe("langzeitverbrauch", () => {
it("rechnet bevorzugt mit dem Kilometerstand an den Tankvorgängen", () => {
it("rechnet von Tank zu Tank, ohne den ersten Tankvorgang", () => {
const tanks = [
tankung({ tank_id: "a", ts: "2026-06-01T10:00:00", liters: 50, odometer_km: 40000 }),
tankung({ tank_id: "b", ts: "2026-07-01T10:00:00", liters: 50, odometer_km: 40600 }),
]
const ergebnis = langzeitverbrauch([], tanks)
expect(ergebnis.km).toBe(600)
expect(ergebnis.liter).toBe(100)
// 100 l auf 600 km sind 16,7 l/100 km — plausibel.
expect(ergebnis.wert).toBeCloseTo(16.67, 1)
// Nur die 50 l aus Tankvorgang b: gemessen wird die Strecke ZWISCHEN a
// und b, und was dort verbraucht wurde, ist genau das, was bei b wieder
// hineingefüllt wurde. Die 50 l aus a füllten die Strecke davor - sie
// mitzuzählen war bis zum 2026-08-31 der Fehler und wies den Verbrauch
// um einen ganzen Tankinhalt zu hoch aus (hier: 16,7 statt 8,3).
expect(ergebnis.liter).toBe(50)
expect(ergebnis.wert).toBeCloseTo(8.33, 1)
expect(ergebnis.basis).toBe("Kilometerstand an den Tankvorgängen")
})
it("zählt bei drei Tankvorgängen die letzten beiden", () => {
const tanks = [
tankung({ tank_id: "a", ts: "2026-05-01T10:00:00", liters: 40, odometer_km: 10000 }),
tankung({ tank_id: "b", ts: "2026-06-01T10:00:00", liters: 45, odometer_km: 10500 }),
tankung({ tank_id: "c", ts: "2026-07-01T10:00:00", liters: 55, odometer_km: 11000 }),
]
const ergebnis = langzeitverbrauch([], tanks)
expect(ergebnis.km).toBe(1000)
expect(ergebnis.liter).toBe(100)
expect(ergebnis.wert).toBeCloseTo(10, 1)
})
it("lässt eine rückwärts laufende Kilometerreihe nicht als Wert durchgehen", () => {
// Ein falscher Kilometerstand an einem Tankvorgang ist in diesem Projekt
// schon vorgekommen (AGENTS.md, Abschnitt AF). Eine negative Strecke ist
// kein Messwert.
const tanks = [
tankung({ tank_id: "a", ts: "2026-06-01T10:00:00", liters: 50, odometer_km: 40600 }),
tankung({ tank_id: "b", ts: "2026-07-01T10:00:00", liters: 50, odometer_km: 40000 }),
]
const ergebnis = langzeitverbrauch([], tanks)
expect(ergebnis.km).toBe(0)
expect(ergebnis.wert).toBeNull()
})
it("weicht auf die erfassten Fahrten aus, wenn Kilometerstände fehlen", () => {
const tanks = [tankung({ tank_id: "a", ts: "2026-07-01T10:00:00", liters: 50 })]
const fahrten = [fahrt({ trip_id: "f", ts_start: "2026-06-01T10:00:00", distance_km: 500 })]
@@ -176,7 +228,8 @@ describe("jahresauswertung", () => {
})
describe("nachJahrUndMonat", () => {
it("gruppiert absteigend, Neuestes zuerst", () => {
// Durchgehend absteigend, in beiden Anwendungen (Designprüfung 2026-08-30).
it("gruppiert durchgehend absteigend, Neuestes zuerst", () => {
const eintraege = [
{ id: "alt", ts: "2025-03-04T10:00:00" },
{ id: "neu", ts: "2026-08-01T10:00:00" },
+89 -12
View File
@@ -9,25 +9,54 @@
import type { Fahrt, Tankvorgang } from "../api"
import { summe } from "../format"
export type Zeitraum = "jahr" | "monat" | "woche" | "tag"
/** "absolut" ist kein Zeitraum, sondern alles je Erfasste - die Summe. */
export type Zeitraum = "jahr" | "monat" | "woche" | "tag" | "absolut"
export const ZEITRAEUME: Zeitraum[] = ["jahr", "monat", "woche", "tag"]
/** Alle Zeiträume, die berechnet werden - einschließlich "absolut". */
export const ZEITRAEUME: Zeitraum[] = ["jahr", "monat", "woche", "tag", "absolut"]
/** Die Spalten, die eine Kachel zeigt.
"absolut" fehlt hier bewusst: der Gesamtwert steht seit dem 2026-08-31
als große Zahl in der Kopfzeile der Kachel (Ansage des Eigentümers). Eine
fünfte Spalte passte auf dem Telefon ohnehin nicht in eine Zeile - die
Kachel ist dort innen 307px breit, ein sechsstelliger Wert 67px. */
export const ZEITRAUM_SPALTEN: Zeitraum[] = ["jahr", "monat", "woche", "tag"]
export const ZEITRAUM_NAME: Record<Zeitraum, string> = {
jahr: "Jahr",
monat: "Monat",
woche: "Woche",
tag: "Tag",
absolut: "Absolut",
}
/** Beginn des Zeitraums. Die Woche startet montags, nicht sonntags. */
/**
* Länge der rollenden Zeiträume in Tagen.
*
* Bis zum 02.09.2026 waren die Spannen kalendarisch: Jahr ab 1. Januar, Monat
* ab dem Ersten, Woche ab Montag, Tag ab Mitternacht. Das ergab regelmäßig
* einen unmöglichen Vergleich — am 02.09.2026 stand in der realen Instanz
* "Monat" UNTER "Woche", weil die Woche am Montag, dem 31.08., begann und
* damit zwei Tage mitzählte, die der Monat nicht kennt. Zwischen Monat und
* Jahr passiert dasselbe am Jahreswechsel.
*
* Rollende Fenster können das nicht: 1 ⊂ 7 ⊂ 30 ⊂ 365 gilt an jedem Tag des
* Jahres. Der Preis ist, dass "Jahr" nicht mehr das Kalenderjahr meint,
* sondern die letzten 365 Tage — so entschieden vom Eigentümer am 02.09.2026.
*/
export const ZEITRAUM_TAGE: Record<Exclude<Zeitraum, "absolut">, number> = {
jahr: 365,
monat: 30,
woche: 7,
tag: 1,
}
/** Beginn des Zeitraums: so viele Tage zurück, wie ZEITRAUM_TAGE sagt. */
export function zeitraumStart(art: Zeitraum, jetzt = new Date()): Date {
if (art === "jahr") return new Date(jetzt.getFullYear(), 0, 1)
if (art === "monat") return new Date(jetzt.getFullYear(), jetzt.getMonth(), 1)
const d = new Date(jetzt)
d.setHours(0, 0, 0, 0)
if (art === "woche") d.setDate(d.getDate() - ((d.getDay() + 6) % 7))
return d
// Vor jeder denkbaren Aufzeichnung - damit fällt nichts heraus.
if (art === "absolut") return new Date(0)
return new Date(jetzt.getTime() - ZEITRAUM_TAGE[art] * 24 * 60 * 60 * 1000)
}
export function fahrtenSeit(fahrten: readonly Fahrt[], art: Zeitraum, jetzt = new Date()): Fahrt[] {
@@ -88,33 +117,51 @@ export function langzeitverbrauch(
const letzter = tanks[tanks.length - 1]!
const bis = new Date(letzter.ts)
const liter = summe(tanks, (t) => t.liters ?? 0)
// Bevorzugt der Kilometerstand an den Tankvorgängen selbst — er zählt auch
// Strecke mit, für die keine Fahrt aufgezeichnet wurde.
const mitOdo = tanks.filter((t) => t.odometer_km != null)
let km: number
let liter: number
let basis: Langzeitverbrauch["basis"]
if (mitOdo.length >= 2) {
km = (mitOdo[mitOdo.length - 1]!.odometer_km ?? 0) - (mitOdo[0]!.odometer_km ?? 0)
basis = "Kilometerstand an den Tankvorgängen"
// Von-Tank-zu-Tank: gemessen wird die Strecke ZWISCHEN erstem und
// letztem Tankstop, verbraucht wurde auf ihr alles, was ab dem zweiten
// Tankstop hineingefüllt wurde. Der erste Tankvorgang füllte die Strecke
// DAVOR — ihn mitzuzählen hat den Verbrauch um einen ganzen Tankinhalt
// zu hoch ausgewiesen (bei 13 Tankvorgängen rund 8 %). Korrigiert am
// 2026-08-31.
liter = summe(mitOdo.slice(1), (t) => t.liters ?? 0)
} else {
km = summe(
fahrten.filter((f) => f.distance_km != null && new Date(f.ts_start) <= bis),
(f) => f.distance_km ?? 0,
)
basis = "erfasste Fahrten"
// Ohne Kilometerstände ist die Bezugsstrecke alles Erfasste bis zum
// letzten Tankstop — dann zählt auch der erste Tankvorgang dazu.
liter = summe(tanks, (t) => t.liters ?? 0)
}
// Eine negative Strecke ist kein Messwert, sondern ein falscher
// Kilometerstand an einem Tankvorgang. Sie darf nicht als Zahl durchgehen.
if (km < 0) km = 0
const roh = km > 0 ? (liter / km) * 100 : null
const gueltig = roh !== null && roh >= 3 && roh <= 30
// Auch eine Strecke von null zählt als unplausibel, wenn sie aus
// Kilometerständen kommt: dann laufen die Werte an den Tankvorgängen
// rückwärts. Ohne diesen Zweig stünde dort nur „0 km" und „–", ohne jeden
// Hinweis, woran es liegt.
const streckeFehlt = basis === "Kilometerstand an den Tankvorgängen" && km <= 0
return {
wert: gueltig ? roh : null,
km,
liter,
bis,
basis,
unplausibel: roh !== null && !gueltig,
unplausibel: (roh !== null && !gueltig) || streckeFehlt,
}
}
@@ -124,16 +171,36 @@ export interface Jahresauswertung {
stundenJahr: number
literJahr: number
kostenJahr: number
/** Über den gesamten erfassten Zeitraum - die Zahl in der Kopfzeile. */
kmGesamt: number
stundenGesamt: number
literGesamt: number
kostenGesamt: number
tagKm: number
nachtKm: number
arbeitKm: number
privatKm: number
arbeitFahrten: number
privatFahrten: number
jeZeitraum: Record<Zeitraum, { km: number; stunden: number; liter: number; kosten: number }>
jeZeitraum: Record<
Zeitraum,
{ km: number; stunden: number; liter: number; kosten: number; verbrauch: number | null }
>
langzeit: Langzeitverbrauch
}
/**
* Nach Distanz gewichteter Mittelwert der genäherten Fahrtverbräuche —
* `schnittFahrtVerbrauch()` im Panel. Die Kachel „Langzeitverbrauch" zeigt
* damit einen Näherungswert je Zeitraum; hier fehlte er bisher.
*/
export function schnittFahrtVerbrauch(fahrten: readonly Fahrt[]): number | null {
const mit = fahrten.filter((f) => f.verbrauch_l_100km != null && (f.distance_km ?? 0) > 0)
const km = summe(mit, (f) => f.distance_km ?? 0)
if (km <= 0) return null
return summe(mit, (f) => (f.verbrauch_l_100km ?? 0) * (f.distance_km ?? 0)) / km
}
export function jahresauswertung(
fahrten: readonly Fahrt[],
tankvorgaenge: readonly Tankvorgang[],
@@ -150,6 +217,7 @@ export function jahresauswertung(
stunden: summe(f, (x) => x.duration_s ?? 0) / 3600,
liter: summe(t, (x) => x.liters ?? 0),
kosten: summe(t, (x) => x.fuel_total_eur ?? 0),
verbrauch: schnittFahrtVerbrauch(f),
}
}
@@ -168,6 +236,10 @@ export function jahresauswertung(
stundenJahr: jeZeitraum.jahr.stunden,
literJahr: jeZeitraum.jahr.liter,
kostenJahr: jeZeitraum.jahr.kosten,
kmGesamt: jeZeitraum.absolut.km,
stundenGesamt: jeZeitraum.absolut.stunden,
literGesamt: jeZeitraum.absolut.liter,
kostenGesamt: jeZeitraum.absolut.kosten,
tagKm,
nachtKm: kmJahr - tagKm,
arbeitKm,
@@ -205,6 +277,11 @@ export function nachJahrUndMonat<T>(
): JahresGruppe<T>[] {
const jahre = new Map<number, Map<string, MonatsGruppe<T>>>()
/* Durchgehend absteigend: Jahr, Monat und Zeile. Bis zur Designprüfung
2026-08-30 liefen Monate und Zeilen aufsteigend (der Panel-Stand von
damals) — in einer Liste, deren Jahre absteigen, sind das zwei
Richtungen gleichzeitig, und die neueste Fahrt landete unten außerhalb
des sichtbaren Bereichs. Das Panel ist mitgezogen (neuesteZuerst()). */
const sortiert = eintraege
.slice()
.sort((a, b) => new Date(zeitpunktVon(b)).getTime() - new Date(zeitpunktVon(a)).getTime())
+338
View File
@@ -0,0 +1,338 @@
/**
* Tests zur Tankstellen-Anzeige und zum Kartendienst.
*
* Die Anschriften stammen aus echten Belegen der Testinstanz (04.09.2026) -
* genau an ihnen ist aufgefallen, dass die Wertespalte zu schmal war und die
* App die Tankstelle ohne Anschrift gar nicht erst nachschlug.
*/
import { afterEach, describe, expect, it, vi } from "vitest"
import {
kartendienstUrl,
markeErkennen,
ohneMarkeVorn,
tankstelleKandidaten,
tankstelleSuchtext,
tankstelleKurz,
tankstelleTeile,
tankstelleZeilen,
tankstelleZiel,
} from "./tankstelle"
function alsGeraet(kennung: string) {
vi.stubGlobal("navigator", { userAgent: kennung, platform: "" })
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe("markeErkennen", () => {
it("findet die Kopfmarke im gespeicherten Namen", () => {
expect(markeErkennen("Shell, SÖLDEN")).toBe("Shell")
expect(markeErkennen("ARAL Station")).toBe("Aral")
})
it("haelt die Firmierung des Betreibers NICHT fuer eine Marke", () => {
// Die vier Belege der Testinstanz mit Anschrift tragen genau das.
expect(markeErkennen("A. Zrenner GmbH")).toBeNull()
expect(markeErkennen("Hermann Mogler Mineralölg. GmbH")).toBeNull()
expect(markeErkennen("TC Sengül GmbH")).toBeNull()
})
it("achtet auf Wortgrenzen", () => {
// "Star" ist eine Marke, "Starnberg" ist keine.
expect(markeErkennen("Tankstelle Starnberg")).toBeNull()
expect(markeErkennen("STAR Tankstelle")).toBe("Star")
})
it("nimmt TotalEnergies vor Total", () => {
expect(markeErkennen("TotalEnergies Station")).toBe("TotalEnergies")
})
})
describe("tankstelleZeilen", () => {
it("setzt Kopfmarke und Strasse in die erste Zeile, PLZ und Ort in die zweite", () => {
expect(tankstelleZeilen("Shell, SÖLDEN", "Pascalstr. 8, 85057 Ingolstadt")).toEqual({
oben: "Shell, Pascalstr. 8",
unten: "85057 Ingolstadt",
})
})
it("laesst den Betreibernamen weg - nach aussen sichtbar ist die Kopfmarke", () => {
expect(tankstelleZeilen("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt")).toEqual({
oben: "Pascalstr. 8",
unten: "85057 Ingolstadt",
})
})
it("zeigt ohne Anschrift den gespeicherten Namen unveraendert", () => {
// Sonst hiesse eine bekannte Tankstelle plötzlich "unbekannt".
// Geaendert am 04.09.2026: die Marke gehoert nach oben, der Ort nach unten.
expect(tankstelleZeilen("Shell, SÖLDEN", null)).toEqual({ oben: "Shell", unten: "SÖLDEN" })
expect(tankstelleZeilen("Testtankstelle", null)).toEqual({ oben: "Testtankstelle", unten: "" })
})
it("kommt ohne Namen aus", () => {
expect(tankstelleZeilen("", "Pascalstr. 8, 85057 Ingolstadt")).toEqual({
oben: "Pascalstr. 8",
unten: "85057 Ingolstadt",
})
})
it("sagt „unbekannt“, wenn der Beleg nichts hergibt", () => {
expect(tankstelleZeilen(null, null)).toEqual({ oben: "unbekannt", unten: "" })
})
})
describe("tankstelleKandidaten", () => {
it("stellt die Anschrift voran und nimmt den Namen NICHT dazu", () => {
// Gemessen am 04.09.2026: Nominatim findet die Anschrift, mit dem
// Betreibernamen davor findet es nichts.
expect(tankstelleKandidaten("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt")).toEqual([
"Pascalstr. 8, 85057 Ingolstadt",
"A. Zrenner GmbH",
])
})
it("laesst leere Teile weg", () => {
// Der Ort allein ist der letzte Versuch, er laeuft nur, wenn die beiden
// davor nichts gefunden haben - schaden kann er dann nicht.
expect(tankstelleKandidaten("Shell, SÖLDEN", null)).toEqual(["Shell, SÖLDEN", "SÖLDEN"])
expect(tankstelleKandidaten(null, null)).toEqual([])
})
it("versucht zuletzt den Namen ohne die fuehrende Marke", () => {
// Der Fall der realen Instanz: der Parser hat "Marke, Strasse, Ort" in den
// Namen geschrieben, eine Postleitzahl fehlt. Gemessen am 04.09.2026:
// mit "Shell," davor findet Nominatim nichts, ohne findet es die Strasse.
expect(tankstelleKandidaten("Shell, Pascalstr. 8, Ingolstadt", null)).toEqual([
"Shell, Pascalstr. 8, Ingolstadt",
"Pascalstr. 8, Ingolstadt",
])
})
it("schneidet nichts ab, wenn die Marke nicht vorn steht", () => {
expect(tankstelleKandidaten("Tankstelle Shell", null)).toEqual(["Tankstelle Shell"])
})
})
describe("tankstelleZiel", () => {
it("nimmt fuer den Kartendienst Kopfmarke und Anschrift zusammen", () => {
expect(tankstelleZiel("Shell, SÖLDEN", "Pascalstr. 8, 85057 Ingolstadt")).toBe(
"Shell, Pascalstr. 8, 85057 Ingolstadt",
)
})
it("schickt ohne erkennbare Marke die Anschrift allein hin", () => {
expect(tankstelleZiel("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt")).toBe(
"Pascalstr. 8, 85057 Ingolstadt",
)
})
})
describe("tankstelleSuchtext", () => {
it("ist der erste Kandidat, also die Anschrift", () => {
expect(tankstelleSuchtext("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt")).toBe(
"Pascalstr. 8, 85057 Ingolstadt",
)
})
it("faellt auf den Namen zurueck, wenn keine Anschrift im Beleg steht", () => {
// Genau der Fall, in dem die App bis zum 04.09.2026 gar nichts nachschlug,
// das Panel dagegen schon (f.station_address || f.station_name).
expect(tankstelleSuchtext("Shell, SÖLDEN", null)).toBe("Shell, SÖLDEN")
})
it("ist leer, wenn beides fehlt", () => {
expect(tankstelleSuchtext(null, "")).toBe("")
})
})
describe("kartendienstUrl", () => {
it("nimmt auf dem iPhone Apple Karten", () => {
alsGeraet("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)")
expect(kartendienstUrl("Shell, SÖLDEN", "Pascalstr. 8, 85057 Ingolstadt")).toBe(
"https://maps.apple.com/?daddr=Shell%2C%20Pascalstr.%208%2C%2085057%20Ingolstadt",
)
})
it("zielt mit Koordinaten auf den Punkt und beschriftet ihn", () => {
alsGeraet("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)")
expect(
kartendienstUrl("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt", {
lat: 48.7869101,
lon: 11.39767,
}),
).toBe("https://maps.apple.com/?daddr=48.7869101%2C11.39767&q=A.%20Zrenner%20GmbH")
})
it("nimmt sonst Google Maps", () => {
alsGeraet("Mozilla/5.0 (X11; Linux x86_64)")
expect(kartendienstUrl(null, "Pascalstr. 8, 85057 Ingolstadt")).toBe(
"https://www.google.com/maps/dir/?api=1&destination=Pascalstr.%208%2C%2085057%20Ingolstadt",
)
})
it("bleibt null, wenn nichts bekannt ist", () => {
alsGeraet("Mozilla/5.0 (iPhone)")
expect(kartendienstUrl(null, null)).toBeNull()
})
it("zeigt im Modus \"zeigen\" nur die Tankstelle, ohne Route", () => {
alsGeraet("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)")
const url = kartendienstUrl("Shell, SÖLDEN", "Pascalstr. 8, 85057 Ingolstadt", null, "zeigen")
expect(url).toContain("maps.apple.com/?q=")
expect(url).not.toContain("daddr")
})
it("nimmt bei Google die Suche statt der Routenplanung", () => {
alsGeraet("Mozilla/5.0 (X11; Linux x86_64)")
expect(kartendienstUrl(null, "Pascalstr. 8, 85057 Ingolstadt", null, "zeigen")).toBe(
"https://www.google.com/maps/search/?api=1&query=Pascalstr.%208%2C%2085057%20Ingolstadt",
)
})
})
/* Die Vorgabe des Eigentuemers vom 04.09.2026, an den ECHTEN Datensaetzen der
Instanz geprueft - beide Bauformen des Bestandes:
bis 2025: name = Firmierung des Betreibers, Anschrift daneben
ab 08.2026: name = "Marke, Strasse, Ort", keine Anschrift
Uebersicht: Marke, Strasse ohne Hausnummer, Ort ohne Postleitzahl
Einzelbeleg: oben Marke + Strasse mit Hausnummer, unten Postleitzahl + Ort */
describe("tankstelleKurz und tankstelleZeilen an echten Belegen", () => {
it("holt Strasse und Ort aus der Anschrift, wenn im Namen nur die Firmierung steht", () => {
// Genau der gemeldete Beleg vom 10.11.2025.
const n = "TC Sengül GmbH"
const a = "Nürnberger Str.74, 91522 Ansbach"
expect(tankstelleKurz(n, a)).toBe("Nürnberger Str., Ansbach")
expect(tankstelleZeilen(n, a)).toEqual({ oben: "Nürnberger Str. 74", unten: "91522 Ansbach" })
})
it("zerlegt auch den zusammengesetzten Namen der neueren Belege", () => {
const n = "Shell, Pascalstr. 8, Ingolstadt"
expect(tankstelleKurz(n, null)).toBe("Shell, Pascalstr., Ingolstadt")
expect(tankstelleZeilen(n, null)).toEqual({ oben: "Shell, Pascalstr. 8", unten: "Ingolstadt" })
})
it("kuerzt die ausgeschriebene Strasse", () => {
const n = "Aral, Nürnberger Straße 74, 91522 Ansbach"
expect(tankstelleKurz(n, null)).toBe("Aral, Nürnberger Str., Ansbach")
})
it("laesst die Firmierung ueberall weg", () => {
expect(tankstelleKurz("Hermann Mogler Mineralölg. GmbH", "Heidenheimerstr.6, 89551 Königsbronn")).toBe(
"Heidenheimerstr., Königsbronn",
)
})
it("behaelt den rohen Namen, wenn sich sonst nichts gewinnen laesst", () => {
// Sonst hiesse eine bekannte Tankstelle ploetzlich "unbekannt".
expect(tankstelleKurz("Testtankstelle", null)).toBe("Testtankstelle")
expect(tankstelleKurz("A. Zrenner GmbH", null)).toBe("A. Zrenner GmbH")
expect(tankstelleKurz(null, null)).toBe("unbekannt")
})
it("trennt Hausnummer auch ohne Leerzeichen davor", () => {
// So drucken die Kassen es: "Str.74", nicht "Str. 74".
expect(tankstelleTeile(null, "Heidenheimerstr.6, 89551 Königsbronn")).toMatchObject({
strasse: "Heidenheimerstr.",
hausnummer: "6",
plz: "89551",
ort: "Königsbronn",
})
expect(tankstelleTeile(null, "Am Anger 7a, 85111 Adelschlag")).toMatchObject({
strasse: "Am Anger",
hausnummer: "7a",
})
})
})
describe("gespeicherte Marke (station_brand)", () => {
/* Der Beleg vom 10.11.2025, wie er wirklich gespeichert ist: der Name traegt
den BETREIBER, die Marke steht nur auf dem PDF. Seit dem 04.09.2026 liest
sie shell_beleg_parser._marke aus dem Belegkopf, belege.marken_nachtragen
traegt sie fuer Altbelege nach - und hier kommt sie an. */
const NAME = "TC Sengül GmbH"
const ADRESSE = "Nürnberger Str.74, 91522 Ansbach"
it("nimmt die gespeicherte Marke, wo der Name keine hergibt", () => {
// Ohne sie stand der Einzelbeleg ohne Marke da, obwohl "Shell Station"
// auf dem Beleg steht (Befund des Eigentuemers, 04.09.2026).
expect(tankstelleZeilen(NAME, ADRESSE)).toEqual({
oben: "Nürnberger Str. 74",
unten: "91522 Ansbach",
})
expect(tankstelleZeilen(NAME, ADRESSE, "Shell")).toEqual({
oben: "Shell, Nürnberger Str. 74",
unten: "91522 Ansbach",
})
})
it("nimmt sie auch in der Uebersicht", () => {
expect(tankstelleKurz(NAME, ADRESSE, "Shell")).toBe("Shell, Nürnberger Str., Ansbach")
})
it("geht der Erkennung aus dem Namen vor", () => {
// Die gespeicherte Angabe stammt vom Belegkopf, die andere ist geraten.
expect(tankstelleTeile("Aral, Musterweg 1, Musterort", null, "Shell").marke).toBe("Shell")
})
it("faellt auf die Erkennung zurueck, wo nichts gespeichert ist", () => {
// Vorgaenge ohne Beleg und von Hand eingetragene Tankstellen haben das
// Feld nie - dort bleibt es beim bisherigen Weg.
expect(tankstelleKurz("Shell, Pascalstr. 8, Ingolstadt", null, null)).toBe(
"Shell, Pascalstr., Ingolstadt",
)
expect(tankstelleKurz("Shell, SÖLDEN", null, undefined).startsWith("Shell")).toBe(true)
})
it("behandelt eine leere gespeicherte Marke wie gar keine", () => {
// marken_nachtragen schreibt null, wenn der Beleg keine Marke nennt.
expect(tankstelleTeile("Shell, SÖLDEN", null, "").marke).toBe("Shell")
expect(tankstelleTeile("Shell, SÖLDEN", null, " ").marke).toBe("Shell")
})
it("gibt sie dem Kartendienst mit", () => {
// Apple Karten und Google Maps treffen mit der Marke die Zapfsaeule statt
// des Nachbargebaeudes - die Firmierung hilft dort nirgends.
expect(tankstelleZiel(NAME, ADRESSE, "Shell")).toBe(
"Shell, Nürnberger Str.74, 91522 Ansbach",
)
expect(kartendienstUrl(NAME, ADRESSE, null, "zeigen", "Shell")).toContain(
encodeURIComponent("Shell, Nürnberger Str.74, 91522 Ansbach"),
)
})
})
describe("Marke am Anfang eines Namensteils", () => {
/* Am 04.09.2026 an der Musterseite aufgefallen: ein von Hand eingetragener
Name ohne Komma rutschte komplett als "Ort" durch. */
it("laesst die Marke nicht zweimal erscheinen", () => {
expect(tankstelleKurz("Shell München Ost", "Musterstraße 5")).toBe(
"Shell, Musterstr., München Ost",
)
})
it("nimmt der Zeile die Marke ab, laesst den Ort stehen", () => {
expect(ohneMarkeVorn("Shell München Ost")).toBe("München Ost")
expect(ohneMarkeVorn("Shell, SÖLDEN")).toBe("SÖLDEN")
expect(ohneMarkeVorn("Shell")).toBe("")
})
it("laesst einen Teil in Ruhe, in dem die Marke nicht vorn steht", () => {
// Dort ist nicht sicher, was Marke und was Ortsname ist.
expect(ohneMarkeVorn("Autohof Shell Nord")).toBe("Autohof Shell Nord")
expect(ohneMarkeVorn("Musterstraße 5")).toBe("Musterstraße 5")
})
it("achtet auf die Wortgrenze", () => {
// "Star" darf nicht in "Starnberg" treffen.
expect(ohneMarkeVorn("Starnberg")).toBe("Starnberg")
expect(ohneMarkeVorn("Sprintstraße 5")).toBe("Sprintstraße 5")
})
})
+383
View File
@@ -0,0 +1,383 @@
/**
* Tankstelle eines Belegs: Anzeige in zwei Zeilen und der Weg in den
* Kartendienst des Geräts.
*
* WARUM ZWEI ZEILEN
* -----------------
* Bis zum 04.09.2026 stand die Tankstelle als gewöhnliche Wertezeile rechts in
* der schmalen Wertespalte: Name oben, die komplette Anschrift als Kleintext
* darunter. Gemessen an der echten Kachel blieben dafür 120 px - "Hermann
* Mogler Mineralölg. GmbH, Heidenheimerstr.6, 89551 Königsbronn" brach dort in
* vier bis fünf Zeilen um. Die Aufteilung hier ist die vom Eigentümer
* verlangte: erste Zeile Tankstelle und Straße, zweite Zeile der Ort.
*
* WIE DIE ANSCHRIFT GETEILT WIRD
* ------------------------------
* Der Belegparser setzt sie als `"<Straße>, <PLZ Ort>"` zusammen
* (shell_beleg_parser.py). Geteilt wird deshalb am LETZTEN Komma: ein Name wie
* "Str. 5, Gewerbepark, 85057 Ingolstadt" behält so seinen Zusatz bei der
* Straße statt beim Ort. Ohne Komma bleibt alles in der ersten Zeile - lieber
* ein zu langer erster Eintrag als ein erfundener Ort.
*
* KARTENDIENST STATT EINES FESTEN ANBIETERS
* -----------------------------------------
* "Klick auf die Tankstelle soll den Standard-Kartendienst öffnen" (Eigentümer,
* 04.09.2026). Auf Apple-Geräten ist das Karten, sonst Google Maps - beides
* über die jeweilige Web-Adresse, die das System an die installierte App
* weiterreicht. Ein `geo:`-Verweis wäre der sauberere Weg, aber Safari kennt
* ihn nicht, und ein Verweis, der auf dem Hauptgerät ins Leere läuft, ist
* keiner.
*/
export interface Koordinate {
lat: number
lon: number
}
/* Kopfmarken des deutschsprachigen Marktes.
WORTGLEICH mit `_MARKEN` in custom_components/audi_dashboard/shell_beleg_parser.py
(und mit MARKEN im Panel) - der Parser erkennt die Marke beim Einlesen eines
Belegs, diese Liste erkennt sie nachträglich in dem, was gespeichert ist.
Laufen die beiden auseinander, zeigt die App eine andere Marke als der
Beleg. Reihenfolge zählt: "TotalEnergies" muss vor "Total" stehen. */
const 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",
]
/**
* Die Kopfmarke aus dem gespeicherten Stationsnamen - `null`, wenn keine
* bekannte darin steht.
*
* WARUM ÜBERHAUPT NACHTRÄGLICH: `station_name` trägt je nach Alter des Belegs
* Verschiedenes. Seit dem 16.08.2026 setzt der Parser "Marke, Straße, Ort"
* zusammen ("Shell, SÖLDEN"); ältere Belege - und jeder ohne erkennbare
* Marke - tragen dort die Firmierung des Betreibers ("A. Zrenner GmbH",
* "Hermann Mogler Mineralölg. GmbH"). Die ist nach außen nicht sichtbar und
* gehört deshalb nicht in die Anzeige (Vorgabe des Eigentümers, 04.09.2026);
* sichtbar ist die Kopfmarke.
*
* Wortgrenzen sind Absicht: "Star" darf nicht in "Starnberg" treffen.
*
* NUR DER ZWEITE WEG: seit dem 04.09.2026 speichert die Integration die Marke
* als eigenes Feld `station_brand` (shell_beleg_parser._marke, aus dem
* Belegkopf), und fuer Altbelege traegt sie belege.marken_nachtragen() aus der
* abgelegten Belegdatei nach. Wo dieses Feld etwas hergibt, gilt es - hier
* geraten wird nur, wo es fehlt: bei Vorgaengen ohne Beleg, bei von Hand
* eingetragenen Tankstellen und solange das Nachtragen nicht gelaufen ist.
*/
/**
* Nimmt einem Namensteil die fuehrende Kopfmarke ab.
*
* WARUM NICHT NUR VERGLEICHEN: bis zum 04.09.2026 wurde ein Namensteil nur
* dann uebersprungen, wenn er FUER SICH eine Marke ist ("Shell"). Ein von
* Hand eingetragener Name wie "Shell München Ost" hat aber kein Komma - er
* rutschte komplett durch und landete als ORT in der Zeile:
*
* "Shell München Ost" + "Musterstraße 5"
* -> "Shell, Musterstr., Shell München Ost"
*
* Jetzt faellt die Marke vorn ab und der Rest bleibt der Ort. Steht die Marke
* NICHT am Anfang ("Autohof Shell Nord"), bleibt der Teil unangetastet -
* dort ist nicht sicher, was Marke und was Ortsname ist.
*/
export function ohneMarkeVorn(teil: string): string {
const marke = markeErkennen(teil)
if (!marke) return teil
// Kein Maskieren noetig: `marke` stammt immer aus MARKEN, und dort steht
// kein einziges Sonderzeichen der regulaeren Ausdruecke.
const muster = new RegExp(`^${marke}\\b[\\s,]*`, "i")
return muster.test(teil) ? teil.replace(muster, "").trim() : teil
}
export function markeErkennen(name: string | null | undefined): string | null {
const roh = (name ?? "").trim()
if (!roh) return null
for (const marke of MARKEN) {
const muster = new RegExp(`\\b${marke.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i")
if (muster.test(roh)) return marke
}
return null
}
/* Kennzeichen einer Firmierung. Ein Teil, der so etwas enthaelt, ist der
BETREIBER und gehoert nach Vorgabe des Eigentuemers nicht in die Anzeige -
"TC Sengül GmbH" oder "Hermann Mogler Mineralölg. GmbH" stehen auf keinem
Preisschild und in keiner Karte. */
const FIRMA = /\b(gmbh|mbh|ohg|kg|ag|e\.\s?k\.|inh\.|& co)\b/i
/* "91522 Ansbach" - vier Stellen wegen Oesterreich (der Belegparser hat
denselben Fall, siehe shell_beleg_parser.py). */
const PLZ_ORT = /^(\d{4,5})\s+(.+)$/
/* Strasse mit Hausnummer am Ende. Greedy bis zum letzten Nicht-Ziffernzeichen,
damit auch die zusammengeschriebene Form der Belege trifft:
"Nürnberger Str.74" -> "Nürnberger Str." + "74". */
const STRASSE_NR = /^(.*\D)\s*(\d+\s*[a-zA-Z]?)$/
/**
* "Nürnberger Straße" → "Nürnberger Str.", "Heidenheimerstraße" →
* "Heidenheimerstr.". Vorgabe des Eigentuemers fuer die Uebersicht; die Belege
* liefern beide Schreibweisen, je nach Kasse.
*
* Die Grossschreibung des Treffers entscheidet, ob "Str." oder "str." daraus
* wird - sonst stuende mitten im Wort ein grosses S.
*/
export function strasseKuerzen(strasse: string): string {
return strasse.replace(/stra(?:ß|ss)e\b\.?/gi, (treffer) =>
treffer[0] === treffer[0]?.toUpperCase() ? "Str." : "str.",
)
}
export interface Tankstellenteile {
/** Kopfmarke, sofern erkennbar. Nie die Firmierung des Betreibers. */
marke: string
/** Ohne Hausnummer, in der kurzen Schreibweise. */
strasse: string
hausnummer: string
plz: string
ort: string
/** Der gespeicherte Name, unveraendert - der letzte Rueckfall. */
roh: string
}
/**
* Zerlegt, was ein Beleg ueber die Tankstelle hergibt.
*
* WARUM BEIDE FELDER GELESEN WERDEN
* ---------------------------------
* Der Bestand hat zwei Bauformen, am 04.09.2026 an den echten Datensaetzen
* nachgesehen:
*
* bis 2025: station_name = "TC Sengül GmbH" (der Betreiber!)
* station_address = "Nürnberger Str.74, 91522 Ansbach"
* ab 08.2026: station_name = "Shell, Pascalstr. 8, Ingolstadt"
* station_address = null
*
* Die Markenerkennung lief bis hierher nur ueber `station_name`. Bei den
* aelteren Belegen steht die Marke dort gar nicht - deshalb blieb in der
* Uebersicht die Firmierung stehen ("...GmbH", Nutzerbefund 04.09.2026),
* waehrend die Anschrift daneben ungenutzt blieb. Gelesen werden deshalb
* beide Felder: die Anschrift zuerst (sie ist die strukturierte Quelle), der
* Name danach fuer Marke und alles, was die Anschrift nicht hergibt.
*
* `marke` ist die gespeicherte Angabe des Belegs (`station_brand`). Sie geht
* vor, weil sie vom Belegkopf stammt und nicht aus dem Anzeigenamen geraten
* ist - genau die Luecke, durch die der Beleg vom 10.11.2025 ohne Marke
* dastand, obwohl "Shell Station" auf ihm steht.
*/
export function tankstelleTeile(
name: string | null | undefined,
adresse: string | null | undefined,
marke?: string | null | undefined,
): Tankstellenteile {
const teile: Tankstellenteile = {
marke: (marke ?? "").trim() || markeErkennen(name) || "",
strasse: "",
hausnummer: "",
plz: "",
ort: "",
roh: (name ?? "").trim(),
}
const quellen: string[] = []
for (const stueck of (adresse ?? "").split(",")) quellen.push(stueck)
for (const stueck of (name ?? "").split(",")) {
// Die Marke faellt vorn ab - sie steht schon fest und taugt nie als Ort.
// Bleibt nichts uebrig, war der Teil nur die Marke selbst.
const t = ohneMarkeVorn(stueck.trim())
if (t) quellen.push(t)
}
for (const stueck of quellen) {
const t = stueck.trim()
if (!t || FIRMA.test(t)) continue
const plzOrt = PLZ_ORT.exec(t)
if (plzOrt) {
if (!teile.plz) teile.plz = plzOrt[1] ?? ""
if (!teile.ort) teile.ort = (plzOrt[2] ?? "").trim()
continue
}
const mitNummer = STRASSE_NR.exec(t)
if (mitNummer) {
if (!teile.strasse) {
teile.strasse = strasseKuerzen((mitNummer[1] ?? "").trim())
teile.hausnummer = (mitNummer[2] ?? "").replace(/\s+/g, "")
}
continue
}
// Weder Postleitzahl noch Hausnummer: der Ort. Eine Strasse ohne Nummer
// laesst sich davon nicht sicher unterscheiden - im Zweifel der Ort, weil
// er in der Uebersicht die wichtigere Angabe ist.
if (!teile.ort) teile.ort = t
}
return teile
}
/**
* Die Zeile der Uebersicht: **Marke, Strasse ohne Hausnummer, Ort ohne
* Postleitzahl** (Vorgabe des Eigentuemers, 04.09.2026).
*
* Ergibt die Zerlegung gar nichts - etwa wenn nur eine Firmierung ohne
* Anschrift gespeichert ist - bleibt der rohe Name stehen. Ihn dann
* wegzulassen hiesse, eine bekannte Tankstelle "unbekannt" zu nennen.
*/
export function tankstelleKurz(
name: string | null | undefined,
adresse: string | null | undefined,
marke?: string | null | undefined,
): string {
const t = tankstelleTeile(name, adresse, marke)
const zeile = [t.marke, t.strasse, t.ort].filter(Boolean).join(", ")
return zeile || t.roh || "unbekannt"
}
/**
* Die beiden Zeilen im Einzelbeleg (Vorgabe des Eigentuemers, 04.09.2026):
* oben **Marke, Strasse mit Hausnummer**, unten **Postleitzahl und Ort**.
*
* Geschrieben wie eine Anschrift, also "Shell, Pascalstr. 8" und "85057
* Ingolstadt" - das Komma trennt die Marke ab, zwischen Strasse und Nummer
* und zwischen Postleitzahl und Ort steht keins.
*
* `oben` ist nie leer: gibt der Beleg nur einen Ort her, rueckt der nach oben,
* und kennt er gar nichts, heisst es dort "unbekannt".
*/
export function tankstelleZeilen(
name: string | null | undefined,
adresse: string | null | undefined,
marke?: string | null | undefined,
): { oben: string; unten: string } {
const t = tankstelleTeile(name, adresse, marke)
const strasse = [t.strasse, t.hausnummer].filter(Boolean).join(" ")
let oben = [t.marke, strasse].filter(Boolean).join(", ")
let unten = [t.plz, t.ort].filter(Boolean).join(" ")
if (!oben && !unten) return { oben: t.roh || "unbekannt", unten: "" }
if (!oben) {
oben = unten
unten = ""
}
return { oben, unten }
}
/**
* Suchbegriffe für die Geokodierung, in der Reihenfolge, in der sie versucht
* werden: erst die Anschrift, dann der Name.
*
* NICHT beides zusammen. Am 04.09.2026 gemessen: `"Pascalstr. 8, 85057
* Ingolstadt"` findet Nominatim, `"A. Zrenner GmbH, Pascalstr. 8, 85057
* Ingolstadt"` nicht — der Betreibername steht in keiner Karte und lässt die
* Freitextsuche ins Leere laufen. Genau daran ist die erste Fassung dieser
* Funktion gescheitert, sichtbar als „Keine Position zur Tankstelle" bei einem
* Beleg, dessen Anschrift sich vorher hat auflösen lassen.
*
* Der Name allein ist trotzdem wertvoll: `"Shell, SÖLDEN"` findet Nominatim
* (46.9756/11.0111) — deshalb der zweite Versuch statt gar keinem.
*
* Und als dritter Versuch der Name OHNE die führende Marke. Seit dem 16.08.2026
* setzt der Belegparser den Namen als „Marke, Straße, Ort" zusammen; steht darin
* keine Postleitzahl, kippt die Marke die Suche. Am 04.09.2026 gemessen:
* `"Shell, Pascalstr. 8, Ingolstadt"` findet Nominatim NICHT,
* `"Pascalstr. 8, Ingolstadt"` dagegen schon. Genau daran blieb die Karte auf
* der realen Instanz leer, während dieselbe Tankstelle in der Testinstanz
* (dort steht die Anschrift getrennt daneben) gefunden wurde.
*/
export function tankstelleKandidaten(
name: string | null | undefined,
adresse: string | null | undefined,
): string[] {
const n = (name ?? "").trim()
const marke = markeErkennen(n)
// Nur abschneiden, wenn die Marke wirklich vorn steht und noch etwas folgt.
const ohneMarke =
marke && n.toLowerCase().startsWith(marke.toLowerCase())
? n.slice(marke.length).replace(/^[ ,]+/, "").trim()
: ""
const liste = [(adresse ?? "").trim(), n, ohneMarke].filter(Boolean)
return [...new Set(liste)]
}
/** Der erste Suchbegriff - leer heißt: der Beleg nennt gar nichts. */
export function tankstelleSuchtext(
name: string | null | undefined,
adresse: string | null | undefined,
): string {
return tankstelleKandidaten(name, adresse)[0] ?? ""
}
/**
* Das Ziel für den Kartendienst: Kopfmarke UND Anschrift. Apple Karten und
* Google Maps kennen Tankstellenmarken und treffen damit die Zapfsäule statt
* des Nachbargebäudes - beide sind darin deutlich besser als Nominatims
* Freitextsuche. Die Firmierung des Betreibers hilft dagegen nirgends; ohne
* erkennbare Marke geht die Anschrift allein hin.
*/
export function tankstelleZiel(
name: string | null | undefined,
adresse: string | null | undefined,
marke?: string | null | undefined,
): string {
const a = (adresse ?? "").trim()
if (!a) return (name ?? "").trim()
return [(marke ?? "").trim() || markeErkennen(name) || "", a].filter(Boolean).join(", ")
}
/** Apple-Gerät? Dort ist Karten der Standarddienst. */
function apfelgeraet(): boolean {
if (typeof navigator === "undefined") return false
const kennung = `${navigator.userAgent || ""} ${navigator.platform || ""}`
return /iPhone|iPad|iPod|Macintosh|Mac OS X/i.test(kennung)
}
/**
* "route" berechnet den Weg dorthin, "zeigen" setzt nur eine Nadel. Der
* Tankstellen-Verweis im Einzelbeleg will das Zweite (Vorgabe des Eigentümers,
* 04.09.2026): erst sehen, wo die Tankstelle liegt - eine Route erwartet dort
* niemand, und sie ist auf dem Telefon nur einen weiteren Tipp entfernt.
*/
export type Kartenmodus = "route" | "zeigen"
/**
* Verweis in den Kartendienst - `null`, wenn nichts bekannt ist, wohin.
*
* Mit Koordinaten wird auf sie gezielt und der Name nur als Beschriftung
* mitgegeben; ohne sie muss die Anschrift als Suchtext genügen.
*/
export function kartendienstUrl(
name: string | null | undefined,
adresse: string | null | undefined,
pos?: Koordinate | null,
modus: Kartenmodus = "route",
marke?: string | null | undefined,
): string | null {
const beschriftung = (name ?? "").trim()
const ziel = pos ? `${pos.lat},${pos.lon}` : tankstelleZiel(name, adresse, marke)
if (!ziel) return null
if (apfelgeraet()) {
if (modus === "zeigen") {
// Mit Koordinaten der Punkt selbst, sonst der Suchtext - beides zeigt
// Karten nur an, ohne eine Route zu berechnen.
return pos
? `https://maps.apple.com/?ll=${ziel}&q=${encodeURIComponent(beschriftung || "Ziel")}`
: `https://maps.apple.com/?q=${encodeURIComponent(ziel)}`
}
const q = pos && beschriftung ? `&q=${encodeURIComponent(beschriftung)}` : ""
return `https://maps.apple.com/?daddr=${encodeURIComponent(ziel)}${q}`
}
return modus === "zeigen"
? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(ziel)}`
: `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(ziel)}`
}
/** Wie oben, aber für einen reinen Punkt (Fahrzeugstandort, Autohaus). */
export function kartendienstUrlZuPunkt(pos: Koordinate | null | undefined): string | null {
if (!pos) return null
return kartendienstUrl(null, null, pos)
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Beleg-PDF aus der Zwischenablage oder einer Dateiliste.
*
* Übernommen aus dem Panel (`belegAusZwischenablage()`, `istPdf()`,
* `zwischenablageKnopfSinnvoll()` in audi-dashboard-app.js) — dort gibt es
* diesen Weg seit längerem, in der App fehlte er ganz: der Beleg-Knopf öffnete
* direkt die Dateiauswahl, und ein aus der Mail kopiertes PDF ließ sich
* überhaupt nicht einfügen (gemeldet vom Eigentümer, 2026-08-31).
*
* WARUM DER KNOPF NUR AUF TIPPGERÄTEN ERSCHEINT
* ---------------------------------------------
* `navigator.clipboard.read()` gibt aus Datenschutzgründen nur Text, HTML und
* Bilder heraus. Eine im Explorer oder Finder kopierte Datei taucht dort nie
* auf — am Rechner scheitert der Weg also zwangsläufig, und ein Knopf, der
* immer fehlschlägt, ist schlimmer als kein Knopf. Dort bleiben Ziehen und
* Dateiauswahl.
*
* AUF DEM TELEFON LÄUFT ER SEIT DEM 02.09.2026 NATIV
* --------------------------------------------------
* Dieselbe Grenze der Web-Schnittstelle traf nämlich auch iOS: ein aus Mail
* kopiertes PDF ist über `navigator.clipboard.read()` NIE zu bekommen, der
* Knopf endete dort zwangsläufig mit „In der Zwischenablage liegt kein PDF"
* (gemeldet vom Eigentümer). `UIPasteboard` kennt die Grenze nicht — deshalb
* geht die Anfrage auf dem Gerät über ein kleines eigenes Plugin
* (native/BelegZwischenablage.swift). Die Einfügen-Rückfrage des Systems
* erscheint dabei genauso wie im Web.
*/
import { Capacitor, registerPlugin } from "@capacitor/core"
/** Nicht nur auf den MIME-Typ verlassen: eine aus dem Explorer/Finder kopierte
oder gezogene Datei kommt je nach Browser und Quelle mit leerem `type` an. */
export function istPdf(datei: File | null | undefined): boolean {
if (!datei) return false
return datei.type === "application/pdf" || /\.pdf$/i.test(datei.name || "")
}
/** Nimmt das erste PDF aus einer DataTransfer-/Dateiliste. */
export function pdfAusDateiliste(dateien: FileList | File[] | null | undefined): File | null {
return [...(dateien ?? [])].find(istPdf) ?? null
}
export class ZwischenablageFehler extends Error {}
interface BelegZwischenablage {
/** Leeres Ergebnis heißt „nichts Passendes kopiert" - kein Fehler. */
pdfHolen(): Promise<{ base64?: string; name?: string }>
}
/** Muss zu `jsName` in BelegZwischenablage.swift passen. */
const Nativ = registerPlugin<BelegZwischenablage>("BelegZwischenablage")
function base64ZuDatei(base64: string, name: string): File {
const roh = atob(base64)
const bytes = new Uint8Array(roh.length)
for (let i = 0; i < roh.length; i++) bytes[i] = roh.charCodeAt(i)
return new File([bytes], name, { type: "application/pdf" })
}
/**
* Holt ein PDF aus der Zwischenablage.
*
* Wirft mit einem Text, der zur jeweiligen Ursache die richtige Abhilfe nennt —
* "bitte Datei auswählen" hilft nicht weiter, wenn schlicht kein PDF kopiert
* wurde, und "erneut kopieren" nicht, wenn der Browser die Zwischenablage gar
* nicht herausgibt.
*/
export async function pdfAusZwischenablage(): Promise<File> {
if (Capacitor.isNativePlatform()) {
/* Meldet sich der native Teil nicht bei der Bruecke, hilft nur die
Dateiauswahl - der Web-Weg kann ein PDF grundsaetzlich nicht liefern
(Begruendung im Modulkopf).
Bis zum 04.09.2026 stand hier die Vermutung, die App sei nur ueber die
Aktualisierung gewachsen. Sie war falsch: der Eigentuemer hatte frisch
installiert, und BelegZwischenablagePlugin lag nachweislich im
Programm. Capacitor 8 sucht Plugin-Klassen aber nicht im Programm,
sondern registriert ausschliesslich, was in packageClassList der
capacitor.config.json steht - und `cap sync` traegt dort nur
npm-Pakete ein, keine app-eigenen Plugins. Behoben in
scripts/ios-teilen-einrichten.mjs; hier steht deshalb nur noch die
Tatsache und nicht mehr eine geratene Ursache. */
if (!Capacitor.isPluginAvailable("BelegZwischenablage")) {
throw new ZwischenablageFehler(
'Diese App-Fassung kann die Zwischenablage nicht abfragen - bitte ' +
'"Datei auswählen".',
)
}
let ergebnis: { base64?: string; name?: string }
try {
ergebnis = await Nativ.pdfHolen()
} catch {
// Abgelehnte Einfügen-Rückfrage des Systems.
throw new ZwischenablageFehler(
'Kein Zugriff auf die Zwischenablage - bitte "Datei auswählen".',
)
}
if (!ergebnis?.base64) {
throw new ZwischenablageFehler(
'In der Zwischenablage liegt kein PDF - bitte den Beleg erneut kopieren oder "Datei auswählen".',
)
}
return base64ZuDatei(ergebnis.base64, ergebnis.name || "beleg.pdf")
}
if (!navigator.clipboard?.read) {
throw new ZwischenablageFehler(
'Dieser Browser gibt die Zwischenablage nicht heraus - bitte "Datei auswählen".',
)
}
let eintraege: ClipboardItems
try {
eintraege = await navigator.clipboard.read()
} catch {
// Verweigerte Freigabe, abgebrochene System-Abfrage, unsicherer Ursprung.
throw new ZwischenablageFehler('Kein Zugriff auf die Zwischenablage - bitte "Datei auswählen".')
}
for (const eintrag of eintraege) {
const typ = eintrag.types.find((t) => t === "application/pdf")
if (typ) {
const blob = await eintrag.getType(typ)
// Ein Name muss sein: das Backend legt den Beleg unter ihm ab.
return new File([blob], "beleg.pdf", { type: "application/pdf" })
}
}
throw new ZwischenablageFehler(
'In der Zwischenablage liegt kein PDF - bitte den Beleg erneut kopieren oder "Datei auswählen".',
)
}
/** Tippgerät? Nur dort wird der Einfügen-Knopf angeboten (Begründung oben). */
export function zwischenablageKnopfSinnvoll(): boolean {
return typeof window !== "undefined" && !!window.matchMedia?.("(pointer: coarse)").matches
}
+109 -6
View File
@@ -2,7 +2,20 @@ import { describe, expect, it } from "vitest"
import { alsCsv } from "./screens/csv"
import { kalenderDatei } from "./screens/kalender"
import { dauer, de, deOderStrich, eur, isoTag, schnittpreis, summe } from "./format"
import {
datum,
datumLang,
datumZeit,
dauer,
de,
deOderStrich,
eur,
isoTag,
monatJahr,
schnittpreis,
summe,
uhrzeit,
} from "./format"
describe("Zahlen im deutschen Format", () => {
it("setzt Punkt als Tausender- und Komma als Dezimaltrennzeichen", () => {
@@ -18,22 +31,22 @@ describe("Zahlen im deutschen Format", () => {
it("unterscheidet mit deOderStrich zwischen null und unbekannt", () => {
expect(deOderStrich(0)).toBe("0")
expect(deOderStrich(null)).toBe("")
expect(deOderStrich(null)).toBe("")
})
})
describe("dauer", () => {
it("zeigt unter einer Stunde nur Minuten", () => {
expect(dauer(2700)).toBe("45 min")
expect(dauer(2700)).toBe("45 Min.")
})
it("zeigt darüber Stunden und Minuten zweistellig", () => {
expect(dauer(8040)).toBe("2 h 14 min")
expect(dauer(8040)).toBe("2 h 14 Min.")
})
it("zeigt einen Strich statt „0 min", () => {
expect(dauer(0)).toBe("")
expect(dauer(null)).toBe("")
expect(dauer(0)).toBe("")
expect(dauer(null)).toBe("")
})
})
@@ -101,3 +114,93 @@ describe("Kalenderdatei", () => {
)
})
})
describe("Datumsangaben aus dem Bestand", () => {
/* Alle Faelle am 04.09.2026 an der laufenden Instanz gemessen - vorher
vertauschte `new Date()` Tag und Monat, oder verwarf den Wert ganz. */
it("liest deutsche Tagesdaten richtig herum", () => {
// Der gemeldete Fall: die Erstzulassung stand als 03.01.2025 da.
expect(datum("01.03.2025")).toBe("01.03.2025")
expect(datum("12.11.2024")).toBe("12.11.2024")
expect(datum("1.3.2025")).toBe("01.03.2025")
})
it("verliert Tage ab dem 13. nicht mehr", () => {
// Die waren vorher ungueltig und verschwanden als "".
expect(datum("25.03.2025")).toBe("25.03.2025")
expect(datum("31.12.2023")).toBe("31.12.2023")
})
it("zeigt monatsgenaue Angaben als Monat, nicht als erfundenen Tag", () => {
// Die Hauptuntersuchung steht als "MM/JJJJ" im Profil (so verlangt es das
// Einrichtungsfeld). Vorher: "". Ein "01.08.2026" waere eine Erfindung.
expect(datum("08/2026")).toBe("August 2026")
expect(datum("08.2026")).toBe("August 2026")
expect(datum("2026-08")).toBe("August 2026")
expect(datumLang("08/2026")).toBe("August 2026")
})
it("gibt einer monatsgenauen Angabe keine Uhrzeit", () => {
// 00:00 waere erfunden.
expect(uhrzeit("08/2026")).toBe("")
expect(datumZeit("08/2026")).toBe("August 2026")
})
it("laesst ISO vom Backend unveraendert richtig", () => {
expect(datum("2026-08-14")).toBe("14.08.2026")
expect(datum("2024-09-25T15:47:00")).toBe("25.09.2024")
expect(datumZeit("2024-09-25T15:47:00")).toBe("25.09.2024, 15:47 Uhr")
})
it("weist unmoegliche Daten ab, statt still ein anderes zu zeigen", () => {
// new Date(2025,1,31) waere in JavaScript der 3. Maerz.
expect(datum("31.02.2025")).toBe("")
expect(datum("32.01.2025")).toBe("")
expect(datum("00.01.2025")).toBe("")
expect(datum("01.13.2025")).toBe("")
})
it("bleibt bei Unbekanntem beim Strich", () => {
expect(datum(null)).toBe("")
expect(datum("")).toBe("")
expect(datum("demnaechst")).toBe("")
expect(datum("01/03/2025")).toBe("")
})
it("nimmt Date und Zahl weiterhin an", () => {
expect(datum(new Date(2025, 2, 1))).toBe("01.03.2025")
expect(datum(new Date(2025, 2, 1).getTime())).toBe("01.03.2025")
})
it("monatJahr kann beide Genauigkeiten", () => {
expect(monatJahr("2026-08-14")).toBe("August 2026")
expect(monatJahr("08/2026")).toBe("August 2026")
})
})
/* Der Eigentümer hat die Erstzulassung am 05.09.2026 „mit Punkt oder Strich"
eingetragen und danach ein leeres HU-Feld vorgefunden. Die eigentliche
Ursache lag im Speicherweg (Einstellungen.tsx), aber eine still verworfene
Eingabe soll auch dann nicht entstehen, wenn jemand Bindestriche tippt. */
describe("Datum mit Bindestrich statt Punkt", () => {
it("liest 15-06-2024 wie 15.06.2024", () => {
expect(datum("15-06-2024")).toBe(datum("15.06.2024"))
expect(datum("15-06-2024")).toBe("15.06.2024")
})
it("nimmt auch die einstellige Schreibweise", () => {
expect(datum("1-3-2025")).toBe("01.03.2025")
})
it("weist ein unmögliches Datum weiterhin ab, egal mit welchem Trenner", () => {
// new Date(2025, 1, 31) wäre der 3. März - das darf kein Datum ergeben.
expect(datum("31-02-2025")).toBe("")
expect(datum("31.02.2025")).toBe("")
})
it("verwechselt die ISO-Schreibweise nicht damit", () => {
// 2025-03-01 ist Jahr-Monat-Tag, nicht Tag-Monat-Jahr.
expect(datum("2025-03-01")).toBe("01.03.2025")
})
})
+191 -18
View File
@@ -14,9 +14,44 @@ export function eur(wert: number | null | undefined): string {
return de(wert, 2)
}
/** Wie de(), aber „—" statt „0" wenn nichts bekannt ist. */
/* Fehlende Werte zeigt das Panel durchgängig als Halbgeviertstrich „–", nicht
als Geviertstrich „—" (30 Fundstellen gegen eine). Hier stand app-weit der
längere Strich — sichtbar breiter, sobald beide Anwendungen nebeneinander
liegen. */
/** Wie de(), aber „–" statt „0" wenn nichts bekannt ist. */
/** Eine gefahrene Strecke in der Staffelung, die der Eigentümer am 01.09.2026
* festgelegt hat:
*
* unter 1 km -> Meter, ganzzahlig ("400 m", "999 m")
* bis 99,9 km -> eine Nachkommastelle ("1,0 km", "6,9 km")
* ab 100 km -> ganze Kilometer ("104 km")
*
* Seit der GNSS-Verfeinerung messen wir auf etwa zehn Meter genau; ohne
* Nachkommastelle wurde davon nichts sichtbar (aus 6,896 km wurde "7 km").
* Umgekehrt wäre eine Nachkommastelle bei dreistelligen Summen nur Unruhe.
*
* Gestaffelt wird nach dem GERUNDETEN Wert: 0,9996 km sind gerundet 1000 m
* und gehören damit schon in die km-Stufe, sonst stünde dort "1.000 m".
*
* Wortgleich mit streckeTeile()/streckeText() im Panel. */
export function streckeTeile(km: number | null | undefined): { wert: string; einheit: string } | null {
const n = Number(km)
if (km === null || km === undefined || !Number.isFinite(n)) return null
const meter = Math.round(n * 1000)
if (Math.abs(meter) < 1000) return { wert: de(meter), einheit: "m" }
const fein = Math.round(n * 10) / 10
if (Math.abs(fein) < 100) return { wert: de(fein, 1), einheit: "km" }
return { wert: de(Math.round(n)), einheit: "km" }
}
export function streckeText(km: number | null | undefined, ersatz = ""): string {
const t = streckeTeile(km)
return t ? `${t.wert} ${t.einheit}` : ersatz
}
export function deOderStrich(wert: number | null | undefined, nachkomma = 0): string {
return wert === null || wert === undefined ? "" : de(wert, nachkomma)
return wert === null || wert === undefined ? "" : de(wert, nachkomma)
}
/** Summe über eine Liste. */
@@ -53,45 +88,172 @@ const UHRZEIT = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-d
const MONAT_JAHR = new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" })
function alsDatum(wert: string | number | Date | null | undefined): Date | null {
/**
* Datumsangaben aus dem Bestand kommen in DREI Formen - deshalb reicht
* `new Date(wert)` hier nicht.
*
* WAS AM 04.09.2026 GEMESSEN WURDE
* --------------------------------
* `new Date()` liest einen nicht-ISO-Text nach amerikanischer Lesart, also
* Monat vor Tag. Fuer deutsche Eingaben heisst das:
*
* "01.03.2025" (1. Maerz) -> 3. Januar 2025 - Tag und Monat vertauscht
* "12.11.2024" -> 11. Dezember - dito
* "25.03.2025" -> ungueltig - Wert verschwand als "-"
* "08/2026" (Hauptunters.) -> ungueltig - dito
*
* Tage bis 12 wurden also STILL vertauscht, ab Tag 13 fiel der Wert ganz weg.
* Sichtbar war es an der Erstzulassung: "Mein Audi" zeigte den Rohwert
* richtig, "Identitaet und Technik" formatierte ihn und drehte ihn dabei um -
* derselbe Wert, zwei Ergebnisse. Und die Hauptuntersuchung stand als "-" da,
* obwohl "08/2026" gespeichert war.
*
* WARUM EIGENE MUSTER STATT EINER BIBLIOTHEK
* ------------------------------------------
* Es sind genau drei Formen, alle aus diesem Projekt: ISO vom Backend,
* "TT.MM.JJJJ" aus den Eingabefeldern, "MM/JJJJ" fuer die Hauptuntersuchung
* (so verlangt es das Einrichtungsfeld, siehe Panel `einErstzulassung`).
* Erkannt wird nur, was hier wirklich vorkommt - alles andere bleibt `null`
* und damit "-". Lieber kein Datum als ein erfundenes.
*
* MONATSGENAU IST EINE EIGENE GENAUIGKEIT
* ---------------------------------------
* "08/2026" nennt keinen Tag. Daraus den 1. August zu machen und
* "01.08.2026" anzuzeigen waere eine Erfindung - deshalb traegt das Ergebnis
* seine Genauigkeit mit sich, und `datum()` zeigt dann "August 2026".
*/
type Genauigkeit = "tag" | "monat"
interface Zeitpunkt {
zeit: Date
genau: Genauigkeit
}
/* ISO vom Backend, mit oder ohne Uhrzeit - das kann `new Date` selbst, und
zwar nach Norm statt nach Gutduenken. */
const ISO = /^\d{4}-\d{2}-\d{2}([T ]|$)/
/* "1.3.2025" wie "01.03.2025". */
// Bindestrich ist mitgemeint: der Eigentuemer hat am 05.09.2026 "mit Punkt
// oder Strich" eingetragen. Eine still verworfene Eingabe ist der schlechtere
// Ausgang - der Platzhalter nennt trotzdem TT.MM.JJJJ als die gemeinte Form.
const DE_TAG = /^(\d{1,2})[.\-](\d{1,2})[.\-](\d{4})$/
/* "08/2026" und "08.2026" - Monat und Jahr, ohne Tag. */
const DE_MONAT = /^(\d{1,2})[./](\d{4})$/
/* "2026-08" - dieselbe Genauigkeit, in ISO-Schreibweise. */
const ISO_MONAT = /^(\d{4})-(\d{1,2})$/
/**
* Baut das Datum und weist die Rollover-Falle ab: `new Date(2025, 1, 31)` ist
* in JavaScript der 3. Maerz, nicht der 31. Februar. Ein unmoegliches Datum
* soll "-" ergeben, nicht klammheimlich ein anderes.
*/
function bauen(jahr: number, monat: number, tag: number): Date | null {
if (monat < 1 || monat > 12 || tag < 1 || tag > 31) return null
const d = new Date(jahr, monat - 1, tag)
if (d.getFullYear() !== jahr || d.getMonth() !== monat - 1 || d.getDate() !== tag) return null
return d
}
/** Wortgleich mit datumAusText() im Panel. Exportiert, weil die Ableitung
der Hauptuntersuchung dieselbe Genauigkeit braucht wie die Anzeige: aus
einer Erstzulassung "03/2025" darf kein Termin mit Tag entstehen. */
export function alsZeitpunkt(wert: string | number | Date | null | undefined): Zeitpunkt | null {
if (wert === null || wert === undefined || wert === "") return null
const datum = wert instanceof Date ? wert : new Date(wert)
return Number.isNaN(datum.getTime()) ? null : datum
if (wert instanceof Date) return Number.isNaN(wert.getTime()) ? null : { zeit: wert, genau: "tag" }
if (typeof wert === "number") {
const d = new Date(wert)
return Number.isNaN(d.getTime()) ? null : { zeit: d, genau: "tag" }
}
const text = wert.trim()
if (!text) return null
if (ISO.test(text)) {
const d = new Date(text)
return Number.isNaN(d.getTime()) ? null : { zeit: d, genau: "tag" }
}
const tag = DE_TAG.exec(text)
if (tag) {
const d = bauen(Number(tag[3]), Number(tag[2]), Number(tag[1]))
return d ? { zeit: d, genau: "tag" } : null
}
const monat = DE_MONAT.exec(text)
if (monat) {
const d = bauen(Number(monat[2]), Number(monat[1]), 1)
return d ? { zeit: d, genau: "monat" } : null
}
const isoMonat = ISO_MONAT.exec(text)
if (isoMonat) {
const d = bauen(Number(isoMonat[1]), Number(isoMonat[2]), 1)
return d ? { zeit: d, genau: "monat" } : null
}
return null
}
function alsDatum(wert: string | number | Date | null | undefined): Date | null {
return alsZeitpunkt(wert)?.zeit ?? null
}
export function datum(wert: string | number | Date | null | undefined): string {
const d = alsDatum(wert)
return d ? DATUM_KURZ.format(d) : ""
const z = alsZeitpunkt(wert)
if (!z) return ""
return z.genau === "monat" ? MONAT_JAHR.format(z.zeit) : DATUM_KURZ.format(z.zeit)
}
export function datumLang(wert: string | number | Date | null | undefined): string {
const d = alsDatum(wert)
return d ? DATUM_LANG.format(d) : ""
const z = alsZeitpunkt(wert)
if (!z) return ""
return z.genau === "monat" ? MONAT_JAHR.format(z.zeit) : DATUM_LANG.format(z.zeit)
}
export function uhrzeit(wert: string | number | Date | null | undefined): string {
const d = alsDatum(wert)
return d ? UHRZEIT.format(d) : "—"
const z = alsZeitpunkt(wert)
// Eine monatsgenaue Angabe hat keine Uhrzeit - 00:00 waere erfunden.
return z && z.genau === "tag" ? UHRZEIT.format(z.zeit) : ""
}
export function datumZeit(wert: string | number | Date | null | undefined): string {
const d = alsDatum(wert)
return d ? `${DATUM_KURZ.format(d)}, ${UHRZEIT.format(d)} Uhr` : ""
const z = alsZeitpunkt(wert)
if (!z) return ""
if (z.genau === "monat") return MONAT_JAHR.format(z.zeit)
return `${DATUM_KURZ.format(z.zeit)}, ${UHRZEIT.format(z.zeit)} Uhr`
}
export function monatJahr(wert: string | number | Date | null | undefined): string {
const d = alsDatum(wert)
return d ? MONAT_JAHR.format(d) : ""
const z = alsZeitpunkt(wert)
return z ? MONAT_JAHR.format(z.zeit) : ""
}
/** Dauer in Sekunden als „2 h 14 min" bzw. „14 min". */
export function dauer(sekunden: number | null | undefined): string {
if (!sekunden || sekunden <= 0) return ""
if (!sekunden || sekunden <= 0) return ""
const minutenGesamt = Math.round(sekunden / 60)
const stunden = Math.floor(minutenGesamt / 60)
const minuten = minutenGesamt % 60
if (stunden === 0) return `${minuten} min`
return `${stunden} h ${String(minuten).padStart(2, "0")} min`
// "Min." statt "min": deutsche Abkuerzung, wie sie die Oberflaeche sonst
// auch verwendet ("Geparkt seit 2 Tg. 16 Std. 12 Min.").
if (stunden === 0) return `${minuten} Min.`
return `${stunden} h ${String(minuten).padStart(2, "0")} Min.`
}
/**
* Alter des letzten Datenstands als „vor 3 Std." — wortgleich mit
* `standAlterText()` im Panel, das diesen Text in der Kopfzeile der Übersicht
* neben dem Aktualisieren-Symbol zeigt.
*/
export function standAlter(wert: string | number | Date | null | undefined): string {
const d = alsDatum(wert)
if (!d) return "unbekannt"
const sekunden = Math.max(0, Math.floor((Date.now() - d.getTime()) / 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`
}
/** ISO-Tagesdatum (YYYY-MM-DD) für Vergleiche und Eingabefelder. */
@@ -101,3 +263,14 @@ export function isoTag(wert: string | number | Date | null | undefined = new Dat
const versatz = d.getTimezoneOffset() * 60_000
return new Date(d.getTime() - versatz).toISOString().slice(0, 10)
}
/**
* Datum und Uhrzeit mit „ · " getrennt — so setzt das Panel den Zeitstempel in
* der Batterie-Messwertliste (`dedat(...) + " · " + toLocaleTimeString(...)`).
* `datumZeit()` daneben bleibt die Komma-Schreibweise für die Stellen, die sie
* schon benutzen.
*/
export function datumUndZeit(wert: string | number | Date | null | undefined): string {
const d = alsDatum(wert)
return d ? `${DATUM_KURZ.format(d)} · ${UHRZEIT.format(d)} Uhr` : ""
}
+4 -1
View File
@@ -6,6 +6,7 @@ import "./stile/grundlage.css"
import "./stile/audi-schrift.css"
import { nativeAblageEinrichten } from "./api"
import { App } from "./App"
import { ErrorGrenze } from "./ErrorGrenze"
// In der nativen Hülle den Token in den Schlüsselbund legen statt in
// localStorage. Ohne Hülle passiert hier nichts.
@@ -16,6 +17,8 @@ if (!wurzel) throw new Error("Wurzelelement #wurzel fehlt in index.html")
createRoot(wurzel).render(
<StrictMode>
<App />
<ErrorGrenze>
<App />
</ErrorGrenze>
</StrictMode>,
)
+29 -11
View File
@@ -17,12 +17,14 @@ export type SeitenName =
// Unterseiten von "Mein Audi"
| "ident"
| "battverlauf"
| "battliste"
| "service"
| "werkstatt"
| "sbuch"
| "reifen"
| "vers"
| "beitrag"
| "vertrag"
| "vertragsdetails"
| "schutz"
| "notruf"
@@ -32,6 +34,8 @@ export type SeitenName =
| "fill"
// Sonstige
| "sicherheit"
| "tuerenklappen"
| "standort"
| "live"
| "einst"
@@ -52,8 +56,10 @@ export const ZURUECK: Partial<Record<SeitenName, SeitenName>> = {
reifen: "audi",
ident: "audi",
battverlauf: "audi",
battliste: "battverlauf",
vers: "audi",
beitrag: "vers",
vertrag: "vers",
vertragsdetails: "vers",
schutz: "vertragsdetails",
steuer: "vers",
@@ -62,31 +68,43 @@ export const ZURUECK: Partial<Record<SeitenName, SeitenName>> = {
sbuch: "service",
einst: "home",
sicherheit: "home",
tuerenklappen: "sicherheit",
standort: "home",
live: "home",
}
/** Überschrift je Seite. Leer bedeutet: die Seite bringt ihre eigene mit. */
/**
* Überschrift je Seite — wortgleich mit dem Kopf, den `render()` im Panel
* setzt (`head[1]`, audi-dashboard-app.js). Neun Titel wichen davon ab
* (Fahrzeugdaten/Batterie/Versicherung & Steuer/Beitrag/Notruf/Werkstatt/
* Wartungsplan/Fahrt/Tankvorgang); das waren keine Entscheidungen, sondern
* Drift. `live` hat keine Panel-Entsprechung (companion-app-eigener Screen).
*/
export const TITEL: Record<SeitenName, string> = {
home: "Übersicht",
audi: "Mein Audi",
trips: "Fahrten",
stat: "Statistik",
fuel: "Tanken",
ident: "Fahrzeugdaten",
battverlauf: "Batterie",
ident: "Identität und Technik",
battverlauf: "Batteriespannung",
battliste: "Messwerte",
service: "Service",
werkstatt: "Werkstatt",
sbuch: "Servicebuch",
reifen: "Reifen",
vers: "Versicherung & Steuer",
beitrag: "Beitrag",
werkstatt: "Autohaus",
sbuch: "Eintrag",
reifen: "Räder",
vers: "Versicherung/Steuer",
beitrag: "Beitrag anpassen",
vertrag: "Vertrag",
vertragsdetails: "Vertragsdetails",
schutz: "Schutzbrief",
notruf: "Notruf",
notruf: "Rufnummern",
steuer: "Kfz-Steuer",
trip: "Fahrt",
fill: "Tankvorgang",
trip: "Einzelfahrt",
fill: "Einzelbeleg",
sicherheit: "Fahrzeugstatus",
tuerenklappen: "Türen und Klappen",
standort: "Standort",
live: "Fahrt läuft",
einst: "Einstellungen",
}
+437 -80
View File
@@ -7,17 +7,19 @@
*
* Wichtig für die Einordnung: Der Ladezustand wird aus der **Tagesminimum**-
* Spannung geschätzt (Ruhespannung kurz nach dem Start, bevor die Lichtmaschine
* anhebt); Werte über 13,2 V gelten als „während der Fahrt gemessen" und
* anhebt); Werte über 13,0 V gelten als „während der Fahrt gemessen" und
* taugen nicht als Ruhewert.
*/
import { useEffect, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { Tile } from "@audi-dash/ui"
import { IconButton, Tile } from "@audi-dash/ui"
import { ENTITAETEN } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { datum, de } from "../format"
import type { SeitenName } from "../navigation"
import { SymbolBatterie, SymbolListe } from "../symbole"
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
interface Tageswert {
@@ -26,30 +28,141 @@ interface Tageswert {
max: number | null
min_ts?: string
max_ts?: string
min_temp_c?: number | null
}
/** Ab hier gilt eine Messung als nicht mehr in Ruhe aufgenommen. */
const AGM_RUHE_MAX_V = 13.2
/** Ab hier gilt eine Messung nicht mehr als Batterie(ruhe)spannung, sondern
schon als Generatorspannung - vom Nutzer direkt festgelegt ("max voltage
for AGM Battery is 13V, everything above is the generator"), identisch
mit AGM_RUHE_MAX_V im Panel (audi-dashboard-app.js). Getrennt von
LADEKURVE unten: eine Messung zwischen 12,8 und 13,0 V zählt hier noch als
gültige Batteriemessung (Diagrammpunkt, fließt in den Trend ein), zeigt
aber weiterhin 100 % Ladezustand ("12,8v voll was already right" - der
Nutzer bestätigte den ursprünglichen 12,8-V-Eckwert ausdrücklich). Auch
von BatterieListe.tsx verwendet, für die "Generatorspannung"-
Kennzeichnung dort - eine einzige Schwelle für beide Ansichten. */
export const AGM_RUHE_MAX_V = 13.0
/** Spannung → Ladezustand für eine AGM-Batterie in Ruhe. */
/** Spannung → Ladezustand für eine AGM-Batterie in Ruhe, linear zwischen den
Stützstellen interpoliert - identische Tabelle und Rechenweg wie im Panel
(`bvSocProzent()`/`BV_SOC_TABELLE`, `audi-dashboard-app.js`), auf
Nutzerwunsch angeglichen: die vorherige Stufenkurve hier lieferte bei
derselben Spannung teils deutlich andere Prozentwerte als das Panel (z. B.
12,1 V: 40 % im Panel, 25 % hier) - beide Ansichten sollen bei derselben
Messung dasselbe zeigen. Letzte Stützstelle bleibt bei 12,8 V/100 % -
unabhängig von der oben auf 13,0 V angehobenen AGM_RUHE_MAX_V, siehe
deren eigenen Kommentar. */
const LADEKURVE: [number, number][] = [
[12.8, 100],
[12.6, 90],
[12.4, 75],
[12.2, 50],
[12.0, 25],
[11.8, 10],
[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],
]
/** Deckungsgleich mit bvHexZuRgb()/bvMischen()/bvFarbe()/bvFarbstufen() im
Panel: der einzelne Messpunkt ist nach Ruhespannung eingefärbt (rot →
gelb → grün), nicht - wie hier bis 2026-08-30 - durchgehend `var(--red)`.
Rot als durchgehende Diagrammfarbe verstößt gegen die eigene Projektregel
"Rot ist Akzent, nie Fläche" (siehe die Statistik-/TabBar-Funde weiter
oben in dieser Datei) und war hier schlicht nie an die Panel-Logik
angeglichen worden. */
function hexZuRgb(hex: string): [number, number, number] {
const h = hex.trim().replace("#", "")
const r = parseInt(h.slice(0, 2), 16)
const g = parseInt(h.slice(2, 4), 16)
const b = parseInt(h.slice(4, 6), 16)
return [Number.isFinite(r) ? r : 0, Number.isFinite(g) ? g : 0, Number.isFinite(b) ? b : 0]
}
function mischen(a: [number, number, number], b: [number, number, number], t: number): [number, number, number] {
return [
Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t),
]
}
function farbstufenLesen() {
const stil = typeof getComputedStyle === "function" ? getComputedStyle(document.documentElement) : null
const wert = (name: string, ersatz: string) => {
const v = stil?.getPropertyValue(name).trim()
return v ? v : ersatz
}
return {
bad: hexZuRgb(wert("--bad", "#FF453A")),
warn: hexZuRgb(wert("--warn", "#FFD60A")),
ok: hexZuRgb(wert("--ok", "#30D158")),
}
}
function farbeFuerSpannung(v: number, stufen: ReturnType<typeof farbstufenLesen>): string {
let rgb: [number, number, number]
if (v <= 11.6) rgb = stufen.bad
else if (v <= 12.2) rgb = mischen(stufen.bad, stufen.warn, (v - 11.6) / (12.2 - 11.6))
else if (v <= 12.8) rgb = mischen(stufen.warn, stufen.ok, (v - 12.2) / (12.8 - 12.2))
else rgb = stufen.ok
return `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`
}
function ladezustand(spannung: number): number | null {
if (spannung > AGM_RUHE_MAX_V) return null
const ersterTreffer = LADEKURVE.find(([v]) => spannung >= v)
if (ersterTreffer) return ersterTreffer[1]
return 0
const t = LADEKURVE
if (spannung <= t[0]![0]) return 0
if (spannung >= t[t.length - 1]![0]) return 100
for (let i = 0; i < t.length - 1; i++) {
const [v0, s0] = t[i]!
const [v1, s1] = t[i + 1]!
if (spannung >= v0 && spannung <= v1) return Math.round(s0 + ((s1 - s0) * (spannung - v0)) / (v1 - v0))
}
return 100
}
export function Batterie() {
const { api, fahrzeug } = useDaten()
/** `bvSocLabel()` im Panel. */
function socLabel(soc: number): string {
return soc >= 90 ? "Sehr gut" : soc >= 70 ? "Gut" : soc >= 50 ? "Mittel" : soc >= 30 ? "Schwach" : "Kritisch"
}
/**
* `bvSohBewertung()` im Panel: Trend über die früheren gegen die späteren
* Messwerte, mit denselben Schwellen und demselben Wortlaut. Fehlte hier — die
* Bewertungskachel gab es gar nicht, stattdessen stand der Trend als nackte
* Wertzeile ohne Einordnung.
*/
const TAG_MS = 86_400_000
function sohBewertung(
punkte: { t: number; v: number }[],
): { status: "ok" | "warn" | "bad" | null; text: string; delta: number } {
if (punkte.length < 6)
return { status: null, text: "Noch nicht genug Messwerte für eine Trendbewertung.", delta: 0 }
const spanneTage = (punkte[punkte.length - 1]!.t - punkte[0]!.t) / TAG_MS
if (spanneTage < 60)
return {
status: null,
text: "Noch zu kurzer Zeitraum für eine Trendbewertung (mind. ~2 Monate nötig).",
delta: 0,
}
const fensterMs = Math.min(90, spanneTage / 3) * TAG_MS
const alt = punkte.filter((p) => p.t <= punkte[0]!.t + fensterMs)
const neu = punkte.filter((p) => p.t >= punkte[punkte.length - 1]!.t - fensterMs)
const mittel = (l: { v: number }[]) => l.reduce((s, p) => s + p.v, 0) / l.length
const delta = mittel(neu) - mittel(alt)
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 statusFarbe(status: "ok" | "warn" | "bad" | null): string {
return status === "ok"
? "var(--ok)"
: status === "warn"
? "var(--warn)"
: status === "bad"
? "var(--bad)"
: "var(--fg3)"
}
export function Batterie({ geheZu }: { geheZu: (name: SeitenName) => void }) {
const { api } = useDaten()
const [verlauf, setzeVerlauf] = useState<Tageswert[] | null>(null)
const [fehler, setzeFehler] = useState(false)
@@ -71,7 +184,12 @@ export function Batterie() {
if (!verlauf) return <div className="dm-laden">Verlauf wird geladen </div>
const messwerte = verlauf.filter((t) => t.min != null)
// Wie bvPunkte im Panel: Werte über AGM_RUHE_MAX_V sind Generatorspannung,
// keine Ruhespannung - ausgeschlossen von Kopfzeile, Trend UND Diagramm
// (eine einzige gefilterte Liste, kein separater "alle Messungen"-Bestand).
// Ohne diesen Filter hätte ein Tag, an dem das Fahrzeug nie lange genug
// stand, seine Generatorspannung fälschlich als "Ruhespannung" gezeigt.
const messwerte = verlauf.filter((t) => t.min != null && t.min <= AGM_RUHE_MAX_V)
if (messwerte.length === 0) {
return (
@@ -84,94 +202,333 @@ export function Batterie() {
const letzter = messwerte[messwerte.length - 1]!
const soc = letzter.min != null ? ladezustand(letzter.min) : null
const stufen = farbstufenLesen()
const punkte = messwerte
.filter((t) => t.min != null && t.min_ts)
.map((t) => ({ t: new Date(t.min_ts!).getTime(), v: t.min! }))
const soh = sohBewertung(punkte)
// Trend als Vergleich früher gegen später — keine echte Kapazitätsmessung.
const haelfte = Math.floor(messwerte.length / 2)
const mittel = (liste: Tageswert[]) =>
liste.length > 0 ? liste.reduce((s, t) => s + (t.min ?? 0), 0) / liste.length : null
const frueher = mittel(messwerte.slice(0, haelfte))
const spaeter = mittel(messwerte.slice(haelfte))
const trend = frueher != null && spaeter != null ? spaeter - frueher : null
/* Aufbau wie vBatterieverlauf(): eine Kachel „Verlauf" mit Diagramm und
Ruhespannungs-Skala, darunter eine Kachel „Bewertung" mit Ladezustand und
Gesundheitszustand. Hier stand bis 2026-08-30 eine einzelne Kachel
„Ruhespannung" mit großer Zahl und vier Wertzeilen — die Farbskala, die
Bewertungskachel und der Bewertungstext fehlten ganz, dafür gab es eine
Zeile „Aktuell gemeldet", die das Panel hier nicht führt. */
return (
<>
<Tile>
<span className="ads-eyebrow">Ruhespannung</span>
<div className="dm-detail__kopf">
<div className="dm-detail__zahl">
{letzter.min != null ? de(letzter.min, 2) : "—"}
<span className="dm-detail__einheit">V</span>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span className="ads-eyebrow">Verlauf</span>
<IconButton aria-label="Messwertliste" onClick={() => geheZu("battliste")}>
<SymbolListe groesse={18} />
</IconButton>
</div>
<Verlaufsdiagramm werte={messwerte} />
<div className="dm-skalakopf">
<SymbolBatterie groesse={15} />
<span>Ruhespannung</span>
</div>
<div className="dm-skalabalken" />
<div className="dm-skalamarken">
<small>11,6 V · leer</small>
<small>12,2 V · 50 %</small>
<small>12,8 V · voll</small>
</div>
</Tile>
<Tile>
<span className="ads-eyebrow">Bewertung</span>
<Werteliste
kinder={
<>
<Wertzeile label="Letzte Messung" wert={datum(letzter.datum)} />
<Wertzeile
label="Geschätzter Ladezustand"
wert={soc != null ? `${de(soc)} %` : "—"}
zusatz={soc == null ? "Messung nicht in Ruhe aufgenommen" : undefined}
/>
<Wertzeile
label="Trend über den Zeitraum"
wert={trend != null ? `${trend >= 0 ? "+" : ""}${de(trend, 2)} V` : "—"}
/>
<Wertzeile
label="Aktuell gemeldet"
label="Ladezustand (SOC)"
wert={
fahrzeug?.batteriespannung != null
? `${de(fahrzeug.batteriespannung, 2)} V`
: "—"
letzter.min != null && soc != null ? (
<span className="dm-bewertung">
<span
className="ads-dot"
style={{ background: farbeFuerSpannung(letzter.min, stufen) }}
/>
{de(letzter.min, 1)} V · {de(soc)} % · {socLabel(soc)}
</span>
) : (
<span className="dm-offen">unbekannt</span>
)
}
/>
<Wertzeile
label="Gesundheitszustand (SOH-Trend)"
wert={
soh.status ? (
<span className="dm-bewertung">
<span className="ads-dot" style={{ background: statusFarbe(soh.status) }} />
{soh.delta >= 0 ? "+" : ""}
{de(soh.delta, 2)} V
</span>
) : (
<span className="dm-offen">-</span>
)
}
/>
</>
}
/>
<p className="dm-fussnote">
Der Ladezustand wird aus dem Tagesminimum geschätzt das ist die Spannung kurz nach dem
Start, bevor die Lichtmaschine sie anhebt. Der Trend vergleicht die frühere mit der
späteren Hälfte des Zeitraums; er ist ein Hinweis, keine Kapazitätsmessung.
</p>
<span className="dm-bewertungstext">{soh.text}</span>
</Tile>
</>
)
}
/** Von Hand gezeichnete Verlaufskurve aus Tagesminimum und -maximum. */
function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
const breite = 320
const hoehe = 120
const rand = 4
/** Von Hand gezeichnete Verlaufskurve aus dem Tagesminimum, mit einem Punkt je
Messung. Der Tageshöchstwert bekommt bewusst KEINE eigene Linie mehr
(Nutzerwunsch, Parität zum Panel) - er erscheint als Text, sobald ein Punkt
angetippt wird, zusammen mit Datum und Uhrzeit.
const alleWerte = werte.flatMap((t) => [t.min, t.max].filter((v): v is number => v != null))
const kleinster = Math.min(...alleWerte) - 0.1
const groesster = Math.max(...alleWerte) + 0.1
const spanne = Math.max(0.2, groesster - kleinster)
Die viewBox folgt der tatsächlich gemessenen Pixelbreite, statt eine feste
320er-Fläche mit preserveAspectRatio="none" breitzuziehen: sonst würden die
neuen Punkte auf breiten Anzeigen zu Ellipsen verzerrt (im Panel war
dasselbe Problem an den auseinandergezogenen Achsenzahlen sichtbar). */
/** Hält einen Zoomausschnitt innerhalb des vorhandenen Zeitraums. */
function begrenzen(
bereich: [number, number],
voll: [number, number],
): [number, number] {
const spanne = Math.min(bereich[1] - bereich[0], voll[1] - voll[0])
let von = bereich[0]
if (von < voll[0]) von = voll[0]
if (von + spanne > voll[1]) von = voll[1] - spanne
return [von, von + spanne]
}
const BV_H = 170
const BV_ML = 40
const BV_MR = 10
const BV_MT = 10
const BV_MB = 12
function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
const hoehe = BV_H
const svgRef = useRef<SVGSVGElement | null>(null)
const [breite, setzeBreite] = useState(320)
const [gewaehlt, setzeGewaehlt] = useState<number | null>(null)
/* Zoomen und Verschieben wie im Panel (bvDomain/bvZeiger/bvGestStart):
zwei Finger spreizen den Zeitausschnitt, einer verschiebt ihn, der Knopf
stellt ihn zurück. Fehlte hier ganz — die Kurve war unveränderlich. */
const [ausschnitt, setzeAusschnitt] = useState<[number, number] | null>(null)
const zeiger = useRef(new Map<number, number>())
const gestStart = useRef<{ domain: [number, number]; mitte: number; spanne: number } | null>(null)
useEffect(() => {
const el = svgRef.current
if (!el || typeof ResizeObserver === "undefined") return
const messen = () => {
const w = el.getBoundingClientRect().width
if (w > 0) setzeBreite(w)
}
messen()
const beobachter = new ResizeObserver(messen)
beobachter.observe(el)
return () => beobachter.disconnect()
}, [])
// Feste Achse 10-15V (Nutzerwunsch): deckt den relevanten Bereich ab
// (12V-Bleibatterie, Ladespannung des Alternators mit Reserve).
const kleinster = 10
const groesster = 15
const spanne = groesster - kleinster
// Zeitachse: echte Zeitstempel statt gleichmäßiger Indexschritte, sonst
// ließe sich der Ausschnitt nicht sinnvoll zoomen.
const zeitVon = (t: Tageswert) => new Date(t.min_ts ?? t.datum).getTime()
const vollDomain: [number, number] =
werte.length >= 2
? [zeitVon(werte[0]!), zeitVon(werte[werte.length - 1]!)]
: [0, 1]
const domain = ausschnitt ?? vollDomain
const zeitSpanne = Math.max(1, domain[1] - domain[0])
const x = (i: number) =>
rand + (i / Math.max(1, werte.length - 1)) * (breite - 2 * rand)
const y = (v: number) => hoehe - rand - ((v - kleinster) / spanne) * (hoehe - 2 * rand)
BV_ML +
((zeitVon(werte[i]!) - domain[0]) / zeitSpanne) * (breite - BV_ML - BV_MR)
// Anteil auf [0,1] geklemmt statt roh extrapoliert - ein Ausreißer außerhalb
// von kleinster/groesster zeichnet sich sonst weit außerhalb der Fläche.
const y = (v: number) => {
const anteil = Math.max(0, Math.min(1, (v - kleinster) / spanne))
return hoehe - BV_MB - anteil * (hoehe - BV_MT - BV_MB)
}
const linie = (auswahl: (t: Tageswert) => number | null) =>
werte
.map((t, i) => {
const v = auswahl(t)
return v == null ? null : `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(v).toFixed(1)}`
})
.filter(Boolean)
.join(" ")
const punkteText = werte
.map((t, i) => (t.min == null ? null : `${x(i).toFixed(1)},${y(t.min).toFixed(1)}`))
.filter(Boolean)
.join(" ")
const punktWaehlen = (ereignis: { clientX: number }) => {
const el = svgRef.current
if (!el || werte.length === 0) return
const kasten = el.getBoundingClientRect()
const anteil = (ereignis.clientX - kasten.left - BV_ML) / Math.max(1, breite - BV_ML - BV_MR)
const zeit = domain[0] + anteil * zeitSpanne
let bester = 0
let abstand = Infinity
werte.forEach((t, i) => {
const a = Math.abs(zeitVon(t) - zeit)
if (a < abstand) {
abstand = a
bester = i
}
})
setzeGewaehlt((vorher) => (vorher === bester ? null : bester))
}
const zeigerRunter = (e: React.PointerEvent<SVGSVGElement>) => {
zeiger.current.set(e.pointerId, e.clientX)
if (zeiger.current.size === 2) {
const [a, b] = [...zeiger.current.values()]
gestStart.current = {
domain: [domain[0], domain[1]],
mitte: (a! + b!) / 2,
spanne: Math.abs(a! - b!) || 1,
}
} else if (zeiger.current.size === 1) {
gestStart.current = { domain: [domain[0], domain[1]], mitte: e.clientX, spanne: 0 }
}
}
const zeigerBewegt = (e: React.PointerEvent<SVGSVGElement>) => {
if (!zeiger.current.has(e.pointerId) || !gestStart.current) return
zeiger.current.set(e.pointerId, e.clientX)
const nutzbar = Math.max(1, breite - BV_ML - BV_MR)
const start = gestStart.current
const startSpanne = start.domain[1] - start.domain[0]
if (zeiger.current.size >= 2) {
const [a, b] = [...zeiger.current.values()]
const faktor = start.spanne / Math.max(1, Math.abs(a! - b!))
const neueSpanne = Math.max(
3600_000,
Math.min(vollDomain[1] - vollDomain[0], startSpanne * faktor),
)
const mitteZeit = start.domain[0] + (startSpanne * (start.mitte - BV_ML)) / nutzbar
setzeAusschnitt(begrenzen([mitteZeit - neueSpanne / 2, mitteZeit + neueSpanne / 2], vollDomain))
} else {
const verschub = ((start.mitte - e.clientX) / nutzbar) * startSpanne
setzeAusschnitt(
begrenzen([start.domain[0] + verschub, start.domain[1] + verschub], vollDomain),
)
}
}
const zeigerHoch = (e: React.PointerEvent<SVGSVGElement>) => {
zeiger.current.delete(e.pointerId)
if (zeiger.current.size === 0) gestStart.current = null
}
const aktiv = gewaehlt != null ? werte[gewaehlt] : undefined
const zeitpunkt = aktiv?.min_ts ? new Date(aktiv.min_ts) : null
const stufen = farbstufenLesen()
const kurz = (wert?: string) =>
wert
? new Date(wert).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
})
: ""
/* Gitterlinien mit Achsenbeschriftung und die weiche Flächenfüllung unter der
Linie fehlten hier ganz — das Panel zeichnet beide (bvZeichnen()), samt
dem Zeitraum unter der Fläche. Ohne sie stand die Kurve ohne jeden
Maßstab im Raum. */
const flaeche =
punkteText.length > 0 && werte.length >= 2
? `M${x(0).toFixed(1)},${(hoehe - BV_MB).toFixed(1)} L${punkteText.split(" ").join(" L")} L${x(
werte.length - 1,
).toFixed(1)},${(hoehe - BV_MB).toFixed(1)} Z`
: ""
return (
<svg
className="dm-diagramm"
viewBox={`0 0 ${breite} ${hoehe}`}
preserveAspectRatio="none"
role="img"
aria-label={`Spannungsverlauf über ${werte.length} Tage`}
>
<path d={linie((t) => t.max)} fill="none" stroke="var(--fg3)" strokeWidth="1" />
<path d={linie((t) => t.min)} fill="none" stroke="var(--red)" strokeWidth="1.5" />
</svg>
<>
<svg
ref={svgRef}
className="dm-diagramm"
viewBox={`0 0 ${breite.toFixed(1)} ${hoehe}`}
role="img"
aria-label={`Spannungsverlauf über ${werte.length} Messungen`}
onClick={punktWaehlen}
onPointerDown={zeigerRunter}
onPointerMove={zeigerBewegt}
onPointerUp={zeigerHoch}
onPointerCancel={zeigerHoch}
>
<defs>
<linearGradient id="dmBvGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="var(--fg)" stopOpacity="0.16" />
<stop offset="1" stopColor="var(--fg)" stopOpacity="0" />
</linearGradient>
</defs>
{[0, 1, 2, 3].map((i) => {
const v = kleinster + (spanne * i) / 3
const yy = y(v)
return (
<g key={i}>
<line
x1={BV_ML}
y1={yy}
x2={breite - BV_MR}
y2={yy}
stroke="var(--line)"
strokeWidth="0.5"
/>
<text x={BV_ML - 6} y={yy + 3} fontSize="11" fill="var(--fg3)" textAnchor="end">
{de(v, 1)}
</text>
</g>
)
})}
{flaeche && <path d={flaeche} fill="url(#dmBvGrad)" stroke="none" />}
<polyline
points={punkteText}
fill="none"
stroke="var(--fg)"
strokeWidth="1.5"
strokeLinejoin="round"
strokeLinecap="round"
/>
{werte.map((t, i) =>
t.min == null ? null : (
<circle
key={t.datum}
cx={x(i)}
cy={y(t.min)}
r={gewaehlt === i ? 4.5 : 2.4}
fill={farbeFuerSpannung(t.min, stufen)}
{...(gewaehlt === i ? { stroke: "var(--fg)", strokeWidth: 1 } : {})}
/>
),
)}
</svg>
<div className="dm-diagramm__fuss">
<span className="ads-eyebrow">
{kurz(new Date(domain[0]).toISOString())} {kurz(new Date(domain[1]).toISOString())}
</span>
<button type="button" className="ads-action dm-zoomzurueck" onClick={() => setzeAusschnitt(null)}>
Zoom zurücksetzen
</button>
</div>
{aktiv && (
<div className="dm-diagramm__tooltip">
{[
zeitpunkt
? `${datum(aktiv.datum)} · ${zeitpunkt.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr`
: datum(aktiv.datum),
aktiv.min != null ? `Min ${de(aktiv.min, 2)} V` : null,
aktiv.max != null ? `Max ${de(aktiv.max, 2)} V` : null,
aktiv.min_temp_c != null ? `${de(aktiv.min_temp_c, 1)} °C` : null,
]
.filter(Boolean)
.join(" · ")}
</div>
)}
</>
)
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Batteriespannungs-Messwertliste — ein Eintrag pro Tag (dieselben Daten wie
* das Diagramm in Batterie.tsx), aber als Liste: Datum, Uhrzeit, Spannung,
* Außentemperatur. Erreichbar über das Listen-Symbol oben rechts im
* Diagramm.
*
* Jede Zeile lässt sich wie Fahrten/Tankvorgänge nach links wegwischen, um
* sie zu löschen (Zeilenmenue - Wischgeste plus sichtbares Menü, siehe dort).
*
* Liegt der Tagesminimalwert über AGM_RUHE_MAX_V (Batterie.tsx), wurde er nie
* in Ruhe gemessen (das Fahrzeug stand an diesem Tag nie lange genug still) -
* die Zeile zeigt dann "Generatorspannung" statt so zu tun, als sei es ein
* Ruhewert.
*/
import { useEffect, useState } from "react"
import { Pill, Tile } from "@audi-dash/ui"
import { ENTITAETEN } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { datum, datumUndZeit, de } from "../format"
import { Leerzustand } from "./bausteine"
import { AGM_RUHE_MAX_V } from "./Batterie"
import { LOESCH_HINWEIS } from "./bestaetigung"
import { Zeilenmenue } from "./Zeilenmenue"
interface Tageswert {
datum: string
min: number | null
max: number | null
min_ts?: string
min_temp_c?: number | null
}
export function BatterieListe() {
const { api } = useDaten()
const [verlauf, setzeVerlauf] = useState<Tageswert[] | null>(null)
const [fehler, setzeFehler] = useState(false)
const neuLaden = () => {
void api.rest
.datenLesen<Tageswert[]>(ENTITAETEN.batterieverlauf)
.then((daten) => setzeVerlauf(Array.isArray(daten) ? daten : []))
.catch(() => setzeFehler(true))
}
useEffect(neuLaden, [api])
if (fehler) {
return (
<Leerzustand
titel="Liste nicht abrufbar"
text="Der Server hat die Messwertliste nicht geliefert. Beim nächsten Aufruf klappt es vielleicht wieder."
/>
)
}
if (!verlauf) return <div className="dm-laden">Liste wird geladen </div>
const messwerte = verlauf.filter((t) => t.min != null).slice().reverse()
if (messwerte.length === 0) {
return (
<Leerzustand
titel="Keine Messwerte"
text="Für dieses Fahrzeug liefert die Anbindung derzeit keinen Spannungssensor."
/>
)
}
const loeschen = async (eintrag: Tageswert) => {
await api.batterieverlaufEintragLoeschen(eintrag.datum)
neuLaden()
}
return (
<Tile>
{messwerte.map((t) => (
<Zeilenmenue
key={t.datum}
loeschFrage={`Messwert vom ${datum(t.datum)} löschen?`}
loeschHinweis={LOESCH_HINWEIS.batt}
beiLoeschen={() => void loeschen(t)}
kinder={
<div className="dm-listenzeile">
<span className="dm-listenzeile__haupt">
<span className="dm-listenzeile__titel">
{de(t.min!, 2)} V
<span className="dm-listenzeile__neben">
{t.min_temp_c != null ? `${de(t.min_temp_c, 1)} °C` : ""}
</span>
</span>
<span className="dm-listenzeile__unter">
{t.min_ts ? datumUndZeit(t.min_ts) : datum(t.datum)}
</span>
</span>
{t.min! > AGM_RUHE_MAX_V && <Pill>Generatorspannung</Pill>}
</div>
}
/>
))}
</Tile>
)
}
+129
View File
@@ -0,0 +1,129 @@
/**
* „Beleg hochladen" — Vorlage: `vBelegPopup()` im Panel.
*
* Bis 2026-08-31 öffnete der Beleg-Knopf in der App direkt die Dateiauswahl.
* Auf dem iPhone landete man damit im Dateispeicher, und ein aus der Mail
* kopiertes PDF war überhaupt nicht einzufügen — im Panel gab es diesen Weg
* längst. Geteilt zwischen dem Anlegen (Tanken.tsx) und dem Nachtragen
* (TankDetail.tsx), damit beide nicht auseinanderlaufen.
*
* Ein Auswahlblatt, kein Popup: es ist eine kurze Liste von Wegen zum selben
* Ziel, und genau dafür sieht Apple das Aktionsblatt vor. „Abbrechen" steht
* abgesetzt in eigener Karte — das macht es zum Ausweg statt zur vierten
* Wahlmöglichkeit.
*
* Welche Wege angeboten werden, entscheidet das Gerät, nicht der Geschmack:
* am Telefon ist Einfügen der Hauptweg, am Rechner Ziehen bzw. Dateiauswahl.
* Begründung in daten/zwischenablage.ts.
*/
import { useRef, useState } from "react"
import { ActionSheet } from "@audi-dash/ui"
import {
ZwischenablageFehler,
pdfAusDateiliste,
pdfAusZwischenablage,
zwischenablageKnopfSinnvoll,
} from "../daten/zwischenablage"
export function BelegPopup({
offen,
beiAbbruch,
beiDatei,
}: {
offen: boolean
beiAbbruch: () => void
/** Bekommt das PDF. Das Blatt schließt sich vorher selbst. */
beiDatei: (datei: File) => void
}) {
const dateiwahl = useRef<HTMLInputElement | null>(null)
const [fehler, setzeFehler] = useState<string | null>(null)
const [darueber, setzeDarueber] = useState(false)
// Einmal beim Aufbau bestimmt: ein Gerätewechsel mitten im Dialog gibt es
// nicht, und ein bei jedem Rendern neu befragtes matchMedia wäre nur Aufwand.
const [telefon] = useState(zwischenablageKnopfSinnvoll)
const uebernehmen = (datei: File) => {
setzeFehler(null)
beiDatei(datei)
}
const einfuegen = async () => {
try {
uebernehmen(await pdfAusZwischenablage())
} catch (f) {
setzeFehler(
f instanceof ZwischenablageFehler
? f.message
: 'Die Zwischenablage ließ sich nicht lesen - bitte "Datei auswählen".',
)
}
}
const aktionen = [
...(telefon
? [{ label: "Aus Zwischenablage einfügen", onClick: () => void einfuegen() }]
: []),
{ label: "Datei auswählen", onClick: () => dateiwahl.current?.click() },
]
// Nur wenn es wirklich etwas zu zeigen gibt - sonst entstuende im Blatt
// eine leere Zeile samt Trennlinie.
const zusatz =
!telefon || fehler ? (
<>
{!telefon && (
<div
className={`dm-beleg-drop${darueber ? " dm-beleg-drop--darueber" : ""}`}
onDragOver={(e) => {
e.preventDefault()
setzeDarueber(true)
}}
onDragLeave={() => setzeDarueber(false)}
onDrop={(e) => {
e.preventDefault()
setzeDarueber(false)
const datei = pdfAusDateiliste(e.dataTransfer?.files)
if (datei) uebernehmen(datei)
else setzeFehler("Das war kein PDF - der Beleg muss als PDF vorliegen.")
}}
>
<strong>PDF hierher ziehen</strong>
<span>oder unten auswählen</span>
</div>
)}
{fehler && (
<p className="dm-fehler" role="alert" style={{ margin: 0 }}>
{fehler}
</p>
)}
</>
) : undefined
return (
<>
{/* Ausserhalb des Blattes: unsichtbar, muss aber im Baum stehen, damit
der Verweis darauf zeigt. */}
<input
ref={dateiwahl}
type="file"
accept="application/pdf,.pdf"
hidden
onChange={(e) => {
const datei = e.target.files?.[0]
e.target.value = ""
if (datei) uebernehmen(datei)
}}
/>
{/* Ohne Ueberschrift und ohne Erklaerzeile: wer den Knopf 'Beleg
hochladen' gedrueckt hat, weiss bereits, was hier passiert - der
Kopf haette es nur wiederholt (Nutzerhinweis 2026-08-31). Die
beiden Wege benennen sich selbst. */}
<ActionSheet open={offen} onClose={beiAbbruch} actions={aktionen}>
{zusatz}
</ActionSheet>
</>
)
}
+10 -1
View File
@@ -16,20 +16,29 @@ export function Bild({
beschreibung,
name,
groesse,
dateiZeigen,
}: {
datei: string | null | undefined
beschreibung: string
name: string
groesse?: "default" | "mini" | "round"
/** Zeigt den Dateinamen statt der Handlungsaufforderung - so macht es das
Panel auf der Einstellungen-Seite (dateiZeigen in bildMitPlatzhalter()). */
dateiZeigen?: boolean
}) {
const url = bildUrl(datei)
// Unter der Bezeichnung steht im Panel die Handlungsaufforderung, nicht der
// Dateiname (`.platzhalter-aktion` zeigt "Foto hinzufügen";
// `.platzhalter-datei` ist dort ausdrücklich ausgeblendet). In der 78px
// breiten Miniaturansicht bleibt kein Platz dafür.
const mitAktion = groesse !== "mini" && groesse !== "round"
return (
<ImagePlaceholder
{...(url ? { src: url } : {})}
{...(groesse ? { size: groesse } : {})}
{...(mitAktion ? { hint: dateiZeigen && datei ? datei : "Foto hinzufügen" } : {})}
alt={beschreibung}
label={name}
hint={datei ?? undefined}
/>
)
}
@@ -0,0 +1,248 @@
/**
* "Datensatz sichern"/"Datensatz laden" — Einstellungen/Fahrzeugprofil.
* Vorlage: `vDatensatzPopup()` im Panel (audi-dashboard-app.js).
*
* "Sichern" löst vier sofortige, rein clientseitige Exporte aus
* (Fahrzeugprofil als JSON, Fahrten/Tankvorgänge/Wartungsplan als CSV).
* "Laden" spiegelt dieselben vier Dateien als Dateiauswahl - Fahrzeugprofil
* über den bestehenden profil_schreiben-Dienst (api.profilSchreiben()),
* die drei CSV-Datensätze über den neuen csv_importieren-Dienst
* (api.csvImportieren(), custom_components/audi_dashboard/csv_import.py):
* eine Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles
* andere am Datensatz bleibt unangetastet - siehe dessen Moduldocstring für
* die Begründung.
*
* Die CSV-Spaltennamen sind absichtlich BYTE-IDENTISCH zum Panel-Export
* ("ID","Start","Ende","km","Art","Status" usw.) - derselbe Backend-Parser
* bedient beide Oberflächen, eine hier exportierte Datei muss sich also auch
* im Panel importieren lassen und umgekehrt. Vorher hatte diese Seite eigene,
* abweichende Spaltennamen ("Beginn"/"Strecke km"/"Dauer s") - die gab es
* nur zum Lesen in einer Tabelle, nie zum Reimport, ein Rundlauf wäre also
* gescheitert.
*/
import { useEffect, useRef, useState } from "react"
import { ActionButton, Sheet } from "@audi-dash/ui"
import type { CsvImportErgebnis, DataMetricApi, Fahrt, Profil, Tankvorgang } from "../api"
import { de, eur } from "../format"
import { csvHerunterladen } from "./csv"
import type { ServicebuchEintrag } from "./Service"
type Art = "fahrzeugprofil" | "fahrten" | "tanken" | "service"
const ARTEN: [Art, string][] = [
["fahrzeugprofil", "Fahrzeugprofil"],
["fahrten", "Fahrten"],
["tanken", "Tankvorgänge"],
["service", "Wartungsplan"],
]
function exportieren(
art: Art,
rohprofil: Profil | null,
fahrten: readonly Fahrt[],
tankvorgaenge: readonly Tankvorgang[],
wartungsplan: readonly ServicebuchEintrag[],
) {
if (art === "fahrzeugprofil") {
const blob = new Blob([JSON.stringify(rohprofil, null, 2)], { type: "application/json" })
const url = URL.createObjectURL(blob)
const verweis = document.createElement("a")
verweis.href = url
verweis.download = "fahrzeugprofil.json"
verweis.click()
URL.revokeObjectURL(url)
return
}
if (art === "fahrten") {
csvHerunterladen(
"fahrten",
["ID", "Start", "Ende", "km", "Art", "Status"],
fahrten.map((f) => [f.trip_id, f.ts_start, f.ts_end, f.distance_km ?? "", f.art, f.status]),
)
return
}
if (art === "tanken") {
csvHerunterladen(
"tankvorgaenge",
["ID", "Zeitpunkt", "Station", "Liter", "€/l", "Kosten €"],
tankvorgaenge.map((t) => [
t.tank_id,
t.ts,
t.station_name ?? "",
t.liters ?? "",
t.liters && t.fuel_total_eur != null ? de(t.fuel_total_eur / t.liters, 2) : "",
t.fuel_total_eur != null ? eur(t.fuel_total_eur) : "",
]),
)
return
}
csvHerunterladen(
"wartungsplan",
["Datum", "km", "Art", "Werkstatt", "Kosten €"],
wartungsplan.map((e) => [
e.datum ?? "",
e.km != null ? de(e.km) : "",
e.art ?? "",
e.werkstatt ?? "",
e.kosten != null ? eur(e.kosten) : "",
]),
)
}
function ergebnisText(art: Art, e: CsvImportErgebnis): string {
if (art === "fahrzeugprofil") return "Fahrzeugprofil geladen."
const teile: string[] = []
if (e.angelegt) teile.push(`${de(e.angelegt)} neu angelegt`)
if (e.aktualisiert) teile.push(`${de(e.aktualisiert)} aktualisiert`)
if (e.uebersprungen) teile.push(`${de(e.uebersprungen)} übersprungen`)
return teile.length ? teile.join(" · ") : "Keine gültigen Zeilen in der Datei gefunden."
}
export function DatensatzPopup({
offen,
modus,
beiSchliessen,
api,
rohprofil,
fahrten,
tankvorgaenge,
wartungsplan,
beiProfilGeladen,
beiFertig,
}: {
offen: boolean
modus: "sichern" | "laden"
beiSchliessen: () => void
api: DataMetricApi
rohprofil: Profil | null
fahrten: readonly Fahrt[]
tankvorgaenge: readonly Tankvorgang[]
wartungsplan: readonly ServicebuchEintrag[]
/** Nach einem geladenen Fahrzeugprofil: den neuen Stand übernehmen. */
beiProfilGeladen: (profil: Profil) => Promise<void>
/** Nach einem erfolgreichen CSV-Import: Fahrten-/Tankvorgangslisten neu laden. */
beiFertig: () => void
}) {
const [laeuft, setzeLaeuft] = useState<Art | null>(null)
const [ergebnis, setzeErgebnis] = useState<{ art: Art; text: string } | null>(null)
const [fehler, setzeFehler] = useState<string | null>(null)
const eingaben = useRef<Partial<Record<Art, HTMLInputElement | null>>>({})
const aktiv = useRef(true)
useEffect(() => {
aktiv.current = true
return () => {
aktiv.current = false
}
}, [])
useEffect(() => {
if (offen) {
setzeErgebnis(null)
setzeFehler(null)
setzeLaeuft(null)
}
}, [offen])
const schliessen = () => {
if (laeuft) return
beiSchliessen()
}
const dateiGewaehlt = async (art: Art, datei: File) => {
setzeLaeuft(art)
setzeErgebnis(null)
setzeFehler(null)
let inhalt: string
try {
inhalt = await datei.text()
} catch {
if (!aktiv.current) return
setzeLaeuft(null)
setzeFehler("Die Datei ließ sich nicht lesen.")
return
}
try {
if (art === "fahrzeugprofil") {
const profil = JSON.parse(inhalt) as Profil
await beiProfilGeladen(profil)
if (!aktiv.current) return
setzeErgebnis({ art, text: ergebnisText(art, {}) })
} else {
const e = await api.csvImportieren(art, inhalt)
if (!aktiv.current) return
setzeErgebnis({ art, text: ergebnisText(art, e) })
beiFertig()
}
} catch (err) {
if (!aktiv.current) return
setzeFehler(
art === "fahrzeugprofil"
? `Die Datei ließ sich nicht lesen: ${err instanceof Error ? err.message : "unbekannter Fehler"}`
: `Der Import ist fehlgeschlagen: ${err instanceof Error ? err.message : "unbekannter Fehler"}`,
)
} finally {
if (aktiv.current) setzeLaeuft(null)
}
}
return (
/* Blatt statt schwebender Karte - Apples Vorgabe fuer eine vom Nutzer
angestossene Aufgabe auf dem Telefon. */
<Sheet
open={offen}
onClose={schliessen}
title={modus === "sichern" ? "Datensatz sichern" : "Datensatz laden"}
>
<p className="dm-fussnote">
{modus === "sichern"
? "Semikolon als Trennzeichen und deutsches Zahlenformat, damit die Dateien ohne Umweg in Tabellenprogrammen aufgehen."
: "Eine zuvor gesicherte, ggf. bearbeitete Datei wieder einspielen — eine Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles andere bleibt unangetastet."}
</p>
{ARTEN.map(([art, label]) =>
modus === "sichern" ? (
<div key={art} style={{ marginTop: 12 }}>
<ActionButton
onClick={() => exportieren(art, rohprofil, fahrten, tankvorgaenge, wartungsplan)}
>
{label}
{art === "fahrzeugprofil" ? " sichern" : " als CSV"}
</ActionButton>
</div>
) : (
<div key={art} style={{ marginTop: 12 }}>
<ActionButton
onClick={() => eingaben.current[art]?.click()}
disabled={laeuft !== null}
>
{laeuft === art ? "Lädt …" : `${label}${art === "fahrzeugprofil" ? " laden" : " (CSV) laden"}`}
</ActionButton>
<input
ref={(el) => {
eingaben.current[art] = el
}}
type="file"
accept={art === "fahrzeugprofil" ? "application/json" : "text/csv"}
hidden
onChange={(e) => {
const datei = e.target.files?.[0]
e.target.value = ""
if (datei) void dateiGewaehlt(art, datei)
}}
/>
</div>
),
)}
{ergebnis && <p className="dm-importergebnis">{ergebnis.text}</p>}
{fehler && <p className="dm-fussnote dm-fussnote--warnung">{fehler}</p>}
<div style={{ marginTop: 16 }}>
<ActionButton onClick={beiSchliessen} disabled={laeuft !== null}>
Fertig
</ActionButton>
</div>
</Sheet>
)
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Ein Datumsfeld, das seinen Wert auch dann anzeigt, wenn er erst später
* eintrifft.
*
* DAS PROBLEM
* -----------
* Alle Datumsfelder der App hängen an Werten aus dem Datenkontext, und der
* füllt sich asynchron: beim ersten Rendern steht dort ein leerer String, den
* echten Wert bekommt das Feld erst danach. WebKit — die Engine der iOS-Hülle —
* baut den inneren Aufbau eines `<input type="date">` beim ersten Rendern auf
* und zieht ihn bei einer späteren Wertänderung von außen nicht zuverlässig
* nach; sichtbar blieb „tt.mm.jjjj", und erst ein Antippen brachte das Datum
* zum Vorschein (gemeldet vom Eigentümer, 2026-08-31: „erst beim ersten Klick
* aufs Datumsfeld erscheinen diese korrekt").
*
* Verschärft wird das durch den `appearance`-Reset in screens.css, der für die
* Box-Optik nötig ist — ohne ihn zeigt iOS sein eigenes, stark abgerundetes
* Chrome („gestreckter Kreis statt Box").
*
* DIE LÖSUNG
* ----------
* Der Schlüssel wechselt genau einmal: von „leer" auf „gefuellt", sobald der
* echte Wert da ist. React baut das Feld an dieser Stelle neu auf, und WebKit
* legt seinen inneren Aufbau damit gleich mit dem Wert an.
*
* Bewusst NICHT der Wert selbst als Schlüssel: dann entstünde das Feld bei
* jeder Auswahl neu, verlöre den Fokus und schlösse auf dem Telefon die
* Datumsauswahl mitten in der Bedienung. Der Wechsel leer→gefüllt passiert
* dagegen genau einmal, und zwar bevor jemand das Feld anfassen kann.
*
* Das Panel braucht das nicht: dort schreibt jedes `render()` das Markup neu,
* das Feld entsteht also ohnehin immer mit seinem Wert.
*/
export function Datumsfeld({
wert,
beiAenderung,
className = "dm-eingabe",
...rest
}: {
wert: string
beiAenderung: (wert: string) => void
className?: string
} & Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "onChange" | "type" | "className"
>) {
return (
<input
key={wert ? "gefuellt" : "leer"}
type="date"
className={className}
value={wert}
onChange={(e) => beiAenderung(e.target.value)}
{...rest}
/>
)
}
+28 -1
View File
@@ -12,13 +12,28 @@ import { useState } from "react"
import { ActionButton, Feld, Tile } from "@audi-dash/ui"
import { ApiFehler, HassRest, basisUrlNormalisieren, zugangSpeichern } from "../api"
import { QrScanner, type QrFund } from "./QrScanner"
import { zugangMerken } from "./zugang"
/* Der Scanner hat nur hier einen Platz: er hilft bei der Ersteinrichtung, und
danach gibt es nichts mehr abzutippen. Angeboten wird er nur, wenn das
Gerät überhaupt eine Kamera an die App durchreicht - am Rechner wäre er ein
Knopf, der zwangsläufig in eine Fehlermeldung läuft. */
const kameraVorhanden = () =>
typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia
export function Einrichtung({ fertig }: { fertig: () => void }) {
const [adresse, setzeAdresse] = useState("")
const [token, setzeToken] = useState("")
const [laeuft, setzeLaeuft] = useState(false)
const [fehler, setzeFehler] = useState<string | null>(null)
const [scannerOffen, setzeScannerOffen] = useState(false)
const scanUebernehmen = (fund: QrFund) => {
setzeFehler(null)
if (fund.art === "adresse") setzeAdresse(fund.wert)
else setzeToken(fund.wert)
}
const verbinden = async () => {
setzeFehler(null)
@@ -92,6 +107,12 @@ export function Einrichtung({ fertig }: { fertig: () => void }) {
</Feld>
</Tile>
{kameraVorhanden() && (
<div className="dm-einrichtung__knoepfe">
<ActionButton onClick={() => setzeScannerOffen(true)}>QR-Code scannen</ActionButton>
</div>
)}
{fehler && (
<p className="dm-einrichtung__fehler" role="alert">
{fehler}
@@ -99,11 +120,17 @@ export function Einrichtung({ fertig }: { fertig: () => void }) {
)}
<div className="dm-einrichtung__knoepfe">
<ActionButton onClick={() => void verbinden()} disabled={laeuft}>
<ActionButton variant="primary" onClick={() => void verbinden()} disabled={laeuft}>
{laeuft ? "Verbinde …" : "Verbinden"}
</ActionButton>
</div>
<QrScanner
offen={scannerOffen}
schliessen={() => setzeScannerOffen(false)}
gefunden={scanUebernehmen}
/>
<p className="dm-einrichtung__hinweis">
Den Token erzeugst du in Home Assistant unter Profil Sicherheit Langlebige
Zugriffstoken". Deine Daten bleiben auf deinem Server.
File diff suppressed because it is too large Load Diff
+247 -63
View File
@@ -9,16 +9,64 @@
* ist, statt eine glaubwürdig aussehende Erfindung zu zeigen.
*/
import { Pill, Tile } from "@audi-dash/ui"
import { useEffect, useState } from "react"
import { ActionButton, Feld, Fig, Pill, Tile } from "@audi-dash/ui"
import type { Fahrt } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { datum, dauer, de, deOderStrich, uhrzeit } from "../format"
import { datum, dauer, de, streckeTeile, uhrzeit } from "../format"
import type { SeitenName } from "../navigation"
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
import { LOESCH_HINWEIS, LOESCH_TEXT, bestaetigen } from "./bestaetigung"
import { FahrtFelderFormular, fahrtFelderAusFahrt, fahrtFelderAuswerten } from "./FahrtFelder"
import { Karte } from "./Karte"
export function FahrtDetail({ id }: { id: string | undefined }) {
const { fahrten } = useDaten()
/** "YYYY-MM-DDTHH:mm" in Ortszeit für ein <input type="datetime-local">. */
function alsDatetimeLocal(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ""
const versatz = d.getTimezoneOffset() * 60_000
return new Date(d.getTime() - versatz).toISOString().slice(0, 16)
}
export function FahrtDetail({ id, geheZu }: { id: string | undefined; geheZu: (name: SeitenName) => void }) {
const { fahrten, api, jetztAktualisieren } = useDaten()
const fahrt = fahrten.find((f) => f.trip_id === id)
const [bearbeitenOffen, setzeBearbeitenOffen] = useState(false)
// Vorgriff auf die Antwort des Backends: der Dienstaufruf laeuft ueber die
// Warteschlange und der neue Stand kommt erst mit dem naechsten Push zurueck.
// Ohne diesen Zwischenwert bliebe die Pille nach dem Tippen sekundenlang auf
// dem alten Text stehen und der Tipp saehe aus, als waere er verloren
// gegangen. Das Panel macht dasselbe (dort direkt auf TRIPS, gefolgt von
// render()).
const [artVorgriff, setzeArtVorgriff] = useState<"privat" | "arbeitsweg" | null>(null)
/* Ortsangaben zu Start und Ziel - beide kommen mit der Fahrt.
Bis zum 01.09.2026 loeste dieser Bildschirm die Koordinaten selbst auf und
legte sie in seinen eigenen Zwischenspeicher. Jetzt macht das Backend es
einmal (geokodierung.py) und speichert das Ergebnis in der Fahrt: jedes
Geraet zeigt denselben Ort, ein frisch eingerichtetes Telefon zeigt ihn
sofort, und ein geleerter Speicher wirft nichts weg.
"wird ermittelt …" heisst: die Koordinaten stehen, der Ort noch nicht -
das naechste Screening traegt ihn nach. */
const ortText = (
adresse: string | null | undefined,
lat?: number | null,
lon?: number | null,
) => adresse ?? (lat == null || lon == null ? "unbekannt" : "wird ermittelt …")
const startOrt = ortText(fahrt?.start_address, fahrt?.start_lat, fahrt?.start_lon)
const zielOrt = ortText(fahrt?.end_address, fahrt?.end_lat, fahrt?.end_lon)
// Faellt der Vorgriff mit dem echten Wert zusammen, wird er ueberfluessig.
// Bleibt er stehen, wuerde eine spaetere Aenderung von aussen (Panel,
// zweites Geraet) von einem laengst erledigten Tipp ueberdeckt.
const echteArt = fahrt?.art
useEffect(() => {
if (artVorgriff && echteArt === artVorgriff) setzeArtVorgriff(null)
}, [echteArt, artVorgriff])
if (!fahrt) {
return (
@@ -29,65 +77,48 @@ export function FahrtDetail({ id }: { id: string | undefined }) {
)
}
const art = artVorgriff ?? fahrt.art
const artUmschalten = () => {
const neu = art === "arbeitsweg" ? "privat" : "arbeitsweg"
setzeArtVorgriff(neu)
void api.fahrtAktualisieren(fahrt.trip_id, { art: neu }).then(() => jetztAktualisieren())
}
const loeschen = async () => {
if (!fahrt) return
if (!(await bestaetigen(LOESCH_TEXT.trip, { text: LOESCH_HINWEIS.trip }))) return
void api.fahrtLoeschen(fahrt.trip_id)
geheZu("trips")
}
if (bearbeitenOffen) {
return <FahrtBearbeiten fahrt={fahrt} beiFertig={() => setzeBearbeitenOffen(false)} />
}
const hatPositionen = fahrt.start_lat != null && fahrt.start_lon != null
/* Aufbau 1:1 wie vTrip(): Karte, dann die randlose Distanz-Kachel mit der
52px-Zahl und der Art-Pille darunter, dann eine einzige Wertezeilen-Kachel
(Start/Ziel/Start- und Endkilometer/Dauer/Verbrauch) mit „Werte
bearbeiten", zuletzt „Fahrt löschen".
Vorher stand hier eine eigene Kopfzeile mit Datumsbereich und
Bearbeiten-Textknopf, die Werte in anderer Reihenfolge und unter anderen
Namen („Kilometerstand Beginn/Ende"), eine Zeile „Durchschnitt", die es im
Panel nicht gibt, zwei zusätzliche Erklärabsätze und Start/Ziel erst ganz
unten unter der Karte. */
return (
<>
<Tile>
<span className="ads-eyebrow">
{datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} bis {uhrzeit(fahrt.ts_end)} Uhr
</span>
<div className="dm-detail__kopf">
<div className="dm-detail__zahl">
{deOderStrich(fahrt.distance_km, 1)}
<span className="dm-detail__einheit">km</span>
</div>
<div className="dm-detail__marken">
{fahrt.art === "arbeitsweg" ? <Pill variant="work">Arbeitsweg</Pill> : <Pill>Privat</Pill>}
{fahrt.status === "offen" && <Pill>noch offen</Pill>}
{fahrt.source === "manual" && <Pill>selbst eingetragen</Pill>}
</div>
</div>
<Werteliste
kinder={
<>
<Wertzeile label="Dauer" wert={dauer(fahrt.duration_s)} />
<Wertzeile
label="Kilometerstand Beginn"
wert={fahrt.odo_start != null ? `${de(fahrt.odo_start)} km` : "—"}
/>
<Wertzeile
label="Kilometerstand Ende"
wert={fahrt.odo_end != null ? `${de(fahrt.odo_end)} km` : "—"}
/>
<Wertzeile
label="Durchschnitt"
wert={
fahrt.distance_km != null && fahrt.duration_s > 0
? `${de((fahrt.distance_km / fahrt.duration_s) * 3600, 1)} km/h`
: "—"
}
/>
</>
}
/>
</Tile>
{fahrt.status === "offen" && (
<p className="dm-fussnote">
Diese Fahrt wartet noch auf den Kilometerstand. Das Fahrzeug meldet ihn oft erst bei der
nächsten Fahrt die Strecke wird dann automatisch ergänzt.
</p>
)}
<Tile>
<span className="ads-eyebrow">Strecke</span>
<Tile className="dm-kartenkachel">
{hatPositionen ? (
<Karte
start={{ lat: fahrt.start_lat!, lon: fahrt.start_lon! }}
{...(fahrt.end_lat != null && fahrt.end_lon != null
? { ziel: { lat: fahrt.end_lat, lon: fahrt.end_lon } }
: {})}
{...(fahrt.route && fahrt.route.length >= 2 ? { route: fahrt.route } : {})}
startText={fahrt.start_address ?? fahrt.start_stadt ?? ""}
zielText={fahrt.end_address ?? fahrt.end_stadt ?? ""}
/>
) : (
<Leerzustand
@@ -95,17 +126,170 @@ export function FahrtDetail({ id }: { id: string | undefined }) {
text="Das Fahrzeug liefert derzeit keine Positionsdaten. Sobald es das tut, erscheint hier die gefahrene Strecke auf der Karte."
/>
)}
{(fahrt.start_address || fahrt.end_address) && (
<Werteliste
kinder={
<>
<Wertzeile label="Start" wert={fahrt.start_address ?? "—"} />
<Wertzeile label="Ziel" wert={fahrt.end_address ?? "—"} />
</>
}
/>
)}
</Tile>
<Tile variant="flat" className="dm-distanzkachel">
<span className="ads-eyebrow">Distanz</span>
<div style={{ marginTop: "10px" }}>
<Fig
value={(streckeTeile(fahrt.distance_km) ?? { wert: "" }).wert}
unit={(streckeTeile(fahrt.distance_km) ?? { einheit: "km" }).einheit}
size={52}
/>
</div>
<div style={{ marginTop: "15px" }}>
{/* Knopf, nicht Anzeige: im Panel schaltet dieselbe Pille seit jeher
zwischen Privat und Arbeitsweg um (data-art, fahrt_aktualisieren).
In der App war sie bis 2026-08-31 ein totes <span>. */}
<Pill onClick={artUmschalten} ariaLabel={`Art der Fahrt umschalten, derzeit ${art === "arbeitsweg" ? "Arbeitsweg" : "Privat"}`}>
{art === "arbeitsweg" ? "Arbeitsweg" : "Privat"}
</Pill>
</div>
</Tile>
<Tile>
<Werteliste
kinder={
<>
<Wertzeile
label="Start"
wert={`${datum(fahrt.ts_start)} · ${uhrzeit(fahrt.ts_start)} Uhr`}
zusatz={startOrt}
/>
<Wertzeile
label="Ziel"
wert={`${datum(fahrt.ts_end)} · ${uhrzeit(fahrt.ts_end)} Uhr`}
zusatz={zielOrt}
/>
<Wertzeile
label="Startkilometer"
wert={fahrt.odo_start != null ? `${de(fahrt.odo_start)} km` : "noch offen"}
/>
<Wertzeile
label="Endkilometer"
wert={fahrt.odo_end != null ? `${de(fahrt.odo_end)} km` : "noch offen"}
/>
{/* Der Dongle kommt aus dem Schlaf nur ueber seinen Beschleunigungssensor hoch
und braucht dafuer gemessene 28 s bis 19,5 min. Was in dieser Zeit gefahren
wird, zeichnet er nicht auf. Die Kilometer holt das Screening aus dem
Endkilometerstand der Vorgaengerin zurueck (verlauf.aufzeichnungsluecke_km),
die Route ist unwiederbringlich weg. Deshalb steht es hier: eine Fahrt, die
auf den ersten Metern keine Spur hat, soll nicht so aussehen, als waere sie
vollstaendig aufgezeichnet. */}
{fahrt.luecke_km ? (
<Wertzeile
label="Nicht aufgezeichnet"
wert={
fahrt.luecke_unsicher
? `${de(fahrt.luecke_km)} km am Anfang · Zuordnung unsicher`
: `${de(fahrt.luecke_km)} km am Anfang`
}
/>
) : null}
<Wertzeile label="Dauer" wert={dauer(fahrt.duration_s)} />
<Wertzeile
label="Ø Geschwindigkeit"
wert={
fahrt.avg_speed_kmh != null
? `${de(fahrt.avg_speed_kmh, 1)} km/h`
: "liegt nicht vor"
}
/>
<Wertzeile
label="Höchstgeschwindigkeit"
wert={
fahrt.vmax_kmh != null ? `${de(fahrt.vmax_kmh)} km/h` : "liegt nicht vor"
}
/>
<Wertzeile
label="Verbrauch"
wert={
fahrt.verbrauch_l_100km != null
? `${de(fahrt.verbrauch_l_100km, 1)} l/100 km`
: "liegt nicht vor"
}
/>
</>
}
/>
<ActionButton onClick={() => setzeBearbeitenOffen(true)}>Werte bearbeiten</ActionButton>
</Tile>
<ActionButton variant="destructive" onClick={() => void loeschen()}>
Fahrt löschen
</ActionButton>
</>
)
}
function FahrtBearbeiten({ fahrt, beiFertig }: { fahrt: Fahrt; beiFertig: () => void }) {
const { api } = useDaten()
const [start, setzeStart] = useState(alsDatetimeLocal(fahrt.ts_start))
const [ende, setzeEnde] = useState(alsDatetimeLocal(fahrt.ts_end))
const [felder, setzeFelder] = useState(fahrtFelderAusFahrt(fahrt))
const [fehler, setzeFehler] = useState<string | null>(null)
const [laeuft, setzeLaeuft] = useState(false)
const speichern = async () => {
setzeFehler(null)
if (!start || !ende) {
setzeFehler("Bitte Beginn und Ende angeben.")
return
}
if (new Date(ende) <= new Date(start)) {
setzeFehler("Das Ende muss nach dem Beginn liegen.")
return
}
setzeLaeuft(true)
try {
await api.fahrtAktualisieren(fahrt.trip_id, {
ts_start: new Date(start).toISOString(),
ts_end: new Date(ende).toISOString(),
...fahrtFelderAuswerten(felder),
})
beiFertig()
} catch (ursache) {
setzeFehler(ursache instanceof Error ? ursache.message : String(ursache))
} finally {
setzeLaeuft(false)
}
}
return (
<Tile>
<div className="dm-abschnitt" style={{ marginBottom: 0 }}>
<span className="ads-eyebrow">Fahrt bearbeiten</span>
<button type="button" className="dm-textknopf" onClick={beiFertig} disabled={laeuft}>
Abbrechen
</button>
</div>
<Feld label="Beginn">
<input
className="dm-eingabe"
type="datetime-local"
value={start}
onChange={(e) => setzeStart(e.target.value)}
/>
</Feld>
<Feld label="Ende">
<input
className="dm-eingabe"
type="datetime-local"
value={ende}
onChange={(e) => setzeEnde(e.target.value)}
/>
</Feld>
<FahrtFelderFormular werte={felder} setzeWerte={setzeFelder} />
{fehler && (
<p className="dm-fehler" role="alert">
{fehler}
</p>
)}
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
</div>
</Tile>
)
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Die Felder einer Fahrt jenseits von Zeitpunkt und Dauer - geteilt zwischen
* dem Neuanlegen (Fahrten.tsx) und dem Bearbeiten (FahrtDetail.tsx), damit
* beide Formulare nicht auseinanderlaufen. Vorlage: `fahrtFelder()` im
* Panel (audi-dashboard-app.js).
*/
import { Feld } from "@audi-dash/ui"
import type { FahrtFelder as FahrtFelderApi } from "../api"
export interface FahrtFelderEingabe {
art: "privat" | "arbeitsweg"
startOrt: string
zielOrt: string
odoStart: string
odoEnde: string
distanz: string
}
export const LEERE_FAHRT_FELDER: FahrtFelderEingabe = {
art: "privat",
startOrt: "",
zielOrt: "",
odoStart: "",
odoEnde: "",
distanz: "",
}
/** Vorbelegung aus einer bestehenden Fahrt fürs Bearbeiten-Formular. */
export function fahrtFelderAusFahrt(fahrt: {
art: "privat" | "arbeitsweg"
start_address?: string | null
end_address?: string | null
odo_start?: number | null
odo_end?: number | null
distance_km?: number | null
}): FahrtFelderEingabe {
return {
art: fahrt.art,
startOrt: fahrt.start_address ?? "",
zielOrt: fahrt.end_address ?? "",
odoStart: fahrt.odo_start != null ? String(fahrt.odo_start) : "",
odoEnde: fahrt.odo_end != null ? String(fahrt.odo_end) : "",
distanz: fahrt.distance_km != null ? String(fahrt.distance_km) : "",
}
}
/** Wandelt die Formulareingabe in die Felder um, die das Backend erwartet -
leere Felder werden als "nicht angegeben" (undefined) übertragen, nicht
als 0, sonst würde ein leeres Kilometerfeld einen echten Wert überschreiben. */
export function fahrtFelderAuswerten(
eingabe: FahrtFelderEingabe,
): Pick<FahrtFelderApi, "art" | "start_ort" | "ziel_ort" | "odo_start" | "odo_end" | "distanz"> {
const zahl = (wert: string): number | null => {
if (wert.trim() === "") return null
const n = Number(wert.replace(",", "."))
return Number.isFinite(n) ? n : null
}
return {
art: eingabe.art,
start_ort: eingabe.startOrt.trim() || null,
ziel_ort: eingabe.zielOrt.trim() || null,
odo_start: zahl(eingabe.odoStart),
odo_end: zahl(eingabe.odoEnde),
distanz: zahl(eingabe.distanz),
}
}
export function FahrtFelderFormular({
werte,
setzeWerte,
}: {
werte: FahrtFelderEingabe
setzeWerte: (aendern: (alt: FahrtFelderEingabe) => FahrtFelderEingabe) => void
}) {
const feld = <K extends keyof FahrtFelderEingabe>(schluessel: K) => ({
value: werte[schluessel],
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
setzeWerte((alt) => ({ ...alt, [schluessel]: e.target.value })),
})
return (
<>
<Feld label="Art">
<select
className="dm-auswahl"
value={werte.art}
onChange={(e) =>
setzeWerte((alt) => ({ ...alt, art: e.target.value as "privat" | "arbeitsweg" }))
}
>
<option value="privat">Privat</option>
<option value="arbeitsweg">Arbeitsweg</option>
</select>
</Feld>
<Feld label="Startort">
<input className="dm-eingabe" placeholder="unbekannt" {...feld("startOrt")} />
</Feld>
<Feld label="Zielort">
<input className="dm-eingabe" placeholder="unbekannt" {...feld("zielOrt")} />
</Feld>
<Feld label="Startkilometer" unit="km">
<input className="dm-eingabe" inputMode="numeric" placeholder="wird ergänzt" {...feld("odoStart")} />
</Feld>
<Feld label="Endkilometer" unit="km">
<input className="dm-eingabe" inputMode="numeric" placeholder="wird ergänzt" {...feld("odoEnde")} />
</Feld>
<Feld label="Gefahrene Distanz" unit="km" last>
<input className="dm-eingabe" inputMode="decimal" placeholder="aus km-Stand" {...feld("distanz")} />
</Feld>
</>
)
}
+137 -87
View File
@@ -5,103 +5,160 @@
import { useState } from "react"
import { Accordion, ActionButton, Feld, Pill, Tile } from "@audi-dash/ui"
import { Accordion, ActionButton, Feld, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { nachJahrUndMonat } from "../daten/statistik"
import { datum, dauer, de, deOderStrich, monatJahr, summe, uhrzeit } from "../format"
import { datum, de, streckeText, summe } from "../format"
import type { SeitenName } from "../navigation"
import { Leerzustand } from "./bausteine"
import { Blattzeile, Leerzustand } from "./bausteine"
import { FahrtFelderFormular, LEERE_FAHRT_FELDER, fahrtFelderAuswerten } from "./FahrtFelder"
import type { FahrtFelderEingabe } from "./FahrtFelder"
import { LOESCH_HINWEIS } from "./bestaetigung"
import { Zeilenmenue } from "./Zeilenmenue"
/** Vorlage: strecke() im Panel - "Start → Ziel", nur eine Seite, oder
"Fahrt ohne Ortsangabe". Trug bisher in der Liste hier gar nichts bei
(nur auf der Einzelfahrt-Seite sichtbar) - im Panel ist das die
führende Information jeder Fahrtzeile. */
export function strecke(fahrt: {
start_address?: string | null
end_address?: string | null
start_stadt?: string | null
end_stadt?: string | null
edited_fields?: string[] | null
}): string | null {
// Der Ortsname kommt mit der Fahrt (start_stadt), aufgeloest im Backend -
// kein Abruf, kein Zwischenspeicher, auf jedem Geraet derselbe Wert.
//
// Eine VON HAND eingetragene Anschrift sticht ihn: wer "Zuhause" eintraegt,
// will das auch in der Liste lesen und nicht den Ortsnamen dazu. Erkennbar
// an edited_fields - genau dafuer fuehrt das Backend die Liste.
const handisch = new Set(fahrt.edited_fields ?? [])
const ortsname = (seite: "start" | "end"): string | null => {
const adresse = seite === "start" ? fahrt.start_address : fahrt.end_address
if (adresse && handisch.has(`${seite}_address`)) return adresse
const stadt = seite === "start" ? fahrt.start_stadt : fahrt.end_stadt
return stadt ?? adresse ?? null
}
const a = ortsname("start")
const b = ortsname("end")
if (!a && !b) return "Fahrt ohne Ortsangabe"
if (!b) return a ?? null
if (!a) return b
return `${a}${b}`
}
/** artText() im Panel: erster Buchstabe groß, sonst unverändert. */
function artText(art: string | null | undefined): string {
return art ? art.charAt(0).toUpperCase() + art.slice(1) : ""
}
export function Fahrten({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { fahrten, api } = useDaten()
const [formularOffen, setzeFormularOffen] = useState(false)
const gruppen = nachJahrUndMonat(fahrten, (f) => f.ts_start)
/* Aufbau 1:1 wie vTrips(): eine einzige Kachel um alle Jahre, die
Fahrtenzahl steht in der Jahreszeile (nicht als eigene Überschrift über
der Liste), die Monatszeile nennt nur den Monat, und das Formular steht
unter der Liste statt darüber. Die Zeilen selbst sind Blattzeilen mit der
Strecke als Hauptangabe und "Datum · Art" darunter — hier standen bisher
die Kilometer vorn und alles andere in einer langen Fußzeile, dazu zwei
Pillen (Arbeitsweg/offen), die es im Panel nicht gibt: die Art steht dort
im Zusatz der Beschriftung, der Status im Zusatz des Werts. */
return (
<>
<div className="dm-abschnitt">
<span className="ads-eyebrow">
{fahrten.length === 0
? "Keine Fahrten"
: `${de(fahrten.length)} Fahrt${fahrten.length === 1 ? "" : "en"}`}
</span>
<button
type="button"
className="dm-textknopf"
onClick={() => setzeFormularOffen((offen) => !offen)}
>
{formularOffen ? "Abbrechen" : "Fahrt eintragen"}
</button>
</div>
{formularOffen && <FahrtFormular beiFertig={() => setzeFormularOffen(false)} />}
{fahrten.length === 0 && !formularOffen && (
<Leerzustand
titel="Noch keine Fahrten"
text="Die App erkennt Fahrten automatisch, sobald sich das Fahrzeug meldet. Bis dahin kannst du Fahrten auch selbst eintragen."
aktion={{ beschriftung: "Fahrt eintragen", beiKlick: () => setzeFormularOffen(true) }}
/>
)}
{gruppen.map((jahr) => (
<Accordion
key={jahr.jahr}
title={String(jahr.jahr)}
summary={`${de(summe(jahr.eintraege, (f) => f.distance_km ?? 0))} km`}
defaultOpen={jahr === gruppen[0]}
>
{jahr.monate.map((monat) => (
{gruppen.length === 0 ? (
<Tile>
<Leerzustand
titel="Noch keine Fahrten"
text="Fahrten werden erkannt, sobald der Motor gestartet wird. Bis dahin lässt sich jede Fahrt von Hand erfassen."
/>
</Tile>
) : (
<Tile className="dm-liste">
{gruppen.map((jahr) => (
<Accordion
key={monat.schluessel}
level={2}
title={monatJahr(new Date(monat.jahr, monat.monat, 1))}
summary={`${de(summe(monat.eintraege, (f) => f.distance_km ?? 0))} km`}
defaultOpen={monat === jahr.monate[0]}
key={jahr.jahr}
title={String(jahr.jahr)}
summary={
<>
{streckeText(summe(jahr.eintraege, (f) => f.distance_km ?? 0))}
<br />
{jahr.eintraege.length} Fahrt{jahr.eintraege.length === 1 ? "" : "en"}
</>
}
defaultOpen={jahr === gruppen[0]}
>
{monat.eintraege.map((fahrt) => (
<Zeilenmenue
key={fahrt.trip_id}
loeschFrage={`Fahrt vom ${datum(fahrt.ts_start)} löschen?`}
beiLoeschen={() => void api.fahrtLoeschen(fahrt.trip_id)}
kinder={
<button
type="button"
className="dm-listenzeile"
onClick={() => geheZu("trip", fahrt.trip_id)}
>
<span className="dm-listenzeile__haupt">
<span className="dm-listenzeile__titel">
{deOderStrich(fahrt.distance_km, 1)} km
</span>
<span className="dm-listenzeile__unter">
{datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} Uhr ·{" "}
{dauer(fahrt.duration_s)}
</span>
</span>
{fahrt.art === "arbeitsweg" && <Pill variant="work">Arbeitsweg</Pill>}
{fahrt.status === "offen" && <Pill>offen</Pill>}
</button>
}
/>
{jahr.monate.map((monat) => (
<Accordion
key={monat.schluessel}
level={2}
title={monatName(monat.jahr, monat.monat)}
summary={streckeText(summe(monat.eintraege, (f) => f.distance_km ?? 0))}
defaultOpen={monat === jahr.monate[0]}
>
{monat.eintraege.map((fahrt, i) => (
<Zeilenmenue
key={fahrt.trip_id}
loeschFrage={`Fahrt vom ${datum(fahrt.ts_start)} löschen?`}
loeschHinweis={LOESCH_HINWEIS.trip}
beiLoeschen={() => void api.fahrtLoeschen(fahrt.trip_id)}
kinder={
<Blattzeile
label={strecke(fahrt)}
labelZusatz={`${datum(fahrt.ts_start)} · ${artText(fahrt.art)}`}
wert={streckeText(fahrt.distance_km, "—")}
wertZusatz={
/* Kein Statuswort mehr: "vollstaendig" sagt dem Nutzer nichts,
und "offen" verspricht Daten, die bei einer laengst
abgeschlossenen Fahrt nie mehr kommen. Fehlt der Wert,
steht dort ein Strich. */
fahrt.verbrauch_l_100km != null
? `${de(fahrt.verbrauch_l_100km, 1)} l/100 km`
: "—"
}
beiKlick={() => geheZu("trip", fahrt.trip_id)}
letzte={i === monat.eintraege.length - 1}
/>
}
/>
))}
</Accordion>
))}
</Accordion>
))}
</Accordion>
))}
</Tile>
)}
{formularOffen && <FahrtFormular beiFertig={() => setzeFormularOffen(false)} />}
<ActionButton
variant={formularOffen ? "default" : "primary"}
onClick={() => setzeFormularOffen((offen) => !offen)}
>
{formularOffen ? "Abbrechen" : "Fahrt erfassen"}
</ActionButton>
</>
)
}
/** Manuelles Eintragen — für Fahrten, die die automatische Erkennung verpasst hat. */
/** Monatsname allein, wie im Panel (`toLocaleDateString({month:"long"})`). */
function monatName(jahr: number, monat: number): string {
return new Date(jahr, monat, 1).toLocaleDateString("de-DE", { month: "long" })
}
/** Manuelles Eintragen — für Fahrten, die die automatische Erkennung verpasst hat.
Dieselben Felder wie beim Bearbeiten einer bestehenden Fahrt (siehe
FahrtFelderFormular in FahrtDetail.tsx) - beide teilen sich deshalb die
Feldgruppe. */
function FahrtFormular({ beiFertig }: { beiFertig: () => void }) {
const { api } = useDaten()
const [start, setzeStart] = useState("")
const [ende, setzeEnde] = useState("")
const [art, setzeArt] = useState<"privat" | "arbeitsweg">("privat")
const [felder, setzeFelder] = useState<FahrtFelderEingabe>(LEERE_FAHRT_FELDER)
const [fehler, setzeFehler] = useState<string | null>(null)
const [laeuft, setzeLaeuft] = useState(false)
@@ -117,7 +174,11 @@ function FahrtFormular({ beiFertig }: { beiFertig: () => void }) {
}
setzeLaeuft(true)
try {
await api.fahrtAnlegen(new Date(start).toISOString(), new Date(ende).toISOString(), art)
await api.fahrtAnlegen({
ts_start: new Date(start).toISOString(),
ts_end: new Date(ende).toISOString(),
...fahrtFelderAuswerten(felder),
})
beiFertig()
} catch (ursache) {
setzeFehler(ursache instanceof Error ? ursache.message : String(ursache))
@@ -128,7 +189,7 @@ function FahrtFormular({ beiFertig }: { beiFertig: () => void }) {
return (
<Tile>
<span className="ads-eyebrow">Fahrt eintragen</span>
<span className="ads-eyebrow">Neue Fahrt</span>
<Feld label="Beginn">
<input
className="dm-eingabe"
@@ -145,27 +206,16 @@ function FahrtFormular({ beiFertig }: { beiFertig: () => void }) {
onChange={(e) => setzeEnde(e.target.value)}
/>
</Feld>
<Feld label="Art" last>
<select
className="dm-auswahl"
value={art}
onChange={(e) => setzeArt(e.target.value as "privat" | "arbeitsweg")}
>
<option value="privat">Privat</option>
<option value="arbeitsweg">Arbeitsweg</option>
</select>
</Feld>
<FahrtFelderFormular werte={felder} setzeWerte={setzeFelder} />
{fehler && (
<p className="dm-fehler" role="alert">
{fehler}
</p>
)}
<p className="dm-fussnote">
Die Strecke ergänzt die App selbst, sobald sie den Kilometerstand zu Beginn und Ende der
Fahrt kennt.
</p>
{/* Ohne Erklärabsatz: das Panel führt im Neuanlage-Formular nur die
Felder und die Knöpfe. */}
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
<ActionButton variant="primary" onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
</div>
+245 -52
View File
@@ -1,13 +1,20 @@
/**
* Fahrzeugdaten: Identität, technisches Datenblatt, Ausstattung.
* Vorlage: `vIdent()` im alten Panel. Rein anzeigend — die Inhalte pflegt
* man im Profil, nicht hier.
* Vorlage: `vIdent()`/`vIdentBearbeiten()` im alten Panel.
*
* „Daten bearbeiten" öffnet dieselbe Bearbeitungsansicht wie dort: die Werte
* des Datenblatts und die Ausstattungslisten, jeweils mit „×" zum Entfernen
* und „+ Position hinzufügen". Die Kopfdaten (Modell, Kennzeichen, Ausführung,
* Erstzulassung, FIN) bleiben bewusst außen vor — die pflegt „Fahrzeug
* einrichten" in den Einstellungen.
*/
import { Accordion, Tile } from "@audi-dash/ui"
import { useState } from "react"
import { ActionButton, Feld, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { datum, de } from "../format"
import { datum } from "../format"
import { Wertzeile, Werteliste } from "./bausteine"
interface TechnikGruppe {
@@ -23,6 +30,7 @@ interface AusstattungGruppe {
export function Fahrzeugdaten() {
const { fahrzeug, einstellungen } = useDaten()
const [bearbeiten, setzeBearbeiten] = useState(false)
if (!fahrzeug || !einstellungen) return null
const technik = Array.isArray(fahrzeug.technik)
@@ -32,72 +40,257 @@ export function Fahrzeugdaten() {
? (fahrzeug.ausstattung as AusstattungGruppe[])
: []
if (bearbeiten) {
return (
<DatenBearbeiten
technik={technik}
ausstattung={ausstattung}
beiFertig={() => setzeBearbeiten(false)}
/>
)
}
/* Aufbau 1:1 wie vIdent(): eine Kachel je Technik- und je
Ausstattungsgruppe, alle Angaben unmittelbar sichtbar. Bis 2026-08-30
steckten sie hier in zugeklappten Aufklapp-Elementen — auf dem Bildschirm
blieben von über 3 000 Zeichen Datenblatt nur die Gruppennamen übrig.
Auch die Kopfkachel wich ab: sie hieß „Identität" statt „Fahrzeug", zeigte
„Hauptuntersuchung fällig" und „Kilometerstand", die das Panel hier nicht
führt, und ließ „Motor und Leistung" sowie „Zulassung" weg. */
return (
<>
<Tile>
<span className="ads-eyebrow">Identität</span>
<span className="ads-eyebrow">Fahrzeug</span>
<Werteliste
kinder={
<>
<Wertzeile label="Modell" wert={einstellungen.fahrzeugtitel || ""} />
<Wertzeile label="Ausführung" wert={einstellungen.ausfuehrung || fahrzeug.details || "—"} />
<Wertzeile label="Fahrgestellnummer" wert={fahrzeug.fin || "—"} />
<Wertzeile label="Kennzeichen" wert={einstellungen.kennzeichen || "—"} />
<Wertzeile label="Modell" wert={einstellungen.fahrzeugtitel || ""} />
<Wertzeile
label="Ausführung"
wert={einstellungen.ausfuehrung || "noch offen"}
/>
<Wertzeile label="FIN" wert={fahrzeug.fin || ""} />
<Wertzeile label="Motor und Leistung" wert={fahrzeug.details || ""} />
<Wertzeile
label="Erstzulassung"
wert={fahrzeug.erstzulassung ? datum(fahrzeug.erstzulassung) : ""}
/>
<Wertzeile
label="Hauptuntersuchung fällig"
wert={fahrzeug.hu ? datum(fahrzeug.hu) : "—"}
/>
<Wertzeile
label="Kilometerstand"
wert={fahrzeug.odoBekannt ? `${de(fahrzeug.odo)} km` : "—"}
wert={fahrzeug.erstzulassung ? datum(fahrzeug.erstzulassung) : ""}
/>
<Wertzeile label="Kennzeichen" wert={einstellungen.kennzeichen || ""} />
<Wertzeile label="Zulassung" wert="Deutschland" />
</>
}
/>
</Tile>
{technik.length > 0 && (
<Tile>
<span className="ads-eyebrow">Technische Daten</span>
{technik.map((gruppe, i) => (
<Accordion key={gruppe.gruppe ?? i} title={gruppe.gruppe ?? "Weitere Angaben"}>
<Werteliste
kinder={(gruppe.posten ?? []).map(([bezeichnung, wert]) => (
<Wertzeile key={bezeichnung} label={bezeichnung} wert={wert} />
))}
{technik.map((gruppe, i) => (
<Tile key={gruppe.gruppe ?? i}>
<span className="ads-eyebrow">{gruppe.gruppe ?? "Weitere Angaben"}</span>
<Werteliste
kinder={(gruppe.posten ?? []).map(([bezeichnung, wert]) => (
<Wertzeile
key={bezeichnung}
label={bezeichnung}
wert={wert ? wert : <span className="dm-offen">noch offen</span>}
/>
</Accordion>
))}
))}
/>
</Tile>
)}
))}
{ausstattung.length > 0 && (
<Tile>
<span className="ads-eyebrow">Ausstattung</span>
{ausstattung.map((gruppe, i) => (
<Accordion
key={gruppe.gruppe ?? i}
title={gruppe.gruppe ?? "Weitere Ausstattung"}
summary={`${(gruppe.sonder?.length ?? 0) + (gruppe.serie?.length ?? 0)}`}
>
<ul className="dm-liste">
{(gruppe.sonder ?? []).map((posten) => (
<li key={posten}>{posten}</li>
))}
{(gruppe.serie ?? []).map((posten) => (
<li key={posten} className="dm-liste__serie">
{posten}
</li>
))}
</ul>
</Accordion>
))}
{ausstattung.map((gruppe, i) => (
<Tile key={gruppe.gruppe ?? i}>
<span className="ads-eyebrow">{gruppe.gruppe ?? "Weitere Ausstattung"}</span>
<Werteliste
kinder={(gruppe.sonder ?? []).map((posten) => (
<Wertzeile key={posten} label={posten} wert="" />
))}
/>
</Tile>
)}
))}
<ActionButton onClick={() => setzeBearbeiten(true)}>Daten bearbeiten</ActionButton>
</>
)
}
/** Bearbeitungsansicht, Zuschnitt wie `vIdentBearbeiten()` im Panel. */
function DatenBearbeiten({
technik,
ausstattung,
beiFertig,
}: {
technik: TechnikGruppe[]
ausstattung: AusstattungGruppe[]
beiFertig: () => void
}) {
const { fahrzeug, profilSpeichern } = useDaten()
const [entwurfTechnik, setzeEntwurfTechnik] = useState<TechnikGruppe[]>(() =>
technik.map((g) => ({ ...g, posten: (g.posten ?? []).map((p) => [p[0], p[1]] as [string, string]) })),
)
const [entwurfAusstattung, setzeEntwurfAusstattung] = useState<AusstattungGruppe[]>(() =>
ausstattung.map((g) => ({ ...g, sonder: [...(g.sonder ?? [])] })),
)
const [laeuft, setzeLaeuft] = useState(false)
if (!fahrzeug) return null
/* Das Panel speichert jedes Feld sofort; hier folgt der Bildschirm dem
Muster der übrigen Formulare dieser App — Entwurf plus einem Knopf, der
schreibt und zurückführt (Paritätsregel: gleiches Verhalten, eigenes
Idiom). Bei rund 50 Feldern spart das ebenso viele Schreibvorgänge auf
das gesamte Profil. */
const speichern = async () => {
setzeLaeuft(true)
try {
await profilSpeichern({
fahrzeug: {
...fahrzeug,
technik: entwurfTechnik as unknown as typeof fahrzeug.technik,
ausstattung: entwurfAusstattung as unknown as typeof fahrzeug.ausstattung,
},
})
beiFertig()
} finally {
setzeLaeuft(false)
}
}
return (
<>
<Tile>
<span className="ads-eyebrow">Daten bearbeiten</span>
<span className="ads-eyebrow" style={{ marginTop: "10px" }}>
Hier stehen das technische Datenblatt und die Ausstattung. Modell, Kennzeichen,
Ausführung, Erstzulassung und Fahrgestellnummer pflegst du unter Einstellungen Fahrzeug
einrichten.
</span>
</Tile>
{entwurfTechnik.map((gruppe, gi) => (
<Tile key={gruppe.gruppe ?? gi}>
<span className="ads-eyebrow">{gruppe.gruppe ?? "Weitere Angaben"}</span>
{(gruppe.posten ?? []).map((posten, i) => (
<Feld key={i} label={posten[0]} last={i === (gruppe.posten?.length ?? 0) - 1}>
<span className="dm-selbst-zeile">
<input
className="dm-eingabe"
placeholder="noch offen"
value={posten[1]}
onChange={(e) => {
const wert = e.target.value
setzeEntwurfTechnik((alt) =>
alt.map((g, gj) =>
gj !== gi
? g
: {
...g,
posten: (g.posten ?? []).map((p, pj) =>
pj === i ? ([p[0], wert] as [string, string]) : p,
),
},
),
)
}}
/>
<button
type="button"
className="dm-zeile-loeschen"
aria-label={`${posten[0]} entfernen`}
onClick={() =>
setzeEntwurfTechnik((alt) =>
alt.map((g, gj) =>
gj !== gi
? g
: { ...g, posten: (g.posten ?? []).filter((_, pj) => pj !== i) },
),
)
}
>
×
</button>
</span>
</Feld>
))}
{(gruppe.posten?.length ?? 0) === 0 && (
<span className="ads-eyebrow" style={{ marginTop: "8px" }}>
Keine Angaben
</span>
)}
<ActionButton
onClick={() =>
setzeEntwurfTechnik((alt) =>
alt.map((g, gj) =>
gj !== gi
? g
: { ...g, posten: [...(g.posten ?? []), ["Neue Angabe", ""] as [string, string]] },
),
)
}
>
+ Position hinzufügen
</ActionButton>
</Tile>
))}
{entwurfAusstattung.map((gruppe, gi) => (
<Tile key={gruppe.gruppe ?? gi}>
<span className="ads-eyebrow">{gruppe.gruppe ?? "Weitere Ausstattung"}</span>
{(gruppe.sonder ?? []).map((posten, i) => (
<Feld key={i} label={`Position ${i + 1}`} last={i === (gruppe.sonder?.length ?? 0) - 1}>
<span className="dm-selbst-zeile">
<input
className="dm-eingabe"
value={posten}
onChange={(e) => {
const wert = e.target.value
setzeEntwurfAusstattung((alt) =>
alt.map((g, gj) =>
gj !== gi
? g
: { ...g, sonder: (g.sonder ?? []).map((s, sj) => (sj === i ? wert : s)) },
),
)
}}
/>
<button
type="button"
className="dm-zeile-loeschen"
aria-label={`Position ${i + 1} entfernen`}
onClick={() =>
setzeEntwurfAusstattung((alt) =>
alt.map((g, gj) =>
gj !== gi
? g
: { ...g, sonder: (g.sonder ?? []).filter((_, sj) => sj !== i) },
),
)
}
>
×
</button>
</span>
</Feld>
))}
{(gruppe.sonder?.length ?? 0) === 0 && (
<span className="ads-eyebrow" style={{ marginTop: "8px" }}>
Keine Ausstattung hinterlegt
</span>
)}
<ActionButton
onClick={() =>
setzeEntwurfAusstattung((alt) =>
alt.map((g, gj) => (gj !== gi ? g : { ...g, sonder: [...(g.sonder ?? []), ""] })),
)
}
>
+ Position hinzufügen
</ActionButton>
</Tile>
))}
<ActionButton variant="primary" onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Fertig"}
</ActionButton>
</>
)
}
+103 -51
View File
@@ -1,20 +1,26 @@
/**
* Fahrzeugstatus im Detail. Vorlage: `vSicherheit()` im alten Panel.
*
* Zeigt die 16 Einzelprüfungen aus `_sicherheitscheck`. Entscheidend:
* `ok === null` heißt „Sensor meldet nichts" und wird als unbekannt
* dargestellt — nie als grün geraten. Deshalb ist auch die Gesamtaussage
* unbekannt, sobald eine einzige Prüfung unbekannt ist.
* Vier Sammelzeilen statt der früheren flachen Liste: Fahrzeug verriegelt /
* Türen und Klappen geschlossen / Fenster und Dach geschlossen / Licht
* ausgeschaltet - jede fasst mehrere Punkte aus `_sicherheitscheck` über
* deren `gruppe`-Feld zusammen (siehe `gruppenErgebnis()` in bausteine.tsx).
* Entscheidend bleibt: `ok === null` heißt „Sensor meldet nichts" und wird
* als unbekannt dargestellt — nie als grün geraten. Nur "Türen und Klappen
* geschlossen" führt zu einer Detailseite (TuerenKlappen.tsx) mit jeder Tür,
* Motorhaube und dem Kofferraum einzeln.
*/
import { StatusRow, Tile } from "@audi-dash/ui"
import { Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { datumZeit } from "../format"
import { Leerzustand } from "./bausteine"
import type { SeitenName } from "../navigation"
import { gruppenErgebnis, Leerzustand, StatusKreis } from "./bausteine"
import { Bild } from "./Bild"
import { standortZustand } from "./Standort"
export function Fahrzeugstatus() {
const { fahrzeug, statusStand } = useDaten()
export function Fahrzeugstatus({ geheZu }: { geheZu: (name: SeitenName) => void }) {
const { fahrzeug } = useDaten()
if (!fahrzeug) return null
const punkte = fahrzeug.sicherheitscheck
@@ -27,54 +33,100 @@ export function Fahrzeugstatus() {
)
}
const unbekannt = punkte.filter((p) => p.ok === null).length
const offen = punkte.filter((p) => p.ok === false)
const verriegelt = gruppenErgebnis(punkte.filter((p) => p.gruppe === "verriegelt"))
const tuerenKlappen = gruppenErgebnis(punkte.filter((p) => p.gruppe === "tueren_klappen"))
const fensterDach = gruppenErgebnis(punkte.filter((p) => p.gruppe === "fenster_dach"))
const licht = gruppenErgebnis(punkte.filter((p) => p.gruppe === "licht"))
const gesichert = fahrzeug.gesichert
/* Dieselbe Regel wie auf der Übersicht: unterwegs ist der Wagen nicht
abgestellt (Vorgabe des Eigentümers, 05.09.2026). Die vier Sammelzeilen
darunter bleiben stehen und zeigen weiter, was die Tür-, Fenster- und
Schlosssensoren melden — nur die Überschrift darf nicht „abgestellt"
behaupten, während die Zündung an ist. */
const faehrt = standortZustand(fahrzeug).faehrt
const fehlerAnzahl =
typeof fahrzeug.fehlerspeicher?.anzahl === "number" ? fahrzeug.fehlerspeicher.anzahl : null
const fehlerOk = fehlerAnzahl === null ? null : fehlerAnzahl === 0
const fehlerText =
fehlerAnzahl === null
? "Fehlerspeicher unbekannt"
: fehlerAnzahl === 0
? "Keine Fehlermeldungen"
: fehlerAnzahl === 1
? "1 Fehlermeldung"
: `${fehlerAnzahl} Fehlermeldungen`
const sammelText = faehrt
? "Zündung an"
: gesichert === null
? "Zustand nicht vollständig bekannt"
: gesichert
? "Fahrzeug ist sicher abgestellt"
: "Bitte prüfen"
return (
<>
<StatusRow
status={fahrzeug.gesichert === true ? "ok" : fahrzeug.gesichert === false ? "bad" : "warn"}
title={
fahrzeug.gesichert === true
? "Alles verschlossen"
: fahrzeug.gesichert === false
? `${offen.length} Stelle${offen.length === 1 ? "" : "n"} offen`
: "Zustand nicht vollständig bekannt"
}
{...(statusStand ? { subtitle: `Stand ${datumZeit(statusStand)}` } : {})}
/>
{/* Draufsicht, wie im Panel (.szene.bleed.schmal, "carfix klein") - war
hier komplett unportiert (Parität-Audit 2026-08-30). Seit dem
02.09.2026 eingerückt statt randlos, siehe .dm-szene--schmal. */}
<div className="dm-szene dm-szene--schmal">
<Bild datei="draufsicht.webp" beschreibung="Draufsicht" name="Draufsicht" />
</div>
<Tile>
<span className="ads-eyebrow">Einzelprüfungen</span>
<ul className="dm-pruefliste">
{punkte.map((punkt) => (
<li key={punkt.label} className="dm-pruefliste__zeile">
<span
className={
punkt.ok === true
? "dm-punkt dm-punkt--ok"
: punkt.ok === false
? "dm-punkt dm-punkt--offen"
: "dm-punkt dm-punkt--unbekannt"
}
aria-hidden="true"
/>
<span className="dm-pruefliste__label">{punkt.label}</span>
<span className="dm-pruefliste__wert">
{punkt.ok === true ? "zu" : punkt.ok === false ? "offen" : "unbekannt"}
</span>
</li>
))}
</ul>
{/* Deckungsgleich mit der obersten Kachel in vSicherheit(): dasselbe
Kreis-Häkchen-Symbol wie die vier Zeilen unten (nicht der generische
StatusRow-Punkt), keine Zeitangabe - das Panel zeigt dort keine. */}
<Tile className="dm-sicherheitssammel">
{/* Während der Fahrt weder Haken noch Kreuz noch Fragezeichen, sondern
derselbe Punkt wie im Standortmenü — der Zustand ist dann nicht
„geprüft", sondern schlicht ein anderer. */}
{faehrt ? <span className="ads-dot ads-dot--warn" /> : <StatusKreis ok={gesichert} />}
<span>{sammelText}</span>
</Tile>
{unbekannt > 0 && (
<p className="dm-fussnote">
{unbekannt === 1 ? "Eine Prüfung ist" : `${unbekannt} Prüfungen sind`} unbekannt, weil das
Fahrzeug dazu nichts meldet. Solange das so ist, gilt die Gesamtaussage bewusst als
unbekannt statt als sicher.
</p>
)}
<Tile>
<div className="dm-sicherheitsliste">
<div className="dm-sicherheitszeile">
<StatusKreis ok={verriegelt} />
<span className="dm-sicherheitszeile__text">
{verriegelt === null ? "Verriegelung unbekannt" : verriegelt ? "Fahrzeug verriegelt" : "Fahrzeug nicht verriegelt"}
</span>
</div>
<button type="button" className="dm-sicherheitszeile" onClick={() => geheZu("tuerenklappen")}>
<StatusKreis ok={tuerenKlappen} />
<span className="dm-sicherheitszeile__text">
{tuerenKlappen === null
? "Türen/Klappen unbekannt"
: tuerenKlappen
? "Türen und Klappen geschlossen"
: "Türen oder Klappen offen"}
</span>
<svg className="dm-sicherheitszeile__chev" viewBox="0 0 6 10" width="6" height="10" aria-hidden="true">
<path d="M1 1l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.4" />
</svg>
</button>
<div className="dm-sicherheitszeile">
<StatusKreis ok={fensterDach} />
<span className="dm-sicherheitszeile__text">
{fensterDach === null ? "Fenster/Dach unbekannt" : fensterDach ? "Fenster und Dach geschlossen" : "Fenster oder Dach offen"}
</span>
</div>
<div className="dm-sicherheitszeile">
<StatusKreis ok={licht} />
<span className="dm-sicherheitszeile__text">
{licht === null ? "Licht unbekannt" : licht ? "Licht ausgeschaltet" : "Licht eingeschaltet"}
</span>
</div>
{/* Der Fehlerspeicher des Fahrzeugs. Steht bewusst NICHT im
Sicherheitscheck und geht damit auch nicht in „sicher
abgestellt" ein — ein Eintrag sagt nichts darüber, ob der Wagen
verschlossen dasteht (siehe _fehlerspeicher() im Backend).
WELCHER Fehler vorliegt, meldet das Gerät nicht. */}
<div className="dm-sicherheitszeile">
<StatusKreis ok={fehlerOk} />
<span className="dm-sicherheitszeile__text">{fehlerText}</span>
</div>
</div>
</Tile>
</>
)
}
+23 -2
View File
@@ -7,22 +7,40 @@
*/
import type { Verbindungszustand, WartenderAuftrag } from "../api"
import type { Versionsstand } from "../daten/appVersion"
import { SymbolWarnung } from "../symbole"
export function Hinweisleiste({
verbindung,
warteschlange,
versionsstand = "unbekannt",
}: {
verbindung: Verbindungszustand
warteschlange: readonly WartenderAuftrag[]
/** Ob diese App noch dem Stand entspricht, den das Backend ausliefert.
Siehe daten/appVersion.ts und VERSIONIERUNG.md. */
versionsstand?: Versionsstand
}) {
const offline = verbindung !== "verbunden"
const wartend = warteschlange.length
// Derselbe Grundsatz wie beim Offline-Hinweis oben: keine stille Veraltung.
// Nur hier veraltet nicht die Anzeige, sondern die App selbst — sie trägt
// ihre Dateien fest gebündelt und merkt eine neuere Fassung sonst nie.
//
// "neuer" schlägt bewusst NICHT an: direkt nach einem Xcode-Lauf ist die
// App der Instanz voraus, und daraus einen Handlungsbedarf zu machen
// hiesse, den Nutzer zu einem Rückschritt zu drängen (siehe
// daten/appVersion.ts). Nur bei einer nicht sortierbaren Abweichung wird
// neutral gemeldet, statt eine Richtung zu behaupten - der Grundsatz
// 'keine stille Veraltung' gilt auch dann.
const veraltet = versionsstand === "aelter"
const unklar = versionsstand === "abweichend"
const versionshinweis = veraltet || unklar
// "verbindet" gleich beim Start nicht als Offline melden — das würde bei
// jedem App-Start kurz aufblitzen.
if (!offline && wartend === 0) return null
if (verbindung === "verbindet" && wartend === 0) return null
if (!offline && wartend === 0 && !versionshinweis) return null
if (verbindung === "verbindet" && wartend === 0 && !versionshinweis) return null
const gescheitert = warteschlange.filter((a) => a.fehlversuche > 0).length
@@ -42,6 +60,9 @@ export function Hinweisleiste({
: `${wartend} Änderungen warten auf Übertragung.`
: null}
{gescheitert > 0 ? ` ${gescheitert} davon mit Fehlversuchen.` : null}
{versionshinweis && (offline || wartend > 0) ? " " : null}
{veraltet ? "Diese App ist älter als der Server — bitte aktualisieren." : null}
{unklar ? "App und Server melden verschiedene Fassungen." : null}
</span>
</div>
)

Some files were not shown because too many files have changed in this diff Show More