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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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"}
|
||||
Binary file not shown.
@@ -388,6 +388,103 @@ function startIndex() {
|
||||
const i = BILDER.findIndex((b) => b.name === CONFIG.startbild);
|
||||
return i < 0 ? 0 : i;
|
||||
}
|
||||
|
||||
/* Ein Fahrzeugfoto wird im Browser verkleinert, bevor es hochgeladen wird.
|
||||
|
||||
WARUM: der Upload laeuft als Dienstaufruf ueber die WebSocket-Verbindung,
|
||||
und die hat eine Obergrenze je Nachricht - aiohttps Vorgabe sind 4 MiB, und
|
||||
Base64 blaeht die Datei vorher um ein Drittel auf. Ueber die reale Instanz
|
||||
kommt noch Cloudflare samt Vorschaltserver dazu, die enger sein koennen.
|
||||
Genau das war der Befund vom 03.09.2026: dasselbe Cockpit-Foto ging in der
|
||||
Testinstanz (localhost) durch und scheiterte ueber die reale Instanz mit
|
||||
"connection lost" - eine abgerissene Verbindung, kein Fehler des Backends,
|
||||
deshalb auch keine brauchbare Meldung.
|
||||
|
||||
Verkleinern loest das an der Wurzel statt die Grenze zu verschieben: die
|
||||
groesste Flaeche, auf der ein Foto je erscheint, ist die Inhaltsspalte im
|
||||
Breitbildlayout mit 772px. 2000px lange Kante lassen dafuer reichlich Luft
|
||||
(auch fuer Bildschirme mit doppelter Punktdichte), und WebP bei 0,85 bringt
|
||||
ein solches Foto typischerweise auf einige hundert Kilobyte.
|
||||
|
||||
Faellt irgendein Schritt aus (Format nicht dekodierbar, kein WebP-Encoder,
|
||||
Ergebnis groesser als das Original), bleibt es beim Original - schlechter
|
||||
als vorher wird es dadurch nie. */
|
||||
const BILD_MAX_KANTE = 2000;
|
||||
const BILD_QUALITAET = 0.85;
|
||||
|
||||
function bildAlsBase64(blob) {
|
||||
return new Promise((fertig, fehler) => {
|
||||
const leser = new FileReader();
|
||||
leser.onload = () => fertig(String(leser.result).split(",")[1] || "");
|
||||
leser.onerror = () => fehler(leser.error);
|
||||
leser.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
/* Formate, die jeder Browser dieser App wirklich anzeigen kann.
|
||||
|
||||
Wichtig, weil das Backend nichts prueft: bilder.py schreibt die Bytes roh
|
||||
unter einen der elf festen .webp-Namen. Ein Foto, das der Browser nicht
|
||||
umwandeln konnte und das er auch nicht anzeigen kann, laege danach als
|
||||
Bilddatei auf der Platte, die niemand darstellt - ohne Meldung.
|
||||
|
||||
Der praktische Fall ist HEIC: die iPhone-Fotoauswahl liefert in der Regel
|
||||
JPEG, am Mac im Finder gewaehlt kommt aber eine .heic an, und die kann
|
||||
ausser Safari/WKWebView niemand dekodieren. */
|
||||
const BILD_SICHERE_TYPEN = /^image\/(jpeg|png|webp|gif|bmp|avif)$/i;
|
||||
const BILD_SICHERE_ENDUNGEN = /\.(jpe?g|png|webp|gif|bmp|avif)$/i;
|
||||
|
||||
function bildformatIstSicher(datei) {
|
||||
// Der MIME-Typ zaehlt, wenn er da ist. Aus dem Explorer gezogene Dateien
|
||||
// kommen je nach Quelle mit leerem type an - dann die Endung, dieselbe
|
||||
// Doppelpruefung wie bei istPdf().
|
||||
if (datei.type) return BILD_SICHERE_TYPEN.test(datei.type);
|
||||
return BILD_SICHERE_ENDUNGEN.test(datei.name);
|
||||
}
|
||||
|
||||
async function bildVerkleinern(datei) {
|
||||
let klein = null;
|
||||
const adresse = URL.createObjectURL(datei);
|
||||
try {
|
||||
const bild = await new Promise((fertig, fehler) => {
|
||||
const i = new Image();
|
||||
i.onload = () => fertig(i);
|
||||
i.onerror = () => fehler(new Error("Bild nicht lesbar"));
|
||||
i.src = adresse;
|
||||
});
|
||||
const kante = Math.max(bild.naturalWidth, bild.naturalHeight);
|
||||
if (kante) {
|
||||
const faktor = Math.min(1, BILD_MAX_KANTE / kante);
|
||||
const flaeche = document.createElement("canvas");
|
||||
flaeche.width = Math.round(bild.naturalWidth * faktor);
|
||||
flaeche.height = Math.round(bild.naturalHeight * faktor);
|
||||
flaeche.getContext("2d").drawImage(bild, 0, 0, flaeche.width, flaeche.height);
|
||||
klein = await new Promise((fertig) =>
|
||||
flaeche.toBlob(fertig, "image/webp", BILD_QUALITAET),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Bild konnte nicht umgewandelt werden", err);
|
||||
} finally {
|
||||
URL.revokeObjectURL(adresse);
|
||||
}
|
||||
|
||||
// Ein bereits sparsam gespeichertes Foto soll nicht ein zweites Mal durch
|
||||
// eine verlustbehaftete Kodierung - das kostet Qualitaet ohne Gewinn.
|
||||
if (klein && klein.size < datei.size) return klein;
|
||||
|
||||
/* Umwandeln ging nicht (oder brachte nichts) - dann muss wenigstens das
|
||||
Original anzeigbar sein. Sonst lieber jetzt eine klare Meldung als
|
||||
hinterher ein leeres Bild, dessen Ursache niemand mehr sieht. */
|
||||
if (!bildformatIstSicher(datei)) {
|
||||
const art = datei.type || datei.name.split(".").pop() || "unbekannt";
|
||||
throw new Error(
|
||||
`Dieses Bildformat kann der Browser nicht umwandeln (${art}). ` +
|
||||
"Bitte das Foto als JPEG, PNG oder WebP sichern und noch einmal versuchen.",
|
||||
);
|
||||
}
|
||||
return datei;
|
||||
}
|
||||
function bildInfo(i) {
|
||||
const b = BILDER[i];
|
||||
if (b.name === "Seitenansicht" && CAR.reifen.aktiv === "Winter") {
|
||||
@@ -5937,16 +6034,15 @@ function ereignisseVerdrahten() {
|
||||
if (e.target.dataset.bildupload !== undefined) {
|
||||
const datei = e.target.files[0]; if (!datei) return;
|
||||
const dateiname = e.target.dataset.bildupload;
|
||||
const leser = new FileReader();
|
||||
leser.onload = async () => {
|
||||
const daten_base64 = String(leser.result).split(",")[1] || "";
|
||||
e.target.value = "";
|
||||
(async () => {
|
||||
try {
|
||||
const klein = await bildVerkleinern(datei);
|
||||
const daten_base64 = await bildAlsBase64(klein);
|
||||
await HASS.callService(DOMAIN, "bild_hochladen", { dateiname, daten_base64 });
|
||||
} catch (err) { hinweis("Hochladen fehlgeschlagen", err.message); }
|
||||
bildMenuOffen = null; bildVersion = Date.now(); render();
|
||||
};
|
||||
leser.readAsDataURL(datei);
|
||||
e.target.value = "";
|
||||
})();
|
||||
return;
|
||||
}
|
||||
if (e.target.dataset.backupintervall !== undefined) {
|
||||
|
||||
@@ -41,6 +41,7 @@ import datetime
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from . import screening
|
||||
from .batterie import SPANNUNG_MIN_V
|
||||
from .fahrterkennung import leere_fahrt
|
||||
from .tankerkennung import LITER_SCHWELLE, leerer_tankvorgang, schwelle_prozent
|
||||
@@ -524,6 +525,23 @@ async def importieren(k: Koordinator, start: object, ende: object) -> None:
|
||||
await k.tankvorgang_nachbereiten()
|
||||
await k.batterieverlauf_veroeffentlichen()
|
||||
|
||||
# Der Rückblick trägt nur ein, was er aus dem Verlauf selbst ableiten kann.
|
||||
# Ortsnamen, Verbrauchseichung und der Radzähler hängen dagegen am
|
||||
# Screening - und das lief bis zum 03.09.2026 nur bei einem Wechsel des
|
||||
# Kilometerstands, nach einem Neustart oder aus dem Nachfassen heraus.
|
||||
# Steht das Fahrzeug, ist keines davon der Fall: eine frisch importierte
|
||||
# Fahrt blieb ohne Ort liegen, obwohl beide Koordinaten dastanden und
|
||||
# sogar im Zwischenspeicher schon aufgelöst waren (gemeldet vom
|
||||
# Eigentümer). Dass es so gemeint war, sagt das Nachfassen selbst - sein
|
||||
# Docstring nennt den Import als einen der zwei Fälle, für die es
|
||||
# existiert; nur ausgelöst hat ihn nie jemand.
|
||||
#
|
||||
# Nur bei wirklich angelegten Fahrten: ein Import ohne Ergebnis (der
|
||||
# Normalfall beim zweiten Lauf über denselben Zeitraum) soll keinen
|
||||
# Durchlauf kosten.
|
||||
if fahrten["angelegt"]:
|
||||
await screening.durchfuehren(k)
|
||||
|
||||
ergebnis = {
|
||||
"von": von.isoformat(),
|
||||
"bis": bis.isoformat(),
|
||||
|
||||
@@ -127,6 +127,9 @@ class Koordinator:
|
||||
|
||||
self._store = Store[dict](hass, STORE_VERSION, f"{entry.entry_id}_laufzeit")
|
||||
self.fahrt_start_ts: datetime.datetime | None = None
|
||||
# Zwei Wechsel des Fahrtsignals in derselben Sekunde duerfen sich nicht
|
||||
# ueberholen - Begruendung in fahrterkennung.zuendung_geaendert().
|
||||
self.fahrt_sperre = asyncio.Lock()
|
||||
self.tiefststand_pct: float | None = None
|
||||
self.tiefststand_liter: float | None = None
|
||||
# Seit wann das Fahrzeug steht (Zeit des Geraets). None = unbekannt.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "audi_dashboard",
|
||||
"name": "Audi Dashboard",
|
||||
"version": "2026.9.2.25",
|
||||
"version": "2026.9.3.3",
|
||||
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
|
||||
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
|
||||
"codeowners": ["@paul"],
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user