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 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 01:59:32 +02:00
parent 8c95e50554
commit 6016053a92
18 changed files with 1072 additions and 78 deletions
@@ -254,11 +254,39 @@ async def zuendung_geaendert(
# abbrechen - das ist der Mechanismus hinter der Pausenregel.
k.warte_ende_ab_abbrechen()
an_jetzt = neu == "on"
# Von hier an nur einer zur Zeit. Home Assistant startet für jeden
# Zustandswechsel eine eigene Aufgabe, und dieser Rumpf wartet mehrfach:
# _geraetezeit() bis zu zwei Sekunden auf die Meldezeit, danach liest
# _echtes_ende() den Verlauf, danach schreibt fahrt_beenden() die Fahrt
# und stösst das Screening an.
#
# Ohne die Sperre überholt der nächste Wechsel den laufenden. Genau das ist
# am 02.09.2026 passiert und hat eine ganze Fahrt gekostet: das Gerät hatte
# gepuffert, deshalb kamen "Fahrt zu Ende" (Gerätezeit 21:20:55) und "Fahrt
# begonnen" in DERSELBEN Sekunde bei Home Assistant an, 21:22:12. Das "off"
# ging in den Beende-Zweig und wartete; das "on" traf mitten hinein, sah
# fahrt_start_ts noch gesetzt - also "es läuft eine Fahrt" - und passte
# damit in keinen der beiden Zweige unten. Es fiel wortlos durch. Danach
# räumte der Beende-Vorgang das Feld ab, und das reguläre "off" um 21:58:16
# fand keine laufende Fahrt mehr. 36 Minuten Fahrt, live nie gesehen; nur
# der Rückblick hat sie später gefunden.
#
# Die Sperre statt eines früheren Zurücksetzens von fahrt_start_ts: fällt
# der Beende-Vorgang unterwegs aus, bleibt der Zwischenstand stehen wie
# bisher und der nächste Auslöser schliesst die Fahrt nach. Ein früheres
# Löschen hätte sie in dem Fall verloren.
async with k.fahrt_sperre:
await _signalwechsel(k, neu == "on", ereigniszeit)
async def _signalwechsel(
k: Koordinator, an_jetzt: bool, ereigniszeit: datetime.datetime | None
) -> None:
# Ob eine Fahrt läuft, entscheidet der eigene Zwischenstand
# (fahrt_start_ts) - nicht der vorherige Rohwert der Entität. Der wäre bei
# einer Funklücke "unavailable" statt "on" gewesen, obwohl die Fahrt die
# ganze Zeit lief.
# ganze Zeit lief. Gelesen wird er INNERHALB der Sperre: ein Wechsel, der
# auf seinen Vorgänger gewartet hat, muss dessen Ergebnis sehen.
fahrt_laeuft = k.fahrt_start_ts is not None
jetzt = datetime.datetime.now(datetime.UTC)
@@ -324,6 +352,16 @@ async def nach_neustart_fortsetzen(k: Koordinator) -> None:
if zustand_oder_none(k.hass, sensor) == "on":
return # fährt noch - der Beobachter übernimmt wie sonst auch
# Dieselbe Sperre wie im Beobachter: der erste Wechsel des Fahrtsignals
# kann eintreffen, während dieser Nachlauf noch schreibt.
async with k.fahrt_sperre:
await _nachtraeglich_schliessen(k, sensor)
async def _nachtraeglich_schliessen(k: Koordinator, sensor: str) -> None:
if k.fahrt_start_ts is None:
return # der Beobachter war schneller
ende_ts = await _letztes_lebenszeichen(k, sensor, k.fahrt_start_ts)
_LOGGER.info(
"Fahrt seit %s wurde während eines Ausfalls beendet - wird jetzt mit "
@@ -1 +1 @@
{"version":"2026.9.2.25","sha256":"106acbff40d3a14b59a1759f422e0d4346f29f196d0c3196c80289f6815d6804","bytes":266091,"gebaut":"2026-09-02T20:11:42Z"}
{"version":"2026.9.3.3","sha256":"7b2e99c4c05b9522abc8cb3276a9cca3fd25d23c25db1ebd79d312be712f9945","bytes":266735,"gebaut":"2026-09-02T23:54:05Z"}
@@ -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) {
@@ -41,6 +41,7 @@ import datetime
import logging
from typing import TYPE_CHECKING
from . import screening
from .batterie import SPANNUNG_MIN_V
from .fahrterkennung import leere_fahrt
from .tankerkennung import LITER_SCHWELLE, leerer_tankvorgang, schwelle_prozent
@@ -524,6 +525,23 @@ async def importieren(k: Koordinator, start: object, ende: object) -> None:
await k.tankvorgang_nachbereiten()
await k.batterieverlauf_veroeffentlichen()
# Der Rückblick trägt nur ein, was er aus dem Verlauf selbst ableiten kann.
# Ortsnamen, Verbrauchseichung und der Radzähler hängen dagegen am
# Screening - und das lief bis zum 03.09.2026 nur bei einem Wechsel des
# Kilometerstands, nach einem Neustart oder aus dem Nachfassen heraus.
# Steht das Fahrzeug, ist keines davon der Fall: eine frisch importierte
# Fahrt blieb ohne Ort liegen, obwohl beide Koordinaten dastanden und
# sogar im Zwischenspeicher schon aufgelöst waren (gemeldet vom
# Eigentümer). Dass es so gemeint war, sagt das Nachfassen selbst - sein
# Docstring nennt den Import als einen der zwei Fälle, für die es
# existiert; nur ausgelöst hat ihn nie jemand.
#
# Nur bei wirklich angelegten Fahrten: ein Import ohne Ergebnis (der
# Normalfall beim zweiten Lauf über denselben Zeitraum) soll keinen
# Durchlauf kosten.
if fahrten["angelegt"]:
await screening.durchfuehren(k)
ergebnis = {
"von": von.isoformat(),
"bis": bis.isoformat(),
@@ -127,6 +127,9 @@ class Koordinator:
self._store = Store[dict](hass, STORE_VERSION, f"{entry.entry_id}_laufzeit")
self.fahrt_start_ts: datetime.datetime | None = None
# Zwei Wechsel des Fahrtsignals in derselben Sekunde duerfen sich nicht
# ueberholen - Begruendung in fahrterkennung.zuendung_geaendert().
self.fahrt_sperre = asyncio.Lock()
self.tiefststand_pct: float | None = None
self.tiefststand_liter: float | None = None
# Seit wann das Fahrzeug steht (Zeit des Geraets). None = unbekannt.
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.9.2.25",
"version": "2026.9.3.3",
"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"],