Ortsnamen aus dem Backend, Verbrauchsfaktor aus dem Tankbeleg, Radzaehler ueber Fahrten

Ortsauflösung serverseitig (geokodierung.py): der Ort steht jetzt in der Fahrt
statt im Zwischenspeicher jedes Geräts. Beide Oberflächen fragen nichts mehr ab.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 11:28:37 +02:00
parent 78a8017e95
commit 2af5c469d6
27 changed files with 1575 additions and 352 deletions
+434 -1
View File
@@ -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.<satz>.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:
+4
View File
@@ -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
-50
View File
@@ -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 "<Strasse>, <PLZ> <Ort>".
* 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<string | null> {
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<st
// Nur echte Treffer merken: eine gedrosselte Antwort ist kein "hier gibt
// es keine Adresse", die soll beim nächsten Mal erneut versucht werden.
if (adresse) adresseMerken(schluessel, adresse)
// Der reine Ortsname zusätzlich, für die Fahrtenliste — siehe stadtAusCache().
if (stadt) adresseMerken(`ort:${lat.toFixed(3)},${lon.toFixed(3)}`, stadt)
return adresse
} catch {
return null
+1 -1
View File
@@ -92,7 +92,7 @@ export const TITEL: Record<SeitenName, string> = {
service: "Service",
werkstatt: "Autohaus",
sbuch: "Eintrag",
reifen: "Reifen",
reifen: "Räder",
vers: "Versicherung/Steuer",
beitrag: "Beitrag anpassen",
vertrag: "Vertrag",
+1 -1
View File
@@ -535,7 +535,7 @@ export function Einstellungen({
<span className="ads-eyebrow">Fahrzeugprofil</span>
<p className="dm-erklaerung">
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.
+11 -39
View File
@@ -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,
+18 -22
View File
@@ -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
+1 -1
View File
@@ -233,7 +233,7 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
</Tile>
<Tile variant="button" chevron onClick={() => geheZu("reifen")}>
<span className="ads-eyebrow">Reifen</span>
<span className="ads-eyebrow">Räder</span>
<Werteliste
kinder={
<>
+6 -15
View File
@@ -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<SatzSchluessel | null>(null)
const [kmEntwurf, setzeKmEntwurf] = useState("")
const [wechselDatum, setzeWechselDatum] = useState<string | null>(null)
const [wechselVereinbart, setzeWechselVereinbart] = useState<boolean | null>(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() {
<Tile>
<span className="ads-eyebrow">Wechseltermin</span>
<Feld label="Geplant am">
<Feld label="Geplant am" last>
<Datumsfeld wert={wechselDatumAnzeige} beiAenderung={setzeWechselDatum} />
</Feld>
<Feld label="Termin vereinbart" last>
<Switch
checked={wechselVereinbartAnzeige}
onChange={(checked) => setzeWechselVereinbart(checked)}
aria-label="Termin vereinbart"
/>
</Feld>
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void kalenderUebernehmen()} disabled={!wechselDatumAnzeige}>
In den Kalender übernehmen
+10 -7
View File
@@ -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 && (
<Blattzeile
label="Letzte Fahrt"
labelZusatz={`${letzteFahrt.start_address ?? ""}${letzteFahrt.end_address ?? ""}`}
wert={
letzteFahrt.distance_km != null
? `${de(letzteFahrt.distance_km)} km`
: "offen"
labelZusatz={strecke(letzteFahrt) ?? ""}
wert={streckeText(letzteFahrt.distance_km, "—")}
wertZusatz={
letzteFahrt.verbrauch_l_100km != null
? `${de(letzteFahrt.verbrauch_l_100km, 1)} l/100 km`
: "—"
}
wertZusatz={letzteFahrt.status}
beiKlick={() => geheZu("trip", letzteFahrt.trip_id)}
letzte={!letzteTankung}
/>
+12 -6
View File
@@ -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;
}
+15 -1
View File
@@ -44,7 +44,21 @@ export function SymbolUebersicht({ groesse }: SymbolProps) {
<Rahmen
groesse={groesse}
kinder={
<g>
/* 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 <g> gleicht die Skalierung aus: 1,5 / 1,33 = 1,125, damit
die Strichstaerke gerendert wieder 1,5 betraegt wie bei den Nachbarn. */
<g transform="translate(-1.33,-2.67) scale(1.33)" strokeWidth={1.125}>
<path d="M4 17a9 9 0 0 1 18 0" />
<path d="M13 17l5.5-5" />
<circle cx="13" cy="17" r="1.3" />
+76 -11
View File
@@ -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)
@@ -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)
@@ -1 +1 @@
{"version":"2026.9.1.24","sha256":"1d08e5fe05d30095db34e6eaaf5f9765dad94c8e14fbc1a501cfe3e5daec572a","bytes":266230,"gebaut":"2026-09-01T19:12:38Z"}
{"version":"2026.9.2.3","sha256":"dc96144b41112aae8af2c211111aaff2723bc21eeaefb3bf4af72d2914012bc2","bytes":265967,"gebaut":"2026-09-02T09:26:58Z"}
@@ -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 `<div class="tile teaser"><span class="label">Zuletzt</span>
${t ? `<button class="leaf" style="padding-left:0" data-go="trip:${t.trip_id}">
<span class="k">Letzte Fahrt<small>${esc(t.start_address || "")} ${esc(t.end_address || "")}</small></span>
<span class="k">Letzte Fahrt<small>${strecke(t)}</small></span>
<span class="v">${streckeText(t.distance_km, "—")}<small>${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "—"}</small></span>
<svg class="chev" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg></button>` : ""}
${f ? `<button class="leaf" style="padding-left:0;border-bottom:0" data-go="fill:${f.tank_id}">
@@ -1973,13 +1953,13 @@ function vAudi() {
<dd>${eur(CAR.versicherung.beitrag + CAR.steuer.betrag)} </dd></div>
</dl></button>
<button class="tile tilebtn" data-go="reifen:x">
<span class="label">Reifen</span>
<span class="label">Räder</span>
<svg class="go" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg>
<dl class="rows" style="margin-top:10px">
<div class="row"><dt>aktuell montiert:</dt><dd>${r.aktiv}räder</dd></div>
<div class="row"><dt>Laufleistung</dt><dd>${a.km != null ? de(a.km) + " km" : "unbekannt"}</dd></div>
<div class="row" style="border-bottom:0"><dt>Wechseltermin</dt>
<dd>${dedat(new Date(r.wechsel.datum))}${r.wechsel.vereinbart ? "<small>vereinbart</small>" : ""}</dd></div>
<dd>${dedat(new Date(r.wechsel.datum))}${r.wechsel.vereinbart ? "" : ""}</dd></div>
</dl></button>`;
}
@@ -2971,7 +2951,7 @@ function vEinst() {
</div>
<div class="tile"><span class="label">Kraftstoff-Rabatt</span>
<div class="feld"><label for="sdAktiv" style="display:flex;align-items:center;gap:6px">
<img src="${STATIK}/bilder/${MARKENLOGO_DATEI}" alt="Shell" style="height:16px;width:auto" onerror="this.style.display='none'"> SmartDeal aktiv</label>
<img src="${STATIK}/bilder/${MARKENLOGO_DATEI}" alt="Shell" style="height:19.2px;width:auto" onerror="this.style.display='none'"> SmartDeal aktiv</label>
<label class="switch"><input id="sdAktiv" type="checkbox" data-sd ${CAR.smartdeal.aktiv ? "checked" : ""}><span class="track"></span></label></div>
${CAR.smartdeal.aktiv ? `
<div class="feld" style="border-bottom:0"><label>Gültig bis</label>
@@ -2992,7 +2972,7 @@ function vEinst() {
<span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
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.</span>
@@ -3432,10 +3412,8 @@ function vReifen() {
</div>` : ""}
</div>
<div class="tile"><span class="label">Wechseltermin</span>
<div class="feld"><label for="rfDat">Geplant am</label>
<div class="feld" style="border-bottom:0"><label for="rfDat">Geplant am</label>
<input id="rfDat" type="date" value="${r.wechsel.datum}"></div>
<div class="feld" style="border-bottom:0"><label for="rfOk">Termin vereinbart</label>
<label class="switch"><input id="rfOk" type="checkbox" ${r.wechsel.vereinbart ? "checked" : ""}><span class="track"></span></label></div>
<button class="aktion" data-ics="reifen">In den Kalender übernehmen</button>
</div>
${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 "<Strasse>, <PLZ> <Ort>".
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)}&nbsp;→ ${esc(b)}`;
@@ -4253,7 +4177,21 @@ function vFill(id) {
/* ---------------------------------------------------------- Steuerung */
const ICONS = {
home: '<path d="M4 17a9 9 0 0 1 18 0"/><path d="M13 17l5.5-5"/><circle cx="13" cy="17" r="1.3"/>',
/* 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 <g> gleicht die Skalierung aus: 1,5 / 1,33 = 1,125, damit
die Strichstaerke gerendert wieder 1,5 betraegt wie bei den Nachbarn. */
home: '<g transform="translate(-1.33,-2.67) scale(1.33)" stroke-width="1.125"><path d="M4 17a9 9 0 0 1 18 0"/><path d="M13 17l5.5-5"/><circle cx="13" cy="17" r="1.3"/></g>',
/* Wagen von vorn - dasselbe Symbol wie in der myAudi-App (Audi-Symbolsatz,
assets/icons/car.svg). Flaeche statt Kontur, deshalb die Klasse "voll". */
audi: '<path d="M5.46 4.05a80 80 0 0 1 13.08 0l.24.02.14.2A55 55 0 0 1 21.9 9.2l2.1 2.1v8.2c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V19l-14-.04v.54c0 .83-.67 1.5-1.5 1.5h-2A1.5 1.5 0 0 1 0 19.5v-8.2l2.1-2.1a55 55 0 0 1 2.98-4.93l.14-.2zM2.7 10 1 11.7v.3h4v1H1v6.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-1.55L20 18v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V13h-4v-1h4v-.3L21.3 10zm17.94-1a45 45 0 0 0-2.43-3.98 78 78 0 0 0-12.44 0L4.51 7c-.3.5-.7 1.16-1.16 2.01h17.3ZM7.3 12h9.42l1.36 1.36-.1.3c-.31 1-.6 1.78-.88 2.31-.13.27-.27.5-.41.67-.13.15-.36.36-.68.36H8a.9.9 0 0 1-.68-.36c-.14-.17-.28-.4-.41-.67a17 17 0 0 1-.89-2.32l-.09-.29zm.42 1-.64.64c.28.84.52 1.47.73 1.89a2.4 2.4 0 0 0 .3.47h7.8l.02-.01c.07-.08.16-.23.28-.46.21-.42.45-1.05.73-1.9L16.3 13H7.71Z"/>',
@@ -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;
}
@@ -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; }
@@ -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 - "<Strasse> <Nr>, <PLZ> <Ort>" -, 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}
@@ -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 = {
@@ -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())
@@ -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"],
+128 -62
View File
@@ -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.<satz>.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.<satz>.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]:
+79 -4
View File
@@ -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:
@@ -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
)
@@ -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,
)
@@ -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.