Fahrten, Statistik, Trip-Timer (2026.8.31.3)
Der 15-Minuten-Timer war doppelt: der FMM003 wartet laut Konfiguration (Trip \ Odometer, Ignition OFF Timeout) selbst 900 s, die App noch einmal 15 Minuten. Zusammen eine halbe Stunde. Die Pausenregel ist damit entfallen - in der Live-Erkennung, im Historienimport und in beiden Oberflaechen. Der Schwellwert "ab wann ist es ein Parkplatz" hing an derselben Einstellung, hat damit aber nichts zu tun und steht jetzt als eigene Zahl. Hoechst- und Durchschnittsgeschwindigkeit je Fahrt. Der Durchschnitt folgt aus Strecke und Dauer, vmax aus der neuen Rolle GESCHWINDIGKEIT_SENSOR - laut Konfiguration liest das Geraet die Geschwindigkeit vom OBD/CAN. Es ist der groesste gemeldete Wert, nicht die tatsaechliche Spitze: alle zehn Sekunden ein Datensatz. So ist es auch beschriftet. Der Langzeitverbrauch war um einen ganzen Tankinhalt zu hoch. Gemessen wird die Strecke ZWISCHEN erstem und letztem Tankstop; verbraucht wurde darauf, was ab dem ZWEITEN hineinkam - der erste fuellte die Strecke davor. Bei 13 Tankvorgaengen rund 8 Prozent, im Testfall 16,7 statt 8,3 l/100 km. Der Test hatte die falsche Rechnung festgeschrieben und ist mitkorrigiert. Eine negative Strecke (falscher Kilometerstand an einem Tankvorgang) gilt jetzt als unplausibel statt als Null ohne Erklaerung. Verbrauch je Fahrt bekommt Grenzen: am Sensor nachgemessen loest can_fuel_volume 0,1 l auf, ein Schritt entspricht also 10/Strecke l/100 km Fehler. Unter 3 km sagt die Differenz nichts mehr, ueber 60 l/100 km ist es kein Verbrauch. Statistik: zusaetzlich "Absolut", und die Einheit steht jetzt in eckigen Klammern an der Rubrik statt vier Mal am Zeitraum. Fuenf Spalten passen auf einem Telefon nicht nebeneinander, das Raster bricht um. Radfoto in Reifen war ein reines <Bild> - Ersetzen und Loeschen von dort aus gar nicht erreichbar. Jetzt BildMitMenue wie im Panel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6223,3 +6223,69 @@ Was zwangslaeufig manuell bleibt: die App-Gruppe und die App-ID `app.datametric3
|
|||||||
Apple-Portal (einmalig), das Keychain-„Immer erlauben" beim ersten Signieren (einmalig pro
|
Apple-Portal (einmalig), das Keychain-„Immer erlauben" beim ersten Signieren (einmalig pro
|
||||||
Schluesselbund; der nicht-interaktive Weg verlangt das Anmeldepasswort auf der Kommandozeile und
|
Schluesselbund; der nicht-interaktive Weg verlangt das Anmeldepasswort auf der Kommandozeile und
|
||||||
scheidet damit aus), und `git push` der fertigen `.ipa`, weil das Telefon sie aus dem Repo laedt.
|
scheidet damit aus), und `git push` der fertigen `.ipa`, weil das Telefon sie aus dem Repo laedt.
|
||||||
|
|
||||||
|
## AS. Fahrten, Statistik und der Trip-Timer des FMM003 (2026.8.31.3)
|
||||||
|
|
||||||
|
### Der 15-Minuten-Timer war doppelt
|
||||||
|
|
||||||
|
Die App wartete nach „Zündung aus" 15 Minuten, bevor sie eine Fahrt beendete — und der FMM003 tut
|
||||||
|
dasselbe: in seiner Konfiguration steht unter **Trip \ Odometer** ein **Ignition OFF Timeout von
|
||||||
|
900 s**. Zusammen eine halbe Stunde. Entfallen sind deshalb `pausenzeit_sekunden()`, die abbrechbare
|
||||||
|
Wartezeit in `zuendung_geaendert()`, `_ende_bestaetigen()`, die Verschmelzung in
|
||||||
|
`historienimport._fahrtfenster()` und die Einstellung in beiden Oberflächen.
|
||||||
|
|
||||||
|
**Zurückholen**, falls die Erkennung je wieder ohne das Gerät auskommen muss: eine abbrechbare
|
||||||
|
Wartezeit vor `fahrt_beenden()` legen (`k.warte_ende_ab` / `k.warte_ende_ab_abbrechen` sind dafür
|
||||||
|
noch da) und `fahrten_pausenzeit_min` wieder anbieten. Der Schwellwert „ab wann ist es ein
|
||||||
|
Parkplatz" hing an derselben Einstellung, hat damit aber nichts zu tun und steht jetzt als eigene
|
||||||
|
Zahl (`PARKPLATZ_AB_MIN = 15`) in beiden Codebasen.
|
||||||
|
|
||||||
|
### Höchst- und Durchschnittsgeschwindigkeit
|
||||||
|
|
||||||
|
- **Durchschnitt** braucht keinen Sensor: Strecke ÷ Dauer, `durchschnitt_kmh()` gab es schon.
|
||||||
|
`avg_speed_kmh` war seit jeher im Schema und wurde nie gefüllt — jetzt in `screening.durchfuehren()`
|
||||||
|
für jede Fahrt mit Strecke und Dauer.
|
||||||
|
- **vmax** braucht einen: neue Rolle `GESCHWINDIGKEIT_SENSOR`. Laut Konfiguration liest der FMM003
|
||||||
|
die Geschwindigkeit vom **OBD/CAN**, nicht aus GPS (System → Speed Source) — Beispielname im
|
||||||
|
Katalog entsprechend `vehicle_speed_read_from_can`.
|
||||||
|
- Es ist der **größte gemeldete** Wert, nicht die tatsächliche Spitze: das Gerät schreibt während
|
||||||
|
der Fahrt alle zehn Sekunden einen Datensatz (Data Acquisition → Moving → Min Period = 10 s). So
|
||||||
|
ist es auch beschriftet.
|
||||||
|
|
||||||
|
### Der Langzeitverbrauch war um einen ganzen Tankinhalt zu hoch
|
||||||
|
|
||||||
|
Gerechnet wurde `alle Liter ÷ (odo_letzter − odo_erster)`. Die Strecke liegt aber **zwischen**
|
||||||
|
erstem und letztem Tankstop, und verbraucht wurde darauf genau das, was ab dem **zweiten** Tankstop
|
||||||
|
hineinkam — der erste füllte die Strecke davor. Bei 13 Tankvorgängen sind das rund 8 % zu viel; im
|
||||||
|
Testfall 16,7 statt 8,3 l/100 km. Korrigiert in beiden Codebasen, der Test hatte die falsche
|
||||||
|
Rechnung festgeschrieben und ist mitkorrigiert (plus zwei neue Fälle).
|
||||||
|
|
||||||
|
Dazu zwei Absicherungen: eine **negative Strecke** (falscher Kilometerstand an einem Tankvorgang,
|
||||||
|
in diesem Projekt schon vorgekommen — Abschnitt AF) wird auf 0 gesetzt statt als Zahl angezeigt, und
|
||||||
|
sie gilt jetzt als *unplausibel*, damit statt eines kommentarlosen „0 km / –" der erklärende Hinweis
|
||||||
|
erscheint.
|
||||||
|
|
||||||
|
### Verbrauch je Fahrt: Grenzen statt Vertrauen
|
||||||
|
|
||||||
|
`verbrauch_aus_literstaenden()` hatte weder Unter- noch Obergrenze. Am echten Sensor nachgemessen:
|
||||||
|
`can_fuel_volume` löst **0,1 l** auf — ein Quantisierungsschritt entspricht also 10/Strecke l/100 km
|
||||||
|
Fehler. Unter **3 km** (±3,3) sagt die Differenz nichts mehr, über **60 l/100 km** ist es kein
|
||||||
|
Verbrauch, sondern ein Messfehler. Beides jetzt als benannte Konstante mit dieser Herleitung.
|
||||||
|
|
||||||
|
### Statistik: „Absolut" und Einheiten in eckigen Klammern
|
||||||
|
|
||||||
|
Die Einheit stand vier Mal am Zeitraum („Jahr h", „Monat €") und fehlte in der Überschrift, die man
|
||||||
|
zuerst liest. Jetzt an der Rubrik: **Gefahrene Distanz [km]**, **Fahrzeit [h]**, **Getankte Liter
|
||||||
|
[l]**, **Kraftstoffkosten [€]**, **Näherung je Fahrt, je Zeitraum [l/100 km]**. Die Zeiträume heißen
|
||||||
|
nur noch Jahr / Monat / Woche / Tag / **Absolut**.
|
||||||
|
|
||||||
|
Fünf Spalten passen auf einem Telefon nicht nebeneinander: bei rund 300 px Innenbreite blieben je
|
||||||
|
51 px, und „209.177" braucht bei 16 px Audi Type Wide etwa 60. Das Raster bricht deshalb um
|
||||||
|
(`auto-fit, minmax(84px, 1fr)`) — auf dem Telefon drei plus zwei, auf einem breiten Bildschirm alle
|
||||||
|
fünf in einer Reihe.
|
||||||
|
|
||||||
|
### Radfoto war nicht antippbar
|
||||||
|
|
||||||
|
In `Reifen.tsx` stand ein reines `<Bild>`; das Panel hat an derselben Stelle seit jeher
|
||||||
|
`data-bildklick`. Ersetzen und Löschen waren von dort aus gar nicht erreichbar — jetzt
|
||||||
|
`BildMitMenue`.
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export interface Fahrt {
|
|||||||
edited_fields?: string[];
|
edited_fields?: string[];
|
||||||
/* Ab FMM003 erstmals real befüllbar - bis dahin durchgehend null (§7.2). */
|
/* Ab FMM003 erstmals real befüllbar - bis dahin durchgehend null (§7.2). */
|
||||||
avg_speed_kmh?: number | null;
|
avg_speed_kmh?: number | null;
|
||||||
|
vmax_kmh?: number | null;
|
||||||
start_lat?: number | null;
|
start_lat?: number | null;
|
||||||
start_lon?: number | null;
|
start_lon?: number | null;
|
||||||
end_lat?: number | null;
|
end_lat?: number | null;
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ function beispielprofil(aenderung: Partial<Profil> = {}): Profil {
|
|||||||
},
|
},
|
||||||
einstellungen: {
|
einstellungen: {
|
||||||
uebersichtsbild: "seitenansicht.webp",
|
uebersichtsbild: "seitenansicht.webp",
|
||||||
fahrten_pausenzeit_min: 15,
|
smartdeal: { aktiv: true, laeuft_ab: null },
|
||||||
smartdeal: { aktiv: true, laeuft_ab: null },
|
|
||||||
oelwechsel_intervall: { modus: "hersteller", hersteller_km: 30000, hersteller_monate: 24 },
|
oelwechsel_intervall: { modus: "hersteller", hersteller_km: 30000, hersteller_monate: 24 },
|
||||||
},
|
},
|
||||||
reifen: {
|
reifen: {
|
||||||
@@ -42,13 +41,11 @@ describe("profilZuEinstellungen", () => {
|
|||||||
expect(e.fahrzeugtitel).toBe("RS 4 Avant")
|
expect(e.fahrzeugtitel).toBe("RS 4 Avant")
|
||||||
expect(e.kennzeichen).toBe("M AB 1234")
|
expect(e.kennzeichen).toBe("M AB 1234")
|
||||||
expect(e.tankvolumen).toBe(58)
|
expect(e.tankvolumen).toBe(58)
|
||||||
expect(e.pauseMin).toBe(15)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("fällt auf sinnvolle Werte zurück, wenn Abschnitte fehlen", () => {
|
it("fällt auf sinnvolle Werte zurück, wenn Abschnitte fehlen", () => {
|
||||||
const e = profilZuEinstellungen({})
|
const e = profilZuEinstellungen({})
|
||||||
expect(e.tankvolumen).toBe(58)
|
expect(e.tankvolumen).toBe(58)
|
||||||
expect(e.pauseMin).toBe(15)
|
|
||||||
expect(e.backupIntervall).toBe("aus")
|
expect(e.backupIntervall).toBe("aus")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export interface Einstellungen {
|
|||||||
tankvolumen: number
|
tankvolumen: number
|
||||||
nachtVon: string
|
nachtVon: string
|
||||||
nachtBis: string
|
nachtBis: string
|
||||||
pauseMin: number
|
|
||||||
markenname: string
|
markenname: string
|
||||||
backupIntervall: string
|
backupIntervall: string
|
||||||
letztesBackup: string | null
|
letztesBackup: string | null
|
||||||
@@ -138,7 +137,6 @@ export function profilZuEinstellungen(profil: Profil): Einstellungen {
|
|||||||
tankvolumen: zahl(fahrzeug, "tankvolumen_liter") ?? 58,
|
tankvolumen: zahl(fahrzeug, "tankvolumen_liter") ?? 58,
|
||||||
nachtVon: text(einst, "nacht_von", "22:00"),
|
nachtVon: text(einst, "nacht_von", "22:00"),
|
||||||
nachtBis: text(einst, "nacht_bis", "06:00"),
|
nachtBis: text(einst, "nacht_bis", "06:00"),
|
||||||
pauseMin: zahl(einst, "fahrten_pausenzeit_min") ?? 15,
|
|
||||||
markenname: text(einst, "kraftstoffanbieter", "Shell"),
|
markenname: text(einst, "kraftstoffanbieter", "Shell"),
|
||||||
backupIntervall: text(einst, "backup_intervall", "aus"),
|
backupIntervall: text(einst, "backup_intervall", "aus"),
|
||||||
letztesBackup: text(einst, "letztes_backup") || null,
|
letztesBackup: text(einst, "letztes_backup") || null,
|
||||||
@@ -237,7 +235,6 @@ export function zusammenfuehren(
|
|||||||
f["hauptuntersuchung_faellig"] = fahrzeug.hu
|
f["hauptuntersuchung_faellig"] = fahrzeug.hu
|
||||||
|
|
||||||
e["uebersichtsbild"] = einstellungen.startbild
|
e["uebersichtsbild"] = einstellungen.startbild
|
||||||
e["fahrten_pausenzeit_min"] = einstellungen.pauseMin
|
|
||||||
e["backup_intervall"] = einstellungen.backupIntervall
|
e["backup_intervall"] = einstellungen.backupIntervall
|
||||||
e["nacht_von"] = einstellungen.nachtVon
|
e["nacht_von"] = einstellungen.nachtVon
|
||||||
e["nacht_bis"] = einstellungen.nachtBis
|
e["nacht_bis"] = einstellungen.nachtBis
|
||||||
|
|||||||
@@ -118,19 +118,48 @@ describe("fahrtenSeit und tankSeit", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("langzeitverbrauch", () => {
|
describe("langzeitverbrauch", () => {
|
||||||
it("rechnet bevorzugt mit dem Kilometerstand an den Tankvorgängen", () => {
|
it("rechnet von Tank zu Tank, ohne den ersten Tankvorgang", () => {
|
||||||
const tanks = [
|
const tanks = [
|
||||||
tankung({ tank_id: "a", ts: "2026-06-01T10:00:00", liters: 50, odometer_km: 40000 }),
|
tankung({ tank_id: "a", ts: "2026-06-01T10:00:00", liters: 50, odometer_km: 40000 }),
|
||||||
tankung({ tank_id: "b", ts: "2026-07-01T10:00:00", liters: 50, odometer_km: 40600 }),
|
tankung({ tank_id: "b", ts: "2026-07-01T10:00:00", liters: 50, odometer_km: 40600 }),
|
||||||
]
|
]
|
||||||
const ergebnis = langzeitverbrauch([], tanks)
|
const ergebnis = langzeitverbrauch([], tanks)
|
||||||
expect(ergebnis.km).toBe(600)
|
expect(ergebnis.km).toBe(600)
|
||||||
expect(ergebnis.liter).toBe(100)
|
// Nur die 50 l aus Tankvorgang b: gemessen wird die Strecke ZWISCHEN a
|
||||||
// 100 l auf 600 km sind 16,7 l/100 km — plausibel.
|
// und b, und was dort verbraucht wurde, ist genau das, was bei b wieder
|
||||||
expect(ergebnis.wert).toBeCloseTo(16.67, 1)
|
// hineingefüllt wurde. Die 50 l aus a füllten die Strecke davor - sie
|
||||||
|
// mitzuzählen war bis zum 2026-08-31 der Fehler und wies den Verbrauch
|
||||||
|
// um einen ganzen Tankinhalt zu hoch aus (hier: 16,7 statt 8,3).
|
||||||
|
expect(ergebnis.liter).toBe(50)
|
||||||
|
expect(ergebnis.wert).toBeCloseTo(8.33, 1)
|
||||||
expect(ergebnis.basis).toBe("Kilometerstand an den Tankvorgängen")
|
expect(ergebnis.basis).toBe("Kilometerstand an den Tankvorgängen")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("zählt bei drei Tankvorgängen die letzten beiden", () => {
|
||||||
|
const tanks = [
|
||||||
|
tankung({ tank_id: "a", ts: "2026-05-01T10:00:00", liters: 40, odometer_km: 10000 }),
|
||||||
|
tankung({ tank_id: "b", ts: "2026-06-01T10:00:00", liters: 45, odometer_km: 10500 }),
|
||||||
|
tankung({ tank_id: "c", ts: "2026-07-01T10:00:00", liters: 55, odometer_km: 11000 }),
|
||||||
|
]
|
||||||
|
const ergebnis = langzeitverbrauch([], tanks)
|
||||||
|
expect(ergebnis.km).toBe(1000)
|
||||||
|
expect(ergebnis.liter).toBe(100)
|
||||||
|
expect(ergebnis.wert).toBeCloseTo(10, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("lässt eine rückwärts laufende Kilometerreihe nicht als Wert durchgehen", () => {
|
||||||
|
// Ein falscher Kilometerstand an einem Tankvorgang ist in diesem Projekt
|
||||||
|
// schon vorgekommen (AGENTS.md, Abschnitt AF). Eine negative Strecke ist
|
||||||
|
// kein Messwert.
|
||||||
|
const tanks = [
|
||||||
|
tankung({ tank_id: "a", ts: "2026-06-01T10:00:00", liters: 50, odometer_km: 40600 }),
|
||||||
|
tankung({ tank_id: "b", ts: "2026-07-01T10:00:00", liters: 50, odometer_km: 40000 }),
|
||||||
|
]
|
||||||
|
const ergebnis = langzeitverbrauch([], tanks)
|
||||||
|
expect(ergebnis.km).toBe(0)
|
||||||
|
expect(ergebnis.wert).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it("weicht auf die erfassten Fahrten aus, wenn Kilometerstände fehlen", () => {
|
it("weicht auf die erfassten Fahrten aus, wenn Kilometerstände fehlen", () => {
|
||||||
const tanks = [tankung({ tank_id: "a", ts: "2026-07-01T10:00:00", liters: 50 })]
|
const tanks = [tankung({ tank_id: "a", ts: "2026-07-01T10:00:00", liters: 50 })]
|
||||||
const fahrten = [fahrt({ trip_id: "f", ts_start: "2026-06-01T10:00:00", distance_km: 500 })]
|
const fahrten = [fahrt({ trip_id: "f", ts_start: "2026-06-01T10:00:00", distance_km: 500 })]
|
||||||
|
|||||||
@@ -9,19 +9,23 @@
|
|||||||
import type { Fahrt, Tankvorgang } from "../api"
|
import type { Fahrt, Tankvorgang } from "../api"
|
||||||
import { summe } from "../format"
|
import { summe } from "../format"
|
||||||
|
|
||||||
export type Zeitraum = "jahr" | "monat" | "woche" | "tag"
|
/** "absolut" ist kein Zeitraum, sondern alles je Erfasste - die Summe. */
|
||||||
|
export type Zeitraum = "jahr" | "monat" | "woche" | "tag" | "absolut"
|
||||||
|
|
||||||
export const ZEITRAEUME: Zeitraum[] = ["jahr", "monat", "woche", "tag"]
|
export const ZEITRAEUME: Zeitraum[] = ["jahr", "monat", "woche", "tag", "absolut"]
|
||||||
|
|
||||||
export const ZEITRAUM_NAME: Record<Zeitraum, string> = {
|
export const ZEITRAUM_NAME: Record<Zeitraum, string> = {
|
||||||
jahr: "Jahr",
|
jahr: "Jahr",
|
||||||
monat: "Monat",
|
monat: "Monat",
|
||||||
woche: "Woche",
|
woche: "Woche",
|
||||||
tag: "Tag",
|
tag: "Tag",
|
||||||
|
absolut: "Absolut",
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Beginn des Zeitraums. Die Woche startet montags, nicht sonntags. */
|
/** Beginn des Zeitraums. Die Woche startet montags, nicht sonntags. */
|
||||||
export function zeitraumStart(art: Zeitraum, jetzt = new Date()): Date {
|
export function zeitraumStart(art: Zeitraum, jetzt = new Date()): Date {
|
||||||
|
// Vor jeder denkbaren Aufzeichnung - damit fällt nichts heraus.
|
||||||
|
if (art === "absolut") return new Date(0)
|
||||||
if (art === "jahr") return new Date(jetzt.getFullYear(), 0, 1)
|
if (art === "jahr") return new Date(jetzt.getFullYear(), 0, 1)
|
||||||
if (art === "monat") return new Date(jetzt.getFullYear(), jetzt.getMonth(), 1)
|
if (art === "monat") return new Date(jetzt.getFullYear(), jetzt.getMonth(), 1)
|
||||||
const d = new Date(jetzt)
|
const d = new Date(jetzt)
|
||||||
@@ -88,33 +92,51 @@ export function langzeitverbrauch(
|
|||||||
|
|
||||||
const letzter = tanks[tanks.length - 1]!
|
const letzter = tanks[tanks.length - 1]!
|
||||||
const bis = new Date(letzter.ts)
|
const bis = new Date(letzter.ts)
|
||||||
const liter = summe(tanks, (t) => t.liters ?? 0)
|
|
||||||
|
|
||||||
// Bevorzugt der Kilometerstand an den Tankvorgängen selbst — er zählt auch
|
// Bevorzugt der Kilometerstand an den Tankvorgängen selbst — er zählt auch
|
||||||
// Strecke mit, für die keine Fahrt aufgezeichnet wurde.
|
// Strecke mit, für die keine Fahrt aufgezeichnet wurde.
|
||||||
const mitOdo = tanks.filter((t) => t.odometer_km != null)
|
const mitOdo = tanks.filter((t) => t.odometer_km != null)
|
||||||
let km: number
|
let km: number
|
||||||
|
let liter: number
|
||||||
let basis: Langzeitverbrauch["basis"]
|
let basis: Langzeitverbrauch["basis"]
|
||||||
if (mitOdo.length >= 2) {
|
if (mitOdo.length >= 2) {
|
||||||
km = (mitOdo[mitOdo.length - 1]!.odometer_km ?? 0) - (mitOdo[0]!.odometer_km ?? 0)
|
km = (mitOdo[mitOdo.length - 1]!.odometer_km ?? 0) - (mitOdo[0]!.odometer_km ?? 0)
|
||||||
basis = "Kilometerstand an den Tankvorgängen"
|
basis = "Kilometerstand an den Tankvorgängen"
|
||||||
|
// Von-Tank-zu-Tank: gemessen wird die Strecke ZWISCHEN erstem und
|
||||||
|
// letztem Tankstop, verbraucht wurde auf ihr alles, was ab dem zweiten
|
||||||
|
// Tankstop hineingefüllt wurde. Der erste Tankvorgang füllte die Strecke
|
||||||
|
// DAVOR — ihn mitzuzählen hat den Verbrauch um einen ganzen Tankinhalt
|
||||||
|
// zu hoch ausgewiesen (bei 13 Tankvorgängen rund 8 %). Korrigiert am
|
||||||
|
// 2026-08-31.
|
||||||
|
liter = summe(mitOdo.slice(1), (t) => t.liters ?? 0)
|
||||||
} else {
|
} else {
|
||||||
km = summe(
|
km = summe(
|
||||||
fahrten.filter((f) => f.distance_km != null && new Date(f.ts_start) <= bis),
|
fahrten.filter((f) => f.distance_km != null && new Date(f.ts_start) <= bis),
|
||||||
(f) => f.distance_km ?? 0,
|
(f) => f.distance_km ?? 0,
|
||||||
)
|
)
|
||||||
basis = "erfasste Fahrten"
|
basis = "erfasste Fahrten"
|
||||||
|
// Ohne Kilometerstände ist die Bezugsstrecke alles Erfasste bis zum
|
||||||
|
// letzten Tankstop — dann zählt auch der erste Tankvorgang dazu.
|
||||||
|
liter = summe(tanks, (t) => t.liters ?? 0)
|
||||||
}
|
}
|
||||||
|
// Eine negative Strecke ist kein Messwert, sondern ein falscher
|
||||||
|
// Kilometerstand an einem Tankvorgang. Sie darf nicht als Zahl durchgehen.
|
||||||
|
if (km < 0) km = 0
|
||||||
|
|
||||||
const roh = km > 0 ? (liter / km) * 100 : null
|
const roh = km > 0 ? (liter / km) * 100 : null
|
||||||
const gueltig = roh !== null && roh >= 3 && roh <= 30
|
const gueltig = roh !== null && roh >= 3 && roh <= 30
|
||||||
|
// Auch eine Strecke von null zählt als unplausibel, wenn sie aus
|
||||||
|
// Kilometerständen kommt: dann laufen die Werte an den Tankvorgängen
|
||||||
|
// rückwärts. Ohne diesen Zweig stünde dort nur „0 km" und „–", ohne jeden
|
||||||
|
// Hinweis, woran es liegt.
|
||||||
|
const streckeFehlt = basis === "Kilometerstand an den Tankvorgängen" && km <= 0
|
||||||
return {
|
return {
|
||||||
wert: gueltig ? roh : null,
|
wert: gueltig ? roh : null,
|
||||||
km,
|
km,
|
||||||
liter,
|
liter,
|
||||||
bis,
|
bis,
|
||||||
basis,
|
basis,
|
||||||
unplausibel: roh !== null && !gueltig,
|
unplausibel: (roh !== null && !gueltig) || streckeFehlt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -500,21 +500,10 @@ export function Einstellungen({
|
|||||||
{/* „Nacht beginnt"/„Nacht endet" sind entfallen: das Panel fuehrt sie
|
{/* „Nacht beginnt"/„Nacht endet" sind entfallen: das Panel fuehrt sie
|
||||||
in den Einstellungen nicht, die Nachtgrenze steht dort nur als
|
in den Einstellungen nicht, die Nachtgrenze steht dort nur als
|
||||||
Erlaeuterung auf der Statistik-Seite. */}
|
Erlaeuterung auf der Statistik-Seite. */}
|
||||||
<Tile>
|
{/* Die Kachel „Fahrten zusammenfassen" ist am 2026-08-31 entfallen:
|
||||||
<span className="ads-eyebrow">Fahrten zusammenfassen</span>
|
der FMM003 wendet die Pausenregel selbst an (Ignition OFF Timeout
|
||||||
<Feld label="Pause bis" unit="Minuten" last>
|
= 900 s), eine zweite Wartezeit in der App haette daraus eine
|
||||||
<input
|
halbe Stunde gemacht. */}
|
||||||
className="dm-eingabe"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={werte.pauseMin}
|
|
||||||
onChange={(e) => aendern({ pauseMin: Number(e.target.value) || 0 })}
|
|
||||||
/>
|
|
||||||
</Feld>
|
|
||||||
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
|
|
||||||
Steigst du kürzer als diese Zeit aus — etwa zum Tanken —, bleibt es eine
|
|
||||||
zusammenhängende Fahrt statt zweier
|
|
||||||
</span>
|
|
||||||
</Tile>
|
|
||||||
|
|
||||||
<HistorienImport
|
<HistorienImport
|
||||||
offen={importOffen}
|
offen={importOffen}
|
||||||
|
|||||||
@@ -153,6 +153,22 @@ export function FahrtDetail({ id, geheZu }: { id: string | undefined; geheZu: (n
|
|||||||
}
|
}
|
||||||
zusatz="Näherung aus Tankfüllstand"
|
zusatz="Näherung aus Tankfüllstand"
|
||||||
/>
|
/>
|
||||||
|
<Wertzeile
|
||||||
|
label="Ø Geschwindigkeit"
|
||||||
|
wert={
|
||||||
|
fahrt.avg_speed_kmh != null
|
||||||
|
? `${de(fahrt.avg_speed_kmh, 1)} km/h`
|
||||||
|
: "liegt nicht vor"
|
||||||
|
}
|
||||||
|
zusatz="Strecke ÷ Dauer, Standzeiten eingerechnet"
|
||||||
|
/>
|
||||||
|
<Wertzeile
|
||||||
|
label="Höchstgeschwindigkeit"
|
||||||
|
wert={
|
||||||
|
fahrt.vmax_kmh != null ? `${de(fahrt.vmax_kmh, 1)} km/h` : "liegt nicht vor"
|
||||||
|
}
|
||||||
|
zusatz="größter gemeldeter Wert, alle 10 s ein Datensatz"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,9 +16,8 @@ import { useDaten } from "../daten/DatenKontext"
|
|||||||
import type { Reifensatz, ReifenArchivEintrag } from "../daten/profilAdapter"
|
import type { Reifensatz, ReifenArchivEintrag } from "../daten/profilAdapter"
|
||||||
import { datum, de, deOderStrich } from "../format"
|
import { datum, de, deOderStrich } from "../format"
|
||||||
import { SymbolEdit, SymbolPaket, SymbolZahnradVoll } from "../symbole"
|
import { SymbolEdit, SymbolPaket, SymbolZahnradVoll } from "../symbole"
|
||||||
import { Wertzeile, Werteliste, bestaetigen } from "./bausteine"
|
import { BildMitMenue, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
|
||||||
import { Datumsfeld } from "./Datumsfeld"
|
import { Datumsfeld } from "./Datumsfeld"
|
||||||
import { Bild } from "./Bild"
|
|
||||||
import { kalenderDateiLaden } from "./kalender"
|
import { kalenderDateiLaden } from "./kalender"
|
||||||
|
|
||||||
type SatzSchluessel = "sommer" | "winter"
|
type SatzSchluessel = "sommer" | "winter"
|
||||||
@@ -141,8 +140,11 @@ export function Reifen() {
|
|||||||
<span className="ads-fig__unit">km</span>
|
<span className="ads-fig__unit">km</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Antippbar wie im Panel (data-bildklick am selben Foto): ein
|
||||||
|
reines <Bild> zeigte es nur an, Ersetzen und Loeschen waren
|
||||||
|
von hier aus gar nicht erreichbar. */}
|
||||||
<div className="dm-radfoto">
|
<div className="dm-radfoto">
|
||||||
<Bild
|
<BildMitMenue
|
||||||
datei={satzSchluessel === "winter" ? "rad-winter.webp" : "rad-sommer.webp"}
|
datei={satzSchluessel === "winter" ? "rad-winter.webp" : "rad-sommer.webp"}
|
||||||
beschreibung={radLabel}
|
beschreibung={radLabel}
|
||||||
name={radLabel}
|
name={radLabel}
|
||||||
|
|||||||
@@ -106,10 +106,15 @@ function dauerSeitText(ts: number | null): string {
|
|||||||
|
|
||||||
/* Fahrzustand aus der Zündung + letzter beendeter Fahrt, deckungsgleich mit
|
/* Fahrzustand aus der Zündung + letzter beendeter Fahrt, deckungsgleich mit
|
||||||
standortZustand() im Panel. */
|
standortZustand() im Panel. */
|
||||||
|
/* Ab wann aus „steht gerade" ein „Parkplatz" wird. Hing bis 2026-08-31 an
|
||||||
|
der Einstellung fahrten_pausenzeit_min - die ist mit der Pausenregel
|
||||||
|
entfallen (der FMM003 bringt sie selbst mit), der Schwellwert hier hat
|
||||||
|
damit aber nichts zu tun und bleibt als eigene Zahl stehen. */
|
||||||
|
const PARKPLATZ_AB_MIN = 15
|
||||||
|
|
||||||
function standortZustand(
|
function standortZustand(
|
||||||
fahrzeug: Fahrzeug,
|
fahrzeug: Fahrzeug,
|
||||||
fahrten: readonly { ts_end: string }[],
|
fahrten: readonly { ts_end: string }[],
|
||||||
pauseMin: number,
|
|
||||||
): { faehrt: boolean; unbekannt: boolean; seitTs: number | null; geparkt: boolean } {
|
): { faehrt: boolean; unbekannt: boolean; seitTs: number | null; geparkt: boolean } {
|
||||||
let neuesteTs: number | null = null
|
let neuesteTs: number | null = null
|
||||||
for (const t of fahrten) {
|
for (const t of fahrten) {
|
||||||
@@ -122,7 +127,7 @@ function standortZustand(
|
|||||||
faehrt: false,
|
faehrt: false,
|
||||||
unbekannt: fahrzeug.zuendung == null,
|
unbekannt: fahrzeug.zuendung == null,
|
||||||
seitTs: neuesteTs,
|
seitTs: neuesteTs,
|
||||||
geparkt: minuten != null && minuten >= pauseMin,
|
geparkt: minuten != null && minuten >= PARKPLATZ_AB_MIN,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,7 +442,7 @@ export function Standort() {
|
|||||||
: (positionsFehler ?? "Abstand wird ermittelt …")
|
: (positionsFehler ?? "Abstand wird ermittelt …")
|
||||||
const koordinaten = fahrzeugPos ? `${de(fahrzeugPos.lat, 4)}, ${de(fahrzeugPos.lon, 4)}` : ""
|
const koordinaten = fahrzeugPos ? `${de(fahrzeugPos.lat, 4)}, ${de(fahrzeugPos.lon, 4)}` : ""
|
||||||
const adresseAnzeige = !bekannt ? "Kein GPS-Signal vom Fahrzeug" : (adresse ?? koordinaten)
|
const adresseAnzeige = !bekannt ? "Kein GPS-Signal vom Fahrzeug" : (adresse ?? koordinaten)
|
||||||
const zustand = standortZustand(fahrzeug, fahrten, einstellungen.pauseMin)
|
const zustand = standortZustand(fahrzeug, fahrten)
|
||||||
const kartenlink = fahrzeugPos
|
const kartenlink = fahrzeugPos
|
||||||
? `https://www.google.com/maps/dir/?api=1&destination=${fahrzeugPos.lat},${fahrzeugPos.lon}`
|
? `https://www.google.com/maps/dir/?api=1&destination=${fahrzeugPos.lat},${fahrzeugPos.lon}`
|
||||||
: null
|
: null
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function StatKachel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `quad()` im Panel: Zwischenüberschrift plus vier Werte nebeneinander. */
|
/** `quad()` im Panel: Zwischenüberschrift plus die Werte je Zeitraum. */
|
||||||
function Vierergruppe({ titel, werte }: { titel: string; werte: [string, string][] }) {
|
function Vierergruppe({ titel, werte }: { titel: string; werte: [string, string][] }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -100,13 +100,12 @@ export function Statistik() {
|
|||||||
einstellungen.nachtBis,
|
einstellungen.nachtBis,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Die Einheit steht im Panel in der Beschriftung der Vierergruppe
|
// Die Einheit steht seit dem 2026-08-31 in eckigen Klammern an der
|
||||||
// ("Jahr h", "Monat €"), nicht am Wert.
|
// Rubrik ("Fahrzeit [h]") statt am Zeitraum ("Jahr h") - dort stand sie
|
||||||
const jeZeitraum = (
|
// vier Mal für dieselbe Sache und fehlte in der Überschrift, die man
|
||||||
auswahl: (z: Zeitraum) => string,
|
// zuerst liest.
|
||||||
einheit = "",
|
const jeZeitraum = (auswahl: (z: Zeitraum) => string): [string, string][] =>
|
||||||
): [string, string][] =>
|
ZEITRAEUME.map((z) => [auswahl(z), ZEITRAUM_NAME[z]])
|
||||||
ZEITRAEUME.map((z) => [auswahl(z), `${ZEITRAUM_NAME[z]}${einheit}`])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -117,14 +116,13 @@ export function Statistik() {
|
|||||||
neben={`${de(a.stundenJahr)} h`}
|
neben={`${de(a.stundenJahr)} h`}
|
||||||
>
|
>
|
||||||
<Vierergruppe
|
<Vierergruppe
|
||||||
titel="Gefahrene Distanz"
|
titel="Gefahrene Distanz [km]"
|
||||||
werte={jeZeitraum((z) => de(a.jeZeitraum[z].km))}
|
werte={jeZeitraum((z) => de(a.jeZeitraum[z].km))}
|
||||||
/>
|
/>
|
||||||
<Vierergruppe
|
<Vierergruppe
|
||||||
titel="Fahrzeit"
|
titel="Fahrzeit [h]"
|
||||||
werte={jeZeitraum(
|
werte={jeZeitraum((z) =>
|
||||||
(z) => de(a.jeZeitraum[z].stunden, z === "jahr" || z === "monat" ? 0 : 1),
|
de(a.jeZeitraum[z].stunden, z === "woche" || z === "tag" ? 1 : 0),
|
||||||
" h",
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</StatKachel>
|
</StatKachel>
|
||||||
@@ -136,12 +134,12 @@ export function Statistik() {
|
|||||||
neben={`${de(a.kostenJahr)} €`}
|
neben={`${de(a.kostenJahr)} €`}
|
||||||
>
|
>
|
||||||
<Vierergruppe
|
<Vierergruppe
|
||||||
titel="Getankte Liter"
|
titel="Getankte Liter [l]"
|
||||||
werte={jeZeitraum((z) => de(a.jeZeitraum[z].liter))}
|
werte={jeZeitraum((z) => de(a.jeZeitraum[z].liter))}
|
||||||
/>
|
/>
|
||||||
<Vierergruppe
|
<Vierergruppe
|
||||||
titel="Kraftstoffkosten"
|
titel="Kraftstoffkosten [€]"
|
||||||
werte={jeZeitraum((z) => de(a.jeZeitraum[z].kosten), " €")}
|
werte={jeZeitraum((z) => de(a.jeZeitraum[z].kosten))}
|
||||||
/>
|
/>
|
||||||
</StatKachel>
|
</StatKachel>
|
||||||
|
|
||||||
@@ -174,7 +172,7 @@ export function Statistik() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Vierergruppe
|
<Vierergruppe
|
||||||
titel="Näherung je Fahrt, je Zeitraum"
|
titel="Näherung je Fahrt, je Zeitraum [l/100 km]"
|
||||||
werte={jeZeitraum((z) => deOderStrich(a.jeZeitraum[z].verbrauch, 1))}
|
werte={jeZeitraum((z) => deOderStrich(a.jeZeitraum[z].verbrauch, 1))}
|
||||||
/>
|
/>
|
||||||
<span className="ads-eyebrow" style={{ marginTop: "16px" }}>
|
<span className="ads-eyebrow" style={{ marginTop: "16px" }}>
|
||||||
|
|||||||
@@ -680,9 +680,14 @@ button.dm-serviceblock:active {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* `.quad` im Panel: vier Werte nebeneinander, Zahl im Wide-Schnitt. */
|
/* `.quad` im Panel: vier Werte nebeneinander, Zahl im Wide-Schnitt. */
|
||||||
|
/* Fuenf Spalten (Jahr/Monat/Woche/Tag/Absolut) passen auf einem Telefon nicht
|
||||||
|
nebeneinander: bei rund 300px Innenbreite blieben je 51px, und "209.177"
|
||||||
|
braucht bei 16px Audi Type Wide etwa 60. auto-fit bricht deshalb um - auf dem
|
||||||
|
Telefon drei plus zwei, auf einem breiten Bildschirm alle fuenf in einer
|
||||||
|
Reihe. Deckungsgleich mit .quad im Panel. */
|
||||||
.dm-quad {
|
.dm-quad {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(auto-fit, minmax(84px, 1fr));
|
||||||
gap: var(--sp-3);
|
gap: var(--sp-3);
|
||||||
margin-top: var(--sp-3);
|
margin-top: var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export const beispielProfil: Profil = {
|
|||||||
einstellungen: {
|
einstellungen: {
|
||||||
nacht_von: "22:00",
|
nacht_von: "22:00",
|
||||||
nacht_bis: "06:00",
|
nacht_bis: "06:00",
|
||||||
fahrten_pausenzeit_min: 15,
|
|
||||||
uebersichtsbild: "seitenansicht.webp",
|
uebersichtsbild: "seitenansicht.webp",
|
||||||
kraftstoffanbieter: "Shell",
|
kraftstoffanbieter: "Shell",
|
||||||
smartdeal: { aktiv: true, laeuft_ab: null },
|
smartdeal: { aktiv: true, laeuft_ab: null },
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class Sensorzuordnung:
|
|||||||
TANK_DISTANZ_SENSOR: str = ""
|
TANK_DISTANZ_SENSOR: str = ""
|
||||||
|
|
||||||
# Reichweite (Übersicht).
|
# Reichweite (Übersicht).
|
||||||
|
GESCHWINDIGKEIT_SENSOR: str = ""
|
||||||
RANGE_SENSOR: str = ""
|
RANGE_SENSOR: str = ""
|
||||||
|
|
||||||
# 12V-Batteriespannung. Beim FMM003 ist das external_power_voltage - die
|
# 12V-Batteriespannung. Beim FMM003 ist das external_power_voltage - die
|
||||||
@@ -183,6 +184,11 @@ FELDER: list[dict] = [
|
|||||||
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
|
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
|
||||||
"beispiel": "calculated_distance_covered_after_the_latest_refueling",
|
"beispiel": "calculated_distance_covered_after_the_latest_refueling",
|
||||||
"stichworte": ["strecke tankung", "distance refuel", "distance since refueling", "refuelling"]},
|
"stichworte": ["strecke tankung", "distance refuel", "distance since refueling", "refuelling"]},
|
||||||
|
{"key": "GESCHWINDIGKEIT_SENSOR", "label": "Geschwindigkeit", "gruppe": "fahrterkennung",
|
||||||
|
"hinweis": "Optional - fuer die Hoechstgeschwindigkeit einer Fahrt. Der FMM003 liest sie laut seiner Konfiguration vom OBD/CAN, nicht aus GPS.",
|
||||||
|
"domains": ["sensor"], "device_classes": ["speed"], "units": ["km/h", "mph"], "liste": False, "pflicht": False,
|
||||||
|
"beispiel": "vehicle_speed_read_from_can",
|
||||||
|
"stichworte": ["geschwindigkeit", "speed", "tempo", "kmh"]},
|
||||||
{"key": "RANGE_SENSOR", "label": "Reichweite", "gruppe": "uebersicht",
|
{"key": "RANGE_SENSOR", "label": "Reichweite", "gruppe": "uebersicht",
|
||||||
"hinweis": "Für die Übersicht - bleibt sie leer, zeigt die Oberfläche \"unbekannt\".",
|
"hinweis": "Für die Übersicht - bleibt sie leer, zeigt die Oberfläche \"unbekannt\".",
|
||||||
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
|
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ hier behoben: der Zwischenstand liegt in einem Store (siehe koordinator.py),
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -34,7 +33,15 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
STANDARD_PAUSENZEIT_MIN = 15
|
# Die frühere Pausenregel (STANDARD_PAUSENZEIT_MIN = 15) ist am 2026-08-31
|
||||||
|
# entfallen. Der FMM003 wendet sie selbst an - in seiner Konfiguration unter
|
||||||
|
# "Trip Odometer" steht "Ignition OFF Timeout" auf 900 Sekunden. Eine
|
||||||
|
# zweite Wartezeit hier oben drauf hätte daraus 30 Minuten gemacht.
|
||||||
|
#
|
||||||
|
# Zurückholen, falls die Erkennung je wieder ohne das Gerät auskommen muss:
|
||||||
|
# eine abbrechbare Wartezeit vor fahrt_beenden() legen (k.warte_ende_ab /
|
||||||
|
# k.warte_ende_ab_abbrechen im Koordinator sind dafür noch da) und die
|
||||||
|
# Einstellung fahrten_pausenzeit_min in beiden Oberflächen wieder anbieten.
|
||||||
|
|
||||||
|
|
||||||
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) -> dict:
|
||||||
@@ -52,6 +59,7 @@ def leere_fahrt(start_ts: datetime.datetime, ende_ts: datetime.datetime, quelle:
|
|||||||
"odo_start": None,
|
"odo_start": None,
|
||||||
"odo_end": None,
|
"odo_end": None,
|
||||||
"avg_speed_kmh": None,
|
"avg_speed_kmh": None,
|
||||||
|
"vmax_kmh": None,
|
||||||
"verbrauch_l_100km": None,
|
"verbrauch_l_100km": None,
|
||||||
"start_lat": None,
|
"start_lat": None,
|
||||||
"start_lon": None,
|
"start_lon": None,
|
||||||
@@ -68,15 +76,6 @@ def leere_fahrt(start_ts: datetime.datetime, ende_ts: datetime.datetime, quelle:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def pausenzeit_sekunden(k: Koordinator) -> int:
|
|
||||||
profil = await k.ablage.profil_lesen()
|
|
||||||
if profil is None:
|
|
||||||
return STANDARD_PAUSENZEIT_MIN * 60
|
|
||||||
minuten = profil.get("einstellungen", {}).get(
|
|
||||||
"fahrten_pausenzeit_min", STANDARD_PAUSENZEIT_MIN
|
|
||||||
)
|
|
||||||
return int(minuten) * 60
|
|
||||||
|
|
||||||
|
|
||||||
async def zuendung_geaendert(k: Koordinator, neu: str | None, alt: str | None) -> None:
|
async def zuendung_geaendert(k: Koordinator, neu: str | None, alt: str | None) -> None:
|
||||||
"""Reagiert auf jede Zustandsänderung der Zündungs-Entität.
|
"""Reagiert auf jede Zustandsänderung der Zündungs-Entität.
|
||||||
@@ -112,28 +111,17 @@ async def zuendung_geaendert(k: Koordinator, neu: str | None, alt: str | None) -
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not an_jetzt and fahrt_laeuft:
|
if not an_jetzt and fahrt_laeuft:
|
||||||
start_ts = k.fahrt_start_ts
|
# Ohne Wartezeit: der FMM003 bringt die Pausenregel selbst mit
|
||||||
abbruch_ts = datetime.datetime.now(datetime.UTC)
|
# (Trip Odometer -> "Ignition OFF Timeout" = 900 s). Was hier
|
||||||
wartezeit = await pausenzeit_sekunden(k)
|
# ankommt, ist bereits eine beendete Fahrt - noch einmal 15 Minuten
|
||||||
k.warte_ende_ab(_ende_bestaetigen(k, start_ts, abbruch_ts, wartezeit))
|
# zu warten haette daraus eine halbe Stunde gemacht und zwei
|
||||||
|
# aufeinanderfolgende Fahrten verschmolzen, die das Geraet sauber
|
||||||
|
# getrennt hatte.
|
||||||
|
await fahrt_beenden(k, k.fahrt_start_ts, datetime.datetime.now(datetime.UTC))
|
||||||
|
|
||||||
await k.fahrzeugstatus_veroeffentlichen()
|
await k.fahrzeugstatus_veroeffentlichen()
|
||||||
|
|
||||||
|
|
||||||
async def _ende_bestaetigen(
|
|
||||||
k: Koordinator, start_ts: datetime.datetime, ende_ts: datetime.datetime, wartezeit: int
|
|
||||||
) -> None:
|
|
||||||
"""Wartet die Pausenzeit ab und beendet die Fahrt dann.
|
|
||||||
|
|
||||||
Kommt die Zündung vorher zurück, bricht zuendung_geaendert() genau diese
|
|
||||||
Aufgabe ab und der Code hinter dem sleep läuft nie."""
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(wartezeit)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
_LOGGER.debug("Fahrtende verworfen - Zündung kam innerhalb der Pausenzeit zurück")
|
|
||||||
raise
|
|
||||||
await fahrt_beenden(k, start_ts, ende_ts)
|
|
||||||
|
|
||||||
|
|
||||||
async def nach_neustart_fortsetzen(k: Koordinator) -> None:
|
async def nach_neustart_fortsetzen(k: Koordinator) -> None:
|
||||||
"""Schließt eine Fahrt ab, die während eines HA-Ausfalls zu Ende ging.
|
"""Schließt eine Fahrt ab, die während eines HA-Ausfalls zu Ende ging.
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"version":"2026.8.31.1","sha256":"bd990f600b5bf81ce66105af8d49a77d31a2a4e3b4e5070858c38e1e8d23fcc9","bytes":266016,"gebaut":"2026-08-31T12:23:41Z"}
|
{"version":"2026.8.31.3","sha256":"c0b6aa9b9740fa9c9390a6d200a30aeb8430937a22129e7d61fa88e5452f9283","bytes":266044,"gebaut":"2026-08-31T13:02:37Z"}
|
||||||
Binary file not shown.
@@ -146,7 +146,6 @@ function profilZuConfig(p) {
|
|||||||
tankvolumen: p.fahrzeug.tankvolumen_liter,
|
tankvolumen: p.fahrzeug.tankvolumen_liter,
|
||||||
nachtVon: p.einstellungen.nacht_von,
|
nachtVon: p.einstellungen.nacht_von,
|
||||||
nachtBis: p.einstellungen.nacht_bis,
|
nachtBis: p.einstellungen.nacht_bis,
|
||||||
pauseMin: p.einstellungen.fahrten_pausenzeit_min,
|
|
||||||
markenname: p.einstellungen.kraftstoffanbieter,
|
markenname: p.einstellungen.kraftstoffanbieter,
|
||||||
backupIntervall: p.einstellungen.backup_intervall || "aus",
|
backupIntervall: p.einstellungen.backup_intervall || "aus",
|
||||||
letztesBackup: p.einstellungen.letztes_backup || null,
|
letztesBackup: p.einstellungen.letztes_backup || null,
|
||||||
@@ -233,7 +232,6 @@ function configCarZuProfil(vorherigesProfil) {
|
|||||||
p.fahrzeug.erstzulassung = CAR.erstzulassung;
|
p.fahrzeug.erstzulassung = CAR.erstzulassung;
|
||||||
p.fahrzeug.ausfuehrung = CONFIG.ausfuehrung;
|
p.fahrzeug.ausfuehrung = CONFIG.ausfuehrung;
|
||||||
p.einstellungen.uebersichtsbild = CONFIG.startbild;
|
p.einstellungen.uebersichtsbild = CONFIG.startbild;
|
||||||
p.einstellungen.fahrten_pausenzeit_min = CONFIG.pauseMin;
|
|
||||||
p.einstellungen.backup_intervall = CONFIG.backupIntervall;
|
p.einstellungen.backup_intervall = CONFIG.backupIntervall;
|
||||||
p.einstellungen.smartdeal = { aktiv: CAR.smartdeal.aktiv, laeuft_ab: CAR.smartdeal.laeuftAb };
|
p.einstellungen.smartdeal = { aktiv: CAR.smartdeal.aktiv, laeuft_ab: CAR.smartdeal.laeuftAb };
|
||||||
p.einstellungen.oelwechsel_intervall = {
|
p.einstellungen.oelwechsel_intervall = {
|
||||||
@@ -792,9 +790,14 @@ function standortZustand() {
|
|||||||
faehrt: false,
|
faehrt: false,
|
||||||
unbekannt: CAR.zuendung == null,
|
unbekannt: CAR.zuendung == null,
|
||||||
seitTs,
|
seitTs,
|
||||||
parkplatz: minuten != null && minuten >= (CONFIG.pauseMin ?? 15),
|
parkplatz: minuten != null && minuten >= PARKPLATZ_AB_MIN,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
/* Ab wann aus "steht gerade" ein "Parkplatz" wird. Hing bis 2026-08-31 an
|
||||||
|
der Einstellung fahrten_pausenzeit_min - die ist mit der Pausenregel
|
||||||
|
entfallen (der FMM003 bringt sie selbst mit), der Schwellwert hier hat
|
||||||
|
damit aber nichts zu tun und bleibt als eigene Zahl stehen. */
|
||||||
|
const PARKPLATZ_AB_MIN = 15;
|
||||||
function dauerSeitText(ts) {
|
function dauerSeitText(ts) {
|
||||||
if (ts == null) return "unbekannt";
|
if (ts == null) return "unbekannt";
|
||||||
const sekunden = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
const sekunden = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
||||||
@@ -2901,13 +2904,6 @@ function vEinst() {
|
|||||||
</div>
|
</div>
|
||||||
</div>` : ""}
|
</div>` : ""}
|
||||||
</div>
|
</div>
|
||||||
<div class="tile"><span class="label">Fahrten zusammenfassen</span>
|
|
||||||
<div class="feld" style="border-bottom:0"><label for="pause">Pause bis</label>
|
|
||||||
<span class="mitEinheit"><input id="pause" type="number" step="5" min="0"
|
|
||||||
value="${CONFIG.pauseMin}" data-pause><span>Minuten</span></span></div>
|
|
||||||
<span class="label" style="margin-top:14px">Steigst du kürzer als diese Zeit aus — etwa zum Tanken —,
|
|
||||||
bleibt es eine zusammenhängende Fahrt statt zweier</span>
|
|
||||||
</div>
|
|
||||||
<div class="tile"><span class="label">Fahrzeugprofil</span>
|
<div class="tile"><span class="label">Fahrzeugprofil</span>
|
||||||
<span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
|
<span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
|
||||||
text-transform:none;font-size:12.5px;line-height:1.6">
|
text-transform:none;font-size:12.5px;line-height:1.6">
|
||||||
@@ -3530,6 +3526,8 @@ function vTrip(id) {
|
|||||||
<div class="row"><dt>Startkilometer</dt><dd>${t.odo_start != null ? de(t.odo_start) + " km" : "noch offen"}</dd></div>
|
<div class="row"><dt>Startkilometer</dt><dd>${t.odo_start != null ? de(t.odo_start) + " km" : "noch offen"}</dd></div>
|
||||||
<div class="row"><dt>Endkilometer</dt><dd>${t.odo_end != null ? de(t.odo_end) + " km" : "noch offen"}</dd></div>
|
<div class="row"><dt>Endkilometer</dt><dd>${t.odo_end != null ? de(t.odo_end) + " km" : "noch offen"}</dd></div>
|
||||||
<div class="row"><dt>Dauer</dt><dd>${dauerText(t.duration_s)}</dd></div>
|
<div class="row"><dt>Dauer</dt><dd>${dauerText(t.duration_s)}</dd></div>
|
||||||
|
<div class="row"><dt>Ø Geschwindigkeit</dt><dd>${t.avg_speed_kmh != null ? de(t.avg_speed_kmh, 1) + " km/h" : "liegt nicht vor"}<small>Strecke ÷ Dauer, Standzeiten eingerechnet</small></dd></div>
|
||||||
|
<div class="row"><dt>Höchstgeschwindigkeit</dt><dd>${t.vmax_kmh != null ? de(t.vmax_kmh, 1) + " km/h" : "liegt nicht vor"}<small>größter gemeldeter Wert, alle 10 s ein Datensatz</small></dd></div>
|
||||||
<div class="row"><dt>Verbrauch</dt><dd>${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "liegt nicht vor"}<small>Näherung aus Tankfüllstand</small></dd></div>
|
<div class="row"><dt>Verbrauch</dt><dd>${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "liegt nicht vor"}<small>Näherung aus Tankfüllstand</small></dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
<button class="aktion" data-fahrtbearbeiten>Werte bearbeiten</button>
|
<button class="aktion" data-fahrtbearbeiten>Werte bearbeiten</button>
|
||||||
@@ -3559,6 +3557,8 @@ function statTile(id, label, fig, unit, extra, body) {
|
|||||||
tragen - erst über einen Zeitraum gemittelt ergibt das einen sinnvollen
|
tragen - erst über einen Zeitraum gemittelt ergibt das einen sinnvollen
|
||||||
l/100km-Wert. */
|
l/100km-Wert. */
|
||||||
function periodStart(art) {
|
function periodStart(art) {
|
||||||
|
// Alles, was je erfasst wurde - kein Zeitraum, sondern die Summe.
|
||||||
|
if (art === "absolut") return new Date(0);
|
||||||
const jetzt = new Date();
|
const jetzt = new Date();
|
||||||
if (art === "jahr") return new Date(jetzt.getFullYear(), 0, 1);
|
if (art === "jahr") return new Date(jetzt.getFullYear(), 0, 1);
|
||||||
if (art === "monat") return new Date(jetzt.getFullYear(), jetzt.getMonth(), 1);
|
if (art === "monat") return new Date(jetzt.getFullYear(), jetzt.getMonth(), 1);
|
||||||
@@ -3606,27 +3606,46 @@ function langzeitverbrauch() {
|
|||||||
if (!tanks.length) return { wert: "–", km: 0, liter: 0, bis: null, basis: "–" };
|
if (!tanks.length) return { wert: "–", km: 0, liter: 0, bis: null, basis: "–" };
|
||||||
const letzter = tanks[tanks.length - 1];
|
const letzter = tanks[tanks.length - 1];
|
||||||
const bis = new Date(letzter.ts);
|
const bis = new Date(letzter.ts);
|
||||||
const liter = sum(tanks, (f) => f.liters);
|
|
||||||
/* Kilometerbasis: bevorzugt der Kilometerstand an den Tankvorgängen selbst -
|
/* Kilometerbasis: bevorzugt der Kilometerstand an den Tankvorgängen selbst -
|
||||||
er zählt auch Strecke mit, für die keine Fahrt aufgezeichnet wurde. Fehlt
|
er zählt auch Strecke mit, für die keine Fahrt aufgezeichnet wurde. Fehlt
|
||||||
er, bleibt die Summe der erfassten Fahrten bis zum letzten Tankstop. */
|
er, bleibt die Summe der erfassten Fahrten bis zum letzten Tankstop. */
|
||||||
const mitOdo = tanks.filter((f) => f.odometer_km != null);
|
const mitOdo = tanks.filter((f) => f.odometer_km != null);
|
||||||
let km = 0, basis = "erfasste Fahrten";
|
let km = 0, basis = "erfasste Fahrten", liter;
|
||||||
if (mitOdo.length >= 2) {
|
if (mitOdo.length >= 2) {
|
||||||
km = mitOdo[mitOdo.length - 1].odometer_km - mitOdo[0].odometer_km;
|
km = mitOdo[mitOdo.length - 1].odometer_km - mitOdo[0].odometer_km;
|
||||||
basis = "Kilometerstand an den Tankvorgängen";
|
basis = "Kilometerstand an den Tankvorgängen";
|
||||||
|
/* Von-Tank-zu-Tank: gemessen wird die Strecke ZWISCHEN erstem und
|
||||||
|
letztem Tankstop, verbraucht wurde auf ihr alles, was ab dem zweiten
|
||||||
|
Tankstop hineingefüllt wurde. Der erste Tankvorgang füllte die
|
||||||
|
Strecke DAVOR - ihn mitzuzählen hat den Verbrauch um einen ganzen
|
||||||
|
Tankinhalt zu hoch ausgewiesen (bei 13 Tankvorgängen rund 8 %).
|
||||||
|
Korrigiert am 2026-08-31. */
|
||||||
|
liter = sum(mitOdo.slice(1), (f) => f.liters);
|
||||||
} else {
|
} else {
|
||||||
km = sum(TRIPS.filter((t) => t.distance_km != null && new Date(t.ts_start) <= bis), (t) => t.distance_km);
|
km = sum(TRIPS.filter((t) => t.distance_km != null && new Date(t.ts_start) <= bis), (t) => t.distance_km);
|
||||||
|
/* Ohne Kilometerstände ist die Bezugsstrecke alles Erfasste bis zum
|
||||||
|
letzten Tankstop - dann zählt auch der erste Tankvorgang dazu. */
|
||||||
|
liter = sum(tanks, (f) => f.liters);
|
||||||
}
|
}
|
||||||
|
/* Eine negative Strecke ist kein Messwert, sondern ein falscher
|
||||||
|
Kilometerstand an einem Tankvorgang (schon einmal aufgetreten, siehe
|
||||||
|
AGENTS.md Abschnitt AF). Sie darf nicht als Zahl durchgehen. */
|
||||||
|
if (km < 0) km = 0;
|
||||||
const roh = km > 0 ? (liter / km) * 100 : null;
|
const roh = km > 0 ? (liter / km) * 100 : null;
|
||||||
/* Plausibilitätsfenster: alles außerhalb gilt als unbekannt, statt einen
|
/* Plausibilitätsfenster: alles außerhalb gilt als unbekannt, statt einen
|
||||||
physikalisch unmöglichen Wert als Tatsache anzuzeigen. */
|
physikalisch unmöglichen Wert als Tatsache anzuzeigen. */
|
||||||
const gueltig = roh != null && roh >= 3 && roh <= 30;
|
const gueltig = roh != null && roh >= 3 && roh <= 30;
|
||||||
return { wert: gueltig ? de(roh, 1) : "–", km, liter, bis, basis, unplausibel: roh != null && !gueltig };
|
/* Auch eine Strecke von null zaehlt als unplausibel, wenn sie aus
|
||||||
|
Kilometerstaenden kommt: dann laufen die Werte an den Tankvorgaengen
|
||||||
|
rueckwaerts. Ohne diesen Zweig staende dort nur "0 km" und "-", ohne
|
||||||
|
jeden Hinweis, woran es liegt. */
|
||||||
|
const streckeFehlt = basis === "Kilometerstand an den Tankvorgängen" && km <= 0;
|
||||||
|
return { wert: gueltig ? de(roh, 1) : "–", km, liter, bis, basis, unplausibel: (roh != null && !gueltig) || streckeFehlt };
|
||||||
}
|
}
|
||||||
function vStat() {
|
function vStat() {
|
||||||
const jahr = new Date().getFullYear();
|
const jahr = new Date().getFullYear();
|
||||||
const fJahr = fahrtenSeit("jahr"), fMonat = fahrtenSeit("monat"), fWoche = fahrtenSeit("woche"), fTag = fahrtenSeit("tag");
|
const fJahr = fahrtenSeit("jahr"), fMonat = fahrtenSeit("monat"), fWoche = fahrtenSeit("woche"), fTag = fahrtenSeit("tag");
|
||||||
|
const fAbs = fahrtenSeit("absolut"), tAbs = tankSeit("absolut");
|
||||||
const kmJahr = sum(fJahr, (t) => t.distance_km), hJahr = sum(fJahr, (t) => t.duration_s) / 3600;
|
const kmJahr = sum(fJahr, (t) => t.distance_km), hJahr = sum(fJahr, (t) => t.duration_s) / 3600;
|
||||||
const tJahr = tankSeit("jahr");
|
const tJahr = tankSeit("jahr");
|
||||||
const lJahr = sum(tJahr, (f) => f.liters), eJahr = sum(tJahr, (f) => f.fuel_total_eur || 0);
|
const lJahr = sum(tJahr, (f) => f.liters), eJahr = sum(tJahr, (f) => f.fuel_total_eur || 0);
|
||||||
@@ -3637,11 +3656,11 @@ function vStat() {
|
|||||||
const lv = langzeitverbrauch();
|
const lv = langzeitverbrauch();
|
||||||
return `
|
return `
|
||||||
${statTile("s1", `Distanz und Fahrzeit ${jahr}`, de(kmJahr), "km", de(hJahr) + " h",
|
${statTile("s1", `Distanz und Fahrzeit ${jahr}`, de(kmJahr), "km", de(hJahr) + " h",
|
||||||
quad("Gefahrene Distanz", [[de(kmJahr), "Jahr"], [de(sum(fMonat, (t) => t.distance_km)), "Monat"], [de(sum(fWoche, (t) => t.distance_km)), "Woche"], [de(sum(fTag, (t) => t.distance_km)), "Tag"]]) +
|
quad("Gefahrene Distanz [km]", [[de(kmJahr), "Jahr"], [de(sum(fMonat, (t) => t.distance_km)), "Monat"], [de(sum(fWoche, (t) => t.distance_km)), "Woche"], [de(sum(fTag, (t) => t.distance_km)), "Tag"], [de(sum(fAbs, (t) => t.distance_km)), "Absolut"]]) +
|
||||||
quad("Fahrzeit", [[de(hJahr), "Jahr h"], [de(sum(fMonat, (t) => t.duration_s) / 3600), "Monat h"], [de(sum(fWoche, (t) => t.duration_s) / 3600, 1), "Woche h"], [de(sum(fTag, (t) => t.duration_s) / 3600, 1), "Tag h"]]))}
|
quad("Fahrzeit [h]", [[de(hJahr), "Jahr"], [de(sum(fMonat, (t) => t.duration_s) / 3600), "Monat"], [de(sum(fWoche, (t) => t.duration_s) / 3600, 1), "Woche"], [de(sum(fTag, (t) => t.duration_s) / 3600, 1), "Tag"], [de(sum(fAbs, (t) => t.duration_s) / 3600), "Absolut"]]))}
|
||||||
${statTile("s2", `Getankte Menge und Kosten ${jahr}`, de(lJahr), "l", de(eJahr) + " €",
|
${statTile("s2", `Getankte Menge und Kosten ${jahr}`, de(lJahr), "l", de(eJahr) + " €",
|
||||||
quad("Getankte Liter", [[de(lJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.liters)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.liters)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.liters)), "Tag"]]) +
|
quad("Getankte Liter [l]", [[de(lJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.liters)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.liters)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.liters)), "Tag"], [de(sum(tAbs, (f) => f.liters)), "Absolut"]]) +
|
||||||
quad("Kraftstoffkosten", [[de(eJahr), "Jahr €"], [de(sum(tankSeit("monat"), (f) => f.fuel_total_eur || 0)), "Monat €"], [de(sum(tankSeit("woche"), (f) => f.fuel_total_eur || 0)), "Woche €"], [de(sum(tankSeit("tag"), (f) => f.fuel_total_eur || 0)), "Tag €"]]))}
|
quad("Kraftstoffkosten [€]", [[de(eJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.fuel_total_eur || 0)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.fuel_total_eur || 0)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.fuel_total_eur || 0)), "Tag"], [de(sum(tAbs, (f) => f.fuel_total_eur || 0)), "Absolut"]]))}
|
||||||
${statTile("s3", "Langzeitverbrauch", lv.wert, "l/100 km", lv.bis ? "bis " + dedat(lv.bis) : "–",
|
${statTile("s3", "Langzeitverbrauch", lv.wert, "l/100 km", lv.bis ? "bis " + dedat(lv.bis) : "–",
|
||||||
`<dl class="rows">
|
`<dl class="rows">
|
||||||
<div class="row"><dt>Getankt insgesamt</dt><dd>${de(lv.liter, 1)} l</dd></div>
|
<div class="row"><dt>Getankt insgesamt</dt><dd>${de(lv.liter, 1)} l</dd></div>
|
||||||
@@ -3649,7 +3668,7 @@ function vStat() {
|
|||||||
<div class="row" style="border-bottom:0"><dt>Letzter Tankstop</dt><dd>${lv.bis ? dedat(lv.bis) : "–"}</dd></div>
|
<div class="row" style="border-bottom:0"><dt>Letzter Tankstop</dt><dd>${lv.bis ? dedat(lv.bis) : "–"}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
${lv.unplausibel ? `<span class="label" style="margin-top:16px;color:var(--warn)">Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und Kilometerstände vollständig vorliegen, erscheint er hier.</span>` : ""}
|
${lv.unplausibel ? `<span class="label" style="margin-top:16px;color:var(--warn)">Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und Kilometerstände vollständig vorliegen, erscheint er hier.</span>` : ""}
|
||||||
${quad("Näherung je Fahrt, je Zeitraum", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"]])}
|
${quad("Näherung je Fahrt, je Zeitraum [l/100 km]", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"], [schnittFahrtVerbrauch(fAbs), "Absolut"]])}
|
||||||
<span class="label" style="margin-top:16px">Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten Tankstop. Die Werte je Zeitraum sind eine Näherung aus der Tankfüllstand-Differenz der einzelnen Fahrten, keine Meldung des Fahrzeugs.</span>`)}
|
<span class="label" style="margin-top:16px">Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten Tankstop. Die Werte je Zeitraum sind eine Näherung aus der Tankfüllstand-Differenz der einzelnen Fahrten, keine Meldung des Fahrzeugs.</span>`)}
|
||||||
${statTile("s4", "Tag und Nacht", kmJahr > 0 ? de(Math.round((tagKm / kmJahr) * 100)) : "–", "% bei Tag", de(tagKm) + " km",
|
${statTile("s4", "Tag und Nacht", kmJahr > 0 ? de(Math.round((tagKm / kmJahr) * 100)) : "–", "% bei Tag", de(tagKm) + " km",
|
||||||
`<div class="bar"><i style="flex:${tagKm};background:var(--fg)"></i><i style="flex:${nachtKm};background:var(--fg3)"></i></div>
|
`<div class="bar"><i style="flex:${tagKm};background:var(--fg)"></i><i style="flex:${nachtKm};background:var(--fg3)"></i></div>
|
||||||
@@ -5747,7 +5766,6 @@ function ereignisseVerdrahten() {
|
|||||||
CAR.versicherung.selbst[+i][+feld] = e.target.value;
|
CAR.versicherung.selbst[+i][+feld] = e.target.value;
|
||||||
profilSpeichern(); render(); return;
|
profilSpeichern(); render(); return;
|
||||||
}
|
}
|
||||||
if (e.target.dataset.pause !== undefined) { CONFIG.pauseMin = parseInt(e.target.value, 10) || 0; profilSpeichern(); return; }
|
|
||||||
if (e.target.dataset.sd !== undefined) {
|
if (e.target.dataset.sd !== undefined) {
|
||||||
if (e.target.checked) { smartdealPopupOffen = true; render(); }
|
if (e.target.checked) { smartdealPopupOffen = true; render(); }
|
||||||
else { CAR.smartdeal.aktiv = false; profilSpeichern(); render(); }
|
else { CAR.smartdeal.aktiv = false; profilSpeichern(); render(); }
|
||||||
|
|||||||
@@ -886,7 +886,11 @@ button.leaf:active { background: var(--tile-2); }
|
|||||||
.tile-b.on { display: block; }
|
.tile-b.on { display: block; }
|
||||||
|
|
||||||
/* -------------------------------------------------------------- Statistik */
|
/* -------------------------------------------------------------- Statistik */
|
||||||
.quad { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--sp-3); margin-top: var(--sp-3); }
|
/* Fuenf Spalten (Jahr/Monat/Woche/Tag/Absolut) passen auf einem Telefon nicht
|
||||||
|
nebeneinander: bei 307px Innenbreite blieben je 51px, und "209.177" braucht
|
||||||
|
bei 16px Audi Type Wide rund 60. auto-fit bricht deshalb um - auf dem Telefon
|
||||||
|
drei plus zwei, auf einem breiten Bildschirm alle fuenf in einer Reihe. */
|
||||||
|
.quad { display: grid; grid-template-columns: repeat(auto-fit, minmax(84px, 1fr)); gap: var(--sp-3); margin-top: var(--sp-3); }
|
||||||
.quad .n { display: block; font-family: "Audi Type Wide", var(--font-stack); font-size: 16px; font-weight: 300; color: var(--fg); line-height: 1.2; }
|
.quad .n { display: block; font-family: "Audi Type Wide", var(--font-stack); font-size: 16px; font-weight: 300; color: var(--fg); line-height: 1.2; }
|
||||||
.quad .l { display: block; margin-top: 4px; font-size: 11px; letter-spacing: normal; color: var(--fg3); line-height: 1.3; }
|
.quad .l { display: block; margin-top: 4px; font-size: 11px; letter-spacing: normal; color: var(--fg3); line-height: 1.3; }
|
||||||
.bar { display: flex; height: 4px; border-radius: var(--r-pill); overflow: hidden; gap: 2px; }
|
.bar { display: flex; height: 4px; border-radius: var(--r-pill); overflow: hidden; gap: 2px; }
|
||||||
|
|||||||
@@ -42,15 +42,16 @@ import logging
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .batterie import SPANNUNG_MIN_V
|
from .batterie import SPANNUNG_MIN_V
|
||||||
from .fahrterkennung import leere_fahrt, pausenzeit_sekunden
|
from .fahrterkennung import leere_fahrt
|
||||||
from .tankerkennung import LITER_SCHWELLE, leerer_tankvorgang, schwelle_prozent
|
from .tankerkennung import LITER_SCHWELLE, leerer_tankvorgang, schwelle_prozent
|
||||||
from .verlauf import (
|
from .verlauf import (
|
||||||
UNPLAUSIBLE_KMH,
|
|
||||||
Verlaufspunkt,
|
|
||||||
durchschnitt_kmh,
|
durchschnitt_kmh,
|
||||||
|
hoechstwert_im_fenster,
|
||||||
route_aus_verlauf,
|
route_aus_verlauf,
|
||||||
|
UNPLAUSIBLE_KMH,
|
||||||
verbrauch_aus_literstaenden,
|
verbrauch_aus_literstaenden,
|
||||||
verlauf_lesen,
|
verlauf_lesen,
|
||||||
|
Verlaufspunkt,
|
||||||
wert_bei,
|
wert_bei,
|
||||||
wert_in_naehe,
|
wert_in_naehe,
|
||||||
zahl,
|
zahl,
|
||||||
@@ -83,15 +84,17 @@ als_zeit = zeit_normalisiert # lokaler Name beibehalten, Funktion lebt jetzt in
|
|||||||
|
|
||||||
|
|
||||||
def _fahrtfenster(
|
def _fahrtfenster(
|
||||||
zuendung_verlauf: list[Verlaufspunkt], pausenzeit_s: int
|
zuendung_verlauf: list[Verlaufspunkt],
|
||||||
) -> list[tuple[datetime.datetime, datetime.datetime]]:
|
) -> list[tuple[datetime.datetime, datetime.datetime]]:
|
||||||
"""Aus dem Zündungsverlauf die Zeiträume, in denen gefahren wurde.
|
"""Aus dem Zündungsverlauf die Zeiträume, in denen gefahren wurde.
|
||||||
|
|
||||||
Zwei Schritte, die zusammen die Pausenregel aus §7.1 nachbilden: erst
|
Jeder zusammenhängende "on"-Abschnitt ist eine Fahrt. Bis 2026-08-31
|
||||||
jeden zusammenhängenden "on"-Abschnitt sammeln, dann benachbarte
|
wurden benachbarte Abschnitte hier zusätzlich verschmolzen, wenn ihre
|
||||||
Abschnitte verschmelzen, deren Lücke kürzer als die Pausenzeit ist. Genau
|
Lücke kürzer war als eine Pausenzeit - das bildete die damalige
|
||||||
das tut die Live-Erkennung über die abbrechbare Wartezeit, nur eben im
|
Live-Erkennung nach. Beide sind entfallen, weil der FMM003 die Regel
|
||||||
Nachhinein und ohne Warten."""
|
selbst anwendet ("Ignition OFF Timeout" = 900 s): was im Verlauf als
|
||||||
|
getrennte Abschnitte ankommt, hat das Gerät bereits als getrennte
|
||||||
|
Fahrten beurteilt."""
|
||||||
roh: list[tuple[datetime.datetime, datetime.datetime]] = []
|
roh: list[tuple[datetime.datetime, datetime.datetime]] = []
|
||||||
offen: datetime.datetime | None = None
|
offen: datetime.datetime | None = None
|
||||||
for ts, wert in zuendung_verlauf:
|
for ts, wert in zuendung_verlauf:
|
||||||
@@ -106,17 +109,7 @@ def _fahrtfenster(
|
|||||||
# dem gewählten Fenster. Sie beim Fensterende abzuschneiden würde eine
|
# dem gewählten Fenster. Sie beim Fensterende abzuschneiden würde eine
|
||||||
# Fahrt mit erfundener Endzeit erzeugen.
|
# Fahrt mit erfundener Endzeit erzeugen.
|
||||||
|
|
||||||
if not roh:
|
return roh
|
||||||
return []
|
|
||||||
|
|
||||||
verschmolzen = [roh[0]]
|
|
||||||
for start, ende in roh[1:]:
|
|
||||||
vorheriger_start, vorheriges_ende = verschmolzen[-1]
|
|
||||||
if (start - vorheriges_ende).total_seconds() < pausenzeit_s:
|
|
||||||
verschmolzen[-1] = (vorheriger_start, ende)
|
|
||||||
else:
|
|
||||||
verschmolzen.append((start, ende))
|
|
||||||
return verschmolzen
|
|
||||||
|
|
||||||
|
|
||||||
def _ueberschneidet(
|
def _ueberschneidet(
|
||||||
@@ -147,10 +140,24 @@ def _ueberschneidet(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _vmax(
|
||||||
|
verlauf: list[Verlaufspunkt],
|
||||||
|
start: datetime.datetime,
|
||||||
|
ende: datetime.datetime,
|
||||||
|
) -> float | None:
|
||||||
|
"""Höchstgeschwindigkeit im Fahrtfenster, unplausible Werte verworfen -
|
||||||
|
dieselbe Grenze und dieselbe Begründung wie beim Durchschnitt oben und
|
||||||
|
wie im Live-Weg (screening._vmax_screenen())."""
|
||||||
|
wert = hoechstwert_im_fenster(verlauf, start, ende)
|
||||||
|
if wert is None or wert <= 0 or wert > UNPLAUSIBLE_KMH:
|
||||||
|
return None
|
||||||
|
return wert
|
||||||
|
|
||||||
|
|
||||||
async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
|
async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
|
||||||
"""Fahrten aus dem Zündungsverlauf, mit Kilometerstand und Start-/
|
"""Fahrten aus dem Zündungsverlauf, mit Kilometerstand und Start-/
|
||||||
Zielkoordinaten aus den übrigen Verläufen ergänzt."""
|
Zielkoordinaten aus den übrigen Verläufen ergänzt."""
|
||||||
fenster = _fahrtfenster(verlaeufe["zuendung"], await pausenzeit_sekunden(k))
|
fenster = _fahrtfenster(verlaeufe["zuendung"])
|
||||||
if not fenster:
|
if not fenster:
|
||||||
return {"angelegt": 0, "uebersprungen": 0, "zu_kurz": 0}
|
return {"angelegt": 0, "uebersprungen": 0, "zu_kurz": 0}
|
||||||
|
|
||||||
@@ -159,6 +166,7 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
|
|||||||
lat_verlauf = verlaeufe["lat"]
|
lat_verlauf = verlaeufe["lat"]
|
||||||
lon_verlauf = verlaeufe["lon"]
|
lon_verlauf = verlaeufe["lon"]
|
||||||
tank_liter_verlauf = verlaeufe["tank_liter"]
|
tank_liter_verlauf = verlaeufe["tank_liter"]
|
||||||
|
tempo_verlauf = verlaeufe["tempo"]
|
||||||
|
|
||||||
angelegt = uebersprungen = zu_kurz = 0
|
angelegt = uebersprungen = zu_kurz = 0
|
||||||
neue: list[dict] = []
|
neue: list[dict] = []
|
||||||
@@ -197,6 +205,9 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
|
|||||||
"odo_start": odo_start,
|
"odo_start": odo_start,
|
||||||
"odo_end": odo_end,
|
"odo_end": odo_end,
|
||||||
"avg_speed_kmh": durchschnitt,
|
"avg_speed_kmh": durchschnitt,
|
||||||
|
# Der größte gemeldete Wert im Fahrtfenster, nicht die
|
||||||
|
# tatsächliche Spitze - siehe hoechstwert_im_fenster().
|
||||||
|
"vmax_kmh": _vmax(tempo_verlauf, f_start, f_ende),
|
||||||
"start_lat": wert_bei(lat_verlauf, f_start),
|
"start_lat": wert_bei(lat_verlauf, f_start),
|
||||||
"start_lon": wert_bei(lon_verlauf, f_start),
|
"start_lon": wert_bei(lon_verlauf, f_start),
|
||||||
"end_lat": wert_bei(lat_verlauf, f_ende),
|
"end_lat": wert_bei(lat_verlauf, f_ende),
|
||||||
@@ -439,6 +450,7 @@ async def importieren(k: Koordinator, start: object, ende: object) -> None:
|
|||||||
"aussentemp": await verlauf_lesen(k.hass, werte.AUSSENTEMP_SENSOR, von, bis),
|
"aussentemp": await verlauf_lesen(k.hass, werte.AUSSENTEMP_SENSOR, von, bis),
|
||||||
"lat": await verlauf_lesen(k.hass, werte.STANDORT_LAT_SENSOR, von, bis),
|
"lat": await verlauf_lesen(k.hass, werte.STANDORT_LAT_SENSOR, von, bis),
|
||||||
"lon": await verlauf_lesen(k.hass, werte.STANDORT_LON_SENSOR, von, bis),
|
"lon": await verlauf_lesen(k.hass, werte.STANDORT_LON_SENSOR, von, bis),
|
||||||
|
"tempo": await verlauf_lesen(k.hass, werte.GESCHWINDIGKEIT_SENSOR, von, bis),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Frühester Zeitpunkt, zu dem im gewählten Fenster überhaupt etwas
|
# Frühester Zeitpunkt, zu dem im gewählten Fenster überhaupt etwas
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "audi_dashboard",
|
"domain": "audi_dashboard",
|
||||||
"name": "Audi Dashboard",
|
"name": "Audi Dashboard",
|
||||||
"version": "2026.8.31.1",
|
"version": "2026.8.31.3",
|
||||||
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
|
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
|
||||||
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
|
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
|
||||||
"codeowners": ["@paul"],
|
"codeowners": ["@paul"],
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from typing import TYPE_CHECKING
|
|||||||
from .verlauf import (
|
from .verlauf import (
|
||||||
UNPLAUSIBLE_KMH,
|
UNPLAUSIBLE_KMH,
|
||||||
durchschnitt_kmh,
|
durchschnitt_kmh,
|
||||||
|
hoechstwert_im_fenster,
|
||||||
naechster_wert,
|
naechster_wert,
|
||||||
route_aus_verlauf,
|
route_aus_verlauf,
|
||||||
verbrauch_aus_literstaenden,
|
verbrauch_aus_literstaenden,
|
||||||
@@ -109,6 +110,26 @@ async def durchfuehren(k: Koordinator) -> None:
|
|||||||
for fahrt in ohne_position:
|
for fahrt in ohne_position:
|
||||||
await _position_screenen(k, lat_sensor, lon_sensor, fahrt)
|
await _position_screenen(k, lat_sensor, lon_sensor, 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.
|
||||||
|
for fahrt in fahrten:
|
||||||
|
if fahrt.get("avg_speed_kmh") is not None:
|
||||||
|
continue
|
||||||
|
schnitt = durchschnitt_kmh(fahrt.get("distance_km"), fahrt.get("duration_s"))
|
||||||
|
if schnitt is None:
|
||||||
|
continue
|
||||||
|
await k.ablage.fahrt_aktualisieren(
|
||||||
|
fahrt["trip_id"], {"avg_speed_kmh": schnitt}
|
||||||
|
)
|
||||||
|
fahrt["avg_speed_kmh"] = schnitt
|
||||||
|
|
||||||
|
tempo_sensor = k.zuordnung.werte.GESCHWINDIGKEIT_SENSOR
|
||||||
|
if tempo_sensor:
|
||||||
|
ohne_vmax = [f for f in fahrten if f.get("vmax_kmh") is None]
|
||||||
|
for fahrt in ohne_vmax:
|
||||||
|
await _vmax_screenen(k, tempo_sensor, fahrt)
|
||||||
|
|
||||||
tank_liter_sensor = k.zuordnung.werte.TANK_LITER_SENSOR
|
tank_liter_sensor = k.zuordnung.werte.TANK_LITER_SENSOR
|
||||||
if tank_liter_sensor:
|
if tank_liter_sensor:
|
||||||
ohne_verbrauch = [
|
ohne_verbrauch = [
|
||||||
@@ -229,6 +250,34 @@ async def _verbrauch_screenen(
|
|||||||
fahrt["verbrauch_l_100km"] = verbrauch
|
fahrt["verbrauch_l_100km"] = verbrauch
|
||||||
|
|
||||||
|
|
||||||
|
async def _vmax_screenen(k: Koordinator, tempo_sensor: str, fahrt: dict) -> None:
|
||||||
|
"""Trägt die Höchstgeschwindigkeit der Fahrt nach.
|
||||||
|
|
||||||
|
Anders als beim Kilometerstand wird hier nicht der nächstgelegene Wert
|
||||||
|
gesucht, sondern der größte innerhalb des Fahrtfensters - und ohne
|
||||||
|
Suchfenster drumherum, weil ein Wert von vor oder nach der Fahrt nicht
|
||||||
|
zu ihr gehört.
|
||||||
|
|
||||||
|
Unplausible Werte werden verworfen statt angezeigt: dieselbe Grenze wie
|
||||||
|
beim Durchschnitt (UNPLAUSIBLE_KMH), denn ein Sensorsprung sieht in
|
||||||
|
beiden Fällen gleich aus."""
|
||||||
|
start = _als_zeit(fahrt.get("ts_start"))
|
||||||
|
ende = _als_zeit(fahrt.get("ts_end"))
|
||||||
|
if start is None or ende is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
verlauf = await verlauf_lesen(k.hass, tempo_sensor, start, ende)
|
||||||
|
if not verlauf:
|
||||||
|
return
|
||||||
|
|
||||||
|
vmax = hoechstwert_im_fenster(verlauf, start, ende)
|
||||||
|
if vmax is None or vmax <= 0 or vmax > UNPLAUSIBLE_KMH:
|
||||||
|
return
|
||||||
|
|
||||||
|
await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], {"vmax_kmh": vmax})
|
||||||
|
fahrt["vmax_kmh"] = vmax
|
||||||
|
|
||||||
|
|
||||||
async def _lueckenlos_verketten(k: Koordinator) -> None:
|
async def _lueckenlos_verketten(k: Koordinator) -> None:
|
||||||
"""Fahrt-Ende(N) == Fahrt-Start(N+1), solange dazwischen nicht gefahren
|
"""Fahrt-Ende(N) == Fahrt-Start(N+1), solange dazwischen nicht gefahren
|
||||||
wurde.
|
wurde.
|
||||||
|
|||||||
@@ -176,6 +176,50 @@ def route_aus_verlauf(
|
|||||||
return punkte if len(punkte) >= 2 else None
|
return punkte if len(punkte) >= 2 else None
|
||||||
|
|
||||||
|
|
||||||
|
def hoechstwert_im_fenster(
|
||||||
|
verlauf: list[Verlaufspunkt],
|
||||||
|
start: datetime.datetime,
|
||||||
|
ende: datetime.datetime,
|
||||||
|
) -> float | None:
|
||||||
|
"""Der größte Zahlenwert eines Verlaufs innerhalb von [start, ende].
|
||||||
|
|
||||||
|
Für die Höchstgeschwindigkeit einer Fahrt. Das ist der größte
|
||||||
|
GEMELDETE Wert, nicht die tatsächliche Spitze: der FMM003 schreibt
|
||||||
|
während der Fahrt alle zehn Sekunden einen Datensatz (Data
|
||||||
|
Acquisition -> Moving -> "Min Period" = 10 s), eine kurze Spitze
|
||||||
|
dazwischen sieht niemand. Für eine Fahrtenübersicht ist das genau
|
||||||
|
genug; als Beweismittel taugt es nicht, und so ist es auch
|
||||||
|
beschriftet.
|
||||||
|
|
||||||
|
None, wenn im Fenster kein einziger Zahlenwert liegt."""
|
||||||
|
werte = [
|
||||||
|
float(wert)
|
||||||
|
for ts, wert in verlauf
|
||||||
|
if start <= ts <= ende and _als_zahl(wert) is not None
|
||||||
|
]
|
||||||
|
return round(max(werte), 1) if werte else None
|
||||||
|
|
||||||
|
|
||||||
|
def _als_zahl(wert: object) -> float | None:
|
||||||
|
"""Ein Verlaufswert als Zahl, oder None - Zustände wie "unavailable"
|
||||||
|
kommen hier genauso an wie Messwerte."""
|
||||||
|
try:
|
||||||
|
return float(wert) # type: ignore[arg-type]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Unterhalb dieser Strecke sagt die Literdifferenz nichts mehr: der
|
||||||
|
# Tanksensor löst 0,1 l auf (an der echten Aufzeichnung nachgemessen,
|
||||||
|
# 2026-08-31), ein Quantisierungsschritt entspricht also 10/Strecke
|
||||||
|
# l/100 km Fehler - bei 3 km sind das ±3,3, bei 1 km schon ±10.
|
||||||
|
MIN_STRECKE_VERBRAUCH_KM = 3.0
|
||||||
|
|
||||||
|
# Ein Verbrennerwert darüber ist kein Verbrauch, sondern ein Messfehler
|
||||||
|
# (Sensorsprung, Tankvorgang kurz vor Fahrtende, Betankung im Stand).
|
||||||
|
UNPLAUSIBLE_L_100KM = 60.0
|
||||||
|
|
||||||
|
|
||||||
def verbrauch_aus_literstaenden(
|
def verbrauch_aus_literstaenden(
|
||||||
start_liter: float | None, end_liter: float | None, distance_km: float | None
|
start_liter: float | None, end_liter: float | None, distance_km: float | None
|
||||||
) -> float | None:
|
) -> float | None:
|
||||||
@@ -188,12 +232,15 @@ def verbrauch_aus_literstaenden(
|
|||||||
Literstand nicht gefallen ist (z. B. ein Tankvorgang während der Fahrt,
|
Literstand nicht gefallen ist (z. B. ein Tankvorgang während der Fahrt,
|
||||||
oder unveränderter Füllstand) - in beiden Fällen ist der Quotient nicht
|
oder unveränderter Füllstand) - in beiden Fällen ist der Quotient nicht
|
||||||
aussagekräftig."""
|
aussagekräftig."""
|
||||||
if start_liter is None or end_liter is None or not distance_km or distance_km <= 0:
|
if start_liter is None or end_liter is None or not distance_km:
|
||||||
|
return None
|
||||||
|
if distance_km < MIN_STRECKE_VERBRAUCH_KM:
|
||||||
return None
|
return None
|
||||||
verbraucht = start_liter - end_liter
|
verbraucht = start_liter - end_liter
|
||||||
if verbraucht <= 0:
|
if verbraucht <= 0:
|
||||||
return None
|
return None
|
||||||
return round(verbraucht / distance_km * 100, 1)
|
wert = round(verbraucht / distance_km * 100, 1)
|
||||||
|
return wert if wert <= UNPLAUSIBLE_L_100KM else None
|
||||||
|
|
||||||
|
|
||||||
def naechster_wert(
|
def naechster_wert(
|
||||||
|
|||||||
Reference in New Issue
Block a user