68732aa10f
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 <noreply@anthropic.com>
130 lines
4.6 KiB
Python
130 lines
4.6 KiB
Python
"""Fahrzeugbilder direkt aus der Oberfläche hochladen und löschen
|
|
(Einstellungen -> Fahrzeugbilder).
|
|
|
|
Nur die festen, im Frontend fest verdrahteten Dateinamen sind erlaubt - eine
|
|
Positivliste, damit ein manipulierter Dateiname aus dem Frontend niemals
|
|
außerhalb von www/bilder/ schreiben kann (kein Pfad-Traversal über "../" oder
|
|
absolute Pfade).
|
|
|
|
Die Bytes werden unverändert unter dem erwarteten Namen abgelegt, unabhängig
|
|
vom tatsächlichen Bildformat des Originalfotos (z. B. JPEG von einem iPhone
|
|
unter einem ".webp"-Namen) - Browser stellen <img> nach den tatsächlichen
|
|
Bilddaten dar, nicht nach der Dateiendung. Eine Größenbeschränkung gibt es
|
|
bewusst nicht: jede Fotogröße muss hochladbar sein.
|
|
|
|
WARUM DIESE DATEIEN NICHT IM INTEGRATIONSORDNER LIEGEN: sie sind Nutzerdaten,
|
|
kein Auslieferbestandteil. Lägen sie unter custom_components/audi_dashboard/,
|
|
würde der nächste install.ps1-Lauf sie mitlöschen (er leert den Zielordner
|
|
vor dem Kopieren, siehe install.ps1). /config/www/bilder/ gehört dagegen dem
|
|
Nutzer und wird von keinem Update angefasst; ausgeliefert wird es weiterhin
|
|
unter /local/bilder/.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import binascii
|
|
import logging
|
|
import os
|
|
|
|
from homeassistant.core import HomeAssistant
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
ERLAUBTE_DATEINAMEN = frozenset({
|
|
"seitenansicht.webp",
|
|
"seitenansicht-winter.webp",
|
|
"front-schraeg.webp",
|
|
"frontansicht.webp",
|
|
"heckansicht.webp",
|
|
"cockpit.webp",
|
|
"scheinwerfer.webp",
|
|
"sitze.webp",
|
|
"rad-sommer.webp",
|
|
"rad-winter.webp",
|
|
"draufsicht.webp",
|
|
})
|
|
|
|
|
|
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
|
|
return os.path.join(ordner, dateiname)
|
|
|
|
|
|
def _schreiben(pfad: str, rohdaten: bytes) -> None:
|
|
os.makedirs(os.path.dirname(pfad), exist_ok=True)
|
|
tmp = pfad + ".tmp"
|
|
with open(tmp, "wb") as datei:
|
|
datei.write(rohdaten)
|
|
os.replace(tmp, pfad)
|
|
|
|
|
|
def _loeschen(pfad: str) -> bool:
|
|
if not os.path.exists(pfad):
|
|
return False
|
|
os.remove(pfad)
|
|
return True
|
|
|
|
|
|
async def hochladen(
|
|
hass: HomeAssistant, ordner: str, dateiname: str | None, daten_base64: str | None
|
|
) -> None:
|
|
pfad = _pfad(ordner, dateiname)
|
|
if not pfad or not daten_base64:
|
|
_LOGGER.warning("Bild-Upload abgelehnt (dateiname=%s)", dateiname)
|
|
return
|
|
try:
|
|
rohdaten = base64.b64decode(daten_base64)
|
|
except (binascii.Error, ValueError) as fehler:
|
|
_LOGGER.error("Bild-Upload: Daten nicht lesbar (%s)", fehler)
|
|
return
|
|
await hass.async_add_executor_job(_schreiben, pfad, rohdaten)
|
|
_LOGGER.info("Fahrzeugbild gespeichert (%s, %s Bytes)", dateiname, len(rohdaten))
|
|
|
|
|
|
async def loeschen(hass: HomeAssistant, ordner: str, dateiname: str | None) -> None:
|
|
pfad = _pfad(ordner, dateiname)
|
|
if not pfad:
|
|
_LOGGER.warning("Bild-Löschen abgelehnt (dateiname=%s)", dateiname)
|
|
return
|
|
if await hass.async_add_executor_job(_loeschen, pfad):
|
|
_LOGGER.info("Fahrzeugbild gelöscht (%s)", dateiname)
|