Kalender ueber EventKit, Orte nach dem Import, Fotos verkleinert, Signalsperre (2026.9.3.1-.3)

- Kalendertermine gehen nativ ueber EventKit statt ueber das Teilen-Blatt:
  Apples Kalender meldet sich beim System gar nicht als Teilen-Ziel an, ein
  anderes Dateiformat haette also nie geholfen (Abschnitt BS).
- Der Rueckblick stoesst jetzt selbst das Screening an, wenn er Fahrten
  angelegt hat - sonst blieb eine importierte Fahrt ohne Ort liegen, solange
  das Fahrzeug steht (Abschnitt BT).
- Fahrzeugfotos werden vor dem Upload im Browser auf 2000 px/WebP gebracht.
  Gemessen: 3.099.022 -> 325.994 Bytes. Damit ist die Nachrichtengrenze der
  WebSocket-Verbindung kein Thema mehr ("connection lost" auf der realen
  Instanz). Formate, die der Browser weder umwandeln noch anzeigen kann,
  werden mit klarer Meldung abgelehnt statt roh gespeichert.
- Zwei Wechsel des Fahrtsignals in derselben Sekunde kosteten eine ganze
  Fahrt: das "on" ueberholte den noch laufenden Beende-Vorgang und fiel durch
  beide Zweige. Neue asyncio-Sperre plus Regressionstest, der ohne sie
  nachweislich rot ist (Abschnitt BU).

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-03 01:59:32 +02:00
co-authored by Claude Opus 5
parent 8c95e50554
commit 6016053a92
18 changed files with 1072 additions and 78 deletions
@@ -388,6 +388,103 @@ function startIndex() {
const i = BILDER.findIndex((b) => b.name === CONFIG.startbild);
return i < 0 ? 0 : i;
}
/* Ein Fahrzeugfoto wird im Browser verkleinert, bevor es hochgeladen wird.
WARUM: der Upload laeuft als Dienstaufruf ueber die WebSocket-Verbindung,
und die hat eine Obergrenze je Nachricht - aiohttps Vorgabe sind 4 MiB, und
Base64 blaeht die Datei vorher um ein Drittel auf. Ueber die reale Instanz
kommt noch Cloudflare samt Vorschaltserver dazu, die enger sein koennen.
Genau das war der Befund vom 03.09.2026: dasselbe Cockpit-Foto ging in der
Testinstanz (localhost) durch und scheiterte ueber die reale Instanz mit
"connection lost" - eine abgerissene Verbindung, kein Fehler des Backends,
deshalb auch keine brauchbare Meldung.
Verkleinern loest das an der Wurzel statt die Grenze zu verschieben: die
groesste Flaeche, auf der ein Foto je erscheint, ist die Inhaltsspalte im
Breitbildlayout mit 772px. 2000px lange Kante lassen dafuer reichlich Luft
(auch fuer Bildschirme mit doppelter Punktdichte), und WebP bei 0,85 bringt
ein solches Foto typischerweise auf einige hundert Kilobyte.
Faellt irgendein Schritt aus (Format nicht dekodierbar, kein WebP-Encoder,
Ergebnis groesser als das Original), bleibt es beim Original - schlechter
als vorher wird es dadurch nie. */
const BILD_MAX_KANTE = 2000;
const BILD_QUALITAET = 0.85;
function bildAlsBase64(blob) {
return new Promise((fertig, fehler) => {
const leser = new FileReader();
leser.onload = () => fertig(String(leser.result).split(",")[1] || "");
leser.onerror = () => fehler(leser.error);
leser.readAsDataURL(blob);
});
}
/* Formate, die jeder Browser dieser App wirklich anzeigen kann.
Wichtig, weil das Backend nichts prueft: bilder.py schreibt die Bytes roh
unter einen der elf festen .webp-Namen. Ein Foto, das der Browser nicht
umwandeln konnte und das er auch nicht anzeigen kann, laege danach als
Bilddatei auf der Platte, die niemand darstellt - ohne Meldung.
Der praktische Fall ist HEIC: die iPhone-Fotoauswahl liefert in der Regel
JPEG, am Mac im Finder gewaehlt kommt aber eine .heic an, und die kann
ausser Safari/WKWebView niemand dekodieren. */
const BILD_SICHERE_TYPEN = /^image\/(jpeg|png|webp|gif|bmp|avif)$/i;
const BILD_SICHERE_ENDUNGEN = /\.(jpe?g|png|webp|gif|bmp|avif)$/i;
function bildformatIstSicher(datei) {
// Der MIME-Typ zaehlt, wenn er da ist. Aus dem Explorer gezogene Dateien
// kommen je nach Quelle mit leerem type an - dann die Endung, dieselbe
// Doppelpruefung wie bei istPdf().
if (datei.type) return BILD_SICHERE_TYPEN.test(datei.type);
return BILD_SICHERE_ENDUNGEN.test(datei.name);
}
async function bildVerkleinern(datei) {
let klein = null;
const adresse = URL.createObjectURL(datei);
try {
const bild = await new Promise((fertig, fehler) => {
const i = new Image();
i.onload = () => fertig(i);
i.onerror = () => fehler(new Error("Bild nicht lesbar"));
i.src = adresse;
});
const kante = Math.max(bild.naturalWidth, bild.naturalHeight);
if (kante) {
const faktor = Math.min(1, BILD_MAX_KANTE / kante);
const flaeche = document.createElement("canvas");
flaeche.width = Math.round(bild.naturalWidth * faktor);
flaeche.height = Math.round(bild.naturalHeight * faktor);
flaeche.getContext("2d").drawImage(bild, 0, 0, flaeche.width, flaeche.height);
klein = await new Promise((fertig) =>
flaeche.toBlob(fertig, "image/webp", BILD_QUALITAET),
);
}
} catch (err) {
console.warn("Bild konnte nicht umgewandelt werden", err);
} finally {
URL.revokeObjectURL(adresse);
}
// Ein bereits sparsam gespeichertes Foto soll nicht ein zweites Mal durch
// eine verlustbehaftete Kodierung - das kostet Qualitaet ohne Gewinn.
if (klein && klein.size < datei.size) return klein;
/* Umwandeln ging nicht (oder brachte nichts) - dann muss wenigstens das
Original anzeigbar sein. Sonst lieber jetzt eine klare Meldung als
hinterher ein leeres Bild, dessen Ursache niemand mehr sieht. */
if (!bildformatIstSicher(datei)) {
const art = datei.type || datei.name.split(".").pop() || "unbekannt";
throw new Error(
`Dieses Bildformat kann der Browser nicht umwandeln (${art}). ` +
"Bitte das Foto als JPEG, PNG oder WebP sichern und noch einmal versuchen.",
);
}
return datei;
}
function bildInfo(i) {
const b = BILDER[i];
if (b.name === "Seitenansicht" && CAR.reifen.aktiv === "Winter") {
@@ -5937,16 +6034,15 @@ function ereignisseVerdrahten() {
if (e.target.dataset.bildupload !== undefined) {
const datei = e.target.files[0]; if (!datei) return;
const dateiname = e.target.dataset.bildupload;
const leser = new FileReader();
leser.onload = async () => {
const daten_base64 = String(leser.result).split(",")[1] || "";
e.target.value = "";
(async () => {
try {
const klein = await bildVerkleinern(datei);
const daten_base64 = await bildAlsBase64(klein);
await HASS.callService(DOMAIN, "bild_hochladen", { dateiname, daten_base64 });
} catch (err) { hinweis("Hochladen fehlgeschlagen", err.message); }
bildMenuOffen = null; bildVersion = Date.now(); render();
};
leser.readAsDataURL(datei);
e.target.value = "";
})();
return;
}
if (e.target.dataset.backupintervall !== undefined) {