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:
@@ -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"}
|
||||
Binary file not shown.
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user