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
+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.
+135
View File
@@ -0,0 +1,135 @@
//
// KalenderTermin.swift
// DataMetric360
//
// Legt einen Termin über EventKit an der Weg, den das Teilen-Blatt nicht
// anbietet.
//
// WARUM ES DAS BRAUCHT
// --------------------
// Bis zum 03.09.2026 ging der Werkstatt- und der Radwechseltermin als
// .ics-Datei ins iOS-Teilen-Blatt. Dort erscheint nie ein Kalender, und das
// liegt nicht an der Datei: Apples Kalender-App meldet sich beim System
// überhaupt nicht als Teilen-Ziel an, für kein Format. Das Blatt kann sie
// deshalb gar nicht zeigen, egal wie die Datei aussieht.
//
// Dass ein per Mail geschickter Anhang funktioniert, führt in die Irre: dort
// öffnet nicht das Teilen-Blatt, sondern die Dokumentvorschau des Systems
// (Quick Look). Die erkennt text/calendar und blendet Alle hinzufügen" ein.
// Ein anderer Mechanismus, den eine App nicht aus dem Teilen-Blatt heraus
// erreicht. Gemeldet vom Eigentümer am 02.09.2026.
//
// EventKit umgeht beides: der Termin geht direkt an den Kalender.
//
// WARUM DER SYSTEM-EDITOR UND NICHT `EKEventStore.save`
// -----------------------------------------------------
// `EKEventEditViewController` zeigt den fertig ausgefüllten Termin und lässt
// ihn den Menschen bestätigen. Das passt zur Vorgabe des Eigentümers, dass
// der Werkstatttermin keine eigene Verwaltung bekommt (siehe Abschnitt BQ in
// AGENTS.md): die App liefert was, wann, welche Werkstatt" und ist danach
// raus gespeichert wird im Kalender, nicht bei uns.
//
// Der zweite Grund ist die Berechtigung. Seit iOS 17 braucht der System-
// Editor keine (WWDC23, Discover Calendar and EventKit"): er läuft außerhalb
// unseres Zugriffs, wir übergeben nur den Entwurf. Ein direktes `save()`
// bräuchte dagegen eine Freigabe samt Rückfrage. Deshalb wird hier auch
// `event.calendar` NICHT gesetzt das würde `defaultCalendarForNewEvents`
// lesen und damit doch eine Freigabe verlangen; der Editor sucht den
// Standardkalender selbst.
//
// DIESE DATEI IST VERSIONIERT, ios/ NICHT.
// `npx cap add ios` erzeugt den nativen Ordner neu und würde alles darin
// verlieren. scripts/ios-teilen-einrichten.mjs kopiert sie bei jedem Bau
// wieder hinein und hängt sie an die Sources-Phase des App-Ziels.
//
import Capacitor
import EventKit
import EventKitUI
import Foundation
import UIKit
@objc(KalenderTerminPlugin)
public class KalenderTerminPlugin: CAPPlugin, CAPBridgedPlugin, EKEventEditViewDelegate {
public let identifier = "KalenderTerminPlugin"
public let jsName = "KalenderTermin"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "terminAnlegen", returnType: CAPPluginReturnPromise)
]
/// Muss den Editor überleben eine lokale Instanz wäre beim Erscheinen
/// des Blatts schon wieder abgeräumt.
private let speicher = EKEventStore()
/// Der Aufruf wartet, bis der Mensch im Editor entschieden hat.
private var offenerAufruf: CAPPluginCall?
@objc func terminAnlegen(_ call: CAPPluginCall) {
guard let titel = call.getString("titel"), !titel.isEmpty else {
return call.reject("Ohne Titel kein Termin.")
}
guard let isoTag = call.getString("isoTag"), let tag = tagAus(isoTag) else {
return call.reject("Kein gültiges Datum (erwartet JJJJ-MM-TT).")
}
let termin = EKEvent(eventStore: speicher)
termin.title = titel
termin.isAllDay = true
// Ganztägig heißt für EventKit: Beginn und Ende am selben Tag. Deckt
// sich mit dem DTSTART;VALUE=DATE der bisherigen .ics.
termin.startDate = tag
termin.endDate = tag
if let ort = call.getString("ort"), !ort.isEmpty { termin.location = ort }
if let text = call.getString("beschreibung"), !text.isEmpty { termin.notes = text }
// Zwei Tage vorher erinnern - dieselbe Vorwarnzeit wie im TRIGGER:-P2D
// der .ics, die dieser Weg ablöst.
termin.addAlarm(EKAlarm(relativeOffset: -2 * 24 * 60 * 60))
DispatchQueue.main.async { [weak self] in
guard let self else { return }
guard let eltern = self.bridge?.viewController else {
return call.reject("Kein Fenster zum Anzeigen des Kalender-Editors.")
}
// Ein noch offener Aufruf kann nur von einem Editor stammen, der
// ohne Rueckmeldung verschwunden ist. Ihn stehen zu lassen hiesse,
// ein Versprechen nie einzuloesen.
self.offenerAufruf?.resolve(["status": "abgebrochen"])
self.offenerAufruf = call
let editor = EKEventEditViewController()
editor.event = termin
editor.eventStore = self.speicher
editor.editViewDelegate = self
// Wegwischen wuerde den Delegaten nicht rufen und den Aufruf oben
// haengen lassen; so bleiben nur "Hinzufuegen" und "Abbrechen".
editor.isModalInPresentation = true
eltern.present(editor, animated: true)
}
}
public func eventEditViewController(
_ controller: EKEventEditViewController,
didCompleteWith action: EKEventEditViewAction
) {
controller.dismiss(animated: true)
let aufruf = offenerAufruf
offenerAufruf = nil
// .deleted gibt es hier nicht (der Termin ist neu), zaehlt aber wie ein
// Abbruch: gespeichert wurde nichts.
aufruf?.resolve(["status": action == .saved ? "gespeichert" : "abgebrochen"])
}
/// 2027-03-14" -> Mitternacht dieses Tages in der Zeitzone des Geräts.
private func tagAus(_ isoTag: String) -> Date? {
let teile = isoTag.split(separator: "-").compactMap { Int($0) }
guard teile.count == 3 else { return nil }
var komponenten = DateComponents()
komponenten.year = teile[0]
komponenten.month = teile[1]
komponenten.day = teile[2]
var kalender = Calendar(identifier: .gregorian)
kalender.timeZone = TimeZone.current
return kalender.date(from: komponenten)
}
}
+20
View File
@@ -106,6 +106,26 @@ fi
exit 1
}
# Kalender-Berechtigung, aus demselben Grund an derselben Stelle.
#
# Warum ZWEI Schluessel: seit iOS 17 gibt es einen eigenen Nur-Schreiben-Zugriff
# (NSCalendarsWriteOnlyAccessUsageDescription), aeltere Fassungen kennen nur den
# alten Sammelschluessel. Der System-Editor (native/KalenderTermin.swift) fragt
# auf iOS 17+ von sich aus gar nicht - die Schluessel sind die Absicherung fuer
# den Fall, dass EventKit doch einmal fragt. Fehlt der passende, beendet iOS die
# App wortlos, statt eine Rueckfrage zu zeigen.
echo "==> Kalender-Berechtigung eintragen"
KALENDERGRUND="Traegt Werkstatt- und Wechseltermine in deinen Kalender ein."
for SCHLUESSEL in NSCalendarsWriteOnlyAccessUsageDescription NSCalendarsUsageDescription; do
if ! /usr/libexec/PlistBuddy -c "Set :$SCHLUESSEL $KALENDERGRUND" "$PLIST" 2>/dev/null; then
/usr/libexec/PlistBuddy -c "Add :$SCHLUESSEL string $KALENDERGRUND" "$PLIST"
fi
/usr/libexec/PlistBuddy -c "Print :$SCHLUESSEL" "$PLIST" >/dev/null || {
echo "FEHLER: $SCHLUESSEL liess sich nicht setzen." >&2
exit 1
}
done
# Share-Erweiterung ins Xcode-Projekt haengen.
#
# Aus demselben Grund wie die Team-Kennung und der Standort-Schluessel oben:
+21 -13
View File
@@ -53,6 +53,11 @@ const ZIEL_NAME = "DataMetric360Share"
const BUNDLE_ID = "app.datametric360.teilen"
const GRUPPE = "group.app.datametric360"
/* Eigene Capacitor-Plugins der App - je eine Swift-Datei, die neben der
Erweiterung ins App-Ziel gehört. Sie liegen aus demselben Grund unter
native/ wie alles andere hier: ios/ ist gitignored und wird neu erzeugt. */
const PLUGIN_DATEIEN = ["BelegZwischenablage.swift", "KalenderTermin.swift"]
/** pbxproj-Wert ohne die optionalen Anfuehrungszeichen. */
const entquotet = (wert) => String(wert ?? "").replace(/^"|"$/g, "")
const SCHEMA = "datametric360"
@@ -87,10 +92,9 @@ for (const datei of ["ShareViewController.swift", "Info.plist", `${ZIEL_NAME}.en
copyFileSync(join(quelleErw, datei), join(zielErw, datei))
}
copyFileSync(join(APP, "native", "App.entitlements"), join(iosApp, "App", "App.entitlements"))
copyFileSync(
join(APP, "native", "BelegZwischenablage.swift"),
join(iosApp, "App", "BelegZwischenablage.swift"),
)
for (const datei of PLUGIN_DATEIEN) {
copyFileSync(join(APP, "native", datei), join(iosApp, "App", datei))
}
melden(`Quellen nach ios/App/${ZIEL_NAME}/ kopiert`)
/* ------------------------------------------------------------- URL-Schema */
@@ -129,24 +133,28 @@ if (!appPlist.includes("CFBundleURLTypes")) {
const projekt = xcode.project(pbxPfad)
projekt.parseSync()
/* ------------------------------------------- Zwischenablage-Plugin (App-Ziel)
/* ------------------------------------------------ Eigene Plugins (App-Ziel)
Steht VOR dem Ausstieg unten: die Erweiterung ist nach dem ersten Lauf
vorhanden, das Plugin muss trotzdem bei jedem Bau geprüft werden - ios/
vorhanden, die Plugins müssen trotzdem bei jedem Bau geprüft werden - ios/
wird von `npx cap add ios` jederzeit neu erzeugt.
`addSourceFile` ohne Gruppe hängt die Datei an die ERSTE Sources-Phase des
Projekts, und das ist die des App-Ziels - genau die richtige. (Mit Gruppe
landete die Datei nur im Navigator; daran ist am 02.09.2026 die Erweiterung
ohne Programm entstanden, siehe unten.) */
const PLUGIN_DATEI = "BelegZwischenablage.swift"
const schonDrin = JSON.stringify(projekt.pbxSourcesBuildPhaseObj()).includes(PLUGIN_DATEI)
if (schonDrin) {
melden("Zwischenablage-Plugin war bereits im App-Ziel")
} else {
projekt.addSourceFile(`App/${PLUGIN_DATEI}`)
let pluginsGeaendert = false
for (const datei of PLUGIN_DATEIEN) {
if (JSON.stringify(projekt.pbxSourcesBuildPhaseObj()).includes(datei)) {
melden(`${datei} war bereits im App-Ziel`)
continue
}
projekt.addSourceFile(`App/${datei}`)
melden(`${datei} ins App-Ziel gehängt`)
pluginsGeaendert = true
}
if (pluginsGeaendert) {
writeFileSync(pbxPfad, projekt.writeSync())
melden("Zwischenablage-Plugin ins App-Ziel gehängt")
// Nach dem Schreiben neu einlesen: der Rest des Skripts arbeitet sonst auf
// einem Stand, der die eigene Änderung nicht kennt.
projekt.parseSync()
+11 -6
View File
@@ -18,7 +18,7 @@ import { datum, de, deOderStrich } from "../format"
import { SymbolEdit, SymbolPaket, SymbolZahnradVoll } from "../symbole"
import { BildMitMenue, WischLoeschen, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { Datumsfeld } from "./Datumsfeld"
import { kalenderDateiLaden } from "./kalender"
import { terminUebernehmen } from "./kalender"
type SatzSchluessel = "sommer" | "winter"
@@ -30,6 +30,7 @@ export function Reifen() {
const [kmOffen, setzeKmOffen] = useState<SatzSchluessel | null>(null)
const [kmEntwurf, setzeKmEntwurf] = useState("")
const [wechselDatum, setzeWechselDatum] = useState<string | null>(null)
const [kalenderFehler, setzeKalenderFehler] = useState<string | null>(null)
if (!fahrzeug) return null
@@ -51,6 +52,7 @@ export function Reifen() {
}
const kalenderUebernehmen = async () => {
setzeKalenderFehler(null)
await profilSpeichern({
fahrzeug: {
...fahrzeug,
@@ -58,11 +60,13 @@ export function Reifen() {
},
})
const ziel = aktiv === "Sommer" ? "Winter" : "Sommer"
await kalenderDateiLaden(
"Reifenwechsel",
wechselDatumAnzeige,
`Wechsel auf ${ziel}reifen`,
)
try {
await terminUebernehmen("Reifenwechsel", wechselDatumAnzeige, `Wechsel auf ${ziel}reifen`)
} catch (fehler) {
setzeKalenderFehler(
fehler instanceof Error ? fehler.message : "Der Termin ließ sich nicht übergeben.",
)
}
}
const kmSpeichern = async (satzSchluessel: SatzSchluessel) => {
@@ -309,6 +313,7 @@ export function Reifen() {
In den Kalender übernehmen
</ActionButton>
</div>
{kalenderFehler && <p className="dm-fehler">{kalenderFehler}</p>}
</Tile>
{satzTile("sommer")}
+11 -4
View File
@@ -32,7 +32,7 @@ import {
bestaetigen,
} from "./bausteine"
import { Datumsfeld } from "./Datumsfeld"
import { kalenderDateiLaden } from "./kalender"
import { terminUebernehmen } from "./kalender"
import { vcardParsen } from "./vcard"
export interface ServicebuchEintrag {
@@ -270,6 +270,7 @@ function TerminVereinbaren() {
Sobald jemand ein Datum wählt, bleibt es stehen, auch wenn die Art
wechselt - sonst würde die eigene Eingabe stillschweigend überschrieben. */
const [eigenesDatum, setzeEigenesDatum] = useState<string | null>(null)
const [kalenderFehler, setzeKalenderFehler] = useState<string | null>(null)
if (!fahrzeug) return null
@@ -319,17 +320,23 @@ function TerminVereinbaren() {
Panel (`ics()` dort). Fehlt das Autohaus, bleiben beide Felder leer
und der Termin trägt nur "was" und "wann". */}
<ActionButton
onClick={() =>
void kalenderDateiLaden(
onClick={() => {
setzeKalenderFehler(null)
void terminUebernehmen(
`${einstellungen?.fahrzeugtitel ?? "Fahrzeug"}: ${art}`,
datumWert,
autohaus.adresse ?? "",
autohaus.name ?? "",
).catch((fehler) =>
setzeKalenderFehler(
fehler instanceof Error ? fehler.message : "Der Termin ließ sich nicht übergeben.",
),
)
}
}}
>
In den Kalender übernehmen
</ActionButton>
{kalenderFehler && <p className="dm-fehler">{kalenderFehler}</p>}
</Tile>
)
}
+15 -8
View File
@@ -11,7 +11,7 @@ import type { SicherheitsPunkt } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { SymbolZahnradVoll } from "../symbole"
import { Bild } from "./Bild"
import { bildVersionErneuern } from "./bilder"
import { bildAlsBase64, bildVerkleinern, bildVersionErneuern } from "./bilder"
/** Kraftstoffsorten zur Auswahl - dieselbe feste Liste wie `KRAFTSTOFFSORTEN`
im Panel, geteilt zwischen Tanken.tsx (Neuanlegen) und TankDetail.tsx
@@ -96,15 +96,16 @@ export function BildMitMenue({
}) {
const { api, jetztAktualisieren } = useDaten()
const [offen, setzeOffen] = useState(false)
/* Ein Fehlschlag beim Hochladen war bisher unsichtbar: der Aufruf lief als
`void hochladen(f)` ins Leere. Jetzt steht er unter dem Bild. */
const [fehler, setzeFehler] = useState<string | null>(null)
const datei_ = useRef<HTMLInputElement | null>(null)
const hochladen = async (gewaehlt: File) => {
const base64 = await new Promise<string>((fertig, fehler) => {
const leser = new FileReader()
leser.onload = () => fertig(String(leser.result).split(",")[1] ?? "")
leser.onerror = () => fehler(leser.error)
leser.readAsDataURL(gewaehlt)
})
setzeFehler(null)
/* Verkleinern vor dem Senden - Begründung in bilder.ts. Ein Foto direkt
aus der Kamera sprengt sonst die Nachrichtengrenze der Verbindung. */
const base64 = await bildAlsBase64(await bildVerkleinern(gewaehlt))
await api.bildHochladen(datei, base64)
/* Ohne diese beiden Zeilen bliebe das alte Foto stehen: die Adresse ist
unveraendert, und Home Assistant liefert /local/ mit 31 Tagen
@@ -133,10 +134,16 @@ export function BildMitMenue({
hidden
onChange={(e) => {
const f = e.target.files?.[0]
if (f) void hochladen(f)
if (f)
void hochladen(f).catch((problem) =>
setzeFehler(
problem instanceof Error ? problem.message : "Hochladen fehlgeschlagen.",
),
)
e.target.value = ""
}}
/>
{fehler && <p className="dm-fehler">{fehler}</p>}
{/* Auswahlblatt statt schwebendem Menü: zwei Wege, die der Nutzer
selbst angestoßen hat, plus ein abgesetztes Abbrechen - genau der
Zuschnitt, für den Apple das Aktionsblatt vorsieht. Das frühere
+100
View File
@@ -129,3 +129,103 @@ export function markenlogoUrl(): string | undefined {
if (!basis) return undefined
return `${basis}/audi_dashboard_static/bilder/shell-logo.svg`
}
/* Ein Fahrzeugfoto wird im Browser verkleinert, bevor es hochgeladen wird.
WARUM: der Upload läuft als Dienstaufruf über die WebSocket-Verbindung, und
die hat eine Obergrenze je Nachricht - aiohttps Vorgabe sind 4 MiB, und
Base64 bläht die Datei vorher um ein Drittel auf. Über die reale Instanz
kommt noch Cloudflare samt Vorschaltserver dazu, die enger sein können.
Genau das war der Befund vom 03.09.2026: dasselbe Cockpit-Foto ging in der
Testinstanz (localhost) durch und scheiterte über die reale Instanz mit
"connection lost" - eine abgerissene Verbindung, kein Fehler des Backends,
deshalb auch keine brauchbare Meldung.
Verkleinern löst das an der Wurzel statt die Grenze zu verschieben: die
größte Fläche, auf der ein Foto je erscheint, ist die Inhaltsspalte im
Breitbildlayout mit 772px. 2000px lange Kante lassen dafür reichlich Luft
(auch für Bildschirme mit doppelter Punktdichte), und WebP bei 0,85 bringt
ein solches Foto typischerweise auf einige hundert Kilobyte.
Fällt irgendein Schritt aus (Format nicht dekodierbar, kein WebP-Encoder,
Ergebnis größer als das Original), bleibt es beim Original - schlechter als
vorher wird es dadurch nie. Wortgleich im Panel. */
const MAX_KANTE = 2000
const QUALITAET = 0.85
export function bildAlsBase64(blob: Blob): Promise<string> {
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 prüft: `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, läge 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 gewählt kommt aber eine `.heic` an, und die kann
ausser Safari/WKWebView niemand dekodieren. */
const SICHERE_TYPEN = /^image\/(jpeg|png|webp|gif|bmp|avif)$/i
const SICHERE_ENDUNGEN = /\.(jpe?g|png|webp|gif|bmp|avif)$/i
export function bildformatIstSicher(datei: File): boolean {
// Der MIME-Typ zählt, wenn er da ist. Aus dem Explorer gezogene Dateien
// kommen je nach Quelle mit leerem `type` an - dann die Endung, dieselbe
// Doppelprüfung wie bei `istPdf()` im Panel.
if (datei.type) return SICHERE_TYPEN.test(datei.type)
return SICHERE_ENDUNGEN.test(datei.name)
}
export class BildformatFehler extends Error {}
export async function bildVerkleinern(datei: File): Promise<Blob> {
let klein: Blob | null = null
const adresse = URL.createObjectURL(datei)
try {
const bild = await new Promise<HTMLImageElement>((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, 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<Blob | null>((fertig) =>
flaeche.toBlob(fertig, "image/webp", QUALITAET),
)
}
} catch (fehler) {
console.warn("Bild konnte nicht umgewandelt werden", fehler)
} finally {
URL.revokeObjectURL(adresse)
}
// Ein bereits sparsam gespeichertes Foto soll nicht ein zweites Mal durch
// eine verlustbehaftete Kodierung - das kostet Qualität 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 BildformatFehler(
`Dieses Bildformat kann der Browser nicht umwandeln (${art}). ` +
"Bitte das Foto als JPEG, PNG oder WebP sichern und noch einmal versuchen.",
)
}
return datei
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Der Weg eines Termins auf dem Gerät.
*
* Geprüft wird die Entscheidung, nicht EventKit: dass ein natives Gerät das
* Plugin nimmt und NICHT das Teilen-Blatt (aus dem nie ein Kalender kam, siehe
* Kopf von kalender.ts), dass eine .ipa ohne das Plugin auf die Datei
* zurückfällt, und dass ein echter Fehler nicht hinter diesem Rückfall
* verschwindet.
*
* `@capacitor/*` wird ersetzt: sonst meldet `isNativePlatform()` "Browser" und
* die native Verzweigung wird nie betreten.
*/
import { beforeEach, describe, expect, it, vi } from "vitest"
const { nativ, dateien, teilen } = vi.hoisted(() => ({
nativ: { terminAnlegen: vi.fn() },
dateien: { writeFile: vi.fn() },
teilen: { share: vi.fn() },
}))
vi.mock("@capacitor/core", () => ({
Capacitor: { isNativePlatform: () => true },
registerPlugin: () => nativ,
}))
vi.mock("@capacitor/filesystem", () => ({
Filesystem: dateien,
Directory: { Cache: "CACHE" },
Encoding: { UTF8: "utf8" },
}))
vi.mock("@capacitor/share", () => ({ Share: teilen }))
/** Was Capacitor wirft, wenn die installierte App das Plugin nicht kennt. */
function pluginFehltFehler(): Error {
const fehler = new Error('KalenderTermin does not have an implementation of "terminAnlegen".')
;(fehler as Error & { code: string }).code = "UNIMPLEMENTED"
return fehler
}
beforeEach(() => {
vi.clearAllMocks()
dateien.writeFile.mockResolvedValue({ uri: "file:///cache/termin.ics" })
teilen.share.mockResolvedValue(undefined)
})
describe("Termin an den Kalender übergeben", () => {
it("nimmt auf dem Gerät das Plugin und nicht das Teilen-Blatt", async () => {
nativ.terminAnlegen.mockResolvedValue({ status: "gespeichert" })
const { terminUebernehmen } = await import("./kalender")
await terminUebernehmen("RS4: Ölwechsel", "2027-03-14", "Musterstr. 1", "Autohaus Muster")
expect(nativ.terminAnlegen).toHaveBeenCalledWith({
titel: "RS4: Ölwechsel",
isoTag: "2027-03-14",
beschreibung: "Musterstr. 1",
ort: "Autohaus Muster",
})
expect(teilen.share).not.toHaveBeenCalled()
expect(dateien.writeFile).not.toHaveBeenCalled()
})
/* Ein Abbruch im System-Editor ist kein Fehler - die Funktion kehrt still
zurück, damit die Oberfläche nichts Rotes zeigt. */
it("behandelt einen Abbruch wie einen Erfolg", async () => {
nativ.terminAnlegen.mockResolvedValue({ status: "abgebrochen" })
const { terminUebernehmen } = await import("./kalender")
await expect(terminUebernehmen("Reifenwechsel", "2026-10-15")).resolves.toBeUndefined()
expect(teilen.share).not.toHaveBeenCalled()
})
it("fällt auf die Datei zurück, wenn die App das Plugin nicht kennt", async () => {
nativ.terminAnlegen.mockRejectedValue(pluginFehltFehler())
const { terminUebernehmen } = await import("./kalender")
await terminUebernehmen("Audi RS4: Inspektion", "2027-01-01")
expect(dateien.writeFile).toHaveBeenCalledWith(
expect.objectContaining({ path: "audi-rs4-inspektion.ics" }),
)
expect(teilen.share).toHaveBeenCalledWith(
expect.objectContaining({ files: ["file:///cache/termin.ics"] }),
)
})
/* Der Rückfall gilt NUR für die fehlende Umsetzung. Sonst verschwände ein
echtes Problem hinter einem Teilen-Blatt, aus dem der Termin nie in den
Kalender fände. */
it("reicht einen echten Fehler durch, statt zu teilen", async () => {
nativ.terminAnlegen.mockRejectedValue(new Error("Kein Fenster für den Kalender-Editor."))
const { terminUebernehmen } = await import("./kalender")
await expect(terminUebernehmen("Reifenwechsel", "2026-10-15")).rejects.toThrow(
"Kein Fenster für den Kalender-Editor.",
)
expect(teilen.share).not.toHaveBeenCalled()
})
})
+81 -33
View File
@@ -1,26 +1,59 @@
/**
* Termine als Kalenderdatei. Portiert aus `ics()` im alten Panel.
* Termine an den Kalender übergeben.
*
* Erzeugt eine .ics-Datei ohne Server, mit Erinnerung zwei Tage vorher.
* DREI WEGE, WEIL DREI UMGEBUNGEN
* -------------------------------
* **Auf dem Gerät** geht der Termin über EventKit direkt in den Kalender
* (native/KalenderTermin.swift): der System-Editor erscheint fertig ausgefüllt,
* ein Tipp auf „Hinzufügen" genügt.
*
* ZWEI WEGE, WEIL EIN DOWNLOAD IN DER APP NICHT ANKOMMT
* -----------------------------------------------------
* Im Browser und im HA-Panel genügt ein `<a download>` mit einer
* Blob-Adresse. In der nativen Hülle nicht: **WKWebView ignoriert das
* `download`-Attribut**, der Klick läuft ins Leere und es passiert sichtbar
* nichts — genau so gemeldet („Wechseltermin in Kalender übernehmen klappt
* nicht", 2026-08-31).
* Vorher lief auch dort der Datei-Weg, und der konnte nie ankommen. Nicht wegen
* der Datei: **Apples Kalender-App meldet sich beim System gar nicht als
* Teilen-Ziel an**, für kein Format — das Teilen-Blatt kann sie deshalb
* überhaupt nicht anbieten. Dass ein per Mail geschickter Anhang funktioniert,
* führt in die Irre; dort öffnet die Dokumentvorschau des Systems (Quick Look)
* mit ihrem eigenen „Alle hinzufügen", nicht das Teilen-Blatt. Gemeldet vom
* Eigentümer am 02.09.2026, nachdem der Dateiname am selben Tag repariert war
* und es trotzdem nicht half.
*
* Auf dem Gerät geht die Datei deshalb ins temporäre Verzeichnis und von
* dort ins iOS-Teilen-Blatt, aus dem „Zu Kalender hinzufügen" kommt. Das ist
* auch der ehrlichere Ablauf: man sieht, wohin der Termin geht, statt dass
* eine Datei stillschweigend irgendwo landet.
* **Der Datei-Weg bleibt als Rückfall** für eine .ipa von vor dem 03.09.2026,
* die das Plugin noch nicht kennt. Ein Kalender steht darin weiterhin nicht zur
* Auswahl, aber die Datei lässt sich in „Dateien" sichern und von dort öffnen —
* besser als ein Knopf, der gar nichts tut.
*
* **Im Browser und im HA-Panel** bleibt es beim gewöhnlichen Download; dort
* übernimmt das Betriebssystem die .ics wie jede andere Datei. (In der nativen
* Hülle ginge das nicht: **WKWebView ignoriert das `download`-Attribut**, der
* Klick lief sichtbar ins Leere — so gemeldet am 2026-08-31.)
*/
import { Capacitor } from "@capacitor/core"
import { Capacitor, registerPlugin } from "@capacitor/core"
import { Directory, Encoding, Filesystem } from "@capacitor/filesystem"
import { Share } from "@capacitor/share"
interface KalenderTermin {
/** `status`: "gespeichert" oder "abgebrochen" - ein Abbruch ist kein Fehler. */
terminAnlegen(angaben: {
titel: string
isoTag: string
beschreibung: string
ort: string
}): Promise<{ status: string }>
}
/** Muss zu `jsName` in KalenderTermin.swift passen. */
const Nativ = registerPlugin<KalenderTermin>("KalenderTermin")
/**
* Unterscheidet „dieses Gerät kennt das Plugin nicht" von einem echten Fehler.
* Nur im ersten Fall ist der Rückfall auf die Datei richtig - sonst verschwände
* ein echtes Problem hinter einem Teilen-Blatt, aus dem der Termin nie in den
* Kalender fände.
*/
function pluginFehlt(fehler: unknown): boolean {
return (fehler as { code?: string } | null)?.code === "UNIMPLEMENTED"
}
function zeilenUmbruch(text: string): string {
return text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n")
}
@@ -104,37 +137,52 @@ function dateiname(titel: string): string {
return `${stamm || "termin"}.ics`
}
export async function kalenderDateiLaden(
/** Der Rückfall für Geräte ohne das Plugin - siehe Kopf dieser Datei. */
async function ueberTeilenBlatt(
titel: string,
isoTag: string,
beschreibung: string,
ort: string,
): Promise<void> {
/* Cache statt Documents: der Termin ist im Kalender aufgehoben, sobald er
dort ist - die Zwischendatei muss nicht überleben, und iOS darf den
Cache jederzeit räumen. */
const geschrieben = await Filesystem.writeFile({
path: dateiname(titel),
data: kalenderDatei(titel, isoTag, beschreibung, ort),
directory: Directory.Cache,
encoding: Encoding.UTF8,
})
/* files statt url: beides landet im Plugin als URL in den activityItems,
aber `files` ist der dokumentierte Weg für lokale Dateien - `url` ist
für Links gedacht. dialogTitle ist Android-only und schadet hier nicht. */
await Share.share({ title: titel, files: [geschrieben.uri], dialogTitle: titel })
}
export async function terminUebernehmen(
titel: string,
isoTag: string,
beschreibung = "",
ort = "",
): Promise<void> {
const inhalt = kalenderDatei(titel, isoTag, beschreibung, ort)
const name = dateiname(titel)
if (Capacitor.isNativePlatform()) {
/* Cache statt Documents: der Termin ist im Kalender aufgehoben, sobald er
dort ist - die Zwischendatei muss nicht überleben, und iOS darf den
Cache jederzeit räumen. */
const geschrieben = await Filesystem.writeFile({
path: name,
data: inhalt,
directory: Directory.Cache,
encoding: Encoding.UTF8,
})
/* files statt url: beides landet im Plugin als URL in den activityItems,
aber `files` ist der dokumentierte Weg für lokale Dateien - `url` ist
für Links gedacht. dialogTitle ist Android-only und schadet hier nicht. */
await Share.share({ title: titel, files: [geschrieben.uri], dialogTitle: titel })
try {
await Nativ.terminAnlegen({ titel, isoTag, beschreibung, ort })
return
} catch (fehler) {
if (!pluginFehlt(fehler)) throw fehler
}
await ueberTeilenBlatt(titel, isoTag, beschreibung, ort)
return
}
const blob = new Blob([inhalt], { type: "text/calendar;charset=utf-8" })
const blob = new Blob([kalenderDatei(titel, isoTag, beschreibung, ort)], {
type: "text/calendar;charset=utf-8",
})
const url = URL.createObjectURL(blob)
const verweis = document.createElement("a")
verweis.href = url
verweis.download = name
verweis.download = dateiname(titel)
document.body.appendChild(verweis)
verweis.click()
verweis.remove()
@@ -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"],
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Regressionstest: zwei Wechsel des Fahrtsignals in derselben Sekunde.
Der Fall, der ihn nötig gemacht hat (02.09.2026, im Verlauf nachgemessen):
das Gerät hatte gepuffert, deshalb kamen "Fahrt zu Ende" und "Fahrt begonnen"
gemeinsam um 21:22:12 bei Home Assistant an. Home Assistant startet für jeden
Zustandswechsel eine eigene Aufgabe, und der Beende-Zweig wartet mehrfach
(Meldezeit, Verlauf, Schreiben, Screening). Das "on" überholte ihn, sah
fahrt_start_ts noch gesetzt und fiel durch beide Zweige - die anschliessende
36-Minuten-Fahrt wurde live nie erfasst und tauchte erst über den Rückblick auf.
Geprüft wird deshalb genau die Verschränkung, nicht die Fahrterkennung als
Ganzes: die langsamen Teile (_geraetezeit, _echtes_ende, fahrt_beenden) sind
ersetzt, übrig bleibt die Frage, ob der zweite Wechsel den ersten abwarten muss.
Aufruf: python3 tests/fahrterkennung/test_signalwechsel.py
(braucht das homeassistant-Paket, weil die Integration es importiert - also
z. B. im Test-Container)
"""
import asyncio
import datetime
import os
import sys
import unittest
_HIER = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(os.path.dirname(_HIER)))
from custom_components.audi_dashboard import fahrterkennung as f # noqa: E402
T0 = datetime.datetime(2026, 9, 2, 16, 32, 18, tzinfo=datetime.UTC)
T_WECHSEL = datetime.datetime(2026, 9, 2, 21, 22, 12, tzinfo=datetime.UTC)
class FakeKoordinator:
"""Nur das, was der Signalpfad anfasst."""
def __init__(self, start_ts):
self.fahrt_start_ts = start_ts
self.fahrt_sperre = asyncio.Lock()
self.beendet = []
self.veroeffentlicht = 0
def warte_ende_ab_abbrechen(self):
pass
async def fahrt_start_setzen(self, ts):
self.fahrt_start_ts = ts
async def fahrzeugstatus_veroeffentlichen(self):
self.veroeffentlicht += 1
class SignalwechselTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self._echt = (f._geraetezeit, f._echtes_ende, f.fahrt_beenden)
async def geraetezeit(k, ereigniszeit, standard):
# Der eigentliche Punkt: dieser Schritt wartet wirklich (in echt
# bis zu MELDEZEIT_FRIST_S = 2 s auf die Meldezeit des Geräts).
await asyncio.sleep(0.05)
return ereigniszeit or standard
async def echtes_ende(k, start, signal_ende):
return signal_ende
async def beenden(k, start_ts, ende_ts):
await asyncio.sleep(0.05) # Schreiben und Screening brauchen Zeit
k.beendet.append((start_ts, ende_ts))
await k.fahrt_start_setzen(None)
f._geraetezeit, f._echtes_ende, f.fahrt_beenden = (
geraetezeit, echtes_ende, beenden,
)
async def asyncTearDown(self):
f._geraetezeit, f._echtes_ende, f.fahrt_beenden = self._echt
async def test_off_und_on_in_derselben_sekunde(self):
"""Der genaue Fall vom 02.09.2026: die neue Fahrt darf nicht verloren
gehen, während die alte noch geschlossen wird."""
k = FakeKoordinator(T0)
aus = asyncio.create_task(f.zuendung_geaendert(k, "off", "on", T_WECHSEL))
# Dem "off" gerade so viel Vorlauf, dass es die Sperre hält und im
# ersten await steht - genau die Lage, in der das "on" eintrifft.
await asyncio.sleep(0)
await asyncio.sleep(0)
an = asyncio.create_task(f.zuendung_geaendert(k, "on", "off", T_WECHSEL))
await asyncio.gather(aus, an)
self.assertEqual(len(k.beendet), 1, "die alte Fahrt muss genau einmal enden")
self.assertEqual(k.beendet[0][0], T0)
self.assertIsNotNone(
k.fahrt_start_ts,
"nach dem Paar muss die neue Fahrt laufen - sonst ist sie verloren",
)
self.assertEqual(k.fahrt_start_ts, T_WECHSEL)
async def test_nacheinander_unveraendert(self):
"""Der gewöhnliche Ablauf darf sich durch die Sperre nicht ändern."""
k = FakeKoordinator(T0)
await f.zuendung_geaendert(k, "off", "on", T_WECHSEL)
self.assertEqual(len(k.beendet), 1)
self.assertIsNone(k.fahrt_start_ts)
await f.zuendung_geaendert(k, "on", "off", T_WECHSEL)
self.assertEqual(k.fahrt_start_ts, T_WECHSEL)
async def test_zweites_on_startet_keine_zweite_fahrt(self):
k = FakeKoordinator(T0)
await f.zuendung_geaendert(k, "on", "off", T_WECHSEL)
self.assertEqual(k.fahrt_start_ts, T0, "die laufende Fahrt bleibt stehen")
self.assertEqual(k.beendet, [])
async def test_ohne_vorzustand_passiert_nichts(self):
"""Registrierung beim Start ist kein Wechsel - sonst Phantomfahrten."""
k = FakeKoordinator(None)
await f.zuendung_geaendert(k, "on", None, T_WECHSEL)
self.assertIsNone(k.fahrt_start_ts)
async def test_funkstille_wird_ignoriert(self):
k = FakeKoordinator(T0)
await f.zuendung_geaendert(k, "unavailable", "on", T_WECHSEL)
self.assertEqual(k.fahrt_start_ts, T0)
self.assertEqual(k.beendet, [])
if __name__ == "__main__":
unittest.main(verbosity=2)