diff --git a/AGENTS.md b/AGENTS.md index a195af2..a435313 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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:22–21: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:32–21:05 (4 h 34) | 02.09. 21:22–21: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. diff --git a/companion-app/native/KalenderTermin.swift b/companion-app/native/KalenderTermin.swift new file mode 100644 index 0000000..c47c4b4 --- /dev/null +++ b/companion-app/native/KalenderTermin.swift @@ -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) + } +} diff --git a/companion-app/scripts/ios-signieren.sh b/companion-app/scripts/ios-signieren.sh index 5acb3d1..12fec5d 100755 --- a/companion-app/scripts/ios-signieren.sh +++ b/companion-app/scripts/ios-signieren.sh @@ -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: diff --git a/companion-app/scripts/ios-teilen-einrichten.mjs b/companion-app/scripts/ios-teilen-einrichten.mjs index 9a85546..44a9024 100644 --- a/companion-app/scripts/ios-teilen-einrichten.mjs +++ b/companion-app/scripts/ios-teilen-einrichten.mjs @@ -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() diff --git a/companion-app/src/screens/Reifen.tsx b/companion-app/src/screens/Reifen.tsx index b126401..3db70f6 100644 --- a/companion-app/src/screens/Reifen.tsx +++ b/companion-app/src/screens/Reifen.tsx @@ -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(null) const [kmEntwurf, setzeKmEntwurf] = useState("") const [wechselDatum, setzeWechselDatum] = useState(null) + const [kalenderFehler, setzeKalenderFehler] = useState(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 + {kalenderFehler &&

{kalenderFehler}

} {satzTile("sommer")} diff --git a/companion-app/src/screens/Service.tsx b/companion-app/src/screens/Service.tsx index 3ad72be..1c050c5 100644 --- a/companion-app/src/screens/Service.tsx +++ b/companion-app/src/screens/Service.tsx @@ -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(null) + const [kalenderFehler, setzeKalenderFehler] = useState(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". */} - 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 + {kalenderFehler &&

{kalenderFehler}

} ) } diff --git a/companion-app/src/screens/bausteine.tsx b/companion-app/src/screens/bausteine.tsx index b43698c..3b1954c 100644 --- a/companion-app/src/screens/bausteine.tsx +++ b/companion-app/src/screens/bausteine.tsx @@ -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(null) const datei_ = useRef(null) const hochladen = async (gewaehlt: File) => { - const base64 = await new Promise((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 &&

{fehler}

} {/* 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 diff --git a/companion-app/src/screens/bilder.ts b/companion-app/src/screens/bilder.ts index 857e530..d53419f 100644 --- a/companion-app/src/screens/bilder.ts +++ b/companion-app/src/screens/bilder.ts @@ -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 { + 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 { + let klein: Blob | null = 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, 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", 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 +} diff --git a/companion-app/src/screens/kalender.test.ts b/companion-app/src/screens/kalender.test.ts new file mode 100644 index 0000000..4ceeb85 --- /dev/null +++ b/companion-app/src/screens/kalender.test.ts @@ -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() + }) +}) diff --git a/companion-app/src/screens/kalender.ts b/companion-app/src/screens/kalender.ts index 53f2784..04cbb66 100644 --- a/companion-app/src/screens/kalender.ts +++ b/companion-app/src/screens/kalender.ts @@ -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 `` 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") + +/** + * 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 { + /* 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 { - 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() diff --git a/custom_components/audi_dashboard/fahrterkennung.py b/custom_components/audi_dashboard/fahrterkennung.py index ded7f63..4cec182 100644 --- a/custom_components/audi_dashboard/fahrterkennung.py +++ b/custom_components/audi_dashboard/fahrterkennung.py @@ -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 " diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 6bc3abd..639de80 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.9.2.25","sha256":"106acbff40d3a14b59a1759f422e0d4346f29f196d0c3196c80289f6815d6804","bytes":266091,"gebaut":"2026-09-02T20:11:42Z"} \ No newline at end of file +{"version":"2026.9.3.3","sha256":"7b2e99c4c05b9522abc8cb3276a9cca3fd25d23c25db1ebd79d312be712f9945","bytes":266735,"gebaut":"2026-09-02T23:54:05Z"} \ No newline at end of file diff --git a/custom_components/audi_dashboard/frontend/app/bundle.zip b/custom_components/audi_dashboard/frontend/app/bundle.zip index 89fda9a..487199e 100644 Binary files a/custom_components/audi_dashboard/frontend/app/bundle.zip and b/custom_components/audi_dashboard/frontend/app/bundle.zip differ diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js index cf0835b..1cf2624 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js @@ -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) { diff --git a/custom_components/audi_dashboard/historienimport.py b/custom_components/audi_dashboard/historienimport.py index 6ecfd88..eee7772 100644 --- a/custom_components/audi_dashboard/historienimport.py +++ b/custom_components/audi_dashboard/historienimport.py @@ -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(), diff --git a/custom_components/audi_dashboard/koordinator.py b/custom_components/audi_dashboard/koordinator.py index f3c5f35..000229d 100644 --- a/custom_components/audi_dashboard/koordinator.py +++ b/custom_components/audi_dashboard/koordinator.py @@ -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. diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index fa98a54..aec27c7 100644 --- a/custom_components/audi_dashboard/manifest.json +++ b/custom_components/audi_dashboard/manifest.json @@ -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"], diff --git a/tests/fahrterkennung/test_signalwechsel.py b/tests/fahrterkennung/test_signalwechsel.py new file mode 100644 index 0000000..5775e92 --- /dev/null +++ b/tests/fahrterkennung/test_signalwechsel.py @@ -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)