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
+281 -4
View File
@@ -1,6 +1,9 @@
# AGENTS.md — Project state, review findings, open items, and working rules
**Last updated: 2026-09-02** (Null heisst unbekannt, Momentanwerte nur fuer
**Last updated: 2026-09-03** (Zwei Signalwechsel in derselben Sekunde kosteten eine Fahrt - Sperre
plus Regressionstest, Bildformat-Pruefung, `2026.9.3.3`, Abschnitt BU. Davor: Rueckblick loest Orte
selbst auf, Fotos werden vor dem Upload verkleinert, Abschnitt BT. Davor: Kalendertermine gehen ueber EventKit statt ueber das
Teilen-Blatt, Abschnitt BS. Davor: Null heisst unbekannt, Momentanwerte nur fuer
"jetzt", Uebersicht-Symbol vergroessert, `2026.9.2.7`, Abschnitte BN und BO.
Davor: Verbrauchsfaktor aus dem Tankbeleg, `2026.9.1.32`,
Abschnitt BM. Davor: Reifenzaehler ohne Fahrzeugwechsel, „Raeder“
@@ -8320,9 +8323,9 @@ Zwei verschiedene Termine überschrieben einander damit im Zwischenspeicher. Uml
umgeschrieben statt weggeworfen (ein „ö" im Dateinamen muss über die Teilen-Kette prozentkodiert
werden — eine Fehlerquelle weniger), und die Übergabe läuft über `files` statt `url`.
**Nicht bestätigt ist, dass das den Kalender-Export repariert.** Der Verdacht bleibt, dass iOS
„Kalender" im Teilen-Blatt gar nicht als Ziel führt; der verlässliche Weg wäre EventKit, also
nativ. Steht zur Prüfung beim Eigentümer.
**Der Dateiname war nicht die Ursache.** Der Verdacht daneben — iOS führt „Kalender" im
Teilen-Blatt gar nicht als Ziel — hat sich am 03.09. bestätigt; gebaut ist jetzt der EventKit-Weg,
siehe Abschnitt BS.
### Was am 02.09. abends offen blieb
@@ -8413,3 +8416,277 @@ weil `ios/` jederzeit neu erzeugt werden kann.
Alles Native: der Umbau der Übergabe und das Zwischenablage-Plugin. Die drei Teile davor
(Backend-Feld, Warteschleife, Anzeige) laufen per OTA. **Rein per OTA ist die Brücke über die
App-Gruppe nicht zu reparieren** — egal welchen Weg man nimmt.
---
## BS. Der Kalender kommt über EventKit, weil das Teilen-Blatt ihn nie anbieten kann (2026.9.3.1)
### Der Befund: es lag nie am Dateinamen und nie am Format
Gemeldet am 02.09.2026, nachdem der `-.ics`-Fehler (Abschnitt BQ) behoben war und es trotzdem nicht
half: „iOS bietet keinen Kalender an. Schicke ich mir die ics selbst per Mail zu, kann ich diese
anklicken und der Kalender öffnet sich."
Recherchiert statt geraten, und die Antwort ist eindeutig: **Apples Kalender-App meldet sich beim
System überhaupt nicht als Teilen-Ziel an** — für kein Format. Das Teilen-Blatt zeigt nur Apps mit
einer Share-Erweiterung; damit kann keine App der Welt dort einen Kalender erscheinen lassen. Ein
anderes Format hilft nicht: `.ics` (iCalendar, RFC 5545) ist das einzige, das iOS für Termine kennt.
Der funktionierende Mail-Weg führte in die Irre. Ein angetippter Anhang öffnet **nicht** das
Teilen-Blatt, sondern die Dokumentvorschau des Systems (Quick Look), die `text/calendar` erkennt und
„Alle hinzufügen" einblendet — ein anderer Mechanismus, den eine App aus dem Teilen-Blatt heraus
nicht erreicht. Belege: [Apple Community](https://discussions.apple.com/thread/255910569),
[HomeBase Software](https://hbase.net/2021/07/31/adding-ics-files-to-calendar-on-ios/) (beide
beschreiben denselben Fall und weichen auf Kurzbefehle aus).
Drei mögliche Wege standen zur Wahl: EventKit, Quick Look auf die eigene `.ics` (bildet den
Mail-Weg nach), oder `webcal://` — letzteres scheidet aus, weil es ein **Abonnement** ergibt
(schreibgeschützt, wird abgefragt) und nicht einen Termin. Gewählt: EventKit.
### `native/KalenderTermin.swift`
Ein eigenes kleines Plugin nach dem Muster von `BelegZwischenablage.swift`, kein Fremdpaket
(`@ebarooni/capacitor-calendar` 8.5.0 wäre kompatibel gewesen, bringt aber Android-Code und
Erinnerungen mit, die hier beide niemand braucht).
Zwei Entscheidungen darin, die nicht offensichtlich sind:
* **`EKEventEditViewController` statt `EKEventStore.save`.** Das passt zur Vorgabe des Eigentümers,
dass der Werkstatttermin keine eigene Verwaltung bekommt (Abschnitt BQ): die App liefert „was,
wann, welche Werkstatt" und ist danach raus — bestätigt und gespeichert wird im Kalender. Der
zweite Grund ist die Berechtigung: seit iOS 17 braucht der System-Editor keine (WWDC23, „Discover
Calendar and EventKit"), ein direktes `save()` dagegen schon.
* **`event.calendar` wird bewusst NICHT gesetzt.** Das würde `defaultCalendarForNewEvents` lesen und
damit doch eine Freigabe verlangen; der Editor sucht den Standardkalender selbst.
Dazu zwei Kleinigkeiten mit Grund: `isModalInPresentation = true`, weil ein Wegwischen den
Delegaten nicht ruft und der wartende Aufruf sonst für immer hinge — und ein noch offener Aufruf
wird beim nächsten Öffnen als „abgebrochen" aufgelöst, statt ein Versprechen nie einzulösen.
Ganztägig mit Erinnerung zwei Tage vorher, also dasselbe wie `DTSTART;VALUE=DATE` und
`TRIGGER:-P2D` der bisherigen Datei.
### Der Rückfall gilt nur für eine alte .ipa
`terminUebernehmen()` (früher `kalenderDateiLaden()` — der alte Name beschrieb den Weg, den es nicht
mehr gibt) fällt auf Datei plus Teilen-Blatt **ausschließlich** bei `code === "UNIMPLEMENTED"`
zurück, also wenn die installierte App das Plugin nicht kennt. Jeder andere Fehler wird
durchgereicht und in beiden Aufrufstellen als `.dm-fehler` angezeigt.
Der Unterschied ist der Punkt: würde jeder Fehler in den Rückfall laufen, verschwände ein echtes
Problem hinter einem Teilen-Blatt, aus dem der Termin nie in den Kalender fände — genau die
Sackgasse, die dieser Abschnitt beseitigt.
### Panel unverändert, mit Begründung
Das Panel läuft im Browser, dort lädt die `.ics` ganz normal herunter und das Betriebssystem
übernimmt sie wie jede andere Datei. EventKit gibt es dort nicht. Kein Portierungsbedarf nach der
Paritätsregel — eine echte Ausnahme, keine stillschweigend ausgelassene Arbeit.
### Zwei Info.plist-Schlüssel, obwohl der Editor nicht fragt
`ios-signieren.sh` setzt `NSCalendarsWriteOnlyAccessUsageDescription` (iOS 17+) **und**
`NSCalendarsUsageDescription` (älter), aus demselben Grund wie den Standort-Schlüssel: `ios/` ist
gitignored und wird von `npx cap add ios` neu erzeugt. Sie sind die Absicherung für den Fall, dass
EventKit doch einmal fragt — fehlt der passende, beendet iOS die App wortlos, statt eine Rückfrage
zu zeigen.
`ios-teilen-einrichten.mjs` führt die eigenen Plugins jetzt als Liste (`PLUGIN_DATEIEN`) statt als
Einzelfall; Kopieren und Anhängen an die Sources-Phase des App-Ziels laufen darüber.
### Verifiziert — und was ausdrücklich nicht
`tsc --noEmit` sauber, **173/173** Tests grün (vier neue in `src/screens/kalender.test.ts`:
natives Gerät nimmt das Plugin und nicht das Teilen-Blatt, Abbruch ist kein Fehler, fehlendes
Plugin fällt auf die Datei mit korrektem Namen zurück, echter Fehler wird durchgereicht),
`vite build` sauber, `node --check` auf dem Einrichtungsskript, `bash -n` auf dem Signierskript,
Manifest und Bündel beide auf `2026.9.3.1`.
**Nicht geprüft, weil es ohne Mac nicht geht:** dass der Swift-Code kompiliert und der Editor auf
dem Gerät erscheint. Abschnitt AK ist die Warnung dazu — dort haben vier von fünf Fehlern einen
erfolgreichen Archivlauf erzeugt und sind erst am fertigen `.ipa` aufgefallen. Beim nächsten
`ios-signieren.sh` gehört deshalb geprüft: `KalenderTermin.swift` in der Sources-Phase des
App-Ziels, beide Kalender-Schlüssel im Info.plist des Bündels.
---
## BT. Der Rückblick löst jetzt selbst Orte auf; Fotos werden vor dem Upload verkleinert (2026.9.3.2)
### Die importierte Fahrt blieb ohne Ort, obwohl beide Koordinaten dastanden
Gemeldet am 03.09.2026 an der importierten Fahrt vom 02.09. (`t-b2a9170e7403`, 21:2221:58,
14,0 km). Nachgesehen statt vermutet: `start_lat`/`end_lat` standen da, und **beide Schlüssel lagen
sogar schon im Zwischenspeicher** (`orte.json`: `48.887,11.197` → Eichstätt, `48.842,11.225`
Adelschlag). Es fehlte also weder Netz noch Nominatim — es fehlte der Auslöser.
`historienimport.importieren()` rief das Screening nie. Die einzigen Auslöser waren ein Wechsel des
Kilometerstands, ein Neustart und das Nachfassen. Steht das Fahrzeug — nach einem Import über
Vergangenes der Normalfall —, ist keiner davon der Fall, und die frische Fahrt bleibt liegen. Im
Protokoll ist es exakt so zu sehen: `Import abgeschlossen … fahrten_angelegt: 1` um 23:12:23, danach
keine einzige Screening-Zeile mehr.
**Dass es so gemeint war, sagt der Code selbst:** der Docstring von `_nachfassen_planen()` nennt
„nach einem Rückblick-Import aus Home Assistant" als einen der zwei Fälle, für die das Nachfassen
überhaupt existiert. Ausgelöst hat den Import-Fall nur nie jemand.
Jetzt: `if fahrten["angelegt"]: await screening.durchfuehren(k)` am Ende von `importieren()`. Nur
bei wirklich angelegten Fahrten — ein zweiter Lauf über denselben Zeitraum legt nichts an und soll
keinen Durchlauf kosten. `from . import screening` ist auf Modulebene unbedenklich: `screening`
zieht `reifen`, `verbrauchskorrektur` und `verlauf`, keines davon `historienimport` (geprüft, nicht
angenommen) — und `dienste.py` lädt `historienimport` beim Setup, ein Zyklus wäre also sofort als
Startfehler sichtbar.
Damit trägt der Import auch das nach, was sonst am Screening hängt und ihm bisher entging:
Verbrauchseichung, Radzähler, Durchschnittsgeschwindigkeit auf nachträglich verfeinerten Strecken.
**Verifiziert:** ein Neustart (dessen `_nach_start()`-Screening) füllte beide Orte binnen Sekunden
aus dem Zwischenspeicher — der Beweis, dass an der Fahrt selbst nie etwas fehlte. Der Import läuft
mit der Änderung sauber durch (`fahrten_angelegt: 0` über denselben Zeitraum, kein Traceback,
Screening korrekt **nicht** ausgelöst). **Nicht live gezeigt:** der Zweig selbst bei
`angelegt > 0` — dafür hätte die importierte Fahrt gelöscht und neu importiert werden müssen, und
das Löschen echter Fahrtdaten wurde abgelehnt. Alles, was der Zweig aufruft, ist dagegen bewiesen.
### Die beiden Fahrten, die zu prüfen waren
| | `t-0591dace4194` | `t-b2a9170e7403` |
|---|---|---|
| Zeitraum | 02.09. 16:3221:05 (4 h 34) | 02.09. 21:2221:58 (36 Min.) |
| Herkunft | live erkannt | Import |
| Strecke | **keine** | 14,0 km (209490 → 209504) |
| Orte | Adelschlag → Eichstätt | Eichstätt → Adelschlag (jetzt) |
Die lange Fahrt ist **nicht reparierbar, und der Code hat richtig gehandelt**: sie läuft über einen
Fahrzeugwechsel des Dongles. `odo_start` 61823 (RS4), am Ende meldete der Zähler 209490 — das andere
Auto. Die Plausibilitätsprüfung verwarf beide Werte („unmögliche 32381,1 km/h im Schnitt"), statt
147.667 km festzuschreiben. Dass sie 4 h 34 dauert, ist die schon bekannte Lage: das Trip-Signal
meldete ihr Ende nie (siehe „Was am 02.09. abends offen blieb").
Eine Strecke ließe sich aus dem GNSS-Zähler holen — die Route mit 111 Punkten liegt vor —, aber
`_gnss_verfeinern()` braucht den Kilometerstand als Anker und läuft deshalb nur auf Fahrten, die
schon eine grobe Strecke haben. Der Rückfall „ohne Anker allein aus GNSS" ist der Vorschlag vom
02.09., den der Eigentümer bis zur Dongle-Prüfung zurückgestellt hat. Unverändert offen.
Die importierte Fahrt ist in sich stimmig: Zählerdifferenz 14 km bei 36 Minuten, Route Eichstätt →
Adelschlag, also die Rückfahrt. Die absoluten Zählerstände stammen zwar aus dem anderen Fahrzeug,
die Differenz aber aus einer einzigen Fahrt — deshalb greift die Prüfung zu Recht nicht.
### „Hochladen fehlgeschlagen … connection lost" — und warum es in der Testinstanz ging
Der Upload läuft als Dienstaufruf über die **WebSocket-Verbindung**, und die hat eine Obergrenze je
Nachricht: aiohttps Vorgabe von 4 MiB, wobei Base64 die Datei vorher um ein Drittel aufbläht — die
Decke liegt also bei rund 3,0 MB Rohbild (in dieser Sitzung gemessen). Über die reale Instanz kommen
Cloudflare und der Vorschaltserver dazu, die enger sein können. Genau daher der Unterschied: dasselbe
Foto ging über localhost durch und riss über die reale Instanz die Verbindung ab. „connection lost"
ist deshalb auch keine Meldung des Backends — dort kam nie etwas an.
Statt die Grenze zu verschieben (ein eigener HTTP-Endpunkt mit HAs 16-MiB-Decke wäre der andere Weg,
er steht weiter als offener Punkt in Abschnitt BQ) wird das Foto jetzt **vor dem Senden im Browser
verkleinert**: längste Kante 2000 px, WebP bei 0,85. Die größte Fläche, auf der ein Foto je
erscheint, ist die Inhaltsspalte im Breitbildlayout mit 772 px — 2000 px lassen dafür reichlich Luft.
Drei Rückfälle, alle auf das Original: Format nicht dekodierbar, kein WebP-Encoder, oder das
Ergebnis ist **größer** als das Original (ein bereits sparsam gespeichertes Foto soll nicht ein
zweites Mal verlustbehaftet kodiert werden). Schlechter als vorher kann es dadurch nie werden.
Nebeneffekt, der eine alte Unsauberkeit mitnimmt: das Backend schreibt die Bytes unverändert unter
einen `.webp`-Namen (`ERLAUBTE_DATEINAMEN` in `bilder.py`). Bisher lag dort also je nach Quelle ein
JPEG oder PNG mit falscher Endung; jetzt ist es wirklich WebP.
**Live am laufenden Panel nachgewiesen**, nicht gerechnet: ein fotoähnliches 4000×2250-JPEG von
**3.099.022 Bytes** (base64 4.132.032 — schon dicht an der 4-MiB-Decke) durch den echten
`data-bildupload`-Weg geschickt; auf der Platte landeten **325.994 Bytes** als 2000×1125-WebP, ein
Faktor von 9,5. Das Testbild wurde danach über `bild_loeschen` wieder entfernt, der Platz war vorher
leer.
Verifiziert: `tsc --noEmit` sauber, 173/173 Tests, Panel als **Modul** geparst (Abschnitt AQ),
`audi_ha_test` auf `2026.9.3.2` sauber gestartet, kein Traceback, Bündel und Manifest gleichauf
(sha256 `09c67475…`).
---
## BU. Zwei Signalwechsel in derselben Sekunde kosteten eine ganze Fahrt (2026.9.3.3)
### Die Frage, die es aufgedeckt hat
„Warum ist die Fahrt nicht von alleine erschienen und musste importiert werden?" — die richtige
Frage, und die Antwort steht im Verlauf, nicht in einer Vermutung:
| Ankunft in HA | Trip-Signal | Zündung |
|---|---|---|
| 21:22:12 | **off** | on |
| 21:22:12 | **on** | |
| 21:22:37 | | off, dann on |
| 21:43:15 | | off |
| 21:58:16 | off | |
Das Gerät hatte gepuffert: der Datensatz „Fahrt zu Ende" (Gerätezeit 21:20:55) und der Datensatz
„Fahrt begonnen" kamen **in derselben Sekunde** an. Das Gerät hat sauber geliefert — der Fehler lag
bei uns.
### Der Mechanismus
Home Assistant startet für jeden Zustandswechsel eine **eigene Aufgabe**
(`_fahrtsignal_geaendert` ist eine Koroutine). `zuendung_geaendert()` entscheidet an
`fahrt_start_ts`, ob eine Fahrt läuft — und der Beende-Zweig wartet danach mehrfach:
`_geraetezeit()` bis zu zwei Sekunden auf die Meldezeit, `_echtes_ende()` liest den Verlauf,
`fahrt_beenden()` schreibt und stösst das Screening an. Erst ganz am Ende wurde `fahrt_start_ts`
zurückgesetzt.
Das `on` traf mitten in diese Wartezeit, sah `fahrt_start_ts` noch gesetzt — also „es läuft eine
Fahrt" — und passte damit in **keinen** der beiden Zweige: weder „an und es läuft keine" noch „aus
und es läuft eine". 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 erfasst. Gefunden hat sie nur der Rückblick, weil der den Verlauf als
Ganzes liest, statt auf Ereignisse zu reagieren.
### Die Sperre, und warum nicht früheres Zurücksetzen
`k.fahrt_sperre` (`asyncio.Lock`, im Koordinator) umschliesst den Rumpf; der Rumpf selbst steckt
jetzt in `_signalwechsel()`. Entscheidend ist, dass `fahrt_laeuft` **innerhalb** der Sperre gelesen
wird: ein Wechsel, der auf seinen Vorgänger gewartet hat, muss dessen Ergebnis sehen.
`nach_neustart_fortsetzen()` nimmt dieselbe Sperre — der erste Signalwechsel kann eintreffen,
während der Nachlauf noch schreibt.
Die naheliegende Alternative — `fahrt_start_ts` sofort löschen, statt am Ende — wurde **verworfen**:
fällt der Beende-Vorgang unterwegs aus, bliebe der Zwischenstand dann gelöscht und die Fahrt wäre
verloren. Mit der Sperre bleibt er stehen wie bisher, und der nächste Auslöser schliesst sie nach.
### Der Test beweist es, statt es zu behaupten
`tests/fahrterkennung/test_signalwechsel.py` (5 Fälle, `IsolatedAsyncioTestCase`) stellt genau die
Verschränkung nach: die langsamen Teile sind ersetzt, `_geraetezeit` wartet messbar, zwei Aufgaben
laufen gegeneinander. **Gegen den alten Stand nachgewiesen fehlschlagend** — die Sperrzeile im
Container durch `if True:` ersetzt, und der Test meldet exakt das gemeldete Symptom:
`AssertionError: unexpectedly None : nach dem Paar muss die neue Fahrt laufen`. Danach zurückgesetzt,
5/5 grün.
Merkposten daraus, allgemeiner als dieser eine Fall: **jeder Zustandsbeobachter in dieser
Integration, der awaitet und dabei eigenen Zwischenstand fortschreibt, kann von seinem eigenen
Nachfolger überholt werden.** Home Assistant serialisiert die Rückrufe nicht.
### Bildformate: was der Browser nicht umwandeln kann, wird jetzt abgelehnt
Auf die Frage nach den erlaubten Formaten nachgesehen: `accept="image/*"` in beiden Oberflächen,
und `bilder.py` prüft am Inhalt **nichts** — es dekodiert base64 und schreibt die Bytes unter einen
der elf festen `.webp`-Namen. Bis zur Verkleinerung (Abschnitt BT) lag dort also je nach Quelle ein
JPEG mit falscher Endung; aufgefallen ist das nie, weil Browser den Typ am Inhalt erkennen.
Der Randfall, der dadurch entstand: HEIC. Über die iPhone-Fotoauswahl kommt in der Regel JPEG an,
am Mac im Finder gewählt aber eine `.heic` — und die kann ausser Safari/WKWebView niemand
dekodieren. Der Rückfall auf das Original hätte sie dann roh gespeichert, und das Panel zeigte
hinterher ein leeres Bild ohne jede Erklärung.
`bildformatIstSicher()` (wortgleich in beiden Codebasen) prüft JPEG/PNG/WebP/GIF/BMP/AVIF am
MIME-Typ, bei leerem Typ an der Endung — dieselbe Doppelprüfung wie `istPdf()`. Ist die Umwandlung
gescheitert **und** das Original nicht anzeigbar, wird der Upload mit klarer Meldung abgelehnt statt
etwas zu speichern, das niemand darstellen kann. Ein anzeigbares Original geht unverändert durch.
Nebenbei geschlossen: in der App war ein Fehlschlag beim Hochladen bisher **unsichtbar** — der
Aufruf lief als `void hochladen(f)` ins Leere. Jetzt steht die Meldung unter dem Bild.
Live am laufenden Panel geprüft: eine vorgetäuschte `IMG_4711.heic` erzeugt
„Dieses Bildformat kann der Browser nicht umwandeln (image/heic). Bitte das Foto als JPEG, PNG oder
WebP sichern und noch einmal versuchen." — und auf der Platte entsteht **nichts**.
Verifiziert: 5/5 Backend-Tests (und ohne die Sperre nachweislich rot), `tsc --noEmit` sauber,
173/173 App-Tests, Panel als Modul geparst, `audi_ha_test` auf `2026.9.3.3` sauber gestartet.