Neustart-Festhaenger, OTA-Pruefsumme, Statistik-Kopfzahl (2026.8.31.4)

Die App blieb nach einem Neustart von Home Assistant auf dem alten Stand
stehen, mit einer 502-Fehlerzeile, die erst ein Beenden der App wegbekam.
Zwei Ursachen: 502/503/504 galten als harter Ladefehler statt als "Server
faehrt gerade hoch", und nach dem Wiederverbinden wurde genau einmal
nachgeladen - schlug das fehl, kam nie wieder ein Versuch. Beides behoben,
vier Regressionstests dazu.

OTA: das Buendel ist von HEAD ueber die Platte bis zu den ausgelieferten
Bytes nachweislich deckungsgleich. Die Fassung haengt jetzt trotzdem als
?v= an der Adresse (der statische Pfad kommt ohne Cache-Control, aber mit
ETag), und ein Pruefsummenfehler wird erklaert statt durchgereicht.

Statistik: der Gesamtwert steht jetzt als grosse Zahl in der Kopfzeile,
darunter bleiben vier Spalten - fuenf passen auf dem Telefon nachweislich
nicht in eine Zeile.

Fahrzeugfoto ohne Beschnitt und ohne Bodenschatten, damit auch ohne den
Schalter dafuer. Setup filtert zusaetzlich nach dem Signalnamen aus dem
Katalog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 15:35:56 +02:00
parent 4af38d7e65
commit 90efcc5b00
19 changed files with 565 additions and 161 deletions
@@ -1 +1 @@
{"version":"2026.8.31.3","sha256":"c0b6aa9b9740fa9c9390a6d200a30aeb8430937a22129e7d61fa88e5452f9283","bytes":266044,"gebaut":"2026-08-31T13:02:37Z"}
{"version":"2026.8.31.4","sha256":"48d4965f198c06dbcad882c76eca656de6cc8c18a17c7ff24a39c5c9a1e8c5a9","bytes":266032,"gebaut":"2026-08-31T13:30:52Z"}
@@ -368,12 +368,12 @@ function markenlogo() {
<span class="ph">${esc(label)}</span>
</span>`;
}
const OHNE_SZENE = ["Cockpit", "Sitze", "Scheinwerfer"];
function szeneKlasse(i) {
if (!bodenAn) return "";
if (OHNE_SZENE.includes(BILDER[i].name)) return "";
return CAR.reifen.aktiv === "Winter" ? "winter" : "strasse";
}
/* Der angedeutete Boden unter dem Fahrzeug ist am 2026-08-31 entfallen -
der Eigentuemer wollte den Schatten weg, und damit hatte auch der
Schalter dafuer nichts mehr zu schalten. Frueher stand hier
szeneKlasse(), das je nach Bild und Reifensatz "strasse" oder "winter"
an .szene haengte; die zugehoerigen ::after-Regeln sind in
audi-dashboard.css beschrieben. */
function bildWeiter() {
bild = (bild + 1) % BILDER.length;
const img = ROOT.getElementById("carimg");
@@ -387,7 +387,7 @@ function bildWeiter() {
box.querySelector(".platzhalter-label").textContent = info.label;
}
const sz = ROOT.getElementById("szene");
if (sz) sz.className = "szene " + szeneKlasse(bild);
if (sz) sz.className = "szene";
[...ROOT.getElementById("dots").children].forEach((d, i) => d.classList.toggle("on", i === bild));
}
@@ -1654,7 +1654,7 @@ function vHome() {
zwei kurze, haeufig gelesene Textzeilen lagen bisher unter einer 190px
hohen Karte und damit auf dem Telefon unterhalb des ersten Bildschirms. */
return `
<div class="szene bleed ${szeneKlasse(startIndex())}"><div class="carbox">${carImg("fest")}</div></div>
<div class="szene bleed"><div class="carbox">${carImg("fest")}</div></div>
<div class="tile flat" style="margin-bottom:20px">
${badge()}
<span class="label" style="margin-top:10px">${esc(CONFIG.kennzeichen)}</span>
@@ -1815,7 +1815,7 @@ function vTuerenKlappen() {
function vAudi() {
const r = CAR.reifen, a = r[r.aktiv.toLowerCase()];
return `
<div class="szene ${szeneKlasse(bild)}" id="szene"><div class="carbox">${carImg("galerie")}</div></div>
<div class="szene" id="szene"><div class="carbox">${carImg("galerie")}</div></div>
<div class="tile flat" style="margin:14px 0 16px">
${badge()}
<span class="label" style="margin-top:12px">${esc(CONFIG.kennzeichen)}</span>
@@ -2793,7 +2793,6 @@ const SCHRIFT_KEY = "audi-dashboard-tableiste-schrift";
let beschriftung = (() => {
try { return localStorage.getItem(SCHRIFT_KEY) !== "aus"; } catch (e) { return true; }
})();
let bodenAn = true;
function csvDatei(name, kopf, zeilen) {
const txt = [kopf.join(";")].concat(zeilen.map((r) => r.join(";"))).join("\r\n");
const a = document.createElement("a");
@@ -2858,10 +2857,6 @@ function vEinst() {
<label for="schrift">Beschriftung in der Menüleiste</label>
<label class="switch"><input id="schrift" type="checkbox" data-schrift ${beschriftung ? "checked" : ""}><span class="track"></span></label></div>
<span class="label" style="margin-top:14px">Aus zeigt nur die Symbole und gibt etwas Höhe frei</span>
<div class="feld" style="margin-top:16px;border-bottom:0">
<label for="boden">Fahrbahn unter dem Fahrzeug</label>
<label class="switch"><input id="boden" type="checkbox" data-boden ${bodenAn ? "checked" : ""}><span class="track"></span></label></div>
<span class="label" style="margin-top:14px">Angedeutete Fahrbahn mit Schatten, nur bei Außenansichten</span>
</div>
<div class="tile"><span class="label">Bild der Übersicht</span>
<div class="feld" style="border-bottom:0"><label for="startbild">Ansicht</label>
@@ -3040,6 +3035,10 @@ function entitaetScore(feld, e, suchtext) {
if (feld.domains && feld.domains.includes(e.domain)) score += 3;
if (feld.device_classes && feld.device_classes.length && feld.device_classes.includes(e.deviceClass)) score += 3;
if (feld.units && feld.units.length && feld.units.includes(e.unit)) score += 2;
// Der Signalname aus dem Katalog ("z. B. can_fuel_volume") ist der
// treffsicherste Hinweis, den es gibt - deutlich mehr wert als ein
// einzelnes Stichwort.
if (feld.beispiel && hay.includes(feld.beispiel.toLowerCase())) score += 8;
if (suchtext && hay.includes(suchtext.trim().toLowerCase())) score += 10;
return score;
}
@@ -3048,7 +3047,7 @@ function entitaetKandidaten(feldKey, suchtext, aktuellerWert) {
const feld = setupKatalog().find((f) => f.key === feldKey);
if (!feld) return [];
const text = (suchtext || "").trim().toLowerCase();
const liste = ALLE_ENTITAETEN.filter((e) => {
let liste = ALLE_ENTITAETEN.filter((e) => {
if (e.id === aktuellerWert) return true;
if (setupNurPassend) {
if (feld.domains && feld.domains.length && !feld.domains.includes(e.domain)) return false;
@@ -3065,6 +3064,27 @@ function entitaetKandidaten(feldKey, suchtext, aktuellerWert) {
if (text && !(e.id.toLowerCase().includes(text) || e.name.toLowerCase().includes(text))) return false;
return true;
});
/* "Nur passende Sensoren" sucht zusaetzlich nach dem Namen, der neben der
Ueberschrift als "(z. B. ...)" steht - dem tatsaechlichen Signalnamen der
Integration, nicht einem ausgedachten Beispiel (siehe FELDER in
einstellungen.py). Domain und Einheit allein lassen bei einem Fahrzeug
mit siebzig Sensoren immer noch Dutzende uebrig; der Signalname trifft
genau einen.
Mit Rueckfall: findet der Name nichts, bleibt die ungefilterte Liste
stehen. Eine leere Auswahl waere schlimmer als eine lange - der Beispiel-
name stammt aus dieser einen Installation, eine andere kann ihre Signale
anders nennen. */
if (setupNurPassend && feld.beispiel) {
const beispiel = feld.beispiel.toLowerCase();
const genau = liste.filter(
(e) =>
e.id === aktuellerWert ||
e.id.toLowerCase().includes(beispiel) ||
e.name.toLowerCase().includes(beispiel.replace(/_/g, " ")),
);
if (genau.length) liste = genau;
}
liste.sort((a, b) => entitaetScore(feld, b, suchtext) - entitaetScore(feld, a, suchtext));
return liste.slice(0, 60);
}
@@ -3647,6 +3667,13 @@ function vStat() {
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;
// Die grosse Zahl oben in den beiden Kacheln zeigt seit dem 2026-08-31 den
// Gesamtwert, nicht mehr den des laufenden Jahres (Ansage des Eigentuemers).
// Damit steht "Absolut" dort, wo man es zuerst liest, und die Spalten
// darunter bleiben die vier Zeitraeume - fuenf passten auf dem Telefon
// nachweislich nicht in eine Zeile.
const kmAbs = sum(fAbs, (t) => t.distance_km), hAbs = sum(fAbs, (t) => t.duration_s) / 3600;
const lAbs = sum(tAbs, (f) => f.liters), eAbs = sum(tAbs, (f) => f.fuel_total_eur || 0);
const tJahr = tankSeit("jahr");
const lJahr = sum(tJahr, (f) => f.liters), eJahr = sum(tJahr, (f) => f.fuel_total_eur || 0);
const tagKm = sum(fJahr.filter((t) => !istNachtZeit(t.ts_start)), (t) => t.distance_km);
@@ -3655,12 +3682,12 @@ function vStat() {
const arbeitKm = sum(arbeitFahrten, (t) => t.distance_km), privatKm = kmJahr - arbeitKm;
const lv = langzeitverbrauch();
return `
${statTile("s1", `Distanz und Fahrzeit ${jahr}`, de(kmJahr), "km", de(hJahr) + " h",
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 [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) + " €",
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"], [de(sum(tAbs, (f) => f.fuel_total_eur || 0)), "Absolut"]]))}
${statTile("s1", "Distanz und Fahrzeit", de(kmAbs), "km", de(hAbs) + " h",
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"]]) +
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"]]))}
${statTile("s2", "Getankte Menge und Kosten", de(lAbs), "l", de(eAbs) + " €",
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"]]) +
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"]]))}
${statTile("s3", "Langzeitverbrauch", lv.wert, "l/100 km", lv.bis ? "bis " + dedat(lv.bis) : "",
`<dl class="rows">
<div class="row"><dt>Getankt insgesamt</dt><dd>${de(lv.liter, 1)} l</dd></div>
@@ -3668,7 +3695,7 @@ function vStat() {
<div class="row" style="border-bottom:0"><dt>Letzter Tankstop</dt><dd>${lv.bis ? dedat(lv.bis) : ""}</dd></div>
</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>` : ""}
${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"]])}
${quad("Näherung je Fahrt, je Zeitraum [l/100 km]", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"]])}
<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",
`<div class="bar"><i style="flex:${tagKm};background:var(--fg)"></i><i style="flex:${nachtKm};background:var(--fg3)"></i></div>
@@ -5773,7 +5800,6 @@ function ereignisseVerdrahten() {
}
if (e.target.dataset.oelkm !== undefined) { CAR.oel.km = parseInt(e.target.value, 10); profilSpeichern(); return; }
if (e.target.dataset.oelm !== undefined) { CAR.oel.monate = parseInt(e.target.value, 10); profilSpeichern(); return; }
if (e.target.dataset.boden !== undefined) { bodenAn = e.target.checked; render(); return; }
if (e.target.dataset.setupNurpassend !== undefined) { setupNurPassend = e.target.checked; render(); return; }
if (e.target.dataset.startbild !== undefined) { CONFIG.startbild = e.target.value; profilSpeichern(); render(); return; }
if (e.target.dataset.svsel !== undefined) { svAuswahl = parseInt(e.target.value, 10); render(); return; }
@@ -349,7 +349,9 @@ main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smo
einer 132px hohen, overflow:hidden-Box (leerer Zustand), also sichtbar
abgeschnitten. Bild und Platzhalter fuellen die Box ohnehin per 100%. */
.bildbox.carfix{height:260px}
.bildbox.carfix.klein{height:220px}
/* Feste 220px hier wuerden das Foto wieder beschneiden - die Hoehe
bestimmt seit dem 2026-08-31 das Bild selbst (siehe audi-dashboard.css). */
.bildbox.carfix.klein{height:auto}
.bildgrid{grid-template-columns:repeat(4,1fr)}
.quad{gap:14px}
@@ -596,30 +596,27 @@ button.leaf:active { background: var(--tile-2); }
blendet nach unten in die Fläche aus. */
.szene.bleed { margin: -10px -20px 0; padding: 0 0 10px; }
.szene.bleed .carbox { padding: 0 8px; }
.szene.bleed::before {
content: "";
position: absolute; left: 0; right: 0; bottom: 0;
/* 26/96 statt 42/78: der Verlauf soll die harte Bildkante ausblenden,
nicht das Fahrzeug. Mit 42% Hoehe und Vollton ab 78% war das untere
Drittel des Fotos praktisch einfarbig - Raeder und Schweller lagen
darunter (gemeldet vom Eigentuemer, 2026-08-31). */
height: 26%;
background: linear-gradient(to bottom, transparent, var(--canvas) 96%);
pointer-events: none;
z-index: 1;
}
.szene.strasse::after, .szene.winter::after {
content: "";
position: absolute;
left: 8%; right: 8%; bottom: 6px;
height: 26px;
border-radius: 50%;
background: radial-gradient(ellipse at center, var(--shade), transparent 70%);
}
.szene.winter::after { background: radial-gradient(ellipse at center, rgba(150,190,255,.16), transparent 70%); }
/* Der Ausblend-Verlauf und der Bodenschatten sind am 2026-08-31 entfallen.
Beide lagen ueber dem unteren Teil des Fotos, und der Eigentuemer hat
zweimal darum gebeten, das Fahrzeug unverdeckt zu sehen. Zurueckholen
waere ein linear-gradient auf .szene.bleed::before bzw. ein
radial-gradient auf .szene.strasse::after - beides steht in der
Versionsgeschichte. */
.carbox { position: relative; }
.carfix { width: 100%; aspect-ratio: 16 / 9; }
.carfix.klein { aspect-ratio: 16 / 8; }
/* Das Fahrzeugfoto wird nicht mehr beschnitten.
Gemessen am echten Foto (2026-08-31): es ist 2000x1125 gross, also 16:9.
Der Kasten stand auf 16:8 - bei object-fit:cover wurde das Bild auf die
Kastenbreite skaliert und oben wie unten je ein Viertel abgeschnitten
(796x220 Kasten, 796x448 skaliertes Bild). Das Dach lag darin.
Statt das Seitenverhaeltnis auf 16:9 festzuschreiben - was beim naechsten
Foto in einem anderen Format wieder schneiden wuerde - bestimmt jetzt das
Bild selbst die Hoehe. Der Platzhalter behaelt sein festes Verhaeltnis,
er hat ja keines von sich aus. */
.bildbox.carfix.klein { aspect-ratio: auto; height: auto; }
img.carfix.klein { aspect-ratio: auto; height: auto; }
/* Reifenfoto ist oft eine breite Aufnahme; statt beide Ränder symmetrisch
wegzuschneiden (object-fit:cover-Standard), bleibt bewusst der rechte Rand
des Originalfotos erhalten - dort sitzt das Rad meist im Bild. */
@@ -890,7 +887,23 @@ button.leaf:active { background: var(--tile-2); }
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); }
/* Vier feste Spalten - Jahr, Monat, Woche, Tag muessen in EINE Zeile passen.
Vorher stand hier repeat(auto-fit, minmax(84px, 1fr)). Auf dem Telefon ist
die Kachel innen 307px breit, damit passten bei 12px Abstand nur drei
Spalten (3 x 84 + 2 x 12 = 276; vier waeren 372) - die vierte rutschte in
eine zweite Zeile.
Mit vier festen Spalten bleiben 67,75px je Spalte. Der laengste realistisch
vorkommende Wert ist ein sechsstelliger Kilometerstand wie "209.177": am
laufenden Panel gemessen 67px in Audi Type Wide 300 bei 16px. Es passt
also, aber ohne Reserve - eine fuenfte Spalte gaebe es nur mit kleinerer
Schrift, und genau deshalb steht der Gesamtwert jetzt in der Kopfzeile der
Kachel statt in einer eigenen Spalte.
Bewusst kein white-space:nowrap: waere ein Wert doch einmal breiter,
umbricht er innerhalb seiner Spalte, statt aus der Kachel zu laufen. */
.quad { display: grid; grid-template-columns: repeat(4, minmax(0, 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 .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; }
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.8.31.3",
"version": "2026.8.31.4",
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
"codeowners": ["@paul"],