Ö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 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 12:01:01 +02:00
parent 5ff33c92d9
commit fb251154c7
27 changed files with 1268 additions and 82 deletions
@@ -1 +1 @@
{"version":"2026.8.24.1","sha256":"967ab36fc866187cf5fdc03d95a3ec21cb6e42003d8c012f8f67b99c67373324","bytes":230770,"gebaut":"2026-08-24T07:16:44Z"}
{"version":"2026.8.24.4","sha256":"cbce1baa785a3577fc65168b9f8b2094ecd844aadc2ac36f99d6be8ccb4cb8c1","bytes":231567,"gebaut":"2026-08-24T09:39:59Z"}
@@ -1262,6 +1262,42 @@ function servicePrognose(stichwort, intervallKm, intervallMonate) {
return { km: zielKm, restKm, datum };
}
/* Fahrleistung pro Tag aus dem tatsächlichen Fahrtenlog (TRIPS), 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. */
function kmProTagAusFahrten() {
const gueltig = TRIPS.filter((t) => t.status !== "offen" && t.distance_km != null);
if (!gueltig.length) return null;
const km = gueltig.reduce((s, t) => s + t.distance_km, 0);
const aeltesteZeit = Math.min(...gueltig.map((t) => new Date(t.ts_start).getTime()));
const tage = (heute().getTime() - aeltesteZeit) / dTag;
return tage >= 1 ? km / tage : null;
}
/* Prognose rein aus der Fahrzeugmeldung, für den Fall ganz ohne
Servicebucheintrag (siehe Aufrufer in vService()) - 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 - fährt der
Nutzer zu wenig, um die km-Grenze rechtzeitig zu erreichen, ist die
gemeldete Zeitgrenze maßgeblich, nicht die hochgerechnete km-Grenze. */
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;
const restKm = Math.abs(fm.km);
if (restKm <= 0) return { datum: heute(), durchZeitlimit: false };
const rate = kmProTagAusFahrten();
if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true } : null;
const kmDatum = new Date(heute().getTime() + (restKm / rate) * dTag);
const durchZeitlimit = !!(zeitDatum && zeitDatum < kmDatum);
return { datum: durchZeitlimit ? zeitDatum : kmDatum, durchZeitlimit };
}
function oelwechselPrognose() {
const oel = CAR.oel;
return servicePrognose("öl",
@@ -1776,21 +1812,35 @@ function vService() {
const artIcon = ART_ICON[t.art];
const iconMarkup = artIcon ? ciSVG(artIcon, ART_ICON_GROESSE[t.art]) : "";
const kopf = `<div class="tg-name">${iconMarkup}<span>${t.art}</span></div>`;
// Fahrzeugmeldung zuerst prüfen: sie steht für sich, auch ganz ohne
// Servicebucheintrag - siehe fahrzeugMeldungRoh()'s Kommentar. Ein
// frisch eingerichtetes Fahrzeug mit zugeordnetem Sensor und
// "Herstellervorgabe", aber ohne einen einzigen Servicebucheintrag,
// zeigte vorher trotz korrekt gemeldeter Fälligkeit nur "kein Eintrag
// im Servicebuch" - iv war null und der frühe Return griff, bevor fm
// überhaupt gelesen wurde.
const fm = fahrzeugMeldungRoh(t.art);
const fmDatum = fm.ts ? dedat(new Date(fm.ts)) : null;
const hatMeldung = fm.km != null || fmDatum;
const iv = intervalle(t);
if (!iv) return `<div class="termingruppe">
if (!iv && !hatMeldung) return `<div class="termingruppe">
<div class="tg-kopf">${kopf}</div>
<div class="tg-wert leer">kein Eintrag im Servicebuch</div>
</div>`;
const anzeige = iv.hersteller || iv.eigen;
const fm = fahrzeugMeldungRoh(t.art);
const fmDatum = fm.ts ? dedat(new Date(fm.ts)) : null;
const anzeige = iv ? (iv.hersteller || iv.eigen) : null;
// Forecast-Zeile aus dem kundeneigenen Intervall in "Einrichten" mit
// eigenständiger Reset-nach-Service-Logik (servicePrognose()): beim
// Ölwechsel mit Restkilometern, bei der Inspektion auf Wunsch nur mit
// dem Datum. Hauptuntersuchung bleibt bewusst ohne.
// dem Datum. Hauptuntersuchung bleibt bewusst ohne. Beide liefern null
// ohne Servicebucheintrag (servicePrognose() braucht letzterEintrag) -
// dann bleibt nur die Fahrzeugmeldung als Wert übrig, siehe wert unten.
const eigenOel = t.art === "Ölwechsel" ? oelwechselPrognose() : null;
const eigenInsp = t.art === "Inspektion" ? inspektionPrognose() : null;
const hatMeldung = fm.km != null || fmDatum;
// 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;
// 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
@@ -1799,7 +1849,8 @@ function vService() {
? (CAR.oel.modus === "hersteller"
? `voraussichtlich am ${dedat(eigenOel.datum)}`
: `${de(eigenOel.restKm)} km, voraussichtlich am ${dedat(eigenOel.datum)}`)
: eigenInsp ? `voraussichtlich am ${dedat(eigenInsp.datum)}` : "";
: eigenInsp ? `voraussichtlich am ${dedat(eigenInsp.datum)}`
: meldungProg ? `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));
@@ -1808,7 +1859,7 @@ function vService() {
// Intervall gar nicht. Deshalb zaehlt sie unabhaengig von iv.hersteller
// als Herstellervorgabe; nur der App-eigene Eigen-Intervall-Fallback
// (anzeige.eigen/eigenOel bei modus "eigen") ist es nicht.
const herstellervorgabe = hatMeldung || iv.hersteller;
const herstellervorgabe = hatMeldung || (iv && iv.hersteller);
return `<div class="termingruppe">
<div class="tg-kopf">
${kopf}
@@ -2331,11 +2382,37 @@ function vEinst() {
<div class="row"><dt>Fahrzeugdaten</dt><dd>FMM003<small>Zündung · Batterie</small></dd></div>
<div class="row" style="border-bottom:0"><dt>Position</dt><dd>FMM003<small>GPS-Tracker</small></dd></div>
</dl>
<span class="label" style="margin-top:14px">Updates laufen über HACS</span>
<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13.5px;line-height:1.6">
HACS meldet selbst, wenn eine neuere Fassung bereitliegt, und installiert sie.
Fahrzeugprofil, Fahrten, Tankvorgänge und eigene Fotos bleiben dabei unangetastet
die liegen außerhalb des Integrationsordners.</span>
</div>
<div class="tile"><span class="label">Integration-Update</span>
${(() => {
const u = INTEGRATION_UPDATE;
// Ersetzt install.ps1 als laufenden Update-Weg (Windows Smart App
// Control blockiert dessen Ausführung zuverlässig) - install.ps1
// bleibt nur noch für die Erstinstallation nötig, bevor die
// Integration überhaupt läuft und sich selbst aktualisieren kann.
if (u && u.installiert) {
return `<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13.5px;line-height:1.6">
Version ${esc(u.version)} installiert. Home Assistant neu starten, damit die neue
Fassung geladen wird - Einstellungen -> System -> Neu starten.</span>
<button class="aktion" style="margin-top:14px" data-update="pruefen">Erneut prüfen</button>`;
}
if (u && u.verfuegbar) {
return `<dl class="rows"><div class="row" style="border-bottom:0"><dt>Verfügbare Version</dt><dd>${esc(u.version)}</dd></div></dl>
<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13.5px;line-height:1.6">
Lädt die neueste Fassung von Gitea und ersetzt den Integrationsordner. Home Assistant
muss danach neu gestartet werden. Fahrzeugprofil, Fahrten, Tankvorgänge und eigene
Fotos bleiben dabei unangetastet - die liegen außerhalb des Integrationsordners.</span>
<button class="aktion" style="margin-top:14px" data-update="installieren">Update installieren</button>`;
}
return `<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13.5px;line-height:1.6">
${u ? `Integration ist aktuell${u.geprueft_am ? ` (geprüft ${dedat(new Date(u.geprueft_am))})` : ""}.` : "Noch nicht geprüft."}
Ein Gitea-Zugriffstoken muss zuvor unter Einstellungen -> Geräte & Dienste -> Audi
Dashboard -> Konfigurieren eingetragen sein.</span>
<button class="aktion" style="margin-top:14px" data-update="pruefen">Auf Update prüfen</button>`;
})()}
${INTEGRATION_UPDATE && INTEGRATION_UPDATE.fehler
? `<span class="label" style="margin-top:10px;color:var(--red)">${esc(INTEGRATION_UPDATE.fehler)}</span>`
: ""}
</div>`;
}
@@ -3022,18 +3099,25 @@ let entwurfIndex = null;
const SERVICE_ARTEN = ["Ölwechsel", "Inspektion", "Hauptuntersuchung", "Weitere"];
/* Welche Fassung der Integration gerade installiert ist (Einstellungen ->
Version). Kommt aus sensor.audi_dashboard_app_version, also aus der
manifest.json der Integration - der Zahl, die auch HACS anzeigt.
Version). Kommt aus sensor.audi_dashboard_app_version, also direkt aus der
manifest.json der Integration.
Hier stand bis 2026-08-23 eine eigene Update-Funktion: "Update suchen"
klonte ein Git-Repository in einen Zwischenordner und kopierte pyscript/
und www/ von dort über den laufenden Code. Das war nötig, solange die App
keine Installationsform hatte, die Home Assistant selbst kennt. Als
HACS-Integration gibt es die: HACS prüft, lädt, installiert und kann
zurückrollen, mit Versionsanzeige und Änderungsprotokoll. Eine zweite,
selbstgebaute Update-Mechanik daneben wäre mehr Angriffsfläche als
Nutzen - der eigene Updater ist deshalb ersatzlos entfallen. */
keine Installationsform hatte, die Home Assistant selbst kennt - jetzt
gibt es eine: install.ps1 kopiert genau einen Ordner, das Panel darin
inklusive, und ist damit der einzige Installations- wie Update-Weg (HACS
scheidet für dieses private Repository dauerhaft aus, siehe AGENTS.md
Abschnitt H). Eine zweite, selbstgebaute Update-Mechanik daneben wäre
mehr Angriffsfläche als Nutzen - der eigene Updater ist deshalb
ersatzlos entfallen. */
let APP_VERSION = null;
// Ergebnis der letzten "Auf Update prüfen"/"Update installieren"
// (custom_components/audi_dashboard/aktualisierung.py) - null, solange nie
// geprüft wurde. Ersetzt install.ps1 als Update-Weg, das Windows Smart App
// Control zuverlässig blockiert; siehe vVersion()/Einstellungen-Ansicht.
let INTEGRATION_UPDATE = null;
/* Auswahl für "Modell" unter Einrichten - setzt nur CONFIG.fahrzeugtitel
(Zeile "Modell" in der Identität, Kopfzeilen, Kalender-Einträge, und den
@@ -4392,6 +4476,9 @@ function ereignisseVerdrahten() {
a.download = `backup_${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(a); a.click(); a.remove();
return;
}
const upd = e.target.closest("[data-update]");
if (upd && upd.dataset.update === "pruefen") { serviceRufen("update_pruefen", {}); return; }
if (upd && upd.dataset.update === "installieren") { serviceRufen("update_installieren", {}); return; }
const cs = e.target.closest("[data-csv]");
if (cs) {
const w = cs.dataset.csv;
@@ -4755,6 +4842,11 @@ async function datenLaden(erzwingen) {
const entitaetenState = HASS.states[E.entitaeten] || NACHGELADEN.entitaeten;
const versionState = HASS.states[E.appVersion];
if (versionState && versionState.state) APP_VERSION = versionState.state;
if (versionState && versionState.attributes) {
INTEGRATION_UPDATE = versionState.attributes.daten
? versionState.attributes.daten.integration_update ?? null
: null;
}
if (entitaetenState && entitaetenState.attributes) ENTITAETEN = entitaetenState.attributes.daten;
if (belegState && belegState !== letzterBelegState) {