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:
@@ -297,6 +297,8 @@ export interface AppVersionAngabe {
|
||||
zugaenge?: ZugangAngabe[];
|
||||
dongle?: DongleAngabe;
|
||||
letzter_datensatz?: LetzterDatensatz | null;
|
||||
/** Zeitstempel je vorhandenem Fahrzeugfoto - siehe bilder.staende(). */
|
||||
bildstaende?: Record<string, number> | null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- Entitäts-Verzeichnis */
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
type Verbindungszustand,
|
||||
type WartenderAuftrag,
|
||||
} from "../api"
|
||||
import { bildStaendeSetzen } from "../screens/bilder"
|
||||
import type { Versionsstand } from "./appVersion"
|
||||
import { eigeneVersion, versionVergleichen } from "./appVersion"
|
||||
import type { Einstellungen, Fahrzeug } from "./profilAdapter"
|
||||
@@ -172,6 +173,10 @@ export function DatenAnbieter({
|
||||
setzeZugaenge(versionsangabe?.zugaenge ?? [])
|
||||
setzeDongle(versionsangabe?.dongle ?? null)
|
||||
setzeLetzterDatensatz(versionsangabe?.letzter_datensatz ?? null)
|
||||
// Kein React-Zustand: die Bildadressen werden beim Rendern gebraucht,
|
||||
// teils ausserhalb von Komponenten (bilderVorladen) - dieselbe
|
||||
// Ueberlegung wie bei zugangMerken().
|
||||
bildStaendeSetzen(versionsangabe?.bildstaende ?? null)
|
||||
return true
|
||||
} catch (fehler) {
|
||||
// Ein voruebergehender Ausfall ist kein Ladefehler, solange schon
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Der Bildstand entscheidet, wann ein Foto neu geholt wird.
|
||||
*
|
||||
* Genau daran hing der gemeldete Fehler: bis zum 04.09.2026 stand am Ende
|
||||
* jeder Bildadresse die Startzeit der App, und damit holte jeder Start alle
|
||||
* Fotos erneut (gemessen: 5,3 MB) - der Zwischenspeicher konnte zwischen zwei
|
||||
* Starts gar nicht greifen. Jetzt kommt der Zeitstempel je Datei vom Backend,
|
||||
* und diese Tests halten die drei Faelle fest, auf die es dabei ankommt.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
vi.mock("./zugang", () => ({
|
||||
zugangLesenSynchron: () => ({ basisUrl: "http://muster.local:8123", token: "x" }),
|
||||
zugangVergessen: () => {},
|
||||
zugangMerken: async () => {},
|
||||
}))
|
||||
|
||||
const { bildStaendeSetzen, bildStand, bildUrl } = await import("./bilder")
|
||||
|
||||
describe("Bildstand", () => {
|
||||
beforeEach(() => {
|
||||
bildStaendeSetzen(null)
|
||||
})
|
||||
|
||||
it("nimmt den Zeitstempel der Datei, nicht die Startzeit", () => {
|
||||
bildStaendeSetzen({ "seitenansicht.webp": 1788500000 })
|
||||
expect(bildStand("seitenansicht.webp")).toBe(1788500000)
|
||||
expect(bildUrl("seitenansicht.webp")).toBe(
|
||||
"http://muster.local:8123/local/bilder/seitenansicht.webp?v=1788500000",
|
||||
)
|
||||
})
|
||||
|
||||
it("bleibt gleich, solange sich das Foto nicht aendert", () => {
|
||||
bildStaendeSetzen({ "seitenansicht.webp": 1788500000 })
|
||||
const erste = bildUrl("seitenansicht.webp")
|
||||
// Zweiter Start, dieselbe Antwort des Backends.
|
||||
bildStaendeSetzen({ "seitenansicht.webp": 1788500000 })
|
||||
expect(bildUrl("seitenansicht.webp")).toBe(erste)
|
||||
})
|
||||
|
||||
it("aendert sich, sobald das Foto ein anderes ist", () => {
|
||||
bildStaendeSetzen({ "seitenansicht.webp": 1788500000 })
|
||||
const vorher = bildUrl("seitenansicht.webp")
|
||||
bildStaendeSetzen({ "seitenansicht.webp": 1788599999 })
|
||||
expect(bildUrl("seitenansicht.webp")).not.toBe(vorher)
|
||||
})
|
||||
|
||||
it("setzt 0 fuer ein Foto, das es nicht gibt", () => {
|
||||
// Wichtig, weil Home Assistant auch den 404 mit Cache-Vorgabe ausliefert:
|
||||
// sobald das Foto hochgeladen ist, traegt die Adresse seinen Zeitstempel
|
||||
// und der gespeicherte Fehlschlag ist damit ueberholt.
|
||||
bildStaendeSetzen({ "seitenansicht.webp": 1788500000 })
|
||||
expect(bildStand("cockpit.webp")).toBe(0)
|
||||
})
|
||||
|
||||
it("faellt auf die Startzeit zurueck, wenn das Backend keine Staende liefert", () => {
|
||||
// Aeltere Integration: dann gilt das Verhalten von vorher, statt alle
|
||||
// Adressen auf ein festes v=0 zu legen - das waere ein Foto, das sich nie
|
||||
// mehr aktualisiert.
|
||||
bildStaendeSetzen(null)
|
||||
expect(bildStand("seitenansicht.webp")).toBeGreaterThan(1_000_000_000_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Bildstand: gemerkt ueber den Neustart", () => {
|
||||
it("malt den ersten Bildschirm schon mit dem letzten bekannten Stand", async () => {
|
||||
// Die Versionsangabe trifft erst NACH dem ersten Rendern ein. Ohne das
|
||||
// Gedaechtnis traegt dieser eine Durchlauf die Startzeit - und holt das
|
||||
// Uebersichtsfoto bei jedem Start erneut.
|
||||
bildStaendeSetzen({ "seitenansicht.webp": 1788500000 })
|
||||
vi.resetModules()
|
||||
const frisch = await import("./bilder")
|
||||
expect(frisch.bildStand("seitenansicht.webp")).toBe(1788500000)
|
||||
})
|
||||
})
|
||||
@@ -93,13 +93,67 @@ export const ERLAUBTE_DATEINAMEN: readonly string[] = [
|
||||
* setzt den Wert nach jedem Hochladen oder Löschen neu. Hier dasselbe: beim
|
||||
* Start der App einmal, danach bei jeder Änderung.
|
||||
*
|
||||
* Der Preis ist ein erneuter Abruf je App-Start. Der ist gewollt — ein Foto,
|
||||
* das der Nutzer nicht sieht, kostet mehr als ein Megabyte über eine
|
||||
* Verbindung, die ohnehin gerade das ganze Fahrzeugprofil holt.
|
||||
* Bis zum 04.09.2026 war das `Date.now()` beim App-Start - eine Adresse, die
|
||||
* sich bei JEDEM Start ändert. Der Zwischenspeicher griff damit zwischen
|
||||
* zwei Starts nie, und mit dem Vorladen aller Fotos wären das 5,3 MB je
|
||||
* Start gewesen. Jetzt kommt der Zeitstempel jeder Datei vom Backend
|
||||
* (`bilder.staende()`, veröffentlicht an `sensor.audi_dashboard_app_version`):
|
||||
* er ändert sich genau dann, wenn das Foto wirklich ein anderes ist.
|
||||
*
|
||||
* `bildVersion` bleibt als Rückfall für ein Backend, das die Stände noch
|
||||
* nicht mitliefert - dann ist das Verhalten wie zuvor.
|
||||
*/
|
||||
/*
|
||||
* Gemerkt wird der letzte bekannte Stand im Browserspeicher, und zwar aus
|
||||
* einem gemessenen Grund: die App malt ihren ersten Bildschirm, BEVOR die
|
||||
* Versionsangabe (und damit die Stände) eintrifft. Ohne dieses Gedächtnis
|
||||
* trägt genau dieser eine Durchlauf noch die Startzeit als Anhängsel - und
|
||||
* holt das Übersichtsfoto damit bei jedem Start erneut, also exakt die
|
||||
* Kosten, um die es hier geht (am 04.09.2026 live gesehen).
|
||||
*
|
||||
* Ein veralteter gemerkter Stand ist ungefährlich: sobald die frische
|
||||
* Angabe da ist, wechselt die Adresse und das Bild wird nachgeholt.
|
||||
*/
|
||||
const SPEICHERSCHLUESSEL = "dm360.bildstaende"
|
||||
|
||||
function gemerkteStaende(): Record<string, number> | null {
|
||||
try {
|
||||
const roh = window.localStorage.getItem(SPEICHERSCHLUESSEL)
|
||||
if (!roh) return null
|
||||
const wert: unknown = JSON.parse(roh)
|
||||
return wert && typeof wert === "object" ? (wert as Record<string, number>) : null
|
||||
} catch {
|
||||
// Kein Speicher (privates Fenster, Testumgebung) - dann eben ohne.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
let bildStaende: Record<string, number> | null =
|
||||
typeof window === "undefined" ? null : gemerkteStaende()
|
||||
let bildVersion = Date.now()
|
||||
|
||||
/** Nach dem Hochladen oder Löschen eines Fotos aufrufen. */
|
||||
/** Die Stände aus der veröffentlichten Entität übernehmen. */
|
||||
export function bildStaendeSetzen(staende: Record<string, number> | null): void {
|
||||
bildStaende = staende
|
||||
try {
|
||||
if (staende) window.localStorage.setItem(SPEICHERSCHLUESSEL, JSON.stringify(staende))
|
||||
else window.localStorage.removeItem(SPEICHERSCHLUESSEL)
|
||||
} catch {
|
||||
// Siehe oben - das Merken ist eine Beschleunigung, keine Bedingung.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Stand einer Datei. 0 heißt: dieses Foto gibt es nicht - sobald es
|
||||
* hochgeladen wird, ändert sich die Adresse von selbst, und auch ein
|
||||
* gespeicherter 404 löst sich damit auf.
|
||||
*/
|
||||
export function bildStand(dateiname: string): number {
|
||||
if (!bildStaende) return bildVersion
|
||||
return bildStaende[dateiname] ?? 0
|
||||
}
|
||||
|
||||
/** Nach dem Hochladen oder Löschen eines Fotos aufrufen (nur der Rückfall). */
|
||||
export function bildVersionErneuern(): void {
|
||||
bildVersion = Date.now()
|
||||
}
|
||||
@@ -113,7 +167,7 @@ export function bildUrl(dateiname: string | null | undefined): string | undefine
|
||||
if (!dateiname) return undefined
|
||||
const basis = zugangLesenSynchron()?.basisUrl
|
||||
if (!basis) return undefined
|
||||
return `${basis}/local/bilder/${dateiname}?v=${bildVersion}`
|
||||
return `${basis}/local/bilder/${dateiname}?v=${bildStand(dateiname)}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,10 +306,18 @@ export async function bildVerkleinern(datei: File): Promise<Blob> {
|
||||
* Nutzer wirklich braucht. Das Datenvolumen ist dasselbe wie beim Durchblaettern
|
||||
* aller Seiten, nur frueher und ohne Warten.
|
||||
*/
|
||||
let vorgeladen: string | null = null
|
||||
|
||||
export function bilderVorladen(): void {
|
||||
if (typeof window === "undefined" || typeof Image === "undefined") return
|
||||
const namen = [...BILDPLAETZE.map((b) => b.datei), SEITE_WINTER.datei]
|
||||
// Kennung über alle Stände: solange sich kein Foto geändert hat, läuft das
|
||||
// Vorladen kein zweites Mal - und der Browser holt die Bilder beim nächsten
|
||||
// Start aus seinem eigenen Zwischenspeicher.
|
||||
const kennung = namen.map(bildStand).join(",")
|
||||
if (vorgeladen === kennung) return
|
||||
vorgeladen = kennung
|
||||
const starten = () => {
|
||||
const namen = [...BILDPLAETZE.map((b) => b.datei), SEITE_WINTER.datei]
|
||||
for (const name of namen) {
|
||||
const url = bildUrl(name)
|
||||
if (!url) continue
|
||||
|
||||
Reference in New Issue
Block a user