Selbst-Update-Sicherung, Service-Prognose ohne Servicebuch und Sicherheit neu gestaltet

- aktualisierung.py: Backup-Ordner verliert sein manifest.json beim Sichern,
  sonst laedt HA ihn nach einem Neustart als zweite audi_dashboard-Integration
  (Ursache des raetselhaften "gleicher Pfad"-Umbenennungs-Fehlers).
- service.ts/audi-dashboard-app.js: Uebersicht faellt jetzt wie Service schon
  auf die Fahrzeugmeldung zurueck, wenn kein Servicebucheintrag existiert;
  eigenes (kuerzeres) Oelwechsel-Intervall wird jetzt auch ohne Servicebuch-
  eintrag naeherungsweise aus der Herstellermeldung hochgerechnet, statt
  weiterhin unveraendert die Herstellervorgabe zu zeigen.
- Sicherheit neu gestaltet: vier Sammelzeilen (Fahrzeug verriegelt / Tueren
  und Klappen geschlossen / Fenster und Dach geschlossen / Kein Licht) statt
  einer flachen Liste, mit neuen Tuerschloss-, Dach- und Standlicht-Sensor-
  rollen, Kreis-Haekchen-Symbolen und einer Detailseite je Tuer/Motorhaube/
  Kofferraum in beiden Frontends.

Details, Verifikation und Sensor-Rollen in AGENTS.md (Abschnitte J/M/N).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 15:29:37 +02:00
parent 146f809e9b
commit 5a048488ea
23 changed files with 866 additions and 97 deletions
@@ -1288,14 +1288,53 @@ function kmProTagAusFahrten() {
function meldungsPrognose(art) {
const fm = fahrzeugMeldungRoh(art);
const zeitDatum = fm.ts && gueltigesDatum(new Date(fm.ts)) ? new Date(fm.ts) : null;
if (fm.km == null) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true } : null;
if (fm.km == null) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm: null } : null;
const restKm = Math.abs(fm.km);
if (restKm <= 0) return { datum: heute(), durchZeitlimit: false };
if (restKm <= 0) return { datum: heute(), durchZeitlimit: false, restKm };
const rate = kmProTagAusFahrten();
if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true } : null;
if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm } : null;
const kmDatum = new Date(heute().getTime() + (restKm / rate) * dTag);
const durchZeitlimit = !!(zeitDatum && zeitDatum < kmDatum);
return { datum: durchZeitlimit ? zeitDatum : kmDatum, durchZeitlimit };
return { datum: durchZeitlimit ? zeitDatum : kmDatum, durchZeitlimit, restKm };
}
/* Eigenes (kürzeres) Intervall ganz ohne Servicebucheintrag: die
Fahrzeugmeldung kennt nur das Herstellerintervall, nicht die in
"Einrichten" gewählte kürzere Vorgabe. Ein angenommener letzter Service
wird aus der gemeldeten Fälligkeit und der bekannten Herstellerintervall-
Länge zurückgerechnet ("Fälligkeitspunkt minus Herstellerintervall"), und
das eigene Intervall ab demselben angenommenen Punkt neu hochgerechnet -
eine Näherung (der wahre letzte Service kann näher oder ferner liegen),
aber ohne sie würde die eigene Intervallwahl bis zum ersten
Servicebucheintrag komplett ignoriert (siehe oelMeldungsPrognose()). */
function meldungsPrognoseEigenesIntervall(fm, herstellerKm, herstellerMonate, eigenKm, eigenMonate) {
if (fm.km == null || !herstellerKm || eigenKm == null) return null;
const restKm = Math.max(0, Math.abs(fm.km) - herstellerKm + eigenKm);
let zeitDatum = null;
if (fm.ts && gueltigesDatum(new Date(fm.ts)) && herstellerMonate != null && eigenMonate != null) {
zeitDatum = new Date(fm.ts);
zeitDatum.setMonth(zeitDatum.getMonth() + (eigenMonate - herstellerMonate));
}
if (restKm <= 0) return { datum: heute(), durchZeitlimit: false, restKm };
const rate = kmProTagAusFahrten();
if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm } : null;
const kmDatum = new Date(heute().getTime() + (restKm / rate) * dTag);
const durchZeitlimit = !!(zeitDatum && zeitDatum < kmDatum);
return { datum: durchZeitlimit ? zeitDatum : kmDatum, durchZeitlimit, restKm };
}
/* Fällt ganz ohne Servicebucheintrag auf die Fahrzeugmeldung zurück - bei
Herstellervorgabe unverändert (die Meldung IST die Herstellervorgabe), bei
eigenem (kürzerem) Intervall über meldungsPrognoseEigenesIntervall()
hochgerechnet, damit die Intervallwahl in "Einrichten" auch ganz ohne
Servicebucheintrag etwas bewirkt. Fehlen die Herstellerwerte selbst (kein
Sensor dafür), fällt das sanft auf die reine Meldung zurück. */
function oelMeldungsPrognose() {
const oel = CAR.oel;
if (oel.modus === "hersteller") return meldungsPrognose("Ölwechsel");
const fm = fahrzeugMeldungRoh("Ölwechsel");
const eigen = meldungsPrognoseEigenesIntervall(fm, oel.herstellerKm, oel.herstellerMonate, oel.km, oel.monate);
return eigen || meldungsPrognose("Ölwechsel");
}
function oelwechselPrognose() {
@@ -1437,29 +1476,82 @@ function vHome() {
${teaser()}`;
}
/* Kreis-Häkchen (ok) / Kreis-Kreuz (nicht ok) / Kreis-Fragezeichen (unbekannt)
statt eines einfarbigen Punkts - Zustand ist so schon am Symbol selbst
erkennbar, nicht nur an der Farbe. Nur für die Sicherheit-Ansicht (vSicherheit()/
vTuerenKlappen()) gedacht, der übrige Bestand behält den einfachen .dot. */
function statusKreis(ok) {
const klasse = ok === null ? "warn" : ok ? "" : "bad";
const glyph = ok === null
? '<path d="M5.7 6a2.3 2.3 0 1 1 2.7 2.27c-.5.09-.7.5-.7 1.03v.2M8 11.6v.01" stroke="#fff" stroke-width="1.5" stroke-linecap="round" fill="none"/>'
: ok
? '<path d="M3 8.3 6.2 11.5 13 4.5" stroke="#fff" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/>'
: '<path d="M4 4l8 8M12 4l-8 8" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/>';
return `<span class="statuskreis ${klasse}" aria-hidden="true"><svg viewBox="0 0 16 16" width="13" height="13">${glyph}</svg></span>`;
}
/* Gruppiert die flache CAR.sicherheitscheck-Liste (siehe _sicherheitscheck()
in veroeffentlichung.py) nach ihrem "gruppe"-Feld. ok ist null, sobald ein
einziger Punkt der Gruppe unbekannt ist, oder wenn die Gruppe ganz ohne
Einträge dasteht (kein Sensor dieser Rolle im Setup-Menü zugeordnet) -
nie true durch eine leere Liste erschlichen. */
function sicherheitsGruppe(gruppe) {
const eintraege = CAR.sicherheitscheck.filter((e) => e.gruppe === gruppe);
const ok = eintraege.length === 0 || eintraege.some((e) => e.ok === null)
? null
: eintraege.every((e) => e.ok);
return { eintraege, ok };
}
function vSicherheit() {
const eintraege = CAR.sicherheitscheck;
const g = CAR.gesichert;
const offen = eintraege.filter((e) => e.ok === false).length;
const unbekannt = eintraege.filter((e) => e.ok === null).length;
const sammelText = g === null
? `${unbekannt} von ${eintraege.length} Punkten liefern derzeit keinen Wert`
: g ? "Fahrzeug ist sicher abgestellt" : `${offen} Punkt${offen === 1 ? "" : "e"} prüfen`;
/* C6: Draufsicht oben, grüne Sammelmeldung, dann die Prüfliste - dieselbe
Reihenfolge wie der Fahrzeugstatus in der myAudi-App. */
const verriegelt = sicherheitsGruppe("verriegelt");
const tuerenKlappen = sicherheitsGruppe("tueren_klappen");
const fensterDach = sicherheitsGruppe("fenster_dach");
const licht = sicherheitsGruppe("licht");
const sammelText = g === null ? "Zustand nicht vollständig bekannt" : g ? "Fahrzeug ist sicher abgestellt" : "Bitte prüfen";
// Jede Zeile nennt beim Nutzer-Zustand ("ok") genau die Formulierung, die
// der Nutzer vorgegeben hat - beim Licht ist das "Kein Licht", nicht
// "Licht ausgeschaltet" (das beschreibt nur die Prüfung, nicht den Wert).
const zeile = (label, ok, labelBad, labelWarn, route) => {
const text = ok === null ? labelWarn : ok ? label : labelBad;
const klickbar = !!route;
return `<${klickbar ? "button" : "div"} class="row sicherheitszeile"${klickbar ? ` data-go="${route}:x"` : ""}>
${statusKreis(ok)}
<span class="sicherheitszeile-text">${esc(text)}</span>
${klickbar ? '<svg class="chev" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg>' : ""}
</${klickbar ? "button" : "div"}>`;
};
/* C6: Draufsicht oben, grüne Sammelmeldung, dann die vier Sammelzeilen -
dieselbe Reihenfolge wie der Fahrzeugstatus in der myAudi-App. */
return `
<div class="szene bleed"><div class="carbox">${bildMitPlatzhalter(`/local/bilder/draufsicht.webp?v=${bildVersion}`, "draufsicht.webp", "Draufsicht", "carfix klein")}</div></div>
<div class="tile" style="display:flex;align-items:center;gap:12px;padding:16px 20px">
<span class="dot ${g === null ? "warn" : g ? "" : "bad"}"></span>
${statusKreis(g)}
<span style="font-size:15px;color:var(--fg)">${esc(sammelText)}</span>
</div>
<div class="tile"><span class="label">Geprüfte Punkte</span>
<div class="tile sicherheitsliste">
${zeile("Fahrzeug verriegelt", verriegelt.ok, "Fahrzeug nicht verriegelt", "Verriegelung unbekannt")}
${zeile("Türen und Klappen geschlossen", tuerenKlappen.ok, "Türen oder Klappen offen", "Türen/Klappen unbekannt", "tuerenklappen")}
${zeile("Fenster und Dach geschlossen", fensterDach.ok, "Fenster oder Dach offen", "Fenster/Dach unbekannt")}
${zeile("Kein Licht", licht.ok, "Licht eingeschaltet", "Licht unbekannt")}
</div>`;
}
/* Detailansicht zu "Türen und Klappen geschlossen" - jede Tür einzeln,
Motorhaube, Kofferraum. Einzige der vier Sammelzeilen mit eigener
Detailseite (siehe vSicherheit()), im Stil der früheren flachen
"Geprüfte Punkte"-Liste. */
function vTuerenKlappen() {
const eintraege = sicherheitsGruppe("tueren_klappen").eintraege;
return `
<div class="tile"><span class="label">Türen und Klappen</span>
<dl class="rows" style="margin-top:10px">
${eintraege.length ? eintraege.map((e, i, arr) => `<div class="row" ${i === arr.length - 1 ? 'style="border-bottom:0"' : ""}>
<dt>${esc(e.label)}</dt>
<dd style="display:flex;align-items:center;justify-content:flex-end;gap:8px">
<span class="dot ${e.ok === null ? "warn" : e.ok ? "" : "bad"}"></span>
${e.ok === null ? "unbekannt" : e.ok ? "in Ordnung" : "prüfen"}
${e.ok === null ? "unbekannt" : e.ok ? "zu" : "offen"}
</dd>
</div>`).join("") : `<div class="row" style="border-bottom:0"><dt style="color:var(--fg3)">Keine Daten verfügbar</dt></div>`}
</dl>
@@ -1837,10 +1929,15 @@ function vService() {
const eigenOel = t.art === "Ölwechsel" ? oelwechselPrognose() : null;
const eigenInsp = t.art === "Inspektion" ? inspektionPrognose() : null;
// Ganz ohne Servicebucheintrag (eigenOel/eigenInsp beide null) übernimmt
// die Fahrzeugmeldung selbst die Rolle der Prognosequelle, hochgerechnet
// mit der echten Fahrleistung aus dem Fahrtenlog statt einer
// Servicebuch-Rate - siehe meldungsPrognose().
const meldungProg = hatMeldung && !eigenOel && !eigenInsp ? meldungsPrognose(t.art) : null;
// die Fahrzeugmeldung selbst die Rolle der Prognosequelle - bei
// Herstellervorgabe unverändert, beim Ölwechsel mit eigenem (kürzerem)
// Intervall über oelMeldungsPrognose() auf dieses umgerechnet, statt es
// bis zum ersten Servicebucheintrag zu ignorieren (siehe deren
// Kommentar). Hochgerechnet wird dabei mit der echten Fahrleistung aus
// dem Fahrtenlog statt einer Servicebuch-Rate.
const meldungProg = hatMeldung && !eigenOel && !eigenInsp
? (t.art === "Ölwechsel" ? oelMeldungsPrognose() : meldungsPrognose(t.art))
: null;
// Bei Herstellervorgabe rechnet die eigene Prognose ohnehin schon mit dem
// Herstellerintervall - die Restkilometerzahl daneben waere nur eine
// zweite, verwirrende km-Angabe neben dem Fahrzeug-Wert oben. Nur beim
@@ -1850,7 +1947,11 @@ function vService() {
? `voraussichtlich am ${dedat(eigenOel.datum)}`
: `${de(eigenOel.restKm)} km, voraussichtlich am ${dedat(eigenOel.datum)}`)
: eigenInsp ? `voraussichtlich am ${dedat(eigenInsp.datum)}`
: meldungProg ? `voraussichtlich am ${dedat(meldungProg.datum)}` : "";
: meldungProg
? (t.art === "Ölwechsel" && CAR.oel.modus !== "hersteller" && meldungProg.restKm != null
? `${de(meldungProg.restKm)} km, voraussichtlich am ${dedat(meldungProg.datum)}`
: `voraussichtlich am ${dedat(meldungProg.datum)}`)
: "";
const wert = hatMeldung
? `${fm.km != null ? de(fm.km) + " km" : ""}${fm.km != null && fmDatum ? " / " : ""}${fmDatum || ""}`
: (eigenOel ? de(eigenOel.restKm) + " km · " + dedat(eigenOel.datum) : (anzeige.km !== null ? de(anzeige.km) + " km · " : "") + dedat(anzeige.datum));
@@ -1961,9 +2062,26 @@ function termine() {
oelTermin.zuerst = oelTermin.datum && oelProg.datum >= oelTermin.datum ? "zeit" : "km";
oelTermin.ziel = oelTermin.zuerst === "km" ? oelProg.datum : oelTermin.datum;
}
const inspTermin = bauen("Inspektion", letzterEintrag("inspektion"), 30000, 24);
// Ganz ohne Servicebucheintrag (kein basis) übernimmt die Fahrzeugmeldung
// selbst die Rolle der Prognosequelle - genau wie vService()'s eigenes
// meldungProg das schon tut (siehe dessen Kommentar dort). Ohne diesen
// Fallback hier zeigte "Übersicht" trotz korrekt gemeldeter Fälligkeit nur
// "kein Eintrag im Servicebuch", während "Service" dieselbe Meldung
// längst richtig anzeigte - zwei Ansichten, ein Datenstand, zwei
// widersprüchliche Aussagen.
[oelTermin, inspTermin].forEach((termin) => {
if (termin.basis) return;
const m = termin.art === "Ölwechsel" ? oelMeldungsPrognose() : meldungsPrognose(termin.art);
if (!m) return;
termin.km = m.restKm != null && CAR.odoBekannt ? CAR.odo + m.restKm : null;
termin.kmDatum = m.datum;
termin.zuerst = "km";
termin.ziel = m.datum;
});
return [
oelTermin,
bauen("Inspektion", letzterEintrag("inspektion"), 30000, 24),
inspTermin,
bauen("Hauptuntersuchung", letzterEintrag("hauptuntersuchung"), null, 24,
CAR.erstzulassung && CAR.erstzulassung.includes("/") ? `01.${CAR.erstzulassung.replace("/", ".")}` : null),
];
@@ -3404,6 +3522,7 @@ const ZURUECK = {
Zurück-Pfeil. */
sicherheit: "home",
standort: "home",
tuerenklappen: "sicherheit",
};
const ICON = {
sonne: '<circle cx="10" cy="10" r="4"/><path d="M10 1v2M10 17v2M1 10h2M17 10h2M3.9 3.9l1.4 1.4M14.7 14.7l1.4 1.4M16.1 3.9l-1.4 1.4M5.3 14.7l-1.4 1.4"/>',
@@ -3748,6 +3867,7 @@ function render() {
else if (route.name === "einst") { head = ["App", "Einstellungen"]; v.innerHTML = vEinst(); }
else if (route.name === "ident") { head = ["Fahrzeug", "Identität und Technik"]; v.innerHTML = vIdent(); }
else if (route.name === "sicherheit") { head = ["Fahrzeug", "Sicherheit"]; v.innerHTML = vSicherheit(); }
else if (route.name === "tuerenklappen") { head = ["Fahrzeug", "Türen und Klappen"]; v.innerHTML = vTuerenKlappen(); }
else if (route.name === "vers") { head = ["Fahrzeug", "Versicherung/Steuer"]; v.innerHTML = vVers(); }
else if (route.name === "beitrag") { head = ["Versicherung", "Beitrag anpassen"]; v.innerHTML = vBeitrag(); }
else if (route.name === "vertragsdetails") { head = ["Versicherung", "Vertragsdetails"]; v.innerHTML = vVertragsdetails(); }