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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={
|
||||
<>
|
||||
|
||||
@@ -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,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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"}
|
||||
Binary file not shown.
@@ -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)} → ${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"],
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user