diff --git a/AGENTS.md b/AGENTS.md index d22c51d..5cc87df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,12 @@ # AGENTS.md — Project state, review findings, open items, and working rules -**Last updated: 2026-09-01** (Einzelfahrt: Orte aus den Koordinaten, Kleingedrucktes weg, „Min.", +**Last updated: 2026-09-02** (Null heisst unbekannt, Momentanwerte nur fuer +"jetzt", Uebersicht-Symbol vergroessert, `2026.9.2.3`, Abschnitt BN. +Davor: Verbrauchsfaktor aus dem Tankbeleg, `2026.9.1.32`, +Abschnitt BM. Davor: Reifenzaehler ohne Fahrzeugwechsel, „Raeder“ +statt „Reifen“, `2026.9.1.29`, Abschnitt BL. Davor: Ortsnamen aus dem Backend, Verbrauchseichung an +den Betankungen, Wertespalte ausgerichtet, `2026.9.1.27`, Abschnitt BK. +Davor: Einzelfahrt: Orte aus den Koordinaten, Kleingedrucktes weg, „Min.", `2026.9.1.23`, Abschnitt BJ - dazu Orte in der Fahrtenliste ohne Netzabruf. Davor: Strecken gestaffelt, Abschnitt BI. Davor: Audit ueber Panel, App und Backend, Abschnitt BH. Davor: Setup mit gruener Bestaetigung und Erwartungswert je Listenposition, Abschnitt BG. Davor: Rueckblick ohne Nullfahrten, Streckenwahl gemeinsam in `verlauf.py`, @@ -7097,6 +7103,433 @@ Geprüft: 7 Fälle gegen den Schnitt (mit/ohne Straße, benannter Platz, ohne PL `null`). Im Browser nachgesehen: `„Eichstätt → Adelschlag"` in der Liste, volle Anschrift auf der Einzelfahrt. +## BK. Der Ort kommt mit der Fahrt, und der Verbrauch wird an den Betankungen geeicht (2026.9.1.25-.27) + +### Die Wertespalte der Fahrtenliste stand nicht in einer Linie + +Gemeldet: km und l/100 km fluchten nicht, und bei langen Ortsnamen wird der +Abstand zu eng. Gemessen: die rechte Kante der Wertespalte lag je nach Zeile +bei 549px oder 520px, obwohl beide Zeilen gleich breit sind und an derselben +Stelle beginnen. + +Ursache war nicht die Breite, sondern die **Anzahl der Kinder**: eine `.leaf` +hat drei (Label, Werte, Pfeil), und `justify-content: space-between` verteilt +freien Raum ZWISCHEN allen dreien. Die Wertespalte stand damit in der Mitte, +ihre Lage abhaengig von der Laenge des Labels. Mit `flex: 1` am Label bleibt +kein freier Raum uebrig, Werte und Pfeil sitzen buendig rechts. + +`min-width: 0` gehoert dazu, sonst darf ein Flex-Kind nicht unter seine +Inhaltsbreite schrumpfen und `text-overflow` greift nie. + +Nachgewiesen: alle sechs Zeilen der Liste teilen sich jetzt 629/721 statt +zweier verschiedener Kanten. + +**Merkposten:** eine Zeile, deren Werte "mal so, mal so" stehen, ist fast nie +ein Breitenproblem - erst die Kinder zaehlen, dann messen. + +### Ortsnamen loest jetzt das Backend auf + +Bis hierher tat es jede Oberflaeche selbst und legte das Ergebnis in ihren +eigenen Zwischenspeicher. Drei Folgen, alle unnoetig: jedes Geraet fragte +dieselben Koordinaten erneut ab, ein frisch eingerichtetes Telefon zeigte fuer +alte Fahrten wieder "unbekannt", und ein geleerter Browserspeicher warf alles +weg. + +Neu `geokodierung.py` plus ein Screening-Schritt `_orte_screenen()`. Der Ort +steht jetzt in der Fahrt: `start_address`/`end_address` (volle Anschrift) und +`start_stadt`/`end_stadt` (nur der Ortsname, fuer die Liste). + +Nominatims Nutzungsbedingungen bestimmen fast jede Entscheidung darin: eine +Anfrage je Sekunde (`ABSTAND_S = 1.1`), hoechstens acht je Durchlauf +(`PRO_DURCHLAUF`, sonst waere ein Jahresrueckblick genau der Vorratsabruf, um +dessen Unterlassung gebeten wird), ein identifizierbarer User-Agent, und jedes +Ergebnis wird in `orte.json` gemerkt statt erneut abgefragt. + +**Zwei Sperren gegen das Ueberschreiben von Hand Eingetragenem**, absichtlich +beide: der Schritt fuellt nur ein LEERES Feld, und `fahrt_aktualisieren()` +laesst ohnehin jedes Feld in `edited_fields` unberuehrt. Der Ortsname wird +dagegen auch neben einer handischen Anschrift gesetzt - er beschreibt die +Koordinate, nicht die Eingabe. In der Liste sticht die handische Anschrift +trotzdem: beide Oberflaechen pruefen dafuer `edited_fields`. + +Ein Netz- oder Serverfehler wird NICHT gemerkt - sonst stuende eine +voruebergehend gedrosselte Antwort fuer immer als "kein Ort hier". Ein echtes +leeres Ergebnis dagegen schon. + +Beide Oberflaechen fragen jetzt gar nichts mehr ab; `ortAusCache()`, +`stadtAusCache()`, `stadtAusAnschrift()` und `fahrtOrteNachtragen()` sind +ersatzlos entfallen. + +Nachgewiesen: nach zwei Screening-Laeufen tragen 12 von 14 Fahrten einen Ort, +die beiden anderen haben gar keine Koordinaten. Die Liste zeigt +"Eichstaett -> Adelschlag"; damit ist auch der gemeldete Unterschied zwischen +Panel ("Adelschlag") und App ("Adelschlag -> Eichstaett") weg - er kam daher, +dass die beiden verschiedene Zwischenspeicher hatten. + +### Verbrauch: Rohwert, Faktor, Anzeigewert + +Der Anzeigewert einer bereits gespeicherten Fahrt wurde nie neu gerechnet - +die `wert_ab()`-Korrektur vom selben Tag erreichte die Altfahrten also nicht. +Das Auswahlkriterium haengt jetzt am ROHwert, nicht am Anzeigewert: die Fahrt +vom 01.09. ist damit von 11,4 auf **8,7 l/100 km** gewandert, genau auf den +Wert, den die Messung vorhergesagt hatte. + +Drei Felder statt einem: `verbrauch_roh_l_100km` (die reine Messung, damit die +Rechnung wiederholbar bleibt), `verbrauch_faktor`, und `verbrauch_l_100km` als +Produkt - der Wert, den die Oberflaechen anzeigen. Die brauchten deshalb keine +Aenderung. + +### Die Eichung an zwei Betankungen (verbrauchskorrektur.py) + +Vorschlag des Eigentuemers, und er ist besser als meiner: + + Faktor = (Tankstand nach A - Tankstand vor B) / Summe der Fahrtverbraeuche + +**Dafuer braucht es keinen Tankbeleg.** Zaehler und Nenner sind mit demselben +Geber gemessen; ein Massstabsfehler stuende in beiden und kuerzt sich heraus. +Damit taugt auch eine Teilbetankung - die Voll-zu-voll-Bedingung, die mein +erster Ansatz ("Verbrauch zwischen zwei Betankungen") gebraucht haette, +entfaellt. + +Ebenso entfaellt mein Nichtlinearitaets-Vorbehalt: `can_fuel_volume` kommt +schon als Liter, die Kennlinie des Schwimmers hat das Steuergeraet angewandt +(Hinweis des Eigentuemers). Was bleibt, ist die **Daempfung** - der gemeldete +Stand hinkt nach, auf sieben Kilometern anteilig weit mehr als auf +dreihundert. Genau die faengt der Faktor mit ab, weil sie Kraftstoff nur +zwischen Fahrten verschiebt und die Intervallsumme stimmt. + +Der Faktor enthaelt auch Verbrauch, der zu KEINER Fahrt gehoert (Standheizung, +nicht erkannte Fahrt, Rangieren unter 0,1 km). Entscheidung des Eigentuemers: +auf die Fahrten verteilen, damit deren Summe dem entspricht, was im Tank +fehlt - eine einzelne Fahrt traegt dafuer einen Anteil, den sie nicht selbst +verbraucht hat. + +**Schranke 0,7-1,5**, ausserhalb wird verworfen und protokolliert. Sie hat auf +echten Daten sofort gegriffen: das Intervall 15.08. bis 27.08. ergab **5,616** +- 45 l im Tank verbraucht, aber nur 8 l aus zwei Fahrten gerechnet. Das ist +kein Rechenfehler, sondern der Befund, dass in diesen zwoelf Tagen fast keine +Fahrt erkannt wurde (die Umsteckphase des Dongles). Ohne die Schranke haetten +beide Fahrten das Fuenffache angezeigt. + +Gerechnet werden nur Intervalle, in denen noch eine Fahrt ohne Faktor steht - +das Screening laeuft bei jeder Aenderung des Kilometerstands, und jedes +Intervall kostet eine eigene Abfrage des Tankverlaufs. + +Eine Fahrt, die ueber eine Betankung hinweggeht, bekommt keinen Faktor: sie +laesst sich keinem der beiden Intervalle zuordnen. + +**Noch offen:** in den vorhandenen Daten liefert kein Intervall einen +brauchbaren Faktor. Der Mechanismus ist nachgewiesen, seine Eichung nicht - +dafuer braucht es ein Intervall mit vollstaendig erkannten Fahrten. + +## BL. Der Radzaehler summiert Fahrten statt Tacho-Deltas; Raeder statt Reifen (2026.9.1.29) + +### 375.962 km auf Raedern eines Autos mit 61.823 km + +Gemeldet als "der Zaehler rechnet die Gesamtkilometer des Fahrzeugs auf die +Raeder". Nachgesehen: Sommerraeder 375.962 km, Tacho 61.823. + +`fortschreiben()` zaehlte jedes POSITIVE Delta zum zuletzt verarbeiteten +Kilometerstand. Ein Sprung nach unten fiel schon immer heraus - beim Umstecken +des Dongles zwischen zwei Fahrzeugen gibt es aber beide Richtungen, und +61.823 -> 209.177 ist ein positives Delta von 147.354 km. + +**Erster Ansatz, vom Eigentuemer verworfen:** ein Sprungschutz am Delta, der +nicht die Groesse pruefte, sondern die daraus folgende Geschwindigkeit +(`UNPLAUSIBLE_KMH`, wie bei den Fahrten) - noetig, weil der Modulkopf +verspricht, dass Kilometer aus einem HA-Ausfall noch mitzaehlen. Dafuer haette +das Profil den Zeitpunkt des Referenzstands mitfuehren muessen. Gebaut, an +sechs Faellen belegt, dann auf die Rueckfrage "warum so kompliziert? einfach +die Trip distance addieren?" wieder entfernt. + +### Der Radzaehler summiert jetzt Fahrten + +Die Rueckfrage war berechtigt, und die Messung stuetzt sie. Summe der +Fahrtstrecken gegen die Tacho-Spanne im selben Zeitraum, am echten Bestand: + +| Tacho-Spanne | Summe der Fahrten | erfasst | +|---|---|---| +| 61.816 -> 61.823 (7 km) | 6,9 km | 99 % | +| 209.156 -> 209.281 (125 km) | 125,0 km | 100 % | +| 21.188 -> 21.325 (137 km) | 141,0 km | 103 % | +| 20.500 -> 20.580 (80 km) | 205,0 km | 256 % | + +Der Ausreisser stammt aus dem Bestand von vor den Plausibilitaetspruefungen, +nicht aus dem Verfahren. In den sauberen Bereichen trifft die Fahrtsumme den +Tacho auf ein bis drei Prozent - und sie ist genauer, weil die Fahrten seit +der GNSS-Verfeinerung auf 100 m messen statt auf ganze Kilometer (6,9 gegen 7). + +Damit entfallen `referenz_odo_km`, die Delta-Buchhaltung und der eben erst +gebaute Sprungschutz: die Fahrten pruefen Rueckwaertssprunge, unmoegliches +Tempo und Mindestdauer laengst selbst. + +**Die Form hat der Eigentuemer festgelegt**, nachdem ich zuerst einen Sockel +mit Rueckrechnung vorgeschlagen hatte ("nein. Ich moechte: Manuell jederzeit +einen neuen Wert eintragen koennen und jeder gefahrene Kilometer wird einfach +dazuaddiert"). Also: `saetze..km` IST der Stand, von Hand direkt +gesetzt, und jede Fahrt legt ihre Strecke oben drauf. + +Zwei neue Felder an der Fahrt tragen das: + +* `reifensatz` - welcher Satz montiert war. Der Satz steht an der FAHRT, nicht + am Zeitpunkt; ein Satzwechsel braucht deshalb keine Sonderbehandlung. +* `reifen_gezaehlt_km` - wieviel davon schon gutgeschrieben ist. Verfeinert das + Screening die Strecke spaeter (7,0 -> 6,896 km ist real vorgekommen), wird + nur die Differenz nachgetragen. Ohne diesen Merker waere entweder jede + Korrektur verloren oder jede Fahrt doppelt gezaehlt. + +**Der Rueckblick stempelt bewusst KEINEN Radsatz.** Er rekonstruiert +Vergangenes, und das steckt im gespeicherten Radstand laengst drin - ein +erneuter Import schriebe dieselben Kilometer sonst ein zweites Mal auf die +Raeder. Gleiches gilt fuer den Altbestand: Fahrten ohne `reifensatz` zaehlen +nie mit, der gespeicherte Stand bleibt bei der Umstellung also unveraendert. + +**Was das kostet, vom Eigentuemer ausdruecklich akzeptiert:** was die +Fahrterkennung nicht sieht, landet nie auf den Raedern. Der Tacho ist die +Wahrheit des Fahrzeugs und holt sich alles zurueck, eine Fahrtsumme nicht. + +Live nachgewiesen in `audi_ha_test`: von Hand auf 1000 gesetzt -> 1000,0; +Fahrt ueber 12,3 km angelegt -> 1012,3; Screening erneut ausgeloest -> 1012,3 +(keine zweite Gutschrift). Protokollzeilen: `Laufleistung sommer von Hand auf +1000.0 km gesetzt` und `Fahrt t-bb1ca655...: 12.3 km auf sommer +gutgeschrieben, neuer Stand 1012.3 km`. + +**Der falsche Bestand bleibt stehen.** Die 375.962 km korrigiert der +Eigentuemer auf der realen Instanz selbst ueber den Stift, unabhaengig von der +Entwicklung. + +### Drei Oberflaechen-Aenderungen, beide Codebasen + +* **"Termin vereinbart" samt Schalter entfernt** (Wunsch des Eigentuemers). + Damit entfaellt auch der Zusatz "vereinbart" unter dem Datum auf "Mein + Audi". Das Feld `reifen.wechsel.vereinbart` selbst bleibt im Profil stehen - + es zu loeschen waere eine Datenaenderung, keine Anzeigeaenderung. +* **"Reifen" heisst "Raeder"** in allem Sichtbaren: Seitentitel, Kachel auf + "Mein Audi", Kalendereintrag ("Radwechsel auf ..."), Loeschabfrage im Archiv, + Backup-Beschreibung. NICHT umbenannt: Route, Profilschluessel, Dienstnamen, + Funktionsnamen - dieselbe Trennung wie bei "Servicebuch"/"Wartungsplan" + (Abschnitt AB). +* **Markenlogo 20 % groesser**, 16px -> 19,2px, an allen vier Stellen + (`.marke-logo img` und die eine feste Angabe im Panel, `.dm-marke img` und + `.dm-markenlogo` in der App). Gemessen: 19,19px im Browser, drei Logos auf + der Tanken-Seite. + +## BM. Der Verbrauchsfaktor kommt aus dem Tankbeleg, nicht aus dem Geber (2026.9.1.30) + +### Die Formel ist die des Eigentuemers + + Faktor = getankte Liter laut Beleg / Summe der Liter aus den Einzelfahrten + +Genau die Rechnung, die er ohnehin von Hand macht. Die Annahme dahinter ist +seine und traegt: die Ungenauigkeit des Gebers trifft jede Fahrt +gleichermassen, ist also ein Faktor und kein Summand. + +**Meine erste Fassung rechnete anders** - Tankstand nach A minus Tankstand vor +B, geteilt durch die Fahrtsumme. Die braucht keinen Beleg (der Maszstab steht +in Zaehler und Nenner und kuerzt sich heraus) und funktioniert auch bei +Teilbetankung. Sie korrigiert dafuer aber NUR die Verteilung, nicht den +absoluten Maszstab des Gebers. Die Belegvariante kann beides und ist deshalb +die richtige - der Preis ist die Voll-zu-Voll-Bedingung. + +### Drei Bedingungen, jede aus einem konkreten Fehler heraus + +1. **Volltankung an BEIDEN Enden.** Nur dann gilt "eingefuellte Liter = seit + dem letzten Mal verbrauchte Liter". Erkannt am Tankstand eine Minute nach + der Betankung: mindestens `VOLL_ANTEIL` (95 %) des `tankvolumen_liter` aus + dem Profil. Die eine Minute ist die Vorgabe des Eigentuemers; zu kurz zu + greifen ist die harmlose Richtung (der Stand faellt unter die Grenze, es + entsteht kein Faktor), zu lang die gefaehrliche - nach der Abfahrt sinkt der + Stand wieder und eine echte Volltankung saehe nachtraeglich unvoll aus. +2. **Die Liter muessen aus einem Beleg stammen.** Ein automatisch erkannter + Tankvorgang traegt in `liters` den vom GEBER gemessenen Anstieg. Den gegen + die ebenfalls aus dem Geber gerechneten Fahrten zu stellen, ist ein + Zirkelschluss und ergaebe immer ungefaehr 1,0 - der Faktor koennte gar + nichts korrigieren. `beleg_liter()` laesst deshalb nur durch, was einen + `receipt_key` hat, von Hand bearbeitet wurde oder `source == "manual"` + traegt. An fuenf Faellen geprueft. +3. **Die Fahrten muessen die gefahrene Strecke abdecken** (`DECKUNG_MIN`/ + `DECKUNG_MAX`, 90-110 %). Nicht der Faktor wird begrenzt, sondern der + Nenner geprueft - siehe unten. + +### Warum nicht der Faktor begrenzt wird + +Erst stand hier eine feste Schranke 0,7-1,5. Der Eigentuemer hat sie +hinterfragt ("unsere Rechnung ist gut genug"), und die Pruefung der Frage gab +ihm halb recht: + +* **Am Faktor** war sie tatsaechlich am falschen Ort. Die beiden Tore oben + sichern den Zaehler; ein echter Geberfehler liegt bei ein paar Prozent und + waere an 0,7-1,5 nie angeschlagen. Eine groessere, aber echte Abweichung + haette sie dagegen abgeklemmt. +* **Der Nenner** bleibt aber verwundbar: fehlt im Intervall eine Fahrt, ist + die Summe zu klein und der Faktor zu gross - und er multipliziert jede + Fahrt des Zeitraums mit. Genau das war der 5,616-Fall (45 l im Tank, zwei + erkannte Fahrten ueber zwoelf Tage); Beleg und Volltankung haetten dort + beide gestimmt. + +Geprueft wird deshalb die Vollstaendigkeit der Fahrtmenge, an einem Mass, das +beim Radzaehler-Umbau desselben Tages schon belegt wurde: + + Deckung = Summe der Fahrtstrecken / Tacho-Spanne des Intervalls + +Liegt sie bei 90-110 %, gilt der Faktor - ohne obere oder untere Klemme. +Sonst wird das Intervall uebersprungen und protokolliert. Die Grenze gilt in +BEIDE Richtungen: 256 % (Altbestand mit doppelten Fahrten) ist genauso ein +Zeichen fuer einen unbrauchbaren Nenner wie 15 %. + +An sechs Faellen mit den echten Zahlen geprueft, `_deckung()` direkt +aufgerufen: 100 % / 99 % / 103 % angewandt, 256 % und 15 % verworfen, ein +Rueckwaertssprung (Fahrzeugwechsel) als "nicht bestimmbar" behandelt statt +als Deckung 0. + +### Standlauf verzerrt den Faktor nicht + +Frage des Eigentuemers, und die Antwort ist beruhigend: laeuft der Motor im +Stand bei eingeschalteter Zuendung, bleibt die Fahrt offen. Der Verbrauch +steht damit in der Summe der Einzelfahrten UND in der getankten Menge - er +kuerzt sich heraus. Die betroffene Fahrt zeigt zu Recht einen hohen Verbrauch. + +Verzerrend ist nur Verbrauch OHNE offene Fahrt (Standheizung bei Zuendung +aus): der fehlt im Nenner und verteilt sich ueber den Faktor auf alle Fahrten +des Zeitraums - genau das, was der Eigentuemer bewusst so wollte. + +### Zwei Ausloeser, jeder mit eigenem Grund + +Der Eigentuemer hat den wunden Punkt benannt: der Faktor steht erst +rueckwirkend fest, eine Korrektur "nach einer Fahrt" kann nicht funktionieren. +Stimmt - und die Rechnung hielt sich schon daran (die Schleife laeuft ueber +PAARE aufeinanderfolgender Tankvorgaenge, das laufende Intervall hat kein +Gegenstueck). Die Frage legte aber eine echte Luecke frei: **ein Tankvorgang +loeste gar nichts aus.** Der einzige Aufruf hing am Screening, gerechnet wurde +also erst bei der naechsten Fahrt. + +| Ausloeser | wofuer | +|---|---| +| `koordinator.tankvorgang_nachbereiten()` | das Intervall schliesst sich - hier entsteht der Faktor | +| `screening.durchfuehren()` | eine Fahrt im Intervall bekommt ihren Rohwert erst spaeter | + +`tankvorgang_nachbereiten()` ersetzt `tankvorgaenge_veroeffentlichen()` an +allen sechs Schreibstellen (`belege.py` 4x, `tankerkennung.py`, +`historienimport.py`). Der 60-Sekunden-Takt ruft bewusst weiter nur +`tankvorgaenge_veroeffentlichen()`: dort aendert sich nichts, was einen neuen +Faktor ergeben koennte. +Gerechnet werden nur Intervalle, in denen noch eine Fahrt ohne Faktor steht - +sonst liefen dieselben Verlaufsabfragen bei jedem Fahrtende erneut. Die teure +Voll-Pruefung kommt zuletzt, nach den billigen Ausschluessen. + +**Noch nicht belegt:** in den Testdaten gibt es keinen Tankvorgang mit +Beleg-Litern, also auch kein gerechnetes Intervall. Der Mechanismus ist +geprueft, seine Anwendung auf echte Zahlen nicht. + +### Uebersicht/Zuletzt zeigt Ort -> Ort + +Die Zeile "Letzte Fahrt" zeigte die vollen Anschriften. Sie benutzt jetzt +dieselbe `strecke()` wie die Fahrtenliste - eine Quelle, damit die beiden +nicht auseinanderlaufen koennen; in der App dafuer aus `Fahrten.tsx` +exportiert statt ein zweites Mal gebaut. Dabei fielen in der App zwei +Abweichungen zum Panel auf und sind mitgezogen: die Strecke lief noch ueber +`de()` statt `streckeText()` (also ohne die gestaffelte Aufloesung), und im +Wertzusatz stand das Statuswort, das der Eigentuemer aus der Liste hatte +entfernen lassen - jetzt der Verbrauch wie im Panel. + +## BN. Null heisst unbekannt, und der Momentanwert gehoert nur zu "jetzt" (2026.9.2.1-.3) + +### Der Kilometerstand darf fehlen duerfen + +Wunsch des Eigentuemers: ein Weg, einen Tankvorgang ohne Kilometerstand +einzutragen - "eine 0 waere hilfreich, bzw. ein leeres Feld wenn alle Zahlen +geloescht werden". + +Beides geht jetzt, und beides landet als **None** im Datensatz, nicht als 0. +Das ist der eigentliche Punkt: **sechs Stellen** lesen `odometer_km` und +behandeln ausschliesslich None als unbekannt. Eine gespeicherte 0 waere fuer +sie ein Fahrzeug mit Tachostand null - `distanz_seit_letzter_tankung()` haette +den gesamten Tachostand als "Strecke seit der letzten Tankung" geliefert, und +der Langzeitverbrauch rechnete gegen eine Spanne, die es nie gab. + +`verlauf.als_kilometerstand()` faengt das an der Eingabe ab (0, leer, negativ, +unlesbar -> None). Eine Sonderbehandlung an sechs Lesestellen waere sechsmal +die Gelegenheit gewesen, sie zu vergessen. An acht Faellen geprueft. + +Beide Formulare senden bei geleertem Feld ohnehin schon `null` - geprueft, nicht +angenommen (`kmFeld === "" ? null : ...` im Panel, `Number.isFinite(kmZahl) && +km ? ... : null` in der App). + +### Ein Momentanwert gehoert zu dem Moment, in dem er gemessen wurde + +Beim Pruefen fiel ein aelterer Fehler auf, den die 0 sonst wirkungslos gemacht +haette: `tankvorgang_manuell()` ersetzte einen fehlenden Kilometerstand durch +den **aktuellen** Tacho - unabhaengig vom eingetragenen Zeitpunkt. Fuer eine +nachgetragene Tankung von vorgestern war das doppelt falsch: die gefahrene +Distanz wurde gegen einen zu hohen Stand gerechnet, und die neue +Datumspruefung haette das Intervall verworfen. + +`_km_aus_sensor_wenn_jetzt()` setzt den Sensorwert nur noch ein, wenn der +eingetragene Zeitpunkt hoechstens `SENSOR_ERSATZ_FENSTER` (2 h) von jetzt +entfernt liegt. Dasselbe gilt fuer `distance_km`: `distanz_seit_tankung()` +nimmt `TANK_DISTANZ_SENSOR` nur noch mit `jetzt=True` und faellt sonst auf die +Kilometerstand-Subtraktion zurueck, die zeitunabhaengig ist. + +Live nachgewiesen ueber den echten Dienst, beide Faelle in einem Durchgang: + +| Zeitpunkt | `km` eingegeben | gespeicherter Kilometerstand | +|---|---|---| +| jetzt | 0 (unbekannt) | **61.823** - vom Tacho, Normalfall ohne Tippen | +| vorgestern | 0 (unbekannt) | **leer** - der Tacho von heute passt nicht dazu | + +### Datum und Kilometerstand muessen zusammenpassen + +Die Fahrten werden ueber die ZEIT zugeordnet, nicht ueber den Tacho. Ein +falsch datierter Tankvorgang laesst die Liter deshalb auf den falschen Fahrten +landen - und die Deckungspruefung merkt es nicht, weil sie den Tacho zu +denselben verschobenen Zeitpunkten liest. + +`_datum_passt()` vergleicht deshalb den eigenen `odometer_km` des +Tankvorgangs mit dem Tacho zu seinem Zeitstempel, Toleranz +`ODOMETER_TOLERANZ_KM = 5.0` (Vorgabe des Eigentuemers). Ein automatisch +erkannter Tankvorgang besteht sie zwangslaeufig - beide Werte stammen aus +demselben Datensatz. Ohne Kilometerstand (die 0 von oben) gibt es nichts zu +vergleichen, dann gilt der Zeitpunkt wie eingetragen; der Waechter faellt fuer +diesen Tankvorgang aus, was unvermeidlich und immer noch besser ist, als eine +erfundene Zahl zu verlangen. An sechs Faellen geprueft. + +### Getippte Liter zaehlen als Beleg + +`beleg_liter()` liess nur `receipt_key`, `source == "manual"` oder einen +Vermerk in `edited_fields` durch. Ein automatisch erkannter Tankvorgang, dessen +Liter der Nutzer nachtippt, hatte nichts davon - die Zahl galt weiter als +Geberwert und wurde verworfen. `belege.tankvorgang_aktualisieren()` traegt +jetzt `"liters"` in `edited_fields` ein, wenn Liter hereinkommen. + +Das ist zuverlaessig, weil die automatische Erkennung Tankvorgaenge nur +ANLEGT und nie aktualisiert: was durch diese Funktion an Litern kommt, stammt +konstruktionsbedingt von einem Menschen (Formular oder bearbeitetes CSV). + +Nebenwirkung, bewusst: `edited_fields` schuetzt das Feld vor der Automatik. +Wird spaeter doch ein PDF zu demselben Tankvorgang hochgeladen, bleibt die +getippte Zahl stehen. + +Live nachgewiesen: getippte Liter akzeptiert, Korrektur lief in derselben +Millisekunde wie "Tankvorgang aktualisiert" (nicht erst bei der naechsten +Fahrt), und die Deckungspruefung verwarf das Intervall korrekt mit "0 % der +gefahrenen Strecke" - dort liegt ein Fahrzeugwechsel. + +### Das Uebersicht-Symbol war das kleinste der fuenf + +Gemeldet, live per `getBBox()` nachgemessen: 18,0 x 10,3 von 24 - **43 %** der +Hoehe, waehrend die vier Nachbarn zwischen 65 % (Tanken) und 89 % (Fahrten) +liegen. + +Auf volle Rasterbreite gezogen (Faktor 1,33, `stroke-width` 1,125 als +Ausgleich, damit die Strichstaerke gerendert wieder 1,5 betraegt): jetzt +23,9 x 13,7, also **57 %** - die Breite entspricht exakt "Mein Audi". + +**Bewusst nicht weiter:** das Symbol ist ein Halbkreis und damit bauartbedingt +halb so hoch wie breit. Fuer 72 % Hoehe braeuchte es Faktor 1,68 und waere mit +30 Einheiten breiter als das Raster. Wirklich gleich hoch wird es nur durch +Neuzeichnen des Bogens mit groesserem Winkel - dem Eigentuemer angeboten, +nicht unaufgefordert gemacht. + ## Working conventions (observed — keep them) - German is the project language: identifiers, comments, commits, UI texts. Exceptions: diff --git a/companion-app/src/api/types.ts b/companion-app/src/api/types.ts index e5eeaab..8ae3cc9 100644 --- a/companion-app/src/api/types.ts +++ b/companion-app/src/api/types.ts @@ -59,6 +59,10 @@ export interface Fahrt { end_lon?: number | null; start_address?: string | null; end_address?: string | 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 diff --git a/companion-app/src/daten/geokodierung.ts b/companion-app/src/daten/geokodierung.ts index 59b89ff..a643aae 100644 --- a/companion-app/src/daten/geokodierung.ts +++ b/companion-app/src/daten/geokodierung.ts @@ -90,54 +90,6 @@ function cacheSchreiben(suche: string, treffer: Koordinate | null): void { * vierhundert Anfragen fuer einen Blick auf die Liste. * * Schluessel wie bei der Aufloesung: drei Nachkommastellen (~110 m). */ -/** Der Ortsname allein, fuer die Fahrtenliste. - * - * In der Liste steht eine Fahrt in einer Zeile neben Datum, Art, Strecke und - * Verbrauch. Zwei volle Anschriften passen dort nicht - "Schottenau, 85072 - * Eichstaett -> Am Anger 7a, 85111 Adelschlag" ist laenger als der Rest der - * Zeile zusammen. Auf der Einzelfahrt bleibt die vollstaendige Anschrift. - * - * Der Ort wird beim Aufloesen SEPARAT gemerkt (Schluessel "ort:"), nicht - * nachtraeglich aus der Anschrift geschnitten. Das Zerlegen der eigenen - * Ausgabe waere fast immer richtig und in einem Fall falsch: faellt Nominatim - * auf display_name zurueck, steht hinter dem letzten Komma das LAND. Dann - * stuende in der Liste "Deutschland -> Deutschland". */ -/** Der Ortsname aus einer bereits gespeicherten Anschrift. - * - * Rueckfall fuer alles, was VOR dem Merken des Ortsnamens aufgeloest wurde: - * dort liegt nur die volle Anschrift, und ein neuer Netzabruf, nur um den Ort - * zu erfahren, waere Verschwendung. - * - * Geschnitten wird ausschliesslich, wenn hinter dem letzten Komma eine - * Postleitzahl steht - unser eigenes Format ist ", ". - * Faellt Nominatim auf display_name zurueck, fehlt die PLZ und hinter dem - * letzten Komma stuende das LAND; dann lieber die volle Anschrift zeigen als - * "Deutschland". */ -export function stadtAusAnschrift(anschrift: string | null | undefined): string | null { - if (!anschrift) return null - const teil = String(anschrift).split(", ").pop()?.trim() ?? "" - const treffer = teil.match(/^\d{4,5}\s+(.+)$/) - return treffer && treffer[1] ? treffer[1].trim() : null -} - -export function stadtAusCache( - lat: number | null | undefined, - lon: number | null | undefined, -): string | null { - if (lat === null || lat === undefined || lon === null || lon === undefined) return null - const wert = cacheLesen()[`ort:${lat.toFixed(3)},${lon.toFixed(3)}`] - return typeof wert === "string" && wert ? wert : null -} - -export function ortAusCache( - lat: number | null | undefined, - lon: number | null | undefined, -): string | null { - if (lat === null || lat === undefined || lon === null || lon === undefined) return null - const wert = cacheLesen()[`rev:${lat.toFixed(3)},${lon.toFixed(3)}`] - return typeof wert === "string" ? wert : null -} - export async function koordinatenAufloesen(lat: number, lon: number): Promise { const schluessel = `rev:${lat.toFixed(3)},${lon.toFixed(3)}` const cache = cacheLesen() @@ -173,8 +125,6 @@ export async function koordinatenAufloesen(lat: number, lon: number): Promise = { service: "Service", werkstatt: "Autohaus", sbuch: "Eintrag", - reifen: "Reifen", + reifen: "Räder", vers: "Versicherung/Steuer", beitrag: "Beitrag anpassen", vertrag: "Vertrag", diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx index a974ce8..01b1ec5 100644 --- a/companion-app/src/screens/Einstellungen.tsx +++ b/companion-app/src/screens/Einstellungen.tsx @@ -535,7 +535,7 @@ export function Einstellungen({ Fahrzeugprofil

Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten, - Ausstattung, Reifen, Versicherung, Steuer und Werkstatt. Fahrten, Tankvorgänge und der + Ausstattung, Räder, Versicherung, Steuer und Werkstatt. Fahrten, Tankvorgänge und der Wartungsplan lassen sich einzeln als CSV sichern und bearbeitet wieder einspielen — eine Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles andere bleibt unangetastet. diff --git a/companion-app/src/screens/FahrtDetail.tsx b/companion-app/src/screens/FahrtDetail.tsx index 779a16a..543a6d0 100644 --- a/companion-app/src/screens/FahrtDetail.tsx +++ b/companion-app/src/screens/FahrtDetail.tsx @@ -15,7 +15,6 @@ import { ActionButton, Feld, Fig, Pill, Tile } from "@audi-dash/ui" import type { Fahrt } from "../api" import { useDaten } from "../daten/DatenKontext" -import { koordinatenAufloesen } from "../daten/geokodierung" import { datum, dauer, de, streckeTeile, uhrzeit } from "../format" import type { SeitenName } from "../navigation" import { Leerzustand, Wertzeile, Werteliste, bestaetigen } from "./bausteine" @@ -42,50 +41,23 @@ export function FahrtDetail({ id, geheZu }: { id: string | undefined; geheZu: (n // render()). const [artVorgriff, setzeArtVorgriff] = useState<"privat" | "arbeitsweg" | null>(null) - /* Ortsangaben zu Start und Ziel. + /* Ortsangaben zu Start und Ziel - beide kommen mit der Fahrt. - Das Backend fuellt start_address/end_address NIE von selbst - sie stehen in - HANDFELDER und kommen nur aus einer von Hand angelegten oder bearbeiteten - Fahrt. Die Koordinaten dagegen traegt das Screening ein. Deshalb stand hier - bisher "unbekannt", obwohl die Position bekannt war (vom Eigentuemer - gemeldet, 01.09.2026). - - Aufgeloest wird erst beim Oeffnen der Fahrt und ueber denselben Cache wie - die Standortansicht - ein Abruf je Ort, danach nie wieder. Ein Vorratsabruf - fuer alle Fahrten der Liste waere genau das, worum Nominatim in seinen - Nutzungsbedingungen bittet, es nicht zu tun. */ - const [orte, setzeOrte] = useState<{ start: string | null; ziel: string | null }>({ - start: null, - ziel: null, - }) - const startLat = fahrt?.start_lat - const startLon = fahrt?.start_lon - const endLat = fahrt?.end_lat - const endLon = fahrt?.end_lon - useEffect(() => { - let abgemeldet = false - setzeOrte({ start: null, ziel: null }) - const holen = async (lat?: number | null, lon?: number | null) => - lat == null || lon == null ? null : await koordinatenAufloesen(lat, lon) - void (async () => { - const start = await holen(startLat, startLon) - if (!abgemeldet) setzeOrte((o) => ({ ...o, start })) - const ziel = await holen(endLat, endLon) - if (!abgemeldet) setzeOrte((o) => ({ ...o, ziel })) - })() - return () => { - abgemeldet = true - } - }, [startLat, startLon, endLat, endLon]) + 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, - aufgeloest: string | null, lat?: number | null, lon?: number | null, - ) => adresse ?? aufgeloest ?? (lat == null || lon == null ? "unbekannt" : "wird ermittelt …") - const startOrt = ortText(fahrt?.start_address, orte.start, startLat, startLon) - const zielOrt = ortText(fahrt?.end_address, orte.ziel, endLat, endLon) + ) => 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, diff --git a/companion-app/src/screens/Fahrten.tsx b/companion-app/src/screens/Fahrten.tsx index f03092a..6ddb56e 100644 --- a/companion-app/src/screens/Fahrten.tsx +++ b/companion-app/src/screens/Fahrten.tsx @@ -9,7 +9,6 @@ import { Accordion, ActionButton, Feld, Tile } from "@audi-dash/ui" import { useDaten } from "../daten/DatenKontext" import { nachJahrUndMonat } from "../daten/statistik" -import { ortAusCache, stadtAusAnschrift, stadtAusCache } from "../daten/geokodierung" import { datum, de, streckeText, summe } from "../format" import type { SeitenName } from "../navigation" import { Blattzeile, Leerzustand } from "./bausteine" @@ -21,31 +20,28 @@ import { Zeilenmenue } from "./Zeilenmenue" "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. */ -function strecke(fahrt: { +export function strecke(fahrt: { start_address?: string | null end_address?: string | null - start_lat?: number | null - start_lon?: number | null - end_lat?: number | null - end_lon?: number | null + start_stadt?: string | null + end_stadt?: string | null + edited_fields?: string[] | null }): string | null { - // Was die Einzelfahrt schon einmal aufgeloest hat, zeigt die Liste umsonst - // mit - siehe ortAusCache(), kein zusaetzlicher Abruf. - // Ortsname bevorzugt; ohne ihn die volle Anschrift, damit eine vor dieser - // Aenderung aufgeloeste Fahrt nicht ploetzlich wieder ortlos dasteht. - const ortsname = ( - adresse: string | null | undefined, - lat: number | null | undefined, - lon: number | null | undefined, - ): string | null => { - if (adresse) return adresse - const gemerkt = stadtAusCache(lat, lon) - if (gemerkt) return gemerkt - const voll = ortAusCache(lat, lon) - return stadtAusAnschrift(voll) ?? voll + // 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(fahrt.start_address, fahrt.start_lat, fahrt.start_lon) - const b = ortsname(fahrt.end_address, fahrt.end_lat, fahrt.end_lon) + const a = ortsname("start") + const b = ortsname("end") if (!a && !b) return "Fahrt ohne Ortsangabe" if (!b) return a ?? null if (!a) return b diff --git a/companion-app/src/screens/MeinAudi.tsx b/companion-app/src/screens/MeinAudi.tsx index 5237aa0..ff4fa6a 100644 --- a/companion-app/src/screens/MeinAudi.tsx +++ b/companion-app/src/screens/MeinAudi.tsx @@ -233,7 +233,7 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) = geheZu("reifen")}> - Reifen + Räder diff --git a/companion-app/src/screens/Reifen.tsx b/companion-app/src/screens/Reifen.tsx index 2976ba5..c3a994b 100644 --- a/companion-app/src/screens/Reifen.tsx +++ b/companion-app/src/screens/Reifen.tsx @@ -9,7 +9,7 @@ import { useState } from "react" -import { Accordion, ActionButton, Feld, IconButton, Switch, Tile } from "@audi-dash/ui" +import { Accordion, ActionButton, Feld, IconButton, Tile } from "@audi-dash/ui" import { DIENST_DOMAIN } from "../api" import { useDaten } from "../daten/DatenKontext" @@ -30,17 +30,15 @@ export function Reifen() { const [kmOffen, setzeKmOffen] = useState(null) const [kmEntwurf, setzeKmEntwurf] = useState("") const [wechselDatum, setzeWechselDatum] = useState(null) - const [wechselVereinbart, setzeWechselVereinbart] = useState(null) if (!fahrzeug) return null const aktiv = fahrzeug.reifen.aktiv - const wechsel = fahrzeug.reifen.wechsel as { datum?: string; vereinbart?: boolean } + const wechsel = fahrzeug.reifen.wechsel as { datum?: string } // Lokaler Entwurf, solange der Nutzer nichts geändert hat - Panel- - // Verhalten: Datum/Häkchen speichern erst beim Klick auf "In den Kalender - // übernehmen" (data-ics="reifen" liest #rfDat/#rfOk erst dort aus). + // Verhalten: das Datum wird erst beim Klick auf "In den Kalender + // übernehmen" gespeichert (data-ics="reifen" liest #rfDat erst dort aus). const wechselDatumAnzeige = wechselDatum ?? wechsel.datum ?? "" - const wechselVereinbartAnzeige = wechselVereinbart ?? wechsel.vereinbart ?? false // Deckungsgleich mit data-nmspeichern im Panel: normales Profilfeld, kein // eigener Dienst (anders als der Kilometerstand unten). @@ -56,7 +54,7 @@ export function Reifen() { await profilSpeichern({ fahrzeug: { ...fahrzeug, - reifen: { ...fahrzeug.reifen, wechsel: { ...wechsel, datum: wechselDatumAnzeige, vereinbart: wechselVereinbartAnzeige } }, + reifen: { ...fahrzeug.reifen, wechsel: { ...wechsel, datum: wechselDatumAnzeige } }, }, }) const ziel = aktiv === "Sommer" ? "Winter" : "Sommer" @@ -303,16 +301,9 @@ export function Reifen() { Wechseltermin - + - - setzeWechselVereinbart(checked)} - aria-label="Termin vereinbart" - /> -

void kalenderUebernehmen()} disabled={!wechselDatumAnzeige}> In den Kalender übernehmen diff --git a/companion-app/src/screens/Uebersicht.tsx b/companion-app/src/screens/Uebersicht.tsx index 88fa034..bc4bef5 100644 --- a/companion-app/src/screens/Uebersicht.tsx +++ b/companion-app/src/screens/Uebersicht.tsx @@ -10,10 +10,13 @@ import { Fig, ProgressBar, StatusRow, Tile } from "@audi-dash/ui" import { useDaten } from "../daten/DatenKontext" import { naechsterService } from "../daten/service" -import { datum, de, deOderStrich, eur } from "../format" +import { datum, de, deOderStrich, eur, streckeText } from "../format" import type { SeitenName } from "../navigation" import { BILDPLAETZE, bildInfo } from "./bilder" import { Blattzeile, Leerzustand } from "./bausteine" +// Dieselbe Ortsangabe wie in der Fahrtenliste - eine Quelle, damit "Zuletzt" +// und die Liste nicht auseinanderlaufen koennen. +import { strecke } from "./Fahrten" import { Bild } from "./Bild" import type { ServicebuchEintrag } from "./Service" import { StandortVorschau } from "./Standort" @@ -236,13 +239,13 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string) {letzteFahrt && ( geheZu("trip", letzteFahrt.trip_id)} letzte={!letzteTankung} /> diff --git a/companion-app/src/stile/screens.css b/companion-app/src/stile/screens.css index 562f489..e4c8409 100644 --- a/companion-app/src/stile/screens.css +++ b/companion-app/src/stile/screens.css @@ -1003,8 +1003,12 @@ button.dm-blatt:active { `1 1 auto` (dem Wert, den das Panel nur für die Zuletzt-Kachel setzt) brach „Fahrt ohne Ortsangabe" hier auf zwei Zeilen um, obwohl es im Panel einzeilig steht. */ +/* flex:1 statt 0 1: das Label nimmt den freien Platz, damit space-between + keinen Raum ZWISCHEN Label, Werten und Pfeil verteilt - sonst stuende die + Wertespalte in der Mitte und ihre Lage haenge an der Laenge des Ortsnamens + (im Panel gemessen: 549px gegen 520px rechte Kante). */ .dm-blatt__k { - flex: 0 1 auto; + flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; @@ -1013,14 +1017,16 @@ button.dm-blatt:active { line-height: 1.35; color: var(--fg); } +/* Ein Flex-Kind darf nur mit min-width:0 unter seine Inhaltsbreite schrumpfen - + ohne das greift text-overflow nie und ein langer Ortsname draengt die Werte. */ +.dm-blatt__k > * { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Feste Mindestbreite, damit Strecke und Verbrauch ueber alle Zeilen hinweg in einer Spalte stehen. Ohne sie waechst der Kasten mit dem Inhalt - im Panel gemessen 86px bei "11,4 l/100 km" gegen 37px bei einer Zeile ohne Werte, und die Zahlen standen entsprechend versetzt. */ .dm-blatt__v { - flex: 0 0 auto; - min-width: 104px; + flex: 0 0 104px; display: flex; flex-direction: column; gap: 3px; @@ -1714,8 +1720,8 @@ button.dm-listenzeile { } .dm-marke img { - /* Auf Zeilenhoehe, siehe .dm-markenlogo weiter unten. */ - height: 16px; + /* Siehe .dm-markenlogo weiter unten. */ + height: 19.2px; width: auto; } @@ -1779,7 +1785,7 @@ button.dm-listenzeile { 16px, siehe .ads-feld label. Mit 14px wirkte es neben "SmartDeal aktiv" zu klein (gemeldet vom Eigentuemer, 2026-08-31). */ .dm-markenlogo { - height: 16px; + height: 19.2px; width: auto; vertical-align: middle; } diff --git a/companion-app/src/symbole.tsx b/companion-app/src/symbole.tsx index 85c1cae..9706ee0 100644 --- a/companion-app/src/symbole.tsx +++ b/companion-app/src/symbole.tsx @@ -44,7 +44,21 @@ export function SymbolUebersicht({ groesse }: SymbolProps) { + /* Auf die volle Rasterbreite gezogen. Live gemessen (getBBox, 01.09./02.09.2026) + fuellte das Symbol nur 18,0 x 10,3 von 24 - 43 % der Hoehe, waehrend die + vier Nachbarn zwischen 65 % (Tanken) und 89 % (Fahrten) liegen. In einer + Reihe liest sich das als "zu klein", und genau so wurde es gemeldet. + + Es ist bewusst NUR ein Faktor 1,33 (18 -> 24 Breite, Hoehe 10,3 -> 13,7) + und keine Angleichung auf 72 %: das Symbol ist ein Halbkreis, also + bauartbedingt halb so hoch wie breit. Fuer 72 % Hoehe braeuchte es Faktor + 1,68 und waere mit 30 Einheiten breiter als das Raster. Wer es wirklich + gleich hoch will, muss den Bogen neu zeichnen (groesserer Winkel als 180 + Grad), nicht skalieren. + + stroke-width am gleicht die Skalierung aus: 1,5 / 1,33 = 1,125, damit + die Strichstaerke gerendert wieder 1,5 betraegt wie bei den Nachbarn. */ + diff --git a/custom_components/audi_dashboard/belege.py b/custom_components/audi_dashboard/belege.py index 081f124..3ab1f97 100644 --- a/custom_components/audi_dashboard/belege.py +++ b/custom_components/audi_dashboard/belege.py @@ -36,7 +36,7 @@ from . import shell_beleg_parser from . import tankerkennung from .tankerkennung import leerer_tankvorgang from .veroeffentlichung import zustand_oder_none -from .verlauf import zeit_normalisiert +from .verlauf import als_kilometerstand, zeit_normalisiert if TYPE_CHECKING: from .koordinator import Koordinator @@ -169,7 +169,7 @@ async def _nachtragen( aenderungen = dict(beleg_felder) aenderungen["status"] = "vollständig" if await k.ablage.tankvorgang_aktualisieren(tank_id, aenderungen): - await k.tankvorgaenge_veroeffentlichen() + await k.tankvorgang_nachbereiten() await k.beleg_ergebnis_veroeffentlichen({"erfolg": True, "tank_id": tank_id}) _LOGGER.info("Beleg %s in Tankvorgang %s nachgetragen", receipt_key, tank_id) else: @@ -192,17 +192,23 @@ async def tankvorgang_manuell(k: Koordinator, daten: dict) -> None: _LOGGER.warning("liter und kosten sind Pflichtfelder") return - km = daten.get("km") - odometer_km = km if km is not None else _km_aus_sensor(k) + ts_roh = daten.get("ts") + ts = zeit_normalisiert(ts_roh) if ts_roh else datetime.datetime.now(datetime.UTC) + + # Eine 0 oder ein geleertes Feld heissen "kenne ich nicht" - siehe + # als_kilometerstand(). + odometer_km = als_kilometerstand(daten.get("km")) + if odometer_km is None: + odometer_km = _km_aus_sensor_wenn_jetzt(k, ts) distanz = daten.get("distanz") if distanz is None: # Vorschlag aus TANK_DISTANZ_SENSOR oder, ohne ihn, der Differenz zum # letzten Tankvorgang - aber wie odometer_km selbst frei # überschreibbar, ein übergebener Wert hat deshalb immer Vorrang. - distanz = await tankerkennung.distanz_seit_tankung(k, odometer_km) + distanz = await tankerkennung.distanz_seit_tankung( + k, odometer_km, jetzt=_ist_jetzt(ts) + ) - ts_roh = daten.get("ts") - ts = zeit_normalisiert(ts_roh) if ts_roh else datetime.datetime.now(datetime.UTC) receipt_key = daten.get("receipt_key") tankvorgang = leerer_tankvorgang( ts.isoformat(), @@ -223,10 +229,15 @@ async def tankvorgang_manuell(k: Koordinator, daten: dict) -> None: }) await k.ablage.tankvorgang_anhaengen(tankvorgang) - await k.tankvorgaenge_veroeffentlichen() + await k.tankvorgang_nachbereiten() _LOGGER.info("Tankvorgang %s angelegt", tankvorgang["tank_id"]) +# Wie nah der eingetragene Zeitpunkt an "jetzt" liegen muss, damit der +# aktuelle Tachostand als Ersatz taugt. +SENSOR_ERSATZ_FENSTER = datetime.timedelta(hours=2) + + def _km_aus_sensor(k: Koordinator) -> float | None: wert = zustand_oder_none(k.hass, k.zuordnung.werte.KM_SENSOR) try: @@ -235,6 +246,39 @@ def _km_aus_sensor(k: Koordinator) -> float | None: return None +def _ist_jetzt(ts: datetime.datetime | None) -> bool: + """Liegt der eingetragene Zeitpunkt nah genug an jetzt, dass die + Momentanwerte der Sensoren zu ihm passen?""" + if ts is None: + return False + jetzt = datetime.datetime.now(datetime.UTC) + return abs((jetzt - ts).total_seconds()) <= SENSOR_ERSATZ_FENSTER.total_seconds() + + +def _km_aus_sensor_wenn_jetzt( + k: Koordinator, ts: datetime.datetime | None +) -> float | None: + """Der aktuelle Tachostand - aber nur fuer einen Tankvorgang von jetzt. + + Der Sensor kennt genau einen Wert: den von diesem Augenblick. Fuer eine + Tankung, die gerade passiert ist, ist das der richtige Ersatz und der + Normalfall - der Nutzer muss nichts eintippen. + + Fuer eine nachgetragene Tankung von vorgestern ist es der falsche Wert, + und er richtet zweifachen Schaden an: die gefahrene Distanz wird gegen + einen zu hohen Stand gerechnet, und die Datumspruefung der + Verbrauchskorrektur verwirft das Intervall, weil der gespeicherte Stand + nicht zum Tacho des eingetragenen Zeitpunkts passt. Dann lieber gar kein + Kilometerstand - genau das, was eine 0 im Formular ausdruecken soll.""" + if not _ist_jetzt(ts): + _LOGGER.info( + "Tankvorgang auf %s datiert - der aktuelle Tachostand passt dazu " + "nicht und wird nicht eingesetzt", ts.isoformat(), + ) + return None + return _km_aus_sensor(k) + + async def tankvorgang_aktualisieren(k: Koordinator, daten: dict) -> None: """Bearbeitet einen bestehenden Tankvorgang (Einzelbeleg-Seite, egal ob ursprünglich per Beleg oder manuell angelegt). @@ -252,6 +296,12 @@ async def tankvorgang_aktualisieren(k: Koordinator, daten: dict) -> None: _LOGGER.warning("tankvorgang_aktualisieren ohne tank_id aufgerufen") return + # edited_fields fortschreiben statt ersetzen: was frueher von Hand gesetzt + # wurde, bleibt geschuetzt. + vorhanden = await k.ablage.tankvorgaenge_lesen() + alt = next((t for t in vorhanden if t.get("tank_id") == tank_id), None) + hand = set((alt or {}).get("edited_fields") or []) + aenderungen: dict = {} if "ts" in daten: ts_normalisiert = zeit_normalisiert(daten.get("ts")) @@ -262,8 +312,20 @@ async def tankvorgang_aktualisieren(k: Koordinator, daten: dict) -> None: aenderungen["liters"] = liter aenderungen["fuel_total_eur"] = kosten aenderungen["price_per_l"] = preis_je_liter(kosten, liter) + if liter is not None: + # Von Hand eingetippte Liter sind der Beleg, nur ohne PDF - und + # damit dieselbe Wahrheit wie ein geparster Beleg. Ohne diesen + # Vermerk haelt verbrauchskorrektur.beleg_liter() sie fuer den + # Anstieg des Gebers und verwirft sie als Zirkelschluss; der + # Verbrauchsfaktor entstuende nie. + # + # Nebenwirkung, bewusst in Kauf genommen: edited_fields schuetzt + # das Feld vor der Automatik. Wird spaeter doch noch ein PDF zu + # demselben Tankvorgang hochgeladen, bleibt die getippte Zahl + # stehen - der Mensch hatte den Beleg in der Hand. + hand.add("liters") if "km" in daten: - aenderungen["odometer_km"] = daten.get("km") + aenderungen["odometer_km"] = als_kilometerstand(daten.get("km")) if "ersparnis" in daten: aenderungen["discount"] = daten.get("ersparnis") if "station" in daten: @@ -277,8 +339,11 @@ async def tankvorgang_aktualisieren(k: Koordinator, daten: dict) -> None: aenderungen["receipt_file"] = daten.get("receipt_file") aenderungen["source"] = "beleg" + if hand: + aenderungen["edited_fields"] = sorted(hand) + if await k.ablage.tankvorgang_aktualisieren(tank_id, aenderungen): - await k.tankvorgaenge_veroeffentlichen() + await k.tankvorgang_nachbereiten() _LOGGER.info("Tankvorgang %s aktualisiert", tank_id) else: _LOGGER.warning("Tankvorgang %s nicht gefunden", tank_id) @@ -289,7 +354,7 @@ async def tankvorgang_loeschen(k: Koordinator, tank_id: str | None) -> None: _LOGGER.warning("tankvorgang_loeschen ohne tank_id aufgerufen") return if await k.ablage.tankvorgang_loeschen(tank_id): - await k.tankvorgaenge_veroeffentlichen() + await k.tankvorgang_nachbereiten() _LOGGER.info("Tankvorgang %s gelöscht", tank_id) else: _LOGGER.warning("Tankvorgang %s nicht gefunden", tank_id) diff --git a/custom_components/audi_dashboard/fahrterkennung.py b/custom_components/audi_dashboard/fahrterkennung.py index 5953314..ded7f63 100644 --- a/custom_components/audi_dashboard/fahrterkennung.py +++ b/custom_components/audi_dashboard/fahrterkennung.py @@ -23,7 +23,7 @@ import datetime import logging from typing import TYPE_CHECKING -from . import screening +from . import reifen, screening from .ablage import neue_id from .veroeffentlichung import zustand_oder_none from .verlauf import ( @@ -73,7 +73,12 @@ NACHLAUF_S = 900 NACHLAUF_TOLERANZ_S = 120 -def leere_fahrt(start_ts: datetime.datetime, ende_ts: datetime.datetime, quelle: str) -> dict: +def leere_fahrt( + start_ts: datetime.datetime, + ende_ts: datetime.datetime, + quelle: str, + reifensatz: str | None = None, +) -> dict: """Das vollständige Fahrt-Schema mit allem, was noch nicht bekannt ist, auf None. Bewusst alle Felder ausgeschrieben: die Oberfläche darf sich auf ihre Existenz verlassen, und ein fehlendes Feld wäre schwerer zu finden @@ -89,6 +94,11 @@ def leere_fahrt(start_ts: datetime.datetime, ende_ts: datetime.datetime, quelle: "odo_end": None, "avg_speed_kmh": None, "vmax_kmh": None, + # Die reine Messung, unangetastet - damit die Eichung an den + # Betankungen wiederholbar bleibt (verbrauchskorrektur.py). + "verbrauch_roh_l_100km": None, + "verbrauch_faktor": None, + # Was angezeigt wird: roh mal Faktor, oder roh allein. "verbrauch_l_100km": None, "start_lat": None, "start_lon": None, @@ -96,7 +106,20 @@ def leere_fahrt(start_ts: datetime.datetime, ende_ts: datetime.datetime, quelle: "end_lon": None, "start_address": None, "end_address": None, + # Nur der Ortsname, fuer die Fahrtenliste. Zwei volle Anschriften + # passen dort nicht in eine Zeile. Er wird beim Aufloesen SEPARAT + # gemerkt, nicht nachtraeglich aus der Anschrift geschnitten - + # Begruendung in geokodierung.anschrift_bauen(). + "start_stadt": None, + "end_stadt": None, "art": "privat", + # Welcher Radsatz montiert war. Der Radzaehler summiert danach - siehe + # reifen.py. None heisst "zaehlt fuer keinen Satz": so bleiben Fahrten + # aus der Zeit vor der Umstellung dem Sockel ueberlassen, statt dort + # ein zweites Mal aufzutauchen. + "reifensatz": reifensatz, + # Wieviel von dieser Fahrt dem Radsatz bereits gutgeschrieben ist. + "reifen_gezaehlt_km": None, "route": None, "pausen": [], "source": quelle, @@ -341,7 +364,7 @@ async def fahrt_beenden( MINDESTDAUER_S, ) return - fahrt = leere_fahrt(start_ts, ende_ts, "ha") + fahrt = leere_fahrt(start_ts, ende_ts, "ha", reifen.aktiver_satz(await k.ablage.profil_lesen())) await k.ablage.fahrt_anhaengen(fahrt) await k.fahrt_start_setzen(None) await k.fahrten_veroeffentlichen() @@ -394,7 +417,9 @@ async def manuell_anlegen(k: Koordinator, daten: dict) -> None: daten.get("odo_start"), daten.get("odo_end"), daten.get("distanz"), ) - fahrt = leere_fahrt(start_ts, ende_ts, "manual") + fahrt = leere_fahrt( + start_ts, ende_ts, "manual", reifen.aktiver_satz(await k.ablage.profil_lesen()) + ) fahrt["art"] = daten.get("art") or "privat" fahrt.update(hand) fahrt["edited_fields"] = sorted(hand) diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 9aad6ff..55733e3 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.9.1.24","sha256":"1d08e5fe05d30095db34e6eaaf5f9765dad94c8e14fbc1a501cfe3e5daec572a","bytes":266230,"gebaut":"2026-09-01T19:12:38Z"} \ No newline at end of file +{"version":"2026.9.2.3","sha256":"dc96144b41112aae8af2c211111aaff2723bc21eeaefb3bf4af72d2914012bc2","bytes":265967,"gebaut":"2026-09-02T09:26:58Z"} \ No newline at end of file diff --git a/custom_components/audi_dashboard/frontend/app/bundle.zip b/custom_components/audi_dashboard/frontend/app/bundle.zip index 62ec6b5..080b58b 100644 Binary files a/custom_components/audi_dashboard/frontend/app/bundle.zip and b/custom_components/audi_dashboard/frontend/app/bundle.zip differ diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js index ebe38ed..f78eb09 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js @@ -754,7 +754,6 @@ let USER_POS_FEHLER = null; let USER_WATCH = null; // watchPosition-Kennung, solange die Standortseite offen ist let STANDORT_ADRESSE = null; // zuletzt aufgelöste Adresse (Cache über Koordinaten-Rundung) let STANDORT_ADRESSE_KEY = null; -let STANDORT_STADT = ""; // reiner Ortsname zum selben Punkt let STANDORTMENU_OFFEN = false; let SMAP = null, SMAP_TILE = null, SMAP_STIL = null; // Vollbild-Karte (eigenständig von MAP/TMAP); SMAP_STIL: null/"strasse" oder "satellit" let TMAP = null, TMAP_TILE = null; // Vorschau-Karte der Übersicht-Kachel @@ -956,34 +955,19 @@ function standortVerfolgungBeenden() { der Einzelfahrt bisher "unbekannt", obwohl die Position bekannt war (vom Eigentümer gemeldet, 01.09.2026). - Aufgelöst wird erst beim Öffnen der Fahrt und über denselben Cache wie die - Standortansicht - ein Abruf je Ort, danach nie wieder. Ein Vorratsabruf für - alle Fahrten der Liste wäre genau das, worum Nominatim in seinen - Nutzungsbedingungen bittet, es nicht zu tun. */ + Aufgelöst wird seit dem 01.09.2026 im Backend (geokodierung.py) und mit der + Fahrt gespeichert. Der Client fragt nichts mehr ab und hält keinen eigenen + Zwischenspeicher: jedes Gerät zeigt denselben Ort, ein frisch eingerichtetes + Telefon zeigt ihn sofort, und ein geleerter Browserspeicher wirft nichts weg. + + "wird ermittelt …" heißt: die Koordinaten stehen, der Ort noch nicht. Das + nächste Screening trägt ihn nach. */ function fahrtOrtText(adresse, lat, lon) { if (adresse) return esc(adresse); if (lat == null || lon == null) return "unbekannt"; return "wird ermittelt …"; } -/* Trägt die aufgelösten Orte nach dem Zeichnen ein. Die Fahrt-Kennung wird - mitgeführt: wer während des Abrufs weiterblättert, soll nicht die Adresse - der vorigen Fahrt in der neuen sehen. */ -async function fahrtOrteNachtragen(t) { - const id = t.trip_id; - const paare = [ - ["start", t.start_address, t.start_lat, t.start_lon], - ["ziel", t.end_address, t.end_lat, t.end_lon], - ]; - for (const [feld, adresse, lat, lon] of paare) { - if (adresse || lat == null || lon == null) continue; - const ort = await standortAdresseAufloesen(lat, lon); - const ziel = ROOT.querySelector(`[data-fahrtort="${feld}"]`); - if (!ziel || route.name !== "trip" || route.id !== id) return; - ziel.textContent = ort || "unbekannt"; - } -} - /* Reverse-Geocoding über die öffentliche Nominatim-API (OpenStreetMap) - wie die Kartenkacheln oben (TILE_URL) ohne eigenen Schlüssel; bei diesem Aufrufvolumen (ein Fahrzeug, nur bei geöffneter Standortansicht) im Rahmen @@ -1017,8 +1001,7 @@ async function standortAdresseAufloesen(lat, lon) { const daten = await res.json(); const a = daten.address || {}; const strasse = [a.road, a.house_number].filter(Boolean).join(" "); - STANDORT_STADT = (a.city || a.town || a.village || "").trim(); - const ort = [a.postcode, STANDORT_STADT].filter(Boolean).join(" "); + const ort = [a.postcode, a.city || a.town || a.village].filter(Boolean).join(" "); /* Steht das Auto auf einem benannten Platz - Parkplatz, Parkhaus, Rastplatz -, sagt dessen Name mehr als die naechstgelegene Hausnummer. Nominatim liefert ihn im Feld name; die Pruefung auf die Art der @@ -1040,9 +1023,6 @@ async function standortAdresseAufloesen(lat, lon) { // Nur echte Treffer merken - eine gedrosselte Antwort ist kein "hier gibt es // keine Adresse" und soll beim naechsten Mal erneut versucht werden. if (STANDORT_ADRESSE) geoCacheSchreiben(cacheKey, STANDORT_ADRESSE); - // Der reine Ortsname zusaetzlich, fuer die Fahrtenliste - siehe - // stadtAusCache(). - if (STANDORT_STADT) geoCacheSchreiben("ort:" + key, STANDORT_STADT); return STANDORT_ADRESSE; } function standortAdresseLaden() { @@ -1502,7 +1482,7 @@ function teaser() { const t = juengster(TRIPS, "ts_start"), f = juengster(FILLS, "ts"); return `
Zuletzt ${t ? `` : ""} ${f ? `
`; } @@ -2971,7 +2951,7 @@ function vEinst() {
Kraftstoff-Rabatt
+ Shell SmartDeal aktiv
${CAR.smartdeal.aktiv ? `
@@ -2992,7 +2972,7 @@ function vEinst() { Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten, - Ausstattung, Reifen, Versicherung, Steuer und Werkstatt. Fahrten, Tankvorgänge und der + Ausstattung, Räder, Versicherung, Steuer und Werkstatt. Fahrten, Tankvorgänge und der Wartungsplan lassen sich einzeln als CSV sichern und bearbeitet wieder einspielen — eine Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles andere bleibt unangetastet. @@ -3432,10 +3412,8 @@ function vReifen() {
` : ""}
Wechseltermin -
+
-
-
${satz("sommer")} @@ -3725,74 +3703,20 @@ function istNachtZeit(ts) { } /* D3: Die Strecke führt die Zeile, das Datum steht darunter. Umbruch nur zwischen den Orten, nie am Pfeil. */ -/* 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). */ -function ortAusCache(lat, lon) { - if (lat == null || lon == null) return null; - const wert = geoCacheLesen()["rev:" + lat.toFixed(3) + "," + lon.toFixed(3)]; - return typeof wert === "string" ? wert : null; -} - -/* Der Ortsname allein, fuer die Fahrtenliste. - - In der Liste steht eine Fahrt in einer Zeile neben Datum, Art, Strecke und - Verbrauch. Zwei volle Anschriften passen dort nicht - "Schottenau, 85072 - Eichstaett -> Am Anger 7a, 85111 Adelschlag" ist laenger als der Rest der - Zeile zusammen. Auf der Einzelfahrt bleibt die vollstaendige Anschrift. - - Der Ort wird beim Aufloesen SEPARAT gemerkt (Schluessel "ort:"), nicht - nachtraeglich aus der Anschrift geschnitten. Das Zerlegen der eigenen - Ausgabe waere fast immer richtig und in einem Fall falsch: faellt Nominatim - auf display_name zurueck, steht hinter dem letzten Komma das LAND. Dann - stuende in der Liste "Deutschland -> Deutschland". */ -/* Der Ortsname aus einer bereits gespeicherten Anschrift. - - Rueckfall fuer alles, was VOR dem Merken des Ortsnamens aufgeloest wurde: - dort liegt nur die volle Anschrift, und ein neuer Netzabruf, nur um den Ort - zu erfahren, waere Verschwendung. - - Geschnitten wird ausschliesslich, wenn hinter dem letzten Komma eine - Postleitzahl steht - unser eigenes Format ist ", ". - Faellt Nominatim auf display_name zurueck, fehlt die PLZ und hinter dem - letzten Komma stuende das LAND; dann lieber die volle Anschrift zeigen als - "Deutschland". */ -function stadtAusAnschrift(anschrift) { - if (!anschrift) return null; - const teil = String(anschrift).split(", ").pop().trim(); - const treffer = teil.match(/^\d{4,5}\s+(.+)$/); - return treffer ? treffer[1].trim() : null; -} - -function stadtAusCache(lat, lon) { - if (lat == null || lon == null) return null; - const wert = geoCacheLesen()["ort:" + lat.toFixed(3) + "," + lon.toFixed(3)]; - return typeof wert === "string" && wert ? wert : null; -} - function strecke(t) { - // Ortsname bevorzugt; ohne ihn die volle Anschrift, damit eine vor dieser - // Aenderung aufgeloeste Fahrt nicht ploetzlich wieder ortlos dasteht. - const ortsname = (adresse, lat, lon) => { - if (adresse) return adresse; - const gemerkt = stadtAusCache(lat, lon); - if (gemerkt) return gemerkt; - const voll = ortAusCache(lat, lon); - return stadtAusAnschrift(voll) || voll; + // Der Ortsname kommt mit der Fahrt (start_stadt), aufgeloest im Backend. + // + // 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(t.edited_fields || []); + const ortsname = (seite) => { + const adresse = t[seite + "_address"]; + if (adresse && handisch.has(seite + "_address")) return adresse; + return t[seite + "_stadt"] || adresse || null; }; - const a = ortsname(t.start_address, t.start_lat, t.start_lon); - const b = ortsname(t.end_address, t.end_lat, t.end_lon); - if (!a && !b) return "Fahrt ohne Ortsangabe"; + const a = ortsname("start"); + const b = ortsname("end"); if (!a && !b) return "Fahrt ohne Ortsangabe"; if (!b) return esc(a); if (!a) return esc(b); return `${esc(a)} → ${esc(b)}`; @@ -4253,7 +4177,21 @@ function vFill(id) { /* ---------------------------------------------------------- Steuerung */ const ICONS = { - home: '', + /* Auf die volle Rasterbreite gezogen. Live gemessen (getBBox, 01.09./02.09.2026) + fuellte das Symbol nur 18,0 x 10,3 von 24 - 43 % der Hoehe, waehrend die + vier Nachbarn zwischen 65 % (Tanken) und 89 % (Fahrten) liegen. In einer + Reihe liest sich das als "zu klein", und genau so wurde es gemeldet. + + Es ist bewusst NUR ein Faktor 1,33 (18 -> 24 Breite, Hoehe 10,3 -> 13,7) + und keine Angleichung auf 72 %: das Symbol ist ein Halbkreis, also + bauartbedingt halb so hoch wie breit. Fuer 72 % Hoehe braeuchte es Faktor + 1,68 und waere mit 30 Einheiten breiter als das Raster. Wer es wirklich + gleich hoch will, muss den Bogen neu zeichnen (groesserer Winkel als 180 + Grad), nicht skalieren. + + stroke-width am gleicht die Skalierung aus: 1,5 / 1,33 = 1,125, damit + die Strichstaerke gerendert wieder 1,5 betraegt wie bei den Nachbarn. */ + home: '', /* Wagen von vorn - dasselbe Symbol wie in der myAudi-App (Audi-Symbolsatz, assets/icons/car.svg). Flaeche statt Kontur, deshalb die Klasse "voll". */ audi: '', @@ -4828,11 +4766,10 @@ function render() { else if (route.name === "trip") { head = ["Fahrt", "Einzelfahrt"]; v.innerHTML = vTrip(route.id); const t = TRIPS.find((x) => x.trip_id === route.id); - if (t) fahrtOrteNachtragen(t); } else if (route.name === "fill") { head = ["Tankvorgang", "Einzelbeleg"]; v.innerHTML = vFill(route.id); } else if (route.name === "service") { head = ["Wartung", "Service"]; v.innerHTML = vService(); } - else if (route.name === "reifen") { head = ["Fahrzeug", "Reifen"]; v.innerHTML = vReifen(); } + else if (route.name === "reifen") { head = ["Fahrzeug", "Räder"]; v.innerHTML = vReifen(); } else if (route.name === "einst") { head = ["App", "Einstellungen"]; v.innerHTML = vEinst(); } else if (route.name === "ident") { head = ["Fahrzeug", "Identität und Technik"]; v.innerHTML = vIdent(); } else if (route.name === "sicherheit") { head = ["Fahrzeug", "Fahrzeugstatus"]; v.innerHTML = vSicherheit(); } @@ -5757,7 +5694,7 @@ function ereignisseVerdrahten() { const rfArchDel = e.target.closest("[data-reifenarchivloeschen]"); if (rfArchDel) { const id = rfArchDel.dataset.reifenarchivloeschen; - bestaetigen("Archivierten Reifensatz löschen?", + bestaetigen("Archivierten Radsatz löschen?", "Das lässt sich nicht widerrufen.", "Löschen", () => { serviceRufen("reifen_archiv_loeschen", { id }); CAR.reifen.archiv = CAR.reifen.archiv.filter((a) => a.id !== id); @@ -5778,10 +5715,9 @@ function ereignisseVerdrahten() { } else { const datum = $("#rfDat").value; CAR.reifen.wechsel.datum = datum; - CAR.reifen.wechsel.vereinbart = $("#rfOk").checked; profilSpeichern(); const ziel = CAR.reifen.aktiv === "Sommer" ? "Winter" : "Sommer"; - ics(`${CONFIG.fahrzeugtitel}: Reifenwechsel auf ${ziel}`, datum, CAR.service.autohaus.name, `Umstellung des Zählers auf den ${ziel}satz nicht vergessen`); + ics(`${CONFIG.fahrzeugtitel}: Radwechsel auf ${ziel}`, datum, CAR.service.autohaus.name, `Umstellung des Zählers auf den ${ziel}satz nicht vergessen`); } return; } diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard.css b/custom_components/audi_dashboard/frontend/audi-dashboard.css index ff2c1f8..5de67ee 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard.css +++ b/custom_components/audi_dashboard/frontend/audi-dashboard.css @@ -459,14 +459,26 @@ button.sicherheitszeile:active { background: var(--tile-2); } border-bottom: 1px solid var(--line); color: inherit; } -.leaf .k { font-size: 14px; color: var(--fg); display: flex; flex-direction: column; gap: 3px; line-height: 1.35; } +/* Das Label nimmt den freien Platz und kuerzt bei Bedarf mit Auslassung. + + Ohne flex:1 blieb in der Zeile freier Raum, und weil sie DREI Kinder hat + (Label, Werte, Pfeil), verteilte space-between ihn ZWISCHEN alle drei - die + Wertespalte stand damit in der Mitte, ihre Lage abhaengig von der Laenge des + Ortsnamens. Gemessen: rechte Kante bei 549px in der einen Zeile, 520px in + der naechsten. Mit flex:1 bleibt kein freier Raum uebrig, Werte und Pfeil + sitzen buendig rechts. + + min-width:0 ist noetig, damit ein Flex-Kind ueberhaupt unter seine + Inhaltsbreite schrumpfen darf - ohne das greift text-overflow nie. */ +.leaf .k { font-size: 14px; color: var(--fg); display: flex; flex-direction: column; gap: 3px; line-height: 1.35; flex: 1 1 auto; min-width: 0; } +.leaf .k > * { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .leaf .k small { font-size: 12.5px; color: var(--fg3); } /* Feste Mindestbreite, damit Strecke und Verbrauch ueber alle Zeilen hinweg in einer Spalte stehen. Ohne sie waechst der Kasten mit dem Inhalt: gemessen 86px bei "11,4 l/100 km" gegen 37px bei einer Zeile ohne Werte, und die Zahlen standen entsprechend versetzt. 92px traegt die laengste Form ("11,4 l/100 km") ohne Umbruch. */ -.leaf .v { font-size: 13px; color: var(--fg2); text-align: right; display: flex; flex-direction: column; gap: 3px; flex: 0 0 auto; min-width: 92px; } +.leaf .v { font-size: 13px; color: var(--fg2); text-align: right; display: flex; flex-direction: column; gap: 3px; flex: 0 0 92px; } .leaf .v small { font-size: 12.5px; color: var(--fg3); } /* .teaser (die "Zuletzt"-Kachel auf der Übersicht, teaser()): genau zwei Zeilen mit sehr unterschiedlich breiten Werten ("offen" vs. "51,59 l") - @@ -665,9 +677,10 @@ img.carfix.klein { aspect-ratio: auto; height: auto; } .marke { display: inline-flex; align-items: center; gap: 6px; } .marke-logo { display: inline-flex; align-items: center; } -/* Auf Zeilenhoehe (16px) statt 13px: das Markenlogo stand neben Text - derselben Zeile und wirkte daneben zu klein. */ -.marke-logo img { height: 16px; width: auto; } +/* 19,2px = 16px + 20%. Der Eigentuemer wollte das Markenlogo in JEDER + Darstellung um ein Fuenftel groesser; es steht ueberall neben Text der + Zeilenhoehe 16px, also hier eine Zahl statt mehrerer Sondergroessen. */ +.marke-logo img { height: 19.2px; width: auto; } .marke-logo .ph { display: none; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--fg3); } .marke-logo.logo-fehlt img { display: none; } .marke-logo.logo-fehlt .ph { display: inline; } diff --git a/custom_components/audi_dashboard/geokodierung.py b/custom_components/audi_dashboard/geokodierung.py new file mode 100644 index 0000000..db2365d --- /dev/null +++ b/custom_components/audi_dashboard/geokodierung.py @@ -0,0 +1,237 @@ +"""Koordinaten zu Ortsnamen - einmal im Backend, nicht in jedem Geraet. + +Bis zum 01.09.2026 loeste jede Oberflaeche die Fahrtkoordinaten selbst auf und +legte das Ergebnis in ihren eigenen Zwischenspeicher (localStorage im Panel, +derselbe Mechanismus in der App). Drei Folgen, alle drei unnoetig: + +* Jedes Geraet fragte dieselben Koordinaten erneut ab. Bei zwei Telefonen und + dem Panel also dreimal - fuer einen Ort, der sich nie wieder aendert. +* Ein frisch eingerichtetes Geraet zeigte fuer Monate alte Fahrten wieder + "unbekannt", bis jemand jede einzelne Fahrt geoeffnet hatte. +* Ein geleerter Browserspeicher warf alles weg. + +Jetzt steht der Ort in der Fahrt selbst. Er wird genau einmal aufgeloest und +danach von jedem Geraet gelesen wie jedes andere Feld auch. + +NOMINATIMS NUTZUNGSBEDINGUNGEN sind der Grund fuer fast jede Entscheidung +hier: hoechstens eine Anfrage je Sekunde, kein Vorratsabruf, ein +aussagekraeftiger User-Agent, und Ergebnisse sind zu speichern statt erneut +abzurufen. Daher die Sperre zwischen zwei Anfragen, daher die Obergrenze je +Durchlauf, und daher der Zwischenspeicher auf der Platte. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import time + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +_LOGGER = logging.getLogger(__name__) + +DIENST = "https://nominatim.openstreetmap.org/reverse" + +# Nominatim bittet um hoechstens eine Anfrage je Sekunde. 1,1 s laesst Luft +# fuer Ungenauigkeiten der Uhr, ohne spuerbar laenger zu dauern. +ABSTAND_S = 1.1 + +# Wie viele Orte ein einzelner Screening-Durchlauf hoechstens aufloest. Ein +# Rueckblick ueber ein Jahr braechte sonst Hunderte Anfragen am Stueck - genau +# der Vorratsabruf, um dessen Unterlassung Nominatim bittet. Der Rest kommt +# beim naechsten Durchlauf dran; die laufen haeufig genug. +PRO_DURCHLAUF = 8 + +# Drei Nachkommastellen sind rund 110 m. Feiner aufzuloesen braechte fuer eine +# Anschrift nichts und wuerde den Zwischenspeicher unbrauchbar machen: jede +# Fahrt endete auf einem eigenen Schluessel. Dieselbe Rundung wie bisher in +# beiden Oberflaechen, damit deren Altbestand weiter passt. +STELLEN = 3 + +ZEITSPERRE_S = 8 + +# Ein benannter Ort sagt mehr als die naechstgelegene Hausnummer - aber nur, +# wenn er wirklich ein Ziel ist. Ohne diese Pruefung verdraengte jedes +# zufaellig benannte Gebaeude die Anschrift. +BENANNTE_ARTEN = { + "parking", + "parking_space", + "parking_entrance", + "bicycle_parking", + "motorcycle_parking", + "rest_area", + "services", + "fuel", + "charging_station", +} + + +def schluessel(lat: float, lon: float) -> str: + return f"{round(lat, STELLEN):.{STELLEN}f},{round(lon, STELLEN):.{STELLEN}f}" + + +def anschrift_bauen(daten: dict) -> tuple[str | None, str | None]: + """(Anschrift, Ort) aus Nominatims Antwort. + + Das Format ist bewusst dasselbe, das die Oberflaechen bisher selbst + gebaut haben - " , " -, damit bereits + gespeicherte Anschriften und neue nicht unterschiedlich aussehen. + + Der Ort wird SEPARAT zurueckgegeben, nicht spaeter aus der Anschrift + geschnitten: faellt Nominatim auf display_name zurueck, stuende hinter dem + letzten Komma das Land, und in der Fahrtenliste stuende dann + "Deutschland -> Deutschland". + """ + a = daten.get("address") or {} + stadt = ( + a.get("city") + or a.get("town") + or a.get("village") + or a.get("municipality") + or a.get("suburb") + or "" + ).strip() + + name = (daten.get("name") or "").strip() + art = daten.get("type") or daten.get("category") or "" + if name and art in BENANNTE_ARTEN: + vorn = name + else: + strasse = (a.get("road") or a.get("pedestrian") or "").strip() + nummer = (a.get("house_number") or "").strip() + vorn = " ".join(t for t in (strasse, nummer) if t) + + plz = (a.get("postcode") or "").strip() + hinten = " ".join(t for t in (plz, stadt) if t) + anschrift = ", ".join(t for t in (vorn, hinten) if t) + + if not anschrift: + anschrift = (daten.get("display_name") or "").strip() + + return (anschrift or None), (stadt or None) + + +class Geokodierung: + """Loest Koordinaten auf und merkt sich jedes Ergebnis auf der Platte.""" + + def __init__(self, hass: HomeAssistant, basis: str) -> None: + self._hass = hass + self._pfad = os.path.join(basis, "orte.json") + self._speicher: dict[str, dict] | None = None + self._sperre = asyncio.Lock() + self._zuletzt = 0.0 + self.rest = PRO_DURCHLAUF + + def durchlauf_beginnen(self) -> None: + """Setzt das Anfragenbudget fuer einen Screening-Lauf zurueck.""" + self.rest = PRO_DURCHLAUF + + # ------------------------------------------------------ Zwischenspeicher + + def _laden(self) -> dict: + if not os.path.exists(self._pfad): + return {} + try: + with open(self._pfad, encoding="utf-8") as datei: + inhalt = json.load(datei) + return inhalt if isinstance(inhalt, dict) else {} + except (OSError, ValueError): + # Eine beschaedigte Datei darf das Screening nicht anhalten - im + # schlimmsten Fall wird noch einmal abgefragt. + _LOGGER.warning("Ortsspeicher unlesbar, wird neu aufgebaut: %s", self._pfad) + return {} + + def _sichern(self, inhalt: dict) -> None: + tmp = self._pfad + ".tmp" + with open(tmp, "w", encoding="utf-8") as datei: + json.dump(inhalt, datei, ensure_ascii=False, indent=1) + os.replace(tmp, self._pfad) + + async def _speicher_holen(self) -> dict: + if self._speicher is None: + self._speicher = await self._hass.async_add_executor_job(self._laden) + return self._speicher + + # ---------------------------------------------------------- Aufloesung + + async def aufloesen(self, lat: float | None, lon: float | None) -> dict | None: + """{"anschrift": ..., "stadt": ...} oder None. + + None heisst "noch nicht bekannt und diesmal nicht abgefragt" - entweder + weil das Budget aufgebraucht ist oder weil der Abruf scheiterte. Beides + wird beim naechsten Durchlauf erneut versucht. + + Ein Ergebnis OHNE Anschrift ({"anschrift": None}) heisst dagegen "dort + ist wirklich nichts" und wird gemerkt, nicht erneut abgefragt. + """ + if lat is None or lon is None: + return None + + try: + key = schluessel(float(lat), float(lon)) + except (TypeError, ValueError): + return None + + speicher = await self._speicher_holen() + if key in speicher: + return speicher[key] + + if self.rest <= 0: + return None + + async with self._sperre: + # Erneut pruefen: waehrend des Wartens kann ein anderer Aufruf + # dieselbe Koordinate bereits aufgeloest haben. + if key in speicher: + return speicher[key] + abstand = time.monotonic() - self._zuletzt + if abstand < ABSTAND_S: + await asyncio.sleep(ABSTAND_S - abstand) + ergebnis = await self._abfragen(float(lat), float(lon)) + self._zuletzt = time.monotonic() + self.rest -= 1 + + if ergebnis is None: + # Netz- oder Serverfehler: NICHT merken. Sonst stuende ein + # voruebergehend gedrosselter Abruf fuer immer als "kein Ort hier". + return None + + speicher[key] = ergebnis + await self._hass.async_add_executor_job(self._sichern, dict(speicher)) + return ergebnis + + async def _abfragen(self, lat: float, lon: float) -> dict | None: + session = async_get_clientsession(self._hass) + try: + antwort = await session.get( + DIENST, + params={ + "lat": f"{lat}", + "lon": f"{lon}", + "format": "jsonv2", + "zoom": "18", + "addressdetails": "1", + }, + headers={ + # Nominatim verlangt eine identifizierbare Anwendung und + # sperrt anonyme Aufrufer. + "User-Agent": "audi-dashboard (Home Assistant Integration)", + "Accept-Language": "de", + }, + timeout=ZEITSPERRE_S, + ) + if antwort.status != 200: + _LOGGER.debug("Nominatim antwortete mit %s", antwort.status) + return None + daten = await antwort.json(content_type=None) + except Exception as fehler: # Netzfehler, Zeitueberschreitung, kaputtes JSON + _LOGGER.debug("Ortsabfrage fehlgeschlagen: %s", fehler) + return None + + if not isinstance(daten, dict) or daten.get("error"): + return {"anschrift": None, "stadt": None} + anschrift, stadt = anschrift_bauen(daten) + return {"anschrift": anschrift, "stadt": stadt} diff --git a/custom_components/audi_dashboard/historienimport.py b/custom_components/audi_dashboard/historienimport.py index 297b414..6ecfd88 100644 --- a/custom_components/audi_dashboard/historienimport.py +++ b/custom_components/audi_dashboard/historienimport.py @@ -235,6 +235,10 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict: odo_start = odo_end = None durchschnitt = None + # Bewusst OHNE Radsatz: der Rueckblick rekonstruiert Vergangenes, und + # das steckt im gespeicherten Radstand laengst drin. Wuerde er hier + # stempeln, schriebe ein erneuter Import dieselben Kilometer ein + # zweites Mal auf die Raeder. fahrt = leere_fahrt(f_start, f_ende, "import") fahrt.update({ "distance_km": distanz, @@ -517,7 +521,7 @@ async def importieren(k: Koordinator, start: object, ende: object) -> None: batterie = await _batterie_importieren(k, verlaeufe) await k.fahrten_veroeffentlichen() - await k.tankvorgaenge_veroeffentlichen() + await k.tankvorgang_nachbereiten() await k.batterieverlauf_veroeffentlichen() ergebnis = { diff --git a/custom_components/audi_dashboard/koordinator.py b/custom_components/audi_dashboard/koordinator.py index bc392de..c3493f1 100644 --- a/custom_components/audi_dashboard/koordinator.py +++ b/custom_components/audi_dashboard/koordinator.py @@ -53,11 +53,13 @@ from . import ( identitaet, reifen, screening, + verbrauchskorrektur, sicherung, tankerkennung, verlauf, ) from .ablage import Ablage +from .geokodierung import Geokodierung from .const import ( BUENDEL_INFO, BUENDEL_ORDNER, @@ -116,6 +118,9 @@ class Koordinator: self.version = version self.ablage = Ablage(hass, hass.config.path("audi_dashboard")) self.zuordnung = Zuordnung(self.ablage) + # Ortsnamen werden einmal hier aufgeloest und mit der Fahrt + # gespeichert, statt in jedem Geraet erneut - siehe geokodierung.py. + self.geokodierung = Geokodierung(hass, self.ablage.basis) # Was die Entitäten anzeigen: Schlüssel -> (Zustand, Nutzlast). self.werte: dict[str, tuple[str, dict | list | None]] = {} @@ -159,10 +164,10 @@ class Koordinator: # Veröffentlichung den vorgeladenen Wert trägt. spannung_cache_vorladen(await self.ablage.batterieverlauf_lesen()) await self.alles_veroeffentlichen() - # Der Reifenzähler schreibt beim Start einmal fort: Kilometer, die - # während eines HA-Ausfalls gefahren wurden, gehören noch dem damals - # aktiven Satz (siehe reifen.py). - await reifen.fortschreiben(self, reifen.aktueller_km(self)) + # Der Radzähler hängt an den Fahrten, nicht am Kilometerstand - was + # während eines Ausfalls gefahren wurde, kommt über die dabei + # erkannten Fahrten herein (siehe reifen.py). + await reifen.nachtragen(self) self._takte_anmelden() await self.trigger_neu_binden() # Erst wenn Home Assistant vollständig oben ist: bis dahin haben die @@ -312,7 +317,8 @@ class Koordinator: neu, alt = self._zustaende(ereignis) if neu is None or alt is None: return - await reifen.fortschreiben(self, reifen.aktueller_km(self)) + # Der Radzaehler haengt nicht mehr am Kilometerstand, sondern an den + # Fahrten - das Screening rechnet ihn am Ende selbst nach. await screening.durchfuehren(self) async def _tankfuellstand_geaendert(self, ereignis: Event[EventStateChangedData]) -> None: @@ -477,6 +483,27 @@ class Koordinator: async def tankvorgaenge_veroeffentlichen(self) -> None: self.setzen(E_TANKVORGAENGE, "aktuell", await self.ablage.tankvorgaenge_lesen()) + async def tankvorgang_nachbereiten(self) -> None: + """Nach jedem Schreiben eines Tankvorgangs: veroeffentlichen und den + Verbrauchsfaktor rechnen lassen. + + Eine Betankung ist der Moment, in dem sich ein Intervall SCHLIESST - + vorher gibt es kein Paar aus zwei Tankvorgaengen und damit keinen + Faktor. Bis hierher haing die Korrektur allein am Screening, das bei + jeder Aenderung des Kilometerstands laeuft: gerechnet wurde also erst + bei der naechsten Fahrt, nicht beim Tanken. + + Der Aufruf im Screening bleibt trotzdem bestehen, und zwar aus dem + umgekehrten Grund: eine Fahrt im Intervall kann ihren Rohverbrauch + erst NACH der Betankung bekommen (der Tankstand-Verlauf trifft + verzoegert ein), und dann muss der Faktor sie noch einholen. + + Der 60-Sekunden-Takt ruft bewusst weiter nur + tankvorgaenge_veroeffentlichen() - dort aendert sich nichts, was einen + neuen Faktor ergeben koennte.""" + await self.tankvorgaenge_veroeffentlichen() + await verbrauchskorrektur.anwenden(self) + async def batterieverlauf_veroeffentlichen(self) -> None: self.setzen(E_BATTERIEVERLAUF, "aktuell", await self.ablage.batterieverlauf_lesen()) diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 2cbefaf..8f2b5ff 100644 --- a/custom_components/audi_dashboard/manifest.json +++ b/custom_components/audi_dashboard/manifest.json @@ -1,7 +1,7 @@ { "domain": "audi_dashboard", "name": "Audi Dashboard", - "version": "2026.9.1.24", + "version": "2026.9.2.3", "documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md", "issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues", "codeowners": ["@paul"], diff --git a/custom_components/audi_dashboard/reifen.py b/custom_components/audi_dashboard/reifen.py index 6943ce5..66647e9 100644 --- a/custom_components/audi_dashboard/reifen.py +++ b/custom_components/audi_dashboard/reifen.py @@ -1,21 +1,42 @@ -"""Reifenzähler: jeder gefahrene Kilometer wird dem Satz zugerechnet, der zum -Zeitpunkt der Fahrt aktiv war. +"""Radzaehler: der eingetragene Stand, und jeder gefahrene Kilometer kommt +dazu. -Früher war der Zähler = aktueller Kilometerstand minus fester Startwert je -Satz. Das war falsch, sobald einmal zwischen Sommer und Winter umgeschaltet -wurde: die Differenz zum Startwert zählt dann alle seither gefahrenen -Kilometer mit, auch die auf dem jeweils anderen Satz. Jetzt führt -reifen.saetze..km selbst den fortlaufenden Gesamtstand, und -reifen.referenz_odo_km hält den zuletzt verarbeiteten Kilometerstand fest, -gegen den die nächste Änderung als Delta gemessen wird. +DIE VORGESCHICHTE, weil sie erklaert, warum es jetzt so einfach aussieht. +Erst war der Zaehler "aktueller Kilometerstand minus fester Startwert je +Satz" - falsch, sobald einmal umgeschaltet wurde, weil dann alle seither +gefahrenen Kilometer mitzaehlten, auch die auf dem anderen Satz. Danach fuehrte +das Profil einen eigenen Zaehler fort und `referenz_odo_km` hielt den zuletzt +verarbeiteten Kilometerstand, gegen den das naechste Delta gemessen wurde. -Keine Abhängigkeit von total_increasing oder utility_meter: die Fortschreibung -braucht nur den zuletzt bekannten Absolutwert des Kilometerstand-Sensors. Ein -zeitweise "unavailable" Quellsensor verliert dadurch nichts - referenz_odo_km -bleibt einfach stehen, bis wieder ein gültiger Wert kommt, und das nächste -Delta wird dann gegen diesen alten Stand berechnet. Auch Kilometer, die -während eines HA-Ausfalls gefahren wurden, werden so noch dem damals aktiven -Satz zugerechnet. +Auch das war falsch, nur unauffaelliger: gezaehlt wurde jedes POSITIVE Delta. +Ein Sprung nach unten fiel heraus, einer nach oben nicht - und beim Umstecken +des Geraets zwischen zwei Fahrzeugen gibt es beide Richtungen. Am 01.09.2026 +standen deshalb **375.962 km** auf den Sommerraedern, bei einem Tacho von +61.823: der Sprung 61.823 -> 209.177 war als gefahrene Strecke verbucht. + +WARUM JETZT DIE FAHRTEN UND NICHT DER TACHO. Ein Sprungschutz am Delta waere +moeglich gewesen (und war kurz gebaut), aber die Fahrten pruefen laengst genau +das - Rueckwaertssprunge, unmoegliches Tempo, Mindestdauer -, und sie messen +seit der GNSS-Verfeinerung auf 100 m statt auf ganze Kilometer. Am echten +Bestand nachgemessen: in den sauberen Bereichen trifft die Fahrtsumme die +Tacho-Spanne auf 99 bis 103 %, bei einer Fahrt 6,9 gegen 7 km. + +WAS DAS KOSTET, und der Eigentuemer hat es am 01.09.2026 ausdruecklich +akzeptiert: was die Fahrterkennung nicht sieht, landet nie auf den Raedern. +Der Tacho ist die Wahrheit des Fahrzeugs und holt sich alles zurueck, eine +Fahrtsumme nicht. + +WIE GERECHNET WIRD, in genau der Form, die der Eigentuemer verlangt hat: +`saetze..km` IST der Stand. Von Hand eingetragen wird er direkt - kein +Umweg, kein Sockel, keine Rueckrechnung. Jede Fahrt legt ihre Strecke oben +drauf, sobald sie feststeht. + +Damit keine Fahrt zweimal zaehlt, merkt sie sich selbst, was von ihr bereits +gutgeschrieben wurde (`reifen_gezaehlt_km`). Verfeinert das Screening die +Strecke spaeter noch (7,0 -> 6,896 km ist real vorgekommen), wird nur die +Differenz nachgetragen. Fahrten aus der Zeit vor dieser Umstellung tragen +keinen Radsatz und bleiben deshalb unberuecksichtigt - ihre Kilometer stecken +laengst im gespeicherten Stand. """ from __future__ import annotations @@ -25,7 +46,6 @@ import logging from typing import TYPE_CHECKING from .ablage import neue_id -from .veroeffentlichung import zustand_oder_none if TYPE_CHECKING: from .koordinator import Koordinator @@ -40,77 +60,120 @@ SAETZE = ("sommer", "winter") _UEBERNOMMENE_FELDER = ("bild", "mass", "druck_vorne", "druck_hinten") -def aktueller_km(k: Koordinator) -> float | None: - try: - return float(zustand_oder_none(k.hass, k.zuordnung.werte.KM_SENSOR)) # type: ignore[arg-type] - except (TypeError, ValueError): - return None +def aktiver_satz(profil: dict | None) -> str: + """Welcher Satz gerade montiert ist - der Stempel fuer eine neue Fahrt. + + Kleingeschrieben, weil das Profil den Wert historisch mal so und mal + grossgeschrieben fuehrt ("Sommer"), die Schluessel in `saetze` aber immer + klein sind. + """ + satz = str((profil or {}).get("reifen", {}).get("aktiv") or "sommer").lower() + return satz if satz in SAETZE else "sommer" -async def fortschreiben(k: Koordinator, neuer_km: float | None, neuer_aktiv: str | None = None) -> None: - """Rechnet zuerst mit dem bisher aktiven Satz ab (Delta seit - referenz_odo_km), trägt danach optional einen Satzwechsel ein - in dieser - Reihenfolge, damit beim Wechseln selbst keine Kilometer dem neuen Satz - zugerechnet werden, die eigentlich noch auf dem alten gefahren wurden.""" - profil = await k.ablage.profil_lesen() - if profil is None: +def _aufraeumen(reifen: dict) -> bool: + """Reste der frueheren Delta-Buchhaltung wegwerfen. Sie werden nicht mehr + gelesen; stehen zu bleiben waere nur eine Falle fuer den naechsten Leser.""" + geaendert = False + for veraltet in ("referenz_odo_km", "referenz_odo_zeit"): + if veraltet in reifen: + del reifen[veraltet] + geaendert = True + return geaendert + + +async def nachtragen(k: Koordinator, profil: dict | None = None) -> None: + """Schreibt jeder Fahrt ihre Strecke dem montierten Satz gut - genau einmal. + + Der Radsatz steht an der Fahrt selbst, nicht am Zeitpunkt. Ein Satzwechsel + braucht deshalb keine Sonderbehandlung: eine Fahrt zaehlt fuer den Satz, + der WAEHREND ihr montiert war, auch wenn danach umgeschaltet wurde. + + `reifen_gezaehlt_km` haelt fest, wieviel von dieser Fahrt schon + gutgeschrieben ist. Verfeinert das Screening die Strecke spaeter noch, + wird nur die Differenz nachgetragen statt der ganzen Fahrt ein zweites + Mal. Ohne diesen Merker waere entweder jede Korrektur verloren oder jede + Fahrt doppelt gezaehlt.""" + fahrten = await k.ablage.fahrten_lesen() + offen = [] + for f in fahrten: + satz = f.get("reifensatz") + strecke = f.get("distance_km") + if satz not in SAETZE or strecke is None: + continue + gezaehlt = float(f.get("reifen_gezaehlt_km") or 0) + delta = round(float(strecke) - gezaehlt, 1) + if delta: + offen.append((f, satz, delta, round(float(strecke), 1))) + if not offen: return + if profil is None: + profil = await k.ablage.profil_lesen() + if profil is None: + return reifen = profil.setdefault("reifen", {}) saetze = reifen.setdefault("saetze", {}) - aktiv = reifen.get("aktiv", "sommer") - referenz = reifen.get("referenz_odo_km") + _aufraeumen(reifen) - if referenz is not None and neuer_km is not None: - delta = round(neuer_km - referenz, 1) - if delta > 0: - satz = saetze.setdefault(aktiv, {}) - satz["km"] = round((satz.get("km") or 0) + delta, 1) - if neuer_km is not None: - reifen["referenz_odo_km"] = neuer_km - if neuer_aktiv is not None: - reifen["aktiv"] = neuer_aktiv + for fahrt, satz, delta, strecke in offen: + eintrag = saetze.setdefault(satz, {}) + eintrag["km"] = round(float(eintrag.get("km") or 0) + delta, 1) + await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], {"reifen_gezaehlt_km": strecke}) + fahrt["reifen_gezaehlt_km"] = strecke + _LOGGER.info( + "Fahrt %s: %s km auf %s gutgeschrieben, neuer Stand %s km", + fahrt["trip_id"], delta, satz, eintrag["km"], + ) await k.ablage.profil_schreiben(profil) await k.profil_veroeffentlichen(profil) async def wechseln(k: Koordinator, satz: str | None) -> None: + """Umschalten auf den anderen Radsatz. + + Braucht keine Abrechnung vorher: jede Fahrt traegt ihren eigenen Satz, es + kann also nichts dem falschen gutgeschrieben werden.""" if satz not in SAETZE: - _LOGGER.warning("Ungültiger Reifensatz '%s'", satz) + _LOGGER.warning("Ungültiger Radsatz %s", satz) return - await fortschreiben(k, aktueller_km(k), neuer_aktiv=satz) - _LOGGER.info("Aktiver Reifensatz auf '%s' umgeschaltet", satz) + profil = await k.ablage.profil_lesen() + if profil is None: + return + profil.setdefault("reifen", {})["aktiv"] = satz + await k.ablage.profil_schreiben(profil) + await k.profil_veroeffentlichen(profil) + _LOGGER.info("Aktiver Radsatz auf %s umgeschaltet", satz) async def km_setzen(k: Koordinator, satz: str | None, km: object) -> None: - """Manuelle Korrektur des gefahrenen Kilometerstands eines Reifensatzes. + """Laufleistung eines Radsatzes von Hand setzen. - Überschreibt nur den gespeicherten Zähler selbst - referenz_odo_km (der - zuletzt verarbeitete Fahrzeug-Kilometerstand) bleibt unverändert, damit - die nächste Fortschreibung weiterhin korrekt von hier aus weiterzählt, - statt auf den neuen Wert erneut ein Delta seit dem alten Stand - aufzuschlagen.""" + Der eingetragene Wert IST ab sofort der Stand; jeder weitere gefahrene + Kilometer kommt oben drauf. Bereits gutgeschriebene Fahrten tauchen nicht + erneut auf - sie tragen ihren Merker.""" if satz not in SAETZE: - _LOGGER.warning("Ungültiger Reifensatz '%s'", satz) + _LOGGER.warning("Ungültiger Radsatz %s", satz) return try: wert = round(float(km), 1) # type: ignore[arg-type] except (TypeError, ValueError): - _LOGGER.warning("Ungültiger km-Wert '%s'", km) + _LOGGER.warning("Ungültiger km-Wert %s", km) return if wert < 0: - _LOGGER.warning("Negativer km-Wert '%s' abgelehnt", wert) + _LOGGER.warning("Negativer km-Wert %s abgelehnt", wert) return profil = await k.ablage.profil_lesen() if profil is None: return reifen = profil.setdefault("reifen", {}) + _aufraeumen(reifen) reifen.setdefault("saetze", {}).setdefault(satz, {})["km"] = wert await k.ablage.profil_schreiben(profil) await k.profil_veroeffentlichen(profil) - _LOGGER.info("km-Stand '%s' manuell auf %s gesetzt", satz, wert) + _LOGGER.info("Laufleistung %s von Hand auf %s km gesetzt", satz, wert) async def archivieren(k: Koordinator, satz: str | None) -> None: @@ -118,15 +181,19 @@ async def archivieren(k: Koordinator, satz: str | None) -> None: Modell, DOT, Kommentar) als Übersichtszeile ins Archiv und setzt den laufenden Satz auf einen frischen Stand zurück - km auf 0, Marke/Modell/ DOT/Kommentar leer (galten für den alten, jetzt ersetzten Reifen), Größe - und Solldruck unverändert übernommen (siehe _UEBERNOMMENE_FELDER).""" + und Solldruck unverändert übernommen (siehe _UEBERNOMMENE_FELDER). + + Die Fahrten des alten Satzes tragen ihren Merker und werden dem frischen + Satz nicht noch einmal gutgeschrieben.""" if satz not in SAETZE: - _LOGGER.warning("Ungültiger Reifensatz '%s'", satz) + _LOGGER.warning("Ungültiger Radsatz %s", satz) return profil = await k.ablage.profil_lesen() if profil is None: return reifen = profil.setdefault("reifen", {}) + _aufraeumen(reifen) saetze = reifen.setdefault("saetze", {}) alt = dict(saetze.get(satz, {})) @@ -152,12 +219,11 @@ async def archivieren(k: Koordinator, satz: str | None) -> None: await k.ablage.profil_schreiben(profil) await k.profil_veroeffentlichen(profil) - _LOGGER.info("Reifensatz '%s' archiviert (%s km), neuer Satz angelegt", satz, archiv[0]["km"]) - + _LOGGER.info("Radsatz %s archiviert (%s km), neuer Satz angelegt", satz, archiv[0]["km"]) async def archiv_aktualisieren(k: Koordinator, id_: str | None, daten: dict) -> None: """Bearbeitet einen archivierten Reifensatz-Eintrag (Übersichtszeile auf - der Reifen-Seite, aufgeklappt -> "Bearbeiten").""" + der Räder-Seite, aufgeklappt -> "Bearbeiten").""" if not id_: _LOGGER.warning("reifen_archiv_aktualisieren ohne id aufgerufen") return @@ -167,7 +233,7 @@ async def archiv_aktualisieren(k: Koordinator, id_: str | None, daten: dict) -> archiv = profil.setdefault("reifen", {}).setdefault("archiv", []) eintrag = next((e for e in archiv if e.get("id") == id_), None) if eintrag is None: - _LOGGER.warning("Archivierter Reifensatz '%s' nicht gefunden", id_) + _LOGGER.warning("Archivierter Radsatz '%s' nicht gefunden", id_) return erlaubte_felder = ( @@ -180,7 +246,7 @@ async def archiv_aktualisieren(k: Koordinator, id_: str | None, daten: dict) -> await k.ablage.profil_schreiben(profil) await k.profil_veroeffentlichen(profil) - _LOGGER.info("Archivierter Reifensatz '%s' aktualisiert", id_) + _LOGGER.info("Archivierter Radsatz '%s' aktualisiert", id_) async def archiv_loeschen(k: Koordinator, id_: str | None) -> None: @@ -194,12 +260,12 @@ async def archiv_loeschen(k: Koordinator, id_: str | None) -> None: archiv = reifen.get("archiv", []) uebrig = [e for e in archiv if e.get("id") != id_] if len(uebrig) == len(archiv): - _LOGGER.warning("Archivierter Reifensatz '%s' nicht gefunden", id_) + _LOGGER.warning("Archivierter Radsatz '%s' nicht gefunden", id_) return reifen["archiv"] = uebrig await k.ablage.profil_schreiben(profil) await k.profil_veroeffentlichen(profil) - _LOGGER.info("Archivierter Reifensatz '%s' gelöscht", id_) + _LOGGER.info("Archivierter Radsatz '%s' gelöscht", id_) def zustaende(profil: dict | None) -> dict[str, object]: diff --git a/custom_components/audi_dashboard/screening.py b/custom_components/audi_dashboard/screening.py index 135e840..e56fc8e 100644 --- a/custom_components/audi_dashboard/screening.py +++ b/custom_components/audi_dashboard/screening.py @@ -31,6 +31,7 @@ import datetime import logging from typing import TYPE_CHECKING +from . import reifen, verbrauchskorrektur from .verlauf import ( UNPLAUSIBLE_KMH, durchschnitt_kmh, @@ -148,6 +149,24 @@ async def durchfuehren(k: Koordinator) -> None: for fahrt in ohne_position: await _position_screenen(k, lat_sensor, lon_sensor, fahrt) + # Erst NACH der Positionsermittlung: eine Fahrt, die gerade eben ihre + # Koordinaten bekommen hat, soll im selben Durchlauf auch ihren Ort + # bekommen und nicht erst beim naechsten. + k.geokodierung.durchlauf_beginnen() + ohne_ort = [ + f + for f in fahrten + if (f.get("start_lat") is not None and not f.get("start_stadt")) + or (f.get("end_lat") is not None and not f.get("end_stadt")) + ] + # Neueste zuerst: die sieht der Nutzer als naechstes. Ist das Budget + # aufgebraucht, warten die alten auf den naechsten Durchlauf. + ohne_ort.sort(key=lambda f: f.get("ts_start") or "", reverse=True) + for fahrt in ohne_ort: + if k.geokodierung.rest <= 0: + break + await _orte_screenen(k, fahrt) + # Durchschnitt braucht keinen Sensor - er folgt aus Strecke und Dauer, # die beide schon dastehen. Deshalb vor dem Geschwindigkeitssensor und # unabhaengig davon, ob einer zugeordnet ist. @@ -180,11 +199,19 @@ async def durchfuehren(k: Koordinator) -> None: if tank_liter_sensor: ohne_verbrauch = [ f for f in fahrten - if f.get("verbrauch_l_100km") is None and f.get("distance_km") + if f.get("verbrauch_roh_l_100km") is None and f.get("distance_km") ] for fahrt in ohne_verbrauch: await _verbrauch_screenen(k, tank_liter_sensor, fahrt) + # Zuletzt: die Eichung braucht die Rohwerte aller Fahrten eines + # Betankungsintervalls und ist damit auf alle Schritte davor angewiesen. + await verbrauchskorrektur.anwenden(k) + + # Die Laufleistung der Raeder ist die Summe der Fahrten - sie muss also + # nachziehen, sobald eine Strecke dazukommt oder sich aendert. + await reifen.nachtragen(k) + await _lueckenlos_verketten(k) await k.fahrten_veroeffentlichen() @@ -324,6 +351,44 @@ async def _position_screenen( fahrt.update(aenderungen) +async def _orte_screenen(k: Koordinator, fahrt: dict) -> None: + """Traegt Start- und Zielort nach, sobald die Koordinaten dastehen. + + Der Ort gehoert zu den Fahrtdaten, nicht in den Zwischenspeicher jedes + einzelnen Geraets - Begruendung im Kopf von geokodierung.py. + + start_address/end_address stehen in fahrterkennung.HANDFELDER: eine von + Hand eingetragene Anschrift ("Zuhause") darf hier nie ueberschrieben + werden. Zwei Sperren dagegen, absichtlich beide: hier wird nur ein + LEERES Feld gefuellt, und fahrt_aktualisieren() laesst ohnehin jedes + Feld in edited_fields unberuehrt. + + Der Ortsname (start_stadt) wird auch dann gesetzt, wenn die Anschrift + von Hand stammt - er beschreibt die Koordinate, nicht die Eingabe, und + die Liste braucht ihn zum Ausrichten der Spalten.""" + enden = ( + ("start", fahrt.get("start_lat"), fahrt.get("start_lon")), + ("end", fahrt.get("end_lat"), fahrt.get("end_lon")), + ) + aenderungen: dict = {} + for seite, lat, lon in enden: + if lat is None or lon is None: + continue + if fahrt.get(f"{seite}_address") and fahrt.get(f"{seite}_stadt"): + continue + ort = await k.geokodierung.aufloesen(lat, lon) + if ort is None: + continue + if not fahrt.get(f"{seite}_address") and ort.get("anschrift"): + aenderungen[f"{seite}_address"] = ort["anschrift"] + if not fahrt.get(f"{seite}_stadt") and ort.get("stadt"): + aenderungen[f"{seite}_stadt"] = ort["stadt"] + + if aenderungen: + await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], aenderungen) + fahrt.update(aenderungen) + + async def _verbrauch_screenen( k: Koordinator, tank_liter_sensor: str, fahrt: dict ) -> None: @@ -346,9 +411,19 @@ async def _verbrauch_screenen( verbrauch = verbrauch_aus_literstaenden( wert_ab(verlauf, start), wert_bei(verlauf, ende), fahrt.get("distance_km") ) - if verbrauch is not None: - await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], {"verbrauch_l_100km": verbrauch}) - fahrt["verbrauch_l_100km"] = verbrauch + if verbrauch is None: + return + + # Der Rohwert wird immer geschrieben, der Anzeigewert nur, solange noch + # kein Faktor vorliegt - sonst ueberschriebe das Screening eine bereits + # an den Betankungen geeichte Zahl mit der ungeeichten. + aenderungen = {"verbrauch_roh_l_100km": verbrauch} + faktor = fahrt.get("verbrauch_faktor") + aenderungen["verbrauch_l_100km"] = ( + round(verbrauch * float(faktor), 1) if faktor else verbrauch + ) + await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], aenderungen) + fahrt.update(aenderungen) async def _vmax_screenen(k: Koordinator, tempo_sensor: str, fahrt: dict) -> None: diff --git a/custom_components/audi_dashboard/tankerkennung.py b/custom_components/audi_dashboard/tankerkennung.py index 1e6cca8..ba48791 100644 --- a/custom_components/audi_dashboard/tankerkennung.py +++ b/custom_components/audi_dashboard/tankerkennung.py @@ -153,14 +153,25 @@ async def tankvolumen_geaendert( await k.tiefststand_liter_setzen(aktuell) -async def distanz_seit_tankung(k: Koordinator, odometer_km: float | None) -> float | None: +async def distanz_seit_tankung( + k: Koordinator, odometer_km: float | None, jetzt: bool = True +) -> float | None: """Bevorzugt TANK_DISTANZ_SENSOR (vom Fahrzeug selbst berechnet), fällt ohne ihn auf die eigene Kilometerstand-Subtraktion zurück - dieselbe "Sensor bevorzugt, Berechnung als Rückfall"-Präferenz wie bei - TANK_LITER_SENSOR gegenüber TANK_SENSOR oben.""" - sensor = _als_zahl(zustand_oder_none(k.hass, k.zuordnung.werte.TANK_DISTANZ_SENSOR)) - if sensor is not None: - return sensor + TANK_LITER_SENSOR gegenüber TANK_SENSOR oben. + + `jetzt=False` fuer einen nachgetragenen Tankvorgang: der Sensor kennt nur + seinen aktuellen Stand, und der gehoert nicht zu einer Tankung von + vorgestern. Die Kilometerstand-Subtraktion dagegen vergleicht mit dem + vorherigen Tankvorgang und ist damit zeitunabhaengig - sie bleibt auch + dann richtig.""" + if jetzt: + sensor = _als_zahl( + zustand_oder_none(k.hass, k.zuordnung.werte.TANK_DISTANZ_SENSOR) + ) + if sensor is not None: + return sensor return await k.ablage.distanz_seit_letzter_tankung(odometer_km) @@ -193,7 +204,7 @@ async def _automatisch_anlegen( tankvorgang["liters"] = round(liter_schaetzung, 1) await k.ablage.tankvorgang_anhaengen(tankvorgang) - await k.tankvorgaenge_veroeffentlichen() + await k.tankvorgang_nachbereiten() _LOGGER.info( "Tankvorgang %s automatisch erkannt (%s)", tankvorgang["tank_id"], anlass ) diff --git a/custom_components/audi_dashboard/verbrauchskorrektur.py b/custom_components/audi_dashboard/verbrauchskorrektur.py new file mode 100644 index 0000000..b7a1905 --- /dev/null +++ b/custom_components/audi_dashboard/verbrauchskorrektur.py @@ -0,0 +1,368 @@ +"""Der Verbrauch einer Fahrt, korrigiert an den Tankbelegen. + +DAS PROBLEM. Der Verbrauch einer einzelnen Fahrt entsteht aus der Differenz +zweier Tankstaende. Der Geber liefert zwar Liter (das Steuergeraet hat seine +Kennlinie schon angewandt), aber er rundet, und er ist stark gedaempft - sonst +zappelte die Anzeige im Cockpit bei jeder Kurve. Auf einer Fahrt von sieben +Kilometern faellt dieser Verzug anteilig weit staerker ins Gewicht als auf +dreihundert. + +DIE RECHNUNG, so wie der Eigentuemer sie ohnehin von Hand macht: + + Faktor = getankte Liter laut Beleg / Summe der Liter aus den Einzelfahrten + +Die Annahme dahinter ist seine: die Ungenauigkeit des Gebers trifft jede Fahrt +gleichermassen, ist also ein Faktor und kein Summand. Beide Seiten decken +denselben Zeitraum ab; was der Faktor korrigiert, ist genau der Unterschied +zwischen dem, was der Geber gemeldet hat, und dem, was wirklich in den Tank +gelaufen ist. + +WARUM NUR VON VOLLTANKUNG ZU VOLLTANKUNG. Nur dann gilt "eingefuellte Liter = +seit dem letzten Mal verbrauchte Liter": eine Betankung, die den Tank wieder +bis zum selben Punkt fuellt, ersetzt genau das, was seither entnommen wurde. +Nach einer Teilbetankung waere der Zaehler eine beliebige Zahl und der Faktor +falsch. Als voll gilt ein Tankvorgang, wenn der Stand danach mindestens +VOLL_ANTEIL des Tankvolumens erreicht (aus dem Fahrzeugprofil). + +DIE LITER MUESSEN AUS DEM BELEG STAMMEN, nicht aus dem Geber. Ein automatisch +erkannter Tankvorgang traegt in `liters` den vom Geber gemessenen Anstieg - +den gegen die ebenfalls vom Geber gerechneten Fahrten zu stellen, waere ein +Zirkelschluss und ergaebe immer ungefaehr 1,0. Gezaehlt wird deshalb nur, was +aus einem Beleg geparst oder von Hand eingetragen wurde. + +STANDLAUF IM STILLSTAND stoert die Rechnung nicht: laeuft der Motor bei +eingeschalteter Zuendung, bleibt die Fahrt offen, der Verbrauch steht also in +der Summe der Einzelfahrten UND in der getankten Menge und kuerzt sich heraus. +Die betroffene Fahrt zeigt dann zu Recht einen hohen Verbrauch. Verzerrend ist +nur Verbrauch ohne offene Fahrt - Standheizung bei Zuendung aus; der fehlt im +Nenner und verteilt sich ueber den Faktor auf alle Fahrten des Zeitraums. Der +Eigentuemer hat das am 01.09.2026 ausdruecklich so gewollt. + +WAS GEPRUEFT WIRD, UND WARUM NICHT DER FAKTOR SELBST. Die beiden Tore oben +sichern den ZAEHLER: die Liter stammen aus einem Beleg, und voll zu voll +heisst, dass sie dem Verbrauch entsprechen. Verwundbar bleibt der NENNER - +fehlt im Intervall eine Fahrt (Dongle offline, HA unten, Erkennung +ausgesetzt), ist die Summe zu klein und der Faktor zu gross. Er multipliziert +dann jede Fahrt des Zeitraums mit. + +Eine feste Ober- und Untergrenze am Faktor (erst 0,7 bis 1,5) traf das +Symptom, nicht die Ursache - und haette einen echten, groesseren Geberfehler +mit abgeklemmt. Geprueft wird deshalb, ob der Nenner ueberhaupt vollstaendig +ist: die Summe der Fahrtstrecken gegen die Tacho-Spanne desselben Intervalls. + + Deckung = Summe der Fahrtstrecken / (Kilometerstand bis - Kilometerstand von) + +Das Mass ist belegt: beim Umbau des Radzaehlers am 01.09.2026 lag es in den +sauberen Bereichen bei 99 bis 103 %. Liegt es dort, ist der Faktor +vertrauenswuerdig - egal wie er ausfaellt, ohne obere oder untere Klemme. +Liegt es daneben, beschreibt der Faktor fehlende Daten und nicht den Geber, +und das Intervall wird uebersprungen und protokolliert. + +WAS GESPEICHERT WIRD. verbrauch_roh_l_100km ist die reine Messung und bleibt +unangetastet, damit die Rechnung wiederholbar ist. verbrauch_faktor ist der +angewandte Faktor. verbrauch_l_100km ist das Produkt aus beiden - der Wert, +den die Oberflaechen anzeigen; die brauchen von alldem nichts zu wissen. +""" + +from __future__ import annotations + +import datetime +import logging +from typing import TYPE_CHECKING + +from .tankerkennung import STANDARD_TANKVOLUMEN_LITER +from .verlauf import verlauf_lesen, wert_bei + +if TYPE_CHECKING: + from .koordinator import Koordinator + +_LOGGER = logging.getLogger(__name__) + +# Wie vollstaendig die erkannten Fahrten die tatsaechlich gefahrene Strecke +# des Intervalls abdecken muessen. Der Wert sollte per Definition bei 1,0 +# liegen; eine Abweichung in BEIDE Richtungen heisst, dass die Fahrtmenge +# nicht zur Wirklichkeit passt - zu wenig (fehlende Fahrten) ebenso wie zu +# viel (doppelte oder falsch vermessene Fahrten, im Altbestand real +# vorgekommen: 256 %). +DECKUNG_MIN = 0.90 +DECKUNG_MAX = 1.10 + +# Wie weit der Kilometerstand eines Tankvorgangs vom Tacho zu seinem eigenen +# Zeitstempel abweichen darf. Der Tacho springt in ganzen Kilometern, und +# zwischen Zapfsaeule und dem Datensatz, den das Geraet danach schickt, liegen +# selten mehr als ein paar - siehe _datum_passt(). +ODOMETER_TOLERANZ_KM = 5.0 + +# Ab welchem Anteil des Tankvolumens ein Tankvorgang als Volltankung gilt. +# 95 % laesst Luft fuer die Ungenauigkeit des Gebers und fuer den Umstand, dass +# "voll" an der Zapfsaeule nicht auf den Liter reproduzierbar ist. +VOLL_ANTEIL = 0.95 + +# Der gedaempfte Tankstand steht nach dem Tanken nicht sofort. Eine Minute ist +# die Vorgabe des Eigentuemers (01.09.2026). +# +# Zu kurz zu greifen ist hier die harmlose Richtung: liest die Pruefung einen +# noch nicht ganz nachgezogenen Stand, faellt er unter die 95-%-Grenze und das +# Intervall gilt als "nicht voll" - es entsteht dann kein Faktor. Zu lang zu +# warten waere die gefaehrliche Richtung, denn nach der Abfahrt sinkt der Stand +# wieder und eine echte Volltankung saehe nachtraeglich unvoll aus. +SETZZEIT = datetime.timedelta(minutes=1) + +# Wie weit ueber die Intervallgrenzen hinaus der Verlauf geholt wird, damit +# wert_bei() links vom Fensterrand einen Anker findet. +PUFFER = datetime.timedelta(hours=6) + + +def _zeit(wert: str | None) -> datetime.datetime | None: + if not wert: + return None + try: + gelesen = datetime.datetime.fromisoformat(str(wert)) + except ValueError: + return None + if gelesen.tzinfo is None: + gelesen = gelesen.replace(tzinfo=datetime.UTC) + return gelesen + + +def beleg_liter(tankvorgang: dict) -> float | None: + """Die getankte Menge, aber nur wenn sie NICHT vom Geber stammt. + + Ein automatisch erkannter Tankvorgang traegt in `liters` den gemessenen + Anstieg des Tankstands. Den gegen die ebenfalls aus dem Tankstand + gerechneten Fahrten zu stellen, ergaebe zwangslaeufig ungefaehr 1,0 - der + Faktor koennte gar nichts korrigieren. + """ + liter = tankvorgang.get("liters") + if liter is None: + return None + aus_beleg = bool(tankvorgang.get("receipt_key")) + von_hand = "liters" in (tankvorgang.get("edited_fields") or []) + manuell = tankvorgang.get("source") == "manual" + if not (aus_beleg or von_hand or manuell): + return None + try: + return float(liter) + except (TypeError, ValueError): + return None + + +def _liter_der_fahrt(fahrt: dict) -> float | None: + """Was diese Fahrt laut Messung verbraucht hat, in Litern.""" + roh = fahrt.get("verbrauch_roh_l_100km") + strecke = fahrt.get("distance_km") + if roh is None or not strecke or strecke <= 0: + return None + return float(roh) / 100.0 * float(strecke) + + +async def _tacho_spanne( + k: Koordinator, + km_sensor: str, + von: datetime.datetime, + bis: datetime.datetime, +) -> tuple[float, float] | None: + """Die Tachostaende zu Beginn und Ende des Intervalls, oder None. + + Zwei Dinge haengen daran: die Deckungspruefung (passt die Fahrtmenge zur + gefahrenen Strecke?) und die Datumspruefung (passt der eingetragene + Zeitpunkt eines Tankvorgangs zu seinem eigenen Kilometerstand?). + + Der Kilometerstand ist der Tacho des Fahrzeugs: er zaehlt jeden Kilometer, + auch die einer nicht erkannten Fahrt. Genau deshalb taugt er als Mass + dafuer, ob die Fahrtmenge vollstaendig ist.""" + verlauf = await verlauf_lesen(k.hass, km_sensor, von - PUFFER, bis + PUFFER) + if not verlauf: + return None + odo_von = wert_bei(verlauf, von) + odo_bis = wert_bei(verlauf, bis) + if odo_von is None or odo_bis is None: + return None + if odo_bis - odo_von <= 0: + # Kein Fortschritt oder ein Rueckwaertssprung (Fahrzeugwechsel) - der + # Tacho taugt hier nicht als Mass. + return None + return odo_von, odo_bis + + +def _datum_passt(tankvorgang: dict, tacho: float) -> bool: + """Passt der eingetragene Zeitpunkt zum Kilometerstand des Tankvorgangs? + + Die Fahrten werden ueber die ZEIT zugeordnet, nicht ueber den Tacho. Traegt + jemand die Tankung von Dienstag am Freitag mit "jetzt" ein, wandern die + Liter auf die Fahrten von Mittwoch bis Freitag - und die Deckungspruefung + merkt es nicht, weil sie den Tacho zu denselben verschobenen Zeitpunkten + liest: Zaehler und Nenner sind konsistent verschoben. + + Der Tankvorgang traegt aber einen zweiten, unabhaengigen Anker: seinen + eigenen Kilometerstand. Weicht der stark vom Tacho zum eingetragenen + Zeitpunkt ab, passen Datum und Kilometerstand nicht zusammen. + + Ist kein Kilometerstand hinterlegt (der Nutzer kannte ihn nicht, siehe + verlauf.als_kilometerstand()), gibt es nichts zu vergleichen - dann gilt + der Zeitpunkt, wie er eingetragen wurde. Ein automatisch erkannter + Tankvorgang besteht die Pruefung ohnehin immer: sein Kilometerstand und + sein Zeitstempel stammen aus demselben Datensatz.""" + eigener = tankvorgang.get("odometer_km") + if eigener is None: + return True + return abs(float(eigener) - tacho) <= ODOMETER_TOLERANZ_KM + + +async def _ist_voll(k: Koordinator, tank_sensor: str, zeitpunkt: datetime.datetime, + volumen: float) -> bool: + """Stand der Tank nach dieser Betankung (nahezu) voll? + + Gemessen SETZZEIT nach dem Zeitpunkt des Tankvorgangs - siehe dort, warum + die Zahl lieber knapp als grosszuegig ist.""" + verlauf = await verlauf_lesen( + k.hass, tank_sensor, zeitpunkt - PUFFER, zeitpunkt + SETZZEIT + PUFFER + ) + if not verlauf: + return False + stand = wert_bei(verlauf, zeitpunkt + SETZZEIT) + return stand is not None and stand >= volumen * VOLL_ANTEIL + + +async def anwenden(k: Koordinator) -> None: + """Rechnet jedes noch unbewertete Voll-zu-Voll-Intervall durch. + + Bewusst nur die unbewerteten: das Screening laeuft bei jeder Aenderung des + Kilometerstands, und jedes Intervall braucht eigene Abfragen des + Tankverlaufs. Ohne diese Einschraenkung liefen bei dreizehn Betankungen + dieselben Abfragen bei jedem Fahrtende erneut. + """ + tank_sensor = k.zuordnung.werte.TANK_LITER_SENSOR + if not tank_sensor: + return + km_sensor = k.zuordnung.werte.KM_SENSOR + if not km_sensor: + # Ohne Tacho laesst sich nicht pruefen, ob die Fahrtmenge vollstaendig + # ist - siehe Modulkopf. Lieber kein Faktor als ein ungepruefter. + _LOGGER.debug("Kein Kilometerstand-Sensor zugeordnet - keine Verbrauchskorrektur") + return + + profil = await k.ablage.profil_lesen() + volumen = float( + (profil or {}).get("fahrzeug", {}).get("tankvolumen_liter") + or STANDARD_TANKVOLUMEN_LITER + ) + + tankvorgaenge = [t for t in await k.ablage.tankvorgaenge_lesen() if _zeit(t.get("ts"))] + if len(tankvorgaenge) < 2: + return + tankvorgaenge.sort(key=lambda t: t["ts"]) + + fahrten = await k.ablage.fahrten_lesen() + voll_bekannt: dict[str, bool] = {} + + for vorher, nachher in zip(tankvorgaenge, tankvorgaenge[1:]): + von = _zeit(vorher.get("ts")) + bis = _zeit(nachher.get("ts")) + if von is None or bis is None or bis <= von: + continue + + # Der Beleg der SPAETEREN Betankung nennt, was seit der frueheren + # verbraucht wurde - er ist der Zaehler. + getankt = beleg_liter(nachher) + if getankt is None or getankt <= 0: + continue + + # Nur Fahrten, die GANZ im Intervall liegen. Eine Fahrt, die ueber + # eine Betankung hinweggeht, laesst sich keinem der beiden Intervalle + # zuordnen - sie bleibt ohne Faktor, was richtig ist. + im_fenster = [] + for f in fahrten: + start = _zeit(f.get("ts_start")) + ende = _zeit(f.get("ts_end")) + if start is None or ende is None: + continue + if start >= von and ende <= bis: + im_fenster.append(f) + + mit_messung = [f for f in im_fenster if _liter_der_fahrt(f) is not None] + if not mit_messung: + continue + if all(f.get("verbrauch_faktor") is not None for f in mit_messung): + continue + + # Deckt die Fahrtmenge die tatsaechlich gefahrene Strecke ab? Das ist + # die Pruefung des NENNERS und kommt vor der teuren Voll-Pruefung, weil + # eine Luecke in den Fahrten der haeufigere Fall ist. + strecke_fahrten = sum( + float(f["distance_km"]) for f in im_fenster if f.get("distance_km") + ) + spanne = await _tacho_spanne(k, km_sensor, von, bis) + if spanne is None: + _LOGGER.debug( + "Tachospanne fuer %s bis %s nicht bestimmbar - keine Korrektur", + von.isoformat(), bis.isoformat(), + ) + continue + odo_von, odo_bis = spanne + + # Passt das eingetragene Datum zum Kilometerstand? Nur bei Handeingabe + # kann es auseinanderfallen - siehe _datum_passt(). + schief = [ + (v, t) for v, t in ((vorher, odo_von), (nachher, odo_bis)) + if not _datum_passt(v, t) + ] + if schief: + vorgang, tacho = schief[0] + _LOGGER.warning( + "Tankvorgang %s nennt %s km, der Tacho stand zu seinem Zeitpunkt " + "(%s) aber bei %s km - Datum und Kilometerstand passen nicht " + "zusammen, deshalb keine Korrektur", + vorgang.get("tank_id"), vorgang.get("odometer_km"), + vorgang.get("ts"), round(tacho, 1), + ) + continue + + deckung = strecke_fahrten / (odo_bis - odo_von) + if not DECKUNG_MIN <= deckung <= DECKUNG_MAX: + _LOGGER.warning( + "Die erkannten Fahrten decken %.0f %% der gefahrenen Strecke zwischen " + "%s und %s ab (%.1f km aus %d Fahrten) - ausserhalb von %.0f-%.0f %%. " + "Der Verbrauchsfaktor wuerde fehlende Daten beschreiben und nicht den " + "Geber, deshalb keine Korrektur", + deckung * 100, von.isoformat(), bis.isoformat(), + strecke_fahrten, len(im_fenster), DECKUNG_MIN * 100, DECKUNG_MAX * 100, + ) + continue + + # Erst jetzt der teure Teil: beide Enden muessen Volltankungen sein. + for vorgang, zeitpunkt in ((vorher, von), (nachher, bis)): + kennung = str(vorgang.get("tank_id")) + if kennung not in voll_bekannt: + voll_bekannt[kennung] = await _ist_voll(k, tank_sensor, zeitpunkt, volumen) + if not (voll_bekannt[str(vorher.get("tank_id"))] + and voll_bekannt[str(nachher.get("tank_id"))]): + _LOGGER.debug( + "Kein Voll-zu-Voll-Intervall zwischen %s und %s - keine Korrektur", + von.isoformat(), bis.isoformat(), + ) + continue + + gerechnet = sum(_liter_der_fahrt(f) or 0.0 for f in mit_messung) + if gerechnet <= 0: + continue + + # Kein Ober- oder Unterwert am Faktor: der Nenner ist oben geprueft, + # der Zaehler durch Beleg und Volltankung. Was jetzt herauskommt, ist + # die Messung. + gerundet = round(getankt / gerechnet, 4) + for f in mit_messung: + roh = float(f["verbrauch_roh_l_100km"]) + aenderungen = { + "verbrauch_faktor": gerundet, + "verbrauch_l_100km": round(roh * gerundet, 1), + } + await k.ablage.fahrt_aktualisieren(f["trip_id"], aenderungen) + f.update(aenderungen) + + _LOGGER.info( + "Verbrauchsfaktor %.3f auf %d Fahrten zwischen %s und %s angewandt " + "(%.1f l laut Beleg, %.1f l gerechnet, Streckendeckung %.0f %%)", + gerundet, len(mit_messung), von.isoformat(), bis.isoformat(), + getankt, gerechnet, deckung * 100, + ) diff --git a/custom_components/audi_dashboard/verlauf.py b/custom_components/audi_dashboard/verlauf.py index 2b608b2..7657618 100644 --- a/custom_components/audi_dashboard/verlauf.py +++ b/custom_components/audi_dashboard/verlauf.py @@ -93,6 +93,33 @@ def fahrtsignal(werte: object) -> str: return getattr(werte, "TRIP_SENSOR", "") or getattr(werte, "ZUENDUNG_SENSOR", "") +def als_kilometerstand(wert: object) -> float | None: + """Ein eingegebener Kilometerstand, oder None wenn er unbekannt ist. + + **Null heisst unbekannt.** Der Eigentuemer wollte einen Weg, einen + Tankvorgang ohne Kilometerstand einzutragen, und eine 0 im Zahlenfeld ist + dafuer die naheliegende Geste (01.09.2026). + + Sie wird deshalb hier abgefangen und NICHT gespeichert. Sechs Stellen im + Projekt lesen `odometer_km` und behandeln ausschliesslich None als + unbekannt - eine echte 0 waere fuer sie ein Fahrzeug mit Tachostand null: + die "gefahrene Distanz seit der letzten Tankung" waere der gesamte + Tachostand, und der Langzeitverbrauch rechnete gegen eine Spanne, die es + nie gab. Eine Sonderbehandlung an sechs Lesestellen waere sechsmal die + Gelegenheit, sie zu vergessen; eine Normalisierung an der Eingabe ist + einmal. + + Negative Werte fallen aus demselben Grund heraus - ein Tacho zaehlt nicht + rueckwaerts.""" + if wert is None or wert == "": + return None + try: + zahl = float(wert) # type: ignore[arg-type] + except (TypeError, ValueError): + return None + return zahl if zahl > 0 else None + + def zeit_normalisiert(wert: object) -> datetime.datetime | None: """Akzeptiert ISO-Zeit mit oder ohne Zeitzone und normalisiert auf UTC.