Ölwechsel/Inspektion-Anzeige repariert, Selbst-Update der Integration gebaut
Zwei getrennte Themen in einem Commit, beide in derselben Sitzung entstanden:
1. Ölwechsel/Inspektion wurden komplett ausgeblendet ("kein Eintrag im
Servicebuch"), sobald kein Servicebucheintrag vorlag - selbst wenn der
zugeordnete Sensor eine gültige Fälligkeit meldete. In beiden Frontends
prüfte die Anzeige nur den Servicebuch-Zweig, bevor sie die
Fahrzeugmeldung überhaupt las. Jetzt steht die Fahrzeugmeldung für sich;
fehlt zusätzlich ein Servicebucheintrag, übernimmt eine neue,
fahrtenlog-basierte Prognose (kmProTagAusFahrten()/meldungsPrognose())
die Hochrechnung statt der Servicebuch-Rate - deckelt auf die vom
Fahrzeug selbst gemeldete Zeitgrenze, falls zu wenig gefahren wird.
2. install.ps1 als Update-Weg wird von Windows Smart App Control blockiert,
ohne Umgehungsmöglichkeit. Die Integration lädt sich jetzt auf
Tastendruck selbst von Gitea (aktualisierung.py), verifiziert das
Manifest vor jedem Tausch und tauscht per os.rename mit automatischem
Rollback bei Fehlern - install.ps1 bleibt nur noch für die
Erstinstallation nötig. Zugangstoken über einen neuen OptionsFlow in
entry.options, nie in configuration.yaml.
Nebenbei: mehrere seit der HACS-Ausschluss-Entscheidung liegen gebliebene
falsche HACS-Referenzen in Code-Kommentaren und einem UI-Text korrigiert.
Details, Sicherheitsbegründung und Verifikationsstand in AGENTS.md,
Abschnitte I und J.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
||||
type Buendelangabe,
|
||||
type Fahrt,
|
||||
type Fahrzeugstatus,
|
||||
type IntegrationUpdateAngabe,
|
||||
type Profil,
|
||||
type Tankvorgang,
|
||||
type Verbindungszustand,
|
||||
@@ -50,6 +51,9 @@ export interface DatenWert {
|
||||
oder null — kein Fehler, sondern der Zustand ohne Bündel oder ohne
|
||||
Verbindung. */
|
||||
otaBuendel: Buendelangabe | null
|
||||
/** Ergebnis der letzten Selbst-Update-Prüfung/-Installation der
|
||||
Integration (aktualisierung.py), oder null vor der ersten Prüfung. */
|
||||
integrationUpdate: IntegrationUpdateAngabe | null
|
||||
|
||||
einstellungen: Einstellungen | null
|
||||
fahrzeug: Fahrzeug | null
|
||||
@@ -93,6 +97,9 @@ export function DatenAnbieter({
|
||||
const [warteschlange, setzeWarteschlange] = useState<readonly WartenderAuftrag[]>([])
|
||||
const [serverVersion, setzeServerVersion] = useState<string | null>(null)
|
||||
const [otaBuendel, setzeOtaBuendel] = useState<Buendelangabe | null>(null)
|
||||
const [integrationUpdate, setzeIntegrationUpdate] = useState<IntegrationUpdateAngabe | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
// Damit profilSpeichern immer gegen den neuesten Rohstand arbeitet, auch
|
||||
// wenn zwischendurch ein Push hereinkam.
|
||||
@@ -121,6 +128,7 @@ export function DatenAnbieter({
|
||||
const versionsangabe = await api.appVersionAngabeLesen()
|
||||
setzeServerVersion(versionsangabe?.app ?? null)
|
||||
setzeOtaBuendel(versionsangabe?.buendel ?? null)
|
||||
setzeIntegrationUpdate(versionsangabe?.integration_update ?? null)
|
||||
} catch (fehler) {
|
||||
// Kein Netz ist kein Ladefehler, solange schon Daten da sind — dann
|
||||
// zeigt die App den zwischengespeicherten Stand mit Offline-Hinweis.
|
||||
@@ -219,6 +227,7 @@ export function DatenAnbieter({
|
||||
statusStand,
|
||||
versionsstand: versionVergleichen(eigeneVersion(), serverVersion).stand,
|
||||
otaBuendel,
|
||||
integrationUpdate,
|
||||
einstellungen,
|
||||
fahrzeug,
|
||||
fahrten,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { inspektionPrognose, letzterOelwechsel, naechsterService, oelwechselPrognose } from "./service"
|
||||
import {
|
||||
inspektionPrognose,
|
||||
kmProTagAusFahrten,
|
||||
letzterOelwechsel,
|
||||
meldungsPrognose,
|
||||
naechsterService,
|
||||
oelwechselPrognose,
|
||||
} from "./service"
|
||||
import type { Fahrzeug } from "./profilAdapter"
|
||||
|
||||
function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
|
||||
@@ -128,7 +135,7 @@ describe("naechsterService", () => {
|
||||
const insp = inspektionPrognose(fahrzeug(46000), BUCH, jetzt)
|
||||
const erwarteteArt = oel!.datum <= insp!.datum ? "Ölwechsel" : "Inspektion"
|
||||
|
||||
const s = naechsterService(fahrzeug(46000), BUCH, jetzt)
|
||||
const s = naechsterService(fahrzeug(46000), BUCH, [], jetzt)
|
||||
expect(s).not.toBeNull()
|
||||
expect(s!.art).toBe(erwarteteArt)
|
||||
expect(s!.datum).toBe(erwarteteArt === "Ölwechsel" ? oel!.datum : insp!.datum)
|
||||
@@ -136,7 +143,7 @@ describe("naechsterService", () => {
|
||||
|
||||
it("nimmt die Hauptuntersuchung auf, obwohl sie keine eigene Prognose hat", () => {
|
||||
const f = { ...fahrzeug(46000), hu: "2026-09-01" }
|
||||
const s = naechsterService(f, [], jetzt)
|
||||
const s = naechsterService(f, [], [], jetzt)
|
||||
expect(s).not.toBeNull()
|
||||
expect(s!.art).toBe("Hauptuntersuchung")
|
||||
expect(s!.restKm).toBeNull()
|
||||
@@ -144,6 +151,105 @@ describe("naechsterService", () => {
|
||||
})
|
||||
|
||||
it("liefert nichts ohne Servicebuch und ohne gepflegte Hauptuntersuchung", () => {
|
||||
expect(naechsterService(fahrzeug(46000), [], jetzt)).toBeNull()
|
||||
expect(naechsterService(fahrzeug(46000), [], [], jetzt)).toBeNull()
|
||||
})
|
||||
|
||||
it("fällt ohne Servicebucheintrag auf die vom Fahrzeug gemeldete Ölwechsel-Fälligkeit zurück", () => {
|
||||
// Ein frisch eingerichtetes Fahrzeug mit zugeordnetem Sensor, aber noch
|
||||
// ganz ohne Servicebuch, muss die Meldung trotzdem zeigen - siehe
|
||||
// AGENTS.md, Abschnitt H, "no inspektion and ölwechsel shown ...".
|
||||
const f = { ...fahrzeug(46000), oelwechselFaelligTs: "2027-03-14T00:00:00+00:00", oelwechselFaelligKm: 11750 }
|
||||
const s = naechsterService(f, [], [], jetzt)
|
||||
expect(s).not.toBeNull()
|
||||
expect(s!.art).toBe("Ölwechsel")
|
||||
expect(s!.datum).toBe("2027-03-14")
|
||||
expect(s!.restKm).toBe(11750)
|
||||
})
|
||||
|
||||
it("fällt ohne Servicebucheintrag auf die vom Fahrzeug gemeldete Inspektions-Fälligkeit zurück", () => {
|
||||
const f = { ...fahrzeug(46000), inspektionFaelligTs: "2027-01-01T00:00:00+00:00", inspektionFaelligKm: 5000 }
|
||||
const s = naechsterService(f, [], [], jetzt)
|
||||
expect(s).not.toBeNull()
|
||||
expect(s!.art).toBe("Inspektion")
|
||||
expect(s!.datum).toBe("2027-01-01")
|
||||
expect(s!.restKm).toBe(5000)
|
||||
})
|
||||
|
||||
it("bevorzugt die eigene Prognose gegenüber der Fahrzeugmeldung, wenn beide vorliegen", () => {
|
||||
// Servicebucheintrag vorhanden -> oelwechselPrognose() greift; die
|
||||
// Meldung (weit in der Zukunft) darf sie nicht verdrängen.
|
||||
const f = { ...fahrzeug(46000), oelwechselFaelligTs: "2099-01-01T00:00:00+00:00", oelwechselFaelligKm: 99999 }
|
||||
const oel = oelwechselPrognose(f, BUCH, jetzt)
|
||||
expect(oel).not.toBeNull()
|
||||
const s = naechsterService(f, BUCH, [], jetzt)
|
||||
expect(s!.datum).not.toBe("2099-01-01")
|
||||
})
|
||||
})
|
||||
|
||||
describe("kmProTagAusFahrten", () => {
|
||||
const jetzt = new Date("2026-08-11T12:00:00")
|
||||
|
||||
it("rechnet aus abgeschlossenen Fahrten seit der ältesten die km/Tag-Rate", () => {
|
||||
// Exakt 30 Tage vor jetzt (gleiche Uhrzeit), 900 km insgesamt -> 30 km/Tag.
|
||||
const fahrten = [
|
||||
{ ts_start: "2026-07-12T12:00:00", distance_km: 400, status: "abgeschlossen" },
|
||||
{ ts_start: "2026-08-01T08:00:00", distance_km: 500, status: "abgeschlossen" },
|
||||
]
|
||||
expect(kmProTagAusFahrten(fahrten, jetzt)).toBeCloseTo(30, 5)
|
||||
})
|
||||
|
||||
it("ignoriert offene Fahrten und Fahrten ohne bekannte Distanz", () => {
|
||||
const fahrten = [
|
||||
{ ts_start: "2026-07-12T08:00:00", distance_km: 400, status: "abgeschlossen" },
|
||||
{ ts_start: "2026-08-10T08:00:00", distance_km: null, status: "abgeschlossen" },
|
||||
{ ts_start: "2026-08-11T08:00:00", distance_km: 9999, status: "offen" },
|
||||
]
|
||||
// Nur die erste Fahrt zählt -> dieselbe Rate wie ohne die beiden anderen.
|
||||
expect(kmProTagAusFahrten(fahrten, jetzt)).toBe(
|
||||
kmProTagAusFahrten([fahrten[0]!], jetzt),
|
||||
)
|
||||
})
|
||||
|
||||
it("liefert nichts ohne Fahrten oder ohne belastbaren Zeitraum", () => {
|
||||
expect(kmProTagAusFahrten([], jetzt)).toBeNull()
|
||||
expect(kmProTagAusFahrten([{ ts_start: jetzt.toISOString(), distance_km: 5, status: "abgeschlossen" }], jetzt))
|
||||
.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("meldungsPrognose", () => {
|
||||
const jetzt = new Date("2026-08-11T12:00:00")
|
||||
// 30 km/Tag, siehe kmProTagAusFahrten-Tests oben.
|
||||
const fahrten = [
|
||||
{ ts_start: "2026-07-12T12:00:00", distance_km: 400, status: "abgeschlossen" },
|
||||
{ ts_start: "2026-08-01T08:00:00", distance_km: 500, status: "abgeschlossen" },
|
||||
]
|
||||
|
||||
it("rechnet die Restkilometer mit der Fahrleistung aus dem Fahrtenlog auf ein Datum hoch", () => {
|
||||
// 300 km Rest bei 30 km/Tag -> 10 Tage.
|
||||
const p = meldungsPrognose("2099-01-01T00:00:00+00:00", 300, fahrten, jetzt)
|
||||
expect(p).not.toBeNull()
|
||||
expect(p!.datum).toBe("2026-08-21")
|
||||
expect(p!.durchZeitlimit).toBe(false)
|
||||
})
|
||||
|
||||
it("deckelt auf die vom Fahrzeug gemeldete Zeitgrenze, wenn zu wenig gefahren wird", () => {
|
||||
// 30000 km Rest bei 30 km/Tag läge Jahre in der Zukunft - die gemeldete
|
||||
// Zeitgrenze (fünf Tage später) greift zuerst.
|
||||
const p = meldungsPrognose("2026-08-16T00:00:00+00:00", 30000, fahrten, jetzt)
|
||||
expect(p).not.toBeNull()
|
||||
expect(p!.datum).toBe("2026-08-16")
|
||||
expect(p!.durchZeitlimit).toBe(true)
|
||||
})
|
||||
|
||||
it("fällt ohne Fahrtenlog auf die reine Zeitgrenze der Meldung zurück", () => {
|
||||
const p = meldungsPrognose("2027-01-01T00:00:00+00:00", 5000, [], jetzt)
|
||||
expect(p).not.toBeNull()
|
||||
expect(p!.datum).toBe("2027-01-01")
|
||||
expect(p!.durchZeitlimit).toBe(true)
|
||||
})
|
||||
|
||||
it("liefert nichts ohne jede brauchbare Angabe", () => {
|
||||
expect(meldungsPrognose(null, null, fahrten, jetzt)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -126,6 +126,59 @@ export function inspektionPrognose(
|
||||
return servicePrognose(fahrzeug, letzterEintrag(buch, /inspektion/i), 30000, 24, jetzt)
|
||||
}
|
||||
|
||||
interface Fahrtdaten {
|
||||
ts_start: string
|
||||
distance_km: number | null
|
||||
status?: string
|
||||
}
|
||||
|
||||
/** Fahrleistung pro Tag aus dem tatsächlichen Fahrtenlog, unabhängig vom
|
||||
Servicebuch - Grundlage für meldungsPrognose() unten, die auch ganz ohne
|
||||
Servicebucheintrag funktionieren soll. Nur abgeschlossene Fahrten mit
|
||||
bekannter Distanz zählen; die Rate ist der Durchschnitt seit der ältesten
|
||||
erfassten Fahrt, kein kurzes Zeitfenster - damit bleibt sie robust gegen
|
||||
einzelne fahrtenarme Tage. null ohne belastbare Datenlage. */
|
||||
export function kmProTagAusFahrten(fahrten: readonly Fahrtdaten[], jetzt = new Date()): number | null {
|
||||
const gueltig = fahrten.filter((f) => f.status !== "offen" && f.distance_km != null)
|
||||
if (!gueltig.length) return null
|
||||
const km = gueltig.reduce((s, f) => s + (f.distance_km ?? 0), 0)
|
||||
const aeltesteZeit = Math.min(...gueltig.map((f) => new Date(f.ts_start).getTime()))
|
||||
const tage = (jetzt.getTime() - aeltesteZeit) / TAG_MS
|
||||
return tage >= 1 ? km / tage : null
|
||||
}
|
||||
|
||||
export interface Meldungsprognose {
|
||||
/** ISO-Tagesdatum. */
|
||||
datum: string
|
||||
/** Die vom Fahrzeug gemeldete Zeitgrenze hat gegriffen, nicht die
|
||||
hochgerechnete Kilometergrenze - der Nutzer fährt zu wenig, um die
|
||||
km-Grenze rechtzeitig zu erreichen. */
|
||||
durchZeitlimit: boolean
|
||||
}
|
||||
|
||||
/** Prognose rein aus der Fahrzeugmeldung, für den Fall ganz ohne
|
||||
Servicebucheintrag (siehe Aufrufer unten und in Service.tsx) - ein echter
|
||||
Servicebucheintrag bleibt vorrangig, näher am tatsächlichen letzten
|
||||
Service. Restkilometer aus der Meldung, hochgerechnet mit der echten
|
||||
Fahrleistung aus dem Fahrtenlog statt einer Servicebuch-Rate; gedeckelt
|
||||
auf das vom Fahrzeug selbst gemeldete Datum als Zeitgrenze. */
|
||||
export function meldungsPrognose(
|
||||
faelligTs: string | null,
|
||||
faelligKm: number | null,
|
||||
fahrten: readonly Fahrtdaten[],
|
||||
jetzt = new Date(),
|
||||
): Meldungsprognose | null {
|
||||
const zeitDatum = faelligTs ? faelligTs.slice(0, 10) : null
|
||||
if (faelligKm == null) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true } : null
|
||||
const restKm = Math.abs(faelligKm)
|
||||
if (restKm <= 0) return { datum: jetzt.toISOString().slice(0, 10), durchZeitlimit: false }
|
||||
const rate = kmProTagAusFahrten(fahrten, jetzt)
|
||||
if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true } : null
|
||||
const kmDatum = new Date(jetzt.getTime() + (restKm / rate) * TAG_MS).toISOString().slice(0, 10)
|
||||
const durchZeitlimit = !!zeitDatum && zeitDatum < kmDatum
|
||||
return { datum: durchZeitlimit ? (zeitDatum as string) : kmDatum, durchZeitlimit }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ Nächster Service */
|
||||
|
||||
export type Serviceart = "Ölwechsel" | "Inspektion" | "Hauptuntersuchung"
|
||||
@@ -143,23 +196,40 @@ export interface NaechsterService {
|
||||
* Der zeitlich nächste anstehende Service über alle drei Arten hinweg —
|
||||
* Gegenstück zu `naechsterTermin()` im Panel.
|
||||
*
|
||||
* Ölwechsel und Inspektion kommen aus der eigenen Prognose (Servicebuch plus
|
||||
* tatsächliche Fahrleistung), die Hauptuntersuchung aus dem im Profil
|
||||
* gepflegten Termin: sie hat kein Kilometerziel, das Fahrzeug meldet sie nicht,
|
||||
* und hochrechnen lässt sie sich auch nicht.
|
||||
* Ölwechsel und Inspektion kommen vorrangig aus der eigenen Prognose
|
||||
* (Servicebuch plus tatsächliche Fahrleistung); ohne Servicebucheintrag dazu
|
||||
* fällt jede der beiden einzeln auf meldungsPrognose() zurück - die vom
|
||||
* Fahrzeug selbst gemeldete Fälligkeit, hochgerechnet mit der Fahrleistung
|
||||
* aus dem Fahrtenlog. Ein frisch eingerichtetes Fahrzeug mit zugeordnetem
|
||||
* Sensor, aber noch ganz ohne Servicebuch, zeigte hier vorher "kein Eintrag
|
||||
* im Servicebuch", obwohl die Fälligkeit längst bekannt war. Die
|
||||
* Hauptuntersuchung kommt weiterhin nur aus dem im Profil gepflegten
|
||||
* Termin: sie hat kein Kilometerziel, das Fahrzeug meldet sie nicht, und
|
||||
* hochrechnen lässt sie sich auch nicht.
|
||||
*/
|
||||
export function naechsterService(
|
||||
fahrzeug: Fahrzeug,
|
||||
buch: readonly Buchhaltung[],
|
||||
fahrten: readonly Fahrtdaten[] = [],
|
||||
jetzt = new Date(),
|
||||
): NaechsterService | null {
|
||||
const kandidaten: NaechsterService[] = []
|
||||
|
||||
const oel = oelwechselPrognose(fahrzeug, buch, jetzt)
|
||||
if (oel) kandidaten.push({ art: "Ölwechsel", restKm: oel.restKm, datum: oel.datum })
|
||||
if (oel) {
|
||||
kandidaten.push({ art: "Ölwechsel", restKm: oel.restKm, datum: oel.datum })
|
||||
} else {
|
||||
const m = meldungsPrognose(fahrzeug.oelwechselFaelligTs, fahrzeug.oelwechselFaelligKm, fahrten, jetzt)
|
||||
if (m) kandidaten.push({ art: "Ölwechsel", restKm: fahrzeug.oelwechselFaelligKm, datum: m.datum })
|
||||
}
|
||||
|
||||
const insp = inspektionPrognose(fahrzeug, buch, jetzt)
|
||||
if (insp) kandidaten.push({ art: "Inspektion", restKm: insp.restKm, datum: insp.datum })
|
||||
if (insp) {
|
||||
kandidaten.push({ art: "Inspektion", restKm: insp.restKm, datum: insp.datum })
|
||||
} else {
|
||||
const m = meldungsPrognose(fahrzeug.inspektionFaelligTs, fahrzeug.inspektionFaelligKm, fahrten, jetzt)
|
||||
if (m) kandidaten.push({ art: "Inspektion", restKm: fahrzeug.inspektionFaelligKm, datum: m.datum })
|
||||
}
|
||||
|
||||
// fahrzeug.hu wird von Hand gepflegt und kann leer oder unlesbar sein.
|
||||
const hu = new Date(fahrzeug.hu)
|
||||
|
||||
Reference in New Issue
Block a user