Ö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:
2026-08-24 12:01:01 +02:00
co-authored by Claude Sonnet 5
parent 5ff33c92d9
commit fb251154c7
27 changed files with 1268 additions and 82 deletions
+9
View File
@@ -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,
+110 -4
View File
@@ -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()
})
})
+76 -6
View File
@@ -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)