Bilder: der Cache-Brecher haengt am Foto statt an der Startzeit

Beide Oberflaechen haengten Date.now() an jede Bildadresse - richtig
gegen ein veraltetes Foto (31 Tage Cache-Vorgabe unter /local/), aber die
Adresse aendert sich damit bei JEDEM Start, der Zwischenspeicher greift
zwischen zwei Starts also nie. Mit dem Vorladen waeren das 5,3 MB je
Start gewesen.

bilder.staende() liefert jetzt den Zeitstempel je Datei, veroeffentlicht
an sensor.audi_dashboard_app_version; eine Bildadresse aendert sich damit
genau dann, wenn das Foto ein anderes ist. Ein fehlendes Foto bekommt 0
(dann loest sich auch ein gespeicherter 404 von selbst auf), ein Backend
ohne Staende faellt auf das bisherige Verhalten zurueck, und Upload wie
Loeschen veroeffentlichen sofort neu.

Die App merkt sich den letzten Stand im Browserspeicher: sie malt ihren
ersten Bildschirm, bevor die Versionsangabe eintrifft - ohne das
Gedaechtnis trug genau dieser Durchlauf noch die Startzeit und holte das
Uebersichtsfoto doch wieder bei jedem Start.

Live gemessen: Neuladen ohne Aenderung 5.328.771 Bytes Inhalt bei 0 Bytes
uebertragen; ein per touch geaendertes Foto wird neu geholt (513.403
Bytes), die anderen nicht; App-Start ohne eine einzige Adresse mit
Startzeit.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-04 13:29:45 +02:00
co-authored by Claude Opus 5
parent d6f7993504
commit 68732aa10f
12 changed files with 324 additions and 26 deletions
@@ -46,6 +46,43 @@ ERLAUBTE_DATEINAMEN = frozenset({
})
def _staende(ordner: str) -> dict[str, int]:
"""Zeitstempel jedes vorhandenen Fotos, als Sekunden.
Er dient beiden Oberflaechen als Anhaengsel an der Bildadresse
(`?v=`). Home Assistant liefert /local/ mit 31 Tagen Cache-Vorgabe;
ohne wechselndes Anhaengsel zeigt eine Oberflaeche deshalb einen
Monat lang das Foto, das sie beim ersten Mal geholt hat.
WARUM DER ZEITSTEMPEL UND NICHT DIE STARTZEIT DER APP: bis zum
04.09.2026 haengte jede Oberflaeche `Date.now()` an - eine Adresse,
die sich bei JEDEM Start aendert. Der Zwischenspeicher des Browsers
griff damit zwischen zwei Starts nie, jeder Start holte alle Fotos
erneut (gemessen: 5,3 MB). Der Zeitstempel aendert sich dagegen
genau dann, wenn das Foto wirklich ein anderes ist - dann und nur
dann wird neu geholt.
Ein Foto, das es nicht gibt, fehlt hier: die Oberflaechen setzen
dafuer 0 ein, und sobald es hochgeladen wird, aendert sich die
Adresse von selbst - auch ein zwischengespeicherter 404 loest sich
damit auf.
"""
ergebnis: dict[str, int] = {}
for name in ERLAUBTE_DATEINAMEN:
try:
ergebnis[name] = int(os.path.getmtime(os.path.join(ordner, name)))
except OSError:
continue
return ergebnis
async def staende(hass: HomeAssistant, ordner: str) -> dict[str, int]:
# Elf Dateizugriffe sind billig, gehoeren aber trotzdem nicht in die
# Ereignisschleife - Home Assistant meldet blockierende Aufrufe dort
# seit 2026.8 als Fehler.
return await hass.async_add_executor_job(_staende, ordner)
def _pfad(ordner: str, dateiname: str | None) -> str | None:
if not dateiname or dateiname not in ERLAUBTE_DATEINAMEN:
return None
+16 -4
View File
@@ -407,13 +407,14 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None:
D_TANKVORGANG_LOESCHEN: _als_dienst(
lambda a: belege.tankvorgang_loeschen(k, a.data["tank_id"])
),
# Nach dem Schreiben sofort neu veroeffentlichen: der Bildstand
# haengt an der Datei, und ohne diese Zeile saehe die Oberflaeche
# das neue Foto erst beim naechsten Minutentakt.
D_BILD_HOCHLADEN: _als_dienst(
lambda a: bilder.hochladen(
hass, bilder_ordner, a.data["dateiname"], a.data["daten_base64"]
)
lambda a: _bild_hochladen(k, hass, bilder_ordner, a)
),
D_BILD_LOESCHEN: _als_dienst(
lambda a: bilder.loeschen(hass, bilder_ordner, a.data["dateiname"])
lambda a: _bild_loeschen(k, hass, bilder_ordner, a)
),
D_REIFEN_WECHSELN: _als_dienst(lambda a: reifen.wechseln(k, a.data["satz"])),
D_REIFEN_KM_SETZEN: _als_dienst(
@@ -444,3 +445,14 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None:
def abmelden(hass: HomeAssistant) -> None:
for name in SCHEMATA:
hass.services.async_remove(DOMAIN, name)
async def _bild_hochladen(k, hass, ordner, aufruf) -> None:
await bilder.hochladen(
hass, ordner, aufruf.data["dateiname"], aufruf.data["daten_base64"]
)
await k.app_version_veroeffentlichen()
async def _bild_loeschen(k, hass, ordner, aufruf) -> None:
await bilder.loeschen(hass, ordner, aufruf.data["dateiname"])
await k.app_version_veroeffentlichen()
@@ -1 +1 @@
{"version":"2026.9.4.12","sha256":"bb66727726eac2e6be98dc1132bd3b532c8c6b9525f9885020a65d99d2d7fc55","bytes":321283,"gebaut":"2026-09-04T11:05:31Z"}
{"version":"2026.9.4.14","sha256":"38298c65ce8557c805efd30f56e6978ea60857321ae008efd647cddd7b1b50d5","bytes":321436,"gebaut":"2026-09-04T11:28:44Z"}
@@ -365,11 +365,33 @@ const BILDER = [
const GALERIE = BILDER.filter((b) => !b.nurStatus);
let bild = 0;
const SEITE_WINTER = { label: "Auto Seite mit Winterrädern", datei: "seitenansicht-winter.webp" };
// Bei jedem Hochladen/Löschen eines Fahrzeugfotos hochgezählt und als
// Query-Parameter an alle bilder/-URLs gehängt (siehe bildInfo()
// unten) - ohne das würde der Browser nach einem Upload weiter das alte Bild
// oder den zwischenzeitlich gecachten 404 unter derselben URL zeigen.
/* Der Stand eines Fahrzeugfotos, als Anhaengsel an seiner Adresse.
Home Assistant liefert /local/ mit 31 Tagen Cache-Vorgabe aus; ohne
wechselndes Anhaengsel zeigt der Browser nach einem Upload weiter das alte
Bild oder den zwischenzeitlich gespeicherten 404.
Bis zum 04.09.2026 stand hier `Date.now()` - eine Adresse, die sich bei
JEDEM Start aendert. Der Zwischenspeicher griff damit zwischen zwei Starts
nie, und mit dem Vorladen aller Fotos waeren das 5,3 MB je Start gewesen.
Jetzt kommt der Zeitstempel jeder Datei vom Backend (bilder.staende(),
veroeffentlicht an sensor.audi_dashboard_app_version): er aendert sich
genau dann, wenn das Foto wirklich ein anderes ist.
bildVersion bleibt als Rueckfall fuer ein Backend, das die Staende noch
nicht mitliefert - dann ist das Verhalten wie zuvor. */
let BILD_STAENDE = null;
let bildVersion = Date.now();
function bildStand(datei) {
if (!BILD_STAENDE) return bildVersion;
// 0 heisst: dieses Foto gibt es nicht. Sobald es hochgeladen wird,
// aendert sich die Adresse von selbst - auch ein gespeicherter 404
// loest sich damit auf.
return BILD_STAENDE[datei] ?? 0;
}
function bildAdresse(datei) {
return `/local/bilder/${datei}?v=${bildStand(datei)}`;
}
/* Platzhalter statt kaputtem Bild-Symbol: bei Ladefehler (Bilder aus §7a
noch nicht vorhanden) wird die Bildfläche durch ein generisches Symbol
@@ -499,9 +521,9 @@ async function bildVerkleinern(datei) {
function bildInfo(i) {
const b = BILDER[i];
if (b.name === "Seitenansicht" && CAR.reifen.aktiv === "Winter") {
return { label: SEITE_WINTER.label, datei: SEITE_WINTER.datei, src: `/local/bilder/${SEITE_WINTER.datei}?v=${bildVersion}` };
return { label: SEITE_WINTER.label, datei: SEITE_WINTER.datei, src: bildAdresse(SEITE_WINTER.datei) };
}
return { label: b.label, datei: b.datei, src: `/local/bilder/${b.datei}?v=${bildVersion}` };
return { label: b.label, datei: b.datei, src: bildAdresse(b.datei) };
}
/* Alle Fahrzeugfotos einmal vorab holen UND dekodieren.
@@ -516,12 +538,17 @@ function bildInfo(i) {
konkurriert, und nur einmal je Bildstand. */
let bilderVorgeladen = null;
function bilderVorladen() {
if (bilderVorgeladen === bildVersion) return;
bilderVorgeladen = bildVersion;
const dateien = [...BILDER.map((b) => b.datei), SEITE_WINTER.datei];
// Kennung ueber alle Staende: solange sich kein Foto geaendert hat,
// laeuft das Vorladen kein zweites Mal - und der Browser holt die Bilder
// beim naechsten Start aus seinem eigenen Zwischenspeicher.
const kennung = dateien.map(bildStand).join(",");
if (bilderVorgeladen === kennung) return;
bilderVorgeladen = kennung;
const starten = () => {
for (const datei of [...BILDER.map((b) => b.datei), SEITE_WINTER.datei]) {
for (const datei of dateien) {
const img = new Image();
img.src = `/local/bilder/${datei}?v=${bildVersion}`;
img.src = bildAdresse(datei);
// Ein nicht hochgeladenes Foto ist der Normalfall - still schlucken.
if (img.decode) img.decode().catch(() => {});
}
@@ -2181,7 +2208,7 @@ function vSicherheit() {
/* C6: Draufsicht oben, grüne Sammelmeldung, dann die vier Sammelzeilen -
dieselbe Reihenfolge wie der Fahrzeugstatus in der myAudi-App. */
return `
<div class="szene bleed schmal"><div class="carbox">${bildMitPlatzhalter(`/local/bilder/draufsicht.webp?v=${bildVersion}`, "draufsicht.webp", "Draufsicht", "carfix klein")}</div></div>
<div class="szene bleed schmal"><div class="carbox">${bildMitPlatzhalter(bildAdresse("draufsicht.webp"), "draufsicht.webp", "Draufsicht", "carfix klein")}</div></div>
<div class="tile" style="display:flex;align-items:center;gap:12px;padding:16px 20px">
${statusKreis(g)}
<span style="font-size:15px;color:var(--fg)">${esc(sammelText)}</span>
@@ -3842,7 +3869,7 @@ function vReifen() {
</div>
</div>
<div data-bildklick="${radDatei}">
${bildMitPlatzhalter(`/local/bilder/${radDatei}?v=${bildVersion}`, radDatei, radLabel, "radbild")}
${bildMitPlatzhalter(bildAdresse(radDatei), radDatei, radLabel, "radbild")}
</div>
</div>
${kmOffen === k ? `
@@ -6786,6 +6813,9 @@ async function datenLaden(erzwingen) {
LETZTER_DATENSATZ = versionState.attributes.daten
? versionState.attributes.daten.letzter_datensatz ?? null
: null;
BILD_STAENDE = versionState.attributes.daten
? versionState.attributes.daten.bildstaende ?? null
: null;
}
if (entitaetenState && entitaetenState.attributes) ENTITAETEN = entitaetenState.attributes.daten;
@@ -49,6 +49,7 @@ from homeassistant.helpers.storage import Store
from . import (
batterie,
bilder,
fahrterkennung,
flespi,
geraet,
@@ -64,6 +65,7 @@ from . import (
from .ablage import Ablage
from .geokodierung import Geokodierung
from .const import (
BILDER_ORDNER,
BUENDEL_INFO,
BUENDEL_ORDNER,
BUENDEL_URL,
@@ -621,6 +623,9 @@ class Koordinator:
async def app_version_veroeffentlichen(self) -> None:
buendel = await self.hass.async_add_executor_job(self._buendel_lesen)
bildstaende = await bilder.staende(
self.hass, self.hass.config.path(BILDER_ORDNER)
)
# Die App liest alle drei aus derselben Entität: welchen Stand diese
# Installation ausliefert, ob ein nachladbares OTA-Bündel danebenliegt,
# und ob eine neuere Fassung bei Gitea bereitliegt. Zusammen an einer
@@ -649,6 +654,10 @@ class Koordinator:
"letzter_datensatz": geraet.letzter_datensatz(
self.hass, self.zuordnung.werte
),
# Der Stand jedes Fahrzeugfotos, damit beide Oberflaechen
# ein Bild nur dann neu holen, wenn es wirklich ein
# anderes ist - siehe bilder.staende().
"bildstaende": bildstaende,
},
)
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.9.4.12",
"version": "2026.9.4.14",
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
"codeowners": [