diff --git a/AGENTS.md b/AGENTS.md index f287653..ce540dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,12 @@ **Last updated: 2026-09-04** (Tankstelle zweizeilig und antippbar, die Belegkarte sagt bei fehlender Position die Wahrheit statt einen toten Knopf zu zeigen, der Regler bekommt einen -Speichern-Knopf, und die Share-Erweiterung trug eine andere Versionsnummer als die App - `2026.9.4.5`, -Abschnitt CC. Davor: Geraetezeit statt Ankunftszeit - die Wurzel hinter der 3-km-Fahrt: +Speichern-Knopf, und die Share-Erweiterung trug eine andere Versionsnummer als die App; +dazu die Kopfmarke statt der Firmierung des Betreibers und ein +Cache-Brecher auf der .ipa-Adresse; die .ipa liess sich zuletzt nicht mehr +installieren - der Erweiterung fehlte die CFBundleVersion; dazu der QR-Scanner +fuer den Zugangs-Token, `2026.9.4.8`, +Abschnitte CC bis CE. Davor: Geraetezeit statt Ankunftszeit - die Wurzel hinter der 3-km-Fahrt: das Fahrtfenster stand in Geraetezeit, der Verlauf war nach Ankunftszeit sortiert. Dazu die neun Paritaetsbefunde und die verunreinigte Batteriehistorie, `2026.9.4.1`, Abschnitt CB. Davor: Der Regler stellt den Schlaf-Timeout des Dongles, flespi lesend und schreibend, die eigene Warteschlange wieder entfallen, `2026.9.3.10`, Abschnitt BZ. Davor: Zugaenge: die Dienste-Token aus App und Panel bedienbar, der Wert kommt nie @@ -475,7 +479,8 @@ wraps the web app for iPhone; a PWA home-screen install is the accepted intermed - [x] Authenticated smoke tests of the data layer (reads, service calls, queue round-trip) against `audi_ha_test` with a real token - [x] Offline UX per design brief (offline marker, visible pending queue) -- [ ] **QR-Code-Scanner fürs Token-Onboarding (angefragt 2026-08-28, noch nicht geplant/gebaut)** — +- [x] **QR-Code-Scanner fürs Token-Onboarding — gebaut am 2026-09-04, siehe Abschnitt CE.** + Historie der Anforderung: 2026-08-11 bewusst weggelassen ("nur wenn es einfach bleibt"), der Owner will das jetzt doch als Alternative zum manuellen Einfügen. @@ -9629,3 +9634,213 @@ selbst stehen. zur Testinstanz. Das ist der Weg, die App live zu pruefen, ohne an CORS zu scheitern: `cors_allowed_origins` in der `configuration.yaml` blieb wirkungslos (Preflight weiterhin 403), der eingetragene Block ist deshalb wieder entfernt. + +### Nachtrag (2026.9.4.6): die Kopfmarke, nicht die Firmierung + +Praezisierung des Eigentuemers zur Zeile oben: dort gehoert die **Marke** hin +("Shell, Aral, OMV ..."), nicht der Betreiber. `A. Zrenner GmbH` ist die +Firmierung dessen, der die Tankstelle betreibt - nach aussen sichtbar ist die +Kopfmarke, und nur die sagt dem Leser etwas. + +Neu `markeErkennen()` in beiden Codebasen: sucht die Kopfmarke im gespeicherten +`station_name` gegen eine Liste, die **wortgleich** mit `_MARKEN` im +`shell_beleg_parser.py` ist. Der Parser erkennt die Marke beim Einlesen eines +Belegs, diese Liste erkennt sie nachtraeglich in dem, was gespeichert ist - +laufen die beiden auseinander, zeigt die Oberflaeche eine andere Marke als der +Beleg. Gesucht wird mit Wortgrenzen, damit "Star" nicht in "Starnberg" trifft. + +Damit steht oben `, ` und unten ` `; ohne erkennbare +Marke nur die Strasse - der Betreibername tritt **nicht** an ihre Stelle. Traegt +der Beleg gar keine Anschrift, bleibt der gespeicherte Name unveraendert stehen +("Shell, SÖLDEN"), sonst hiesse eine bekannte Tankstelle ploetzlich "unbekannt". + +Gegen die 16 echten Datensaetze der Testinstanz nachgerechnet, mit den Funktionen +des Panels selbst (aus dem Buendel geschnitten und in Node ausgefuehrt, nicht +nachgebaut): drei Betreibernamen fallen korrekt weg (`Hermann Mogler +Mineraloelg. GmbH`, `TC Sengül GmbH`, `A. Zrenner GmbH`), `Shell, SÖLDEN` und +`Testtankstelle` bleiben stehen, leer ergibt "unbekannt". Die App deckt dasselbe +mit 24 Faellen in `src/daten/tankstelle.test.ts` ab. + +### Nachtrag (2026.9.4.6): die .ipa-Adresse bekommt einen Cache-Brecher + +Gemeldet: die alte App geloescht, neu installiert - "Wolke mit Pfeil" und "kann +nicht installiert werden, versuche es spaeter erneut". + +**Das ausgelieferte Artefakt ist in Ordnung**, geprueft statt vermutet: die von +Gitea gelieferte `App.ipa` ist ein gueltiges Zip, App **und** Erweiterung tragen +`2026.9.4.5` (die Versionsangleichung aus Abschnitt CC ist also drin), die +`manifest.plist` passt dazu (`app.datametric360`, `bundle-version 2026.9.4.5`), +beide eingebetteten Profile fuehren beide Geraete-UDIDs und laufen bis +2027-08-29, beide Symbol-Adressen antworten mit HTTP 200 als `image/png`. + +Was fehlte, ist die Vorsorge, die dieses Projekt an jeder anderen ausgelieferten +Datei laengst trifft: `ios-luftweg.sh` schrieb `$BASIS/App.ipa` +**ohne** Versionsanhang. Die Adresse ist damit ueber jeden Bau hinweg +zeichengleich, und der Installations-Dienst von iOS haelt eine einmal geholte +Datei fuer schon bekannt - eine bekannte Ursache genau dieser beiden Symptome. +Jetzt tragen `.ipa`, beide Symbole und die `manifest.plist` selbst ein +`?v=`; die bereits ausgelieferten Dateien unter `auslieferung/` sind +von Hand nachgezogen (das Skript laeuft nur auf dem Mac, es braucht +`PlistBuddy`). + +**Ebenfalls geklaert, weil es zusammen gemeldet wurde:** das veraenderte +App-Symbol kommt nicht von hier, sondern vom Commit `d819f2e` ("iOS-App auf +2026.9.4.5"), der vom Mac gepusht wurde - er hat `auslieferung/` erneuert. + +**Was bleibt, wenn der Cache-Brecher nicht reicht:** das stecken gebliebene +Platzhalter-Symbol auf dem Startbildschirm loeschen (langes Tippen, "App +entfernen"), Geraet neu starten, dann den `itms-services://`-Link erneut in +Safari oeffnen. Und der `?v=` wirkt erst mit dem naechsten Aufruf des Links, +nicht rueckwirkend auf einen laufenden Versuch. + +### Was in dieser Runde NICHT live geprueft werden konnte + +Die Testinstanz zeigte in dieser Sitzung die Anmeldemaske, und ein Passwort +einzugeben ist in diesem Projekt ausgeschlossen - auch bei der Wegwerf-Instanz. +Beide Oberflaechen sind deshalb ueber Modul-Parse, `tsc --noEmit`, 206/206 Tests +und die oben beschriebene Rechnung gegen die echten Datensaetze belegt, **nicht** +ueber einen Screenshot. Die Messungen zur Zeilenhoehe (82px) und zur +SmartDeal-Zeile (26,4px) stammen aus dem Teil der Sitzung davor und sind von der +Markenaenderung nicht beruehrt: sie macht die erste Zeile kuerzer, nicht laenger. + +## CD. Die .ipa liess sich nicht mehr installieren - eine fehlende Pflichtangabe (2026.9.4.7) + +Gemeldet: „bis zum letzten Xcode-Update hatte ich das richtige Logo, jetzt steht +ein anderes auf dem iPhone und die App ist nicht mehr installierbar." + +**Beides ist ein Befund, nicht zwei.** Die letzte funktionierende `.ipa` +(`2026.9.3.3`) aus der Historie geholt und gegen die aktuelle gestellt - beide +entpackt, die Info.plists mit `plistlib` gelesen (im Testcontainer, hier gibt es +kein `PlistBuddy`): + +| | `2026.9.3.3` (lief) | `2026.9.4.5` (lehnt ab) | +|---|---|---| +| App `CFBundleVersion` | `1` | `1` | +| Erweiterung `CFBundleVersion` | `1` | **fehlt ganz** | + +Alles andere ist in beiden gleich und in Ordnung: Bundle-IDs, Profile mit beiden +Geraete-UDIDs bis 2027-08-29, `get-task-allow: false`, App-Gruppe auf beiden +Zielen, `MinimumOSVersion`. + +`CFBundleVersion` ist eine Pflichtangabe. Fehlt sie in **einem** der beiden +Buendel, verweigert iOS die Installation der **ganzen** App - mit genau der +nichtssagenden Meldung "kann nicht installiert werden, versuche es spaeter +erneut". + +**Verursacht hat es die Versionsangleichung aus Abschnitt CC**, also der Versuch, +einen anderen Fehler zu beheben. Der Schritt liest die Build-Nummer aus der +Info.plist der App - dort steht aber keine Zahl, sondern der Platzhalter einer +Build-Einstellung (`$(CURRENT_PROJECT_VERSION)`, aus dem Capacitor-Geruest). +Fuers App-Ziel loest Xcode ihn zu `1` auf; das per Skript angelegte Ziel der +Erweiterung kennt die Einstellung nicht, dort wurde daraus **leer**, und ein +leerer Wert faellt beim Schreiben der Plist ganz heraus. + +Zwei Aenderungen in `ios-signieren.sh`: die Build-Nummer wird nur noch +weitergereicht, wenn sie wirklich eine Zahl ist (sonst `1`), und das +Gegenlesen der fertigen `.ipa` **bricht ab**, wenn einem der beiden Buendel eine +brauchbare `CFBundleVersion` fehlt. Die bisherige Pruefung verglich nur +`CFBundleShortVersionString` - genau daran ist es vorbeigelaufen. + +**Das „andere Logo" ist der Platzhalter der haengenden Installation.** iOS zeigt +waehrend des Ladens das `display-image` aus der `manifest.plist` auf dem +Startbildschirm, und das ist `companion-app/public/symbol-192.png` vom 13.08. - +die alte "DM"-Kachel, nicht das App-Symbol vom 31.08. (DataMetric / Auto / 360). +Weil die Installation nie fertig wird, bleibt der Platzhalter stehen. Beide +Dateien nebeneinander angesehen, sie sind nachweislich verschieden. +**Offen und bewusst nicht angefasst:** die beiden Symbole anzugleichen, damit +schon der Platzhalter richtig aussieht - dafuer muessten aus +`native/logo/AppIcon-hell.svg` neue 192er- und 512er-PNG gerastert werden. + +### Zusaetzlich: die Adressen der Auslieferung tragen jetzt die Version + +`ios-luftweg.sh` schrieb `$BASIS/App.ipa` ohne Anhaengsel. Die +Adresse ist damit ueber jeden Bau hinweg zeichengleich, und der +Installations-Dienst von iOS haelt eine einmal geholte Datei fuer schon bekannt. +Jetzt tragen `.ipa`, beide Symbole und die `manifest.plist` selbst ein +`?v=`; die bereits ausgelieferten Dateien unter `auslieferung/` sind von +Hand nachgezogen, weil das Skript nur auf dem Mac laeuft. + +### Der dritte Suchbegriff: der Name ohne die fuehrende Marke + +Gemeldet: dieselbe Tankstelle loest in Docker und auf der realen Instanz +verschieden auf, und auf der realen Instanz bleibt die Belegkarte leer. + +Die Ursache steht in den Daten, nicht im Code: seit dem 16.08.2026 setzt der +Belegparser den Stationsnamen als „Marke, Strasse, Ort" zusammen - **ohne +Postleitzahl**. Am 04.09.2026 gegen Nominatim gemessen: + +| Suchtext | Antwort | +|---|---| +| `Shell, Pascalstr. 8, Ingolstadt` | leer | +| `Pascalstr. 8, Ingolstadt` | 48.7869/11.3977 | +| `Shell, Pascalstr. 8, 85057 Ingolstadt` | 48.7869/11.3977 | + +Die Marke am Anfang kippt die Suche, sobald die Postleitzahl fehlt. +`tankstelleKandidaten()` haengt deshalb einen dritten Versuch an: den Namen ohne +die fuehrende Marke. Er laeuft nur, wenn die ersten beiden nichts gefunden +haben. + +**Live nachgewiesen** an genau diesem Beleg in der Testinstanz (deren Datensatz +vom 29.07.2026 dieselbe Form hat): im Zwischenspeicher steht +`"Shell, Pascalstr. 8, Ingolstadt" => null` neben +`"Pascalstr. 8, Ingolstadt" => {lat: 48.7869101, lon: 11.39767}`, und die Karte +zeigt eine Nadel. + +### Der Verweis zeigt die Tankstelle, statt eine Route zu rechnen + +Vorgabe des Eigentuemers. `kartendienstUrl()` hat dafuer einen Modus bekommen: +`"route"` bleibt die Vorgabe (Standort-Blatt, Autohaus), der Tankstellen-Verweis +im Einzelbeleg nimmt `"zeigen"` - Apple `?q=` bzw. `?ll=&q=`, Google +`/maps/search/`. Live geprueft: der Verweis lautet jetzt +`google.com/maps/search/?api=1&query=...` statt `/maps/dir/`. + +Verifiziert: Panel als Modul geparst, `tsc --noEmit` sauber, **210/210** Tests +(vier neue: der markenlose Kandidat, der Fall „Marke steht nicht vorn", und die +beiden Kartendienste im Modus „zeigen"), `audi_ha_test` auf `2026.9.4.7` sauber +gestartet, und beide Belege live im Panel nachgesehen (zweizeilig „Nuernberger +Str.74" / „91522 Ansbach" ohne den Betreibernamen, Nadel auf der Karte). + +## CE. QR-Scanner fuer den Zugangs-Token (2026.9.4.8) + +Vorgabe des Eigentuemers: ein Knopf unter „Zugangs-Token", und **nur** bei der +Ersteinrichtung - danach gibt es nichts mehr abzutippen. Damit ist der seit dem +28.08.2026 offene Punkt aus Abschnitt A erledigt. + +**Kein natives Scanner-Plugin, und das ist die eigentliche Entscheidung.** +`@capacitor-mlkit/barcode-scanning` (8.1.1, passt zu Capacitor 8) waere der +bequemere Weg, zieht aber GoogleMLKit ueber CocoaPods nach - mehrere Dutzend +Megabyte und ein `pod install`, das auf dem Mac gelingen muss. Genau der +Xcode-Lauf, der die App nach Abschnitt CD ueberhaupt erst wieder installierbar +macht, darf daran nicht scheitern. Gewaehlt: **jsQR** (1.4.0, 30 KB, MIT), rein +in JavaScript, dazu `getUserMedia` in der WebView. WKWebView liefert die Kamera +seit iOS 14.3 aus, und die App laeuft unter `capacitor://localhost`, also in +einem sicheren Kontext. + +Nativ bleibt genau ein Punkt: **`NSCameraUsageDescription`** in der Info.plist, +gesetzt von `ios-signieren.sh` wie schon der Standort- und die beiden +Kalender-Schluessel (`ios/` ist gitignored, ein in Xcode geklickter Eintrag +waere beim naechsten `cap add ios` wieder weg). Ohne den Schluessel beendet iOS +die App wortlos in dem Moment, in dem sie die Kamera anfordert. + +**Was der Code enthaelt, war die zweite offene Frage** (sie stand seit dem +01.09.2026 in Abschnitt A): Home Assistants eigener QR traegt **nur den Token**, +nicht die Adresse der Instanz. `qrFundDeuten()` entscheidet deshalb: eine +http-Adresse wandert in das Adressfeld, ein langer zusammenhaengender Text in +das Tokenfeld, alles andere wird **nicht** uebernommen (ein WLAN-Code oder eine +Visitenkarte sollen kein Feld fuellen). Vier Faelle in +`src/screens/QrScanner.test.ts`. + +Der Knopf erscheint nur, wenn das Geraet der App ueberhaupt eine Kamera +durchreicht - am Rechner waere er ein Knopf, der zwangslaeufig in eine +Fehlermeldung laeuft; dieselbe Ueberlegung wie beim Zwischenablage-Knopf des +Beleg-Dialogs (Abschnitt C, 2026-08-17). „Verbinden" ist jetzt `variant` +`primary`, der Scanner-Knopf bleibt die zweite Rolle - die Rangfolge, die Apples +Richtlinien fuer genau diese Paarung vorsehen. + +Verifiziert: `tsc --noEmit` sauber, **214/214** Tests, `vite build` sauber, +`audi_ha_test` auf `2026.9.4.8` sauber gestartet, und die Ersteinrichtung live +unter `http://localhost:18123/local/dmapp/index.html` angesehen: der Knopf steht +unter „Zugangs-Token", das Blatt oeffnet mit Videofeld und Zielrahmen, und ohne +Kamerafreigabe steht dort die erklaerende Meldung statt einer leeren Flaeche. +**Nicht geprueft, weil es in dieser Umgebung nicht geht:** ein echter Scan - die +Browser-Vorschau gibt keine Kamera heraus. Das zeigt sich erst auf dem Geraet. diff --git a/companion-app/auslieferung/index.html b/companion-app/auslieferung/index.html index a111383..f872fab 100644 --- a/companion-app/auslieferung/index.html +++ b/companion-app/auslieferung/index.html @@ -15,7 +15,7 @@

DataMetric360

Version 2026.9.4.5

- Installieren + Installieren

In Safari oeffnen. Nach dem Antippen fragt iOS einmal nach,
danach erscheint die App auf dem Startbildschirm.

diff --git a/companion-app/auslieferung/manifest.plist b/companion-app/auslieferung/manifest.plist index f41c045..42a2c7b 100644 --- a/companion-app/auslieferung/manifest.plist +++ b/companion-app/auslieferung/manifest.plist @@ -9,15 +9,15 @@ kindsoftware-package - urlhttps://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/App.ipa + urlhttps://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/App.ipa?v=2026.9.4.5 kinddisplay-image - urlhttps://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/symbol-192.png + urlhttps://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/symbol-192.png?v=2026.9.4.5 kindfull-size-image - urlhttps://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/symbol-512.png + urlhttps://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/symbol-512.png?v=2026.9.4.5 metadata diff --git a/companion-app/package.json b/companion-app/package.json index 4643734..af402d6 100644 --- a/companion-app/package.json +++ b/companion-app/package.json @@ -25,6 +25,7 @@ "@capacitor/preferences": "^8.0.1", "@capacitor/share": "^8.0.1", "@capgo/capacitor-updater": "^8.51.14", + "jsqr": "^1.4.0", "leaflet": "^1.9.4", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/companion-app/scripts/ios-luftweg.sh b/companion-app/scripts/ios-luftweg.sh index 97c3741..119d566 100755 --- a/companion-app/scripts/ios-luftweg.sh +++ b/companion-app/scripts/ios-luftweg.sh @@ -60,6 +60,11 @@ mkdir -p "$ZIEL" cp "$APP/public/symbol-192.png" "$ZIEL/symbol-192.png" cp "$APP/public/symbol-512.png" "$ZIEL/symbol-512.png" +# Die Adressen tragen die Version als Anhaengsel (?v=...). Sie zeigen sonst bei +# jedem Bau auf dieselbe Zeichenkette, und der Installations-Dienst von iOS haelt +# eine einmal geholte .ipa dann fuer schon bekannt - sichtbar als Wolke mit Pfeil, +# die nie fertig wird, oder als "kann nicht installiert werden". Dieselbe Vorsorge +# trifft das Panel seit jeher fuer sein eigenes Buendel (siehe ota.ts). cat > "$ZIEL/manifest.plist" < @@ -72,15 +77,15 @@ cat > "$ZIEL/manifest.plist" < kindsoftware-package - url$BASIS/App.ipa + url$BASIS/App.ipa?v=$VERSION kinddisplay-image - url$BASIS/symbol-192.png + url$BASIS/symbol-192.png?v=$VERSION kindfull-size-image - url$BASIS/symbol-512.png + url$BASIS/symbol-512.png?v=$VERSION metadata @@ -116,7 +121,7 @@ cat > "$ZIEL/index.html" <

DataMetric360

Version $VERSION

- Installieren + Installieren

In Safari oeffnen. Nach dem Antippen fragt iOS einmal nach,
danach erscheint die App auf dem Startbildschirm.

@@ -133,4 +138,4 @@ echo # hier zusaetzlich der fertige Link zum Einfuegen in die Safari-Adresszeile; # der funktioniert unabhaengig davon, ob eine Seite drumherum existiert. echo "Ohne Seite (z. B. aus dem Repository heraus) direkt in Safari einfuegen:" -echo " itms-services://?action=download-manifest&url=$BASIS/manifest.plist" +echo " itms-services://?action=download-manifest&url=$BASIS/manifest.plist?v=$VERSION" diff --git a/companion-app/scripts/ios-signieren.sh b/companion-app/scripts/ios-signieren.sh index 9088073..84b07db 100755 --- a/companion-app/scripts/ios-signieren.sh +++ b/companion-app/scripts/ios-signieren.sh @@ -126,6 +126,22 @@ for SCHLUESSEL in NSCalendarsWriteOnlyAccessUsageDescription NSCalendarsUsageDes } done +# Kamera, fuer den QR-Scanner der Ersteinrichtung. +# +# Gelesen wird der Code in der WebView (jsQR ueber getUserMedia), nicht von +# einem nativen Plugin - siehe den Kopf von src/screens/QrScanner.tsx, warum +# nicht GoogleMLKit. Dieser Schluessel ist trotzdem noetig: ohne ihn beendet +# iOS die App in dem Moment, in dem sie die Kamera anfordert. +echo "==> Kamera-Berechtigung eintragen" +KAMERAGRUND="Liest den QR-Code deines Zugangs-Tokens bei der Ersteinrichtung." +if ! /usr/libexec/PlistBuddy -c "Set :NSCameraUsageDescription $KAMERAGRUND" "$PLIST" 2>/dev/null; then + /usr/libexec/PlistBuddy -c "Add :NSCameraUsageDescription string $KAMERAGRUND" "$PLIST" +fi +/usr/libexec/PlistBuddy -c "Print :NSCameraUsageDescription" "$PLIST" >/dev/null || { + echo "FEHLER: NSCameraUsageDescription liess sich nicht setzen." >&2 + exit 1 +} + # Share-Erweiterung ins Xcode-Projekt haengen. # # Aus demselben Grund wie die Team-Kennung und der Standort-Schluessel oben: @@ -173,6 +189,18 @@ if ! /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$PLI /usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string $VERSION" "$PLIST" fi BUILDNR="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$PLIST" 2>/dev/null || echo 1)" +# In der Info.plist der App steht dort KEINE Zahl, sondern der Platzhalter +# einer Build-Einstellung ($(CURRENT_PROJECT_VERSION), aus dem Capacitor- +# Geruest). Fuer das App-Ziel loest Xcode ihn zu 1 auf - das Ziel der +# Erweiterung kennt die Einstellung aber gar nicht, dort wurde daraus LEER, +# und ein Buendel ohne CFBundleVersion lehnt iOS beim Installieren ab +# ("kann nicht installiert werden"). Genau so ist die .ipa vom 04.09.2026 +# unbrauchbar geworden - an der ausgelieferten Datei nachgemessen: App +# CFBundleVersion 1, Erweiterung gar keine. Deshalb hier nur eine echte Zahl +# weiterreichen, sonst die 1. +case "$BUILDNR" in + ""|*'$('*|*[!0-9.]*) BUILDNR=1 ;; +esac # Die Share-Erweiterung MUSS dieselben beiden Nummern tragen wie die App. # @@ -265,10 +293,6 @@ xcodebuild -exportArchive \ -exportPath "$AUSGABE" \ -allowProvisioningUpdates -# Die fertige .ipa liegt versioniert unter auslieferung/, weil ios/ gitignored -# ist und das Ergebnis sonst nur auf diesem einen Mac existierte. -mkdir -p "$APP/auslieferung" -cp "$AUSGABE/App.ipa" "$APP/auslieferung/App.ipa" # Am FERTIGEN Buendel gegenlesen, nicht am Projekt. # @@ -280,7 +304,7 @@ cp "$AUSGABE/App.ipa" "$APP/auslieferung/App.ipa" # Teilen-Blatt). echo "==> Fertige .ipa gegenlesen" PRUEF="$(mktemp -d)" -if unzip -q "$APP/auslieferung/App.ipa" -d "$PRUEF"; then +if unzip -q "$AUSGABE/App.ipa" -d "$PRUEF"; then ERW="$PRUEF/Payload/App.app/PlugIns/DataMetric360Share.appex" if [ ! -x "$ERW/DataMetric360Share" ] && [ ! -f "$ERW/DataMetric360Share" ]; then echo " WARNUNG: die Erweiterung hat kein Programm - sie wird nichts tun." @@ -293,6 +317,19 @@ if unzip -q "$APP/auslieferung/App.ipa" -d "$PRUEF"; then else echo " Erweiterung und App tragen beide $APP_V" fi + # CFBundleVersion ist Pflicht. Fehlt sie in EINEM der beiden Buendel, + # verweigert iOS die Installation der ganzen App - ohne Hinweis darauf, + # woran es liegt. Deshalb hart pruefen, nicht nur warnen. + for ZIEL in "$PRUEF/Payload/App.app" "$ERW"; do + BV="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$ZIEL/Info.plist" 2>/dev/null || echo '')" + case "$BV" in + ""|*'$('*) + echo " FEHLER: $(basename "$ZIEL") hat keine brauchbare CFBundleVersion ('$BV')." >&2 + echo " iOS wuerde die Installation ablehnen - Bau abgebrochen." >&2 + rm -rf "$PRUEF" + exit 1 ;; + esac + done for ZIEL in "$PRUEF/Payload/App.app" "$ERW"; do if ! codesign -d --entitlements :- "$ZIEL" 2>/dev/null | grep -q "group.app.datametric360"; then echo " WARNUNG: $(basename "$ZIEL") ohne App-Gruppe - Teilen kann nichts uebergeben." @@ -301,6 +338,13 @@ if unzip -q "$APP/auslieferung/App.ipa" -d "$PRUEF"; then fi rm -rf "$PRUEF" +# Erst JETZT ablegen. auslieferung/ ist versioniert und wird ausgeliefert - +# eine Datei, die die Pruefung oben nicht besteht, darf dort nicht landen und +# schon gar nicht committet werden. (ios/ ist gitignored, sonst existierte das +# Ergebnis nur auf diesem einen Mac.) +mkdir -p "$APP/auslieferung" +cp "$AUSGABE/App.ipa" "$APP/auslieferung/App.ipa" + # Auslieferung ueber die Luft gleich mitschreiben. # # manifest.plist und die Installationsseite hingen bisher an einem zweiten, diff --git a/companion-app/src/daten/tankstelle.test.ts b/companion-app/src/daten/tankstelle.test.ts index 060499b..54a046e 100644 --- a/companion-app/src/daten/tankstelle.test.ts +++ b/companion-app/src/daten/tankstelle.test.ts @@ -11,6 +11,7 @@ import { afterEach, describe, expect, it, vi } from "vitest" import { adresseTeilen, kartendienstUrl, + markeErkennen, tankstelleKandidaten, tankstelleSuchtext, tankstelleZeilen, @@ -50,21 +51,51 @@ describe("adresseTeilen", () => { }) }) +describe("markeErkennen", () => { + it("findet die Kopfmarke im gespeicherten Namen", () => { + expect(markeErkennen("Shell, SÖLDEN")).toBe("Shell") + expect(markeErkennen("ARAL Station")).toBe("Aral") + }) + + it("haelt die Firmierung des Betreibers NICHT fuer eine Marke", () => { + // Die vier Belege der Testinstanz mit Anschrift tragen genau das. + expect(markeErkennen("A. Zrenner GmbH")).toBeNull() + expect(markeErkennen("Hermann Mogler Mineralölg. GmbH")).toBeNull() + expect(markeErkennen("TC Sengül GmbH")).toBeNull() + }) + + it("achtet auf Wortgrenzen", () => { + // "Star" ist eine Marke, "Starnberg" ist keine. + expect(markeErkennen("Tankstelle Starnberg")).toBeNull() + expect(markeErkennen("STAR Tankstelle")).toBe("Star") + }) + + it("nimmt TotalEnergies vor Total", () => { + expect(markeErkennen("TotalEnergies Station")).toBe("TotalEnergies") + }) +}) + describe("tankstelleZeilen", () => { - it("setzt Name und Straße in die erste Zeile, den Ort in die zweite", () => { - expect(tankstelleZeilen("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt")).toEqual({ - oben: "A. Zrenner GmbH, Pascalstr. 8", + it("setzt Kopfmarke und Strasse in die erste Zeile, PLZ und Ort in die zweite", () => { + expect(tankstelleZeilen("Shell, SÖLDEN", "Pascalstr. 8, 85057 Ingolstadt")).toEqual({ + oben: "Shell, Pascalstr. 8", unten: "85057 Ingolstadt", }) }) - it("kommt ohne Anschrift aus", () => { - expect(tankstelleZeilen("Testtankstelle", null)).toEqual({ - oben: "Testtankstelle", - unten: "", + it("laesst den Betreibernamen weg - nach aussen sichtbar ist die Kopfmarke", () => { + expect(tankstelleZeilen("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt")).toEqual({ + oben: "Pascalstr. 8", + unten: "85057 Ingolstadt", }) }) + it("zeigt ohne Anschrift den gespeicherten Namen unveraendert", () => { + // Sonst hiesse eine bekannte Tankstelle plötzlich "unbekannt". + expect(tankstelleZeilen("Shell, SÖLDEN", null)).toEqual({ oben: "Shell, SÖLDEN", unten: "" }) + expect(tankstelleZeilen("Testtankstelle", null)).toEqual({ oben: "Testtankstelle", unten: "" }) + }) + it("kommt ohne Namen aus", () => { expect(tankstelleZeilen("", "Pascalstr. 8, 85057 Ingolstadt")).toEqual({ oben: "Pascalstr. 8", @@ -88,15 +119,37 @@ describe("tankstelleKandidaten", () => { }) it("laesst leere Teile weg", () => { - expect(tankstelleKandidaten("Shell, SÖLDEN", null)).toEqual(["Shell, SÖLDEN"]) + // Der Ort allein ist der letzte Versuch, er laeuft nur, wenn die beiden + // davor nichts gefunden haben - schaden kann er dann nicht. + expect(tankstelleKandidaten("Shell, SÖLDEN", null)).toEqual(["Shell, SÖLDEN", "SÖLDEN"]) expect(tankstelleKandidaten(null, null)).toEqual([]) }) + + it("versucht zuletzt den Namen ohne die fuehrende Marke", () => { + // Der Fall der realen Instanz: der Parser hat "Marke, Strasse, Ort" in den + // Namen geschrieben, eine Postleitzahl fehlt. Gemessen am 04.09.2026: + // mit "Shell," davor findet Nominatim nichts, ohne findet es die Strasse. + expect(tankstelleKandidaten("Shell, Pascalstr. 8, Ingolstadt", null)).toEqual([ + "Shell, Pascalstr. 8, Ingolstadt", + "Pascalstr. 8, Ingolstadt", + ]) + }) + + it("schneidet nichts ab, wenn die Marke nicht vorn steht", () => { + expect(tankstelleKandidaten("Tankstelle Shell", null)).toEqual(["Tankstelle Shell"]) + }) }) describe("tankstelleZiel", () => { - it("nimmt fuer den Kartendienst Name und Anschrift zusammen", () => { + it("nimmt fuer den Kartendienst Kopfmarke und Anschrift zusammen", () => { + expect(tankstelleZiel("Shell, SÖLDEN", "Pascalstr. 8, 85057 Ingolstadt")).toBe( + "Shell, Pascalstr. 8, 85057 Ingolstadt", + ) + }) + + it("schickt ohne erkennbare Marke die Anschrift allein hin", () => { expect(tankstelleZiel("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt")).toBe( - "A. Zrenner GmbH, Pascalstr. 8, 85057 Ingolstadt", + "Pascalstr. 8, 85057 Ingolstadt", ) }) }) @@ -122,8 +175,8 @@ describe("tankstelleSuchtext", () => { describe("kartendienstUrl", () => { it("nimmt auf dem iPhone Apple Karten", () => { alsGeraet("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)") - expect(kartendienstUrl("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt")).toBe( - "https://maps.apple.com/?daddr=A.%20Zrenner%20GmbH%2C%20Pascalstr.%208%2C%2085057%20Ingolstadt", + expect(kartendienstUrl("Shell, SÖLDEN", "Pascalstr. 8, 85057 Ingolstadt")).toBe( + "https://maps.apple.com/?daddr=Shell%2C%20Pascalstr.%208%2C%2085057%20Ingolstadt", ) }) @@ -148,4 +201,18 @@ describe("kartendienstUrl", () => { alsGeraet("Mozilla/5.0 (iPhone)") expect(kartendienstUrl(null, null)).toBeNull() }) + + it("zeigt im Modus \"zeigen\" nur die Tankstelle, ohne Route", () => { + alsGeraet("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)") + const url = kartendienstUrl("Shell, SÖLDEN", "Pascalstr. 8, 85057 Ingolstadt", null, "zeigen") + expect(url).toContain("maps.apple.com/?q=") + expect(url).not.toContain("daddr") + }) + + it("nimmt bei Google die Suche statt der Routenplanung", () => { + alsGeraet("Mozilla/5.0 (X11; Linux x86_64)") + expect(kartendienstUrl(null, "Pascalstr. 8, 85057 Ingolstadt", null, "zeigen")).toBe( + "https://www.google.com/maps/search/?api=1&query=Pascalstr.%208%2C%2085057%20Ingolstadt", + ) + }) }) diff --git a/companion-app/src/daten/tankstelle.ts b/companion-app/src/daten/tankstelle.ts index 8e83f3d..5213e6e 100644 --- a/companion-app/src/daten/tankstelle.ts +++ b/companion-app/src/daten/tankstelle.ts @@ -34,6 +34,44 @@ export interface Koordinate { lon: number } +/* Kopfmarken des deutschsprachigen Marktes. + + WORTGLEICH mit `_MARKEN` in custom_components/audi_dashboard/shell_beleg_parser.py + (und mit MARKEN im Panel) - der Parser erkennt die Marke beim Einlesen eines + Belegs, diese Liste erkennt sie nachträglich in dem, was gespeichert ist. + Laufen die beiden auseinander, zeigt die App eine andere Marke als der + Beleg. Reihenfolge zählt: "TotalEnergies" muss vor "Total" stehen. */ +const MARKEN = [ + "TotalEnergies", "Total", "Aral", "Shell", "Eni", "Agip", "Esso", "OMV", + "Avia", "Turmöl", "Turmoel", "Orlen", "Tamoil", "Westfalen", "Allguth", + "Classic", "Sprint", "Raiffeisen", "BayWa", "Elan", "HEM", "Star", "JET", + "bft", "BP", "Q1", +] + +/** + * Die Kopfmarke aus dem gespeicherten Stationsnamen - `null`, wenn keine + * bekannte darin steht. + * + * WARUM ÜBERHAUPT NACHTRÄGLICH: `station_name` trägt je nach Alter des Belegs + * Verschiedenes. Seit dem 16.08.2026 setzt der Parser "Marke, Straße, Ort" + * zusammen ("Shell, SÖLDEN"); ältere Belege - und jeder ohne erkennbare + * Marke - tragen dort die Firmierung des Betreibers ("A. Zrenner GmbH", + * "Hermann Mogler Mineralölg. GmbH"). Die ist nach außen nicht sichtbar und + * gehört deshalb nicht in die Anzeige (Vorgabe des Eigentümers, 04.09.2026); + * sichtbar ist die Kopfmarke. + * + * Wortgrenzen sind Absicht: "Star" darf nicht in "Starnberg" treffen. + */ +export function markeErkennen(name: string | null | undefined): string | null { + const roh = (name ?? "").trim() + if (!roh) return null + for (const marke of MARKEN) { + const muster = new RegExp(`\\b${marke.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i") + if (muster.test(roh)) return marke + } + return null +} + /** `"Pascalstr. 8, 85057 Ingolstadt"` → Straße und Ort. */ export function adresseTeilen(adresse: string | null | undefined): { strasse: string @@ -47,16 +85,24 @@ export function adresseTeilen(adresse: string | null | undefined): { } /** - * Die beiden Zeilen der Tankstellen-Angabe. `oben` ist nie leer: steht weder - * Name noch Anschrift im Beleg, heißt es dort "unbekannt" - dieselbe Auskunft, - * die die Zeile vorher gab. + * Die beiden Zeilen der Tankstellen-Angabe (Vorgabe des Eigentümers): + * oben Kopfmarke und Straße mit Hausnummer, unten Postleitzahl und Ort. + * + * Ohne erkennbare Marke steht oben nur die Straße - die Firmierung des + * Betreibers tritt NICHT an ihre Stelle, sie ist nach außen unsichtbar. + * Trägt der Beleg gar keine Anschrift, bleibt der gespeicherte Name die + * einzige Auskunft und wird unverändert gezeigt ("Shell, SÖLDEN"); ihn dann + * wegzulassen hieße, eine bekannte Tankstelle "unbekannt" zu nennen. + * + * `oben` ist nie leer: nennt der Beleg nichts, heißt es dort "unbekannt". */ export function tankstelleZeilen( name: string | null | undefined, adresse: string | null | undefined, ): { oben: string; unten: string } { const { strasse, ort } = adresseTeilen(adresse) - const oben = [(name ?? "").trim(), strasse].filter(Boolean).join(", ") + if (!strasse && !ort) return { oben: (name ?? "").trim() || "unbekannt", unten: "" } + const oben = [markeErkennen(name) ?? "", strasse].filter(Boolean).join(", ") return { oben: oben || "unbekannt", unten: ort } } @@ -73,12 +119,28 @@ export function tankstelleZeilen( * * Der Name allein ist trotzdem wertvoll: `"Shell, SÖLDEN"` findet Nominatim * (46.9756/11.0111) — deshalb der zweite Versuch statt gar keinem. + * + * Und als dritter Versuch der Name OHNE die führende Marke. Seit dem 16.08.2026 + * setzt der Belegparser den Namen als „Marke, Straße, Ort" zusammen; steht darin + * keine Postleitzahl, kippt die Marke die Suche. Am 04.09.2026 gemessen: + * `"Shell, Pascalstr. 8, Ingolstadt"` findet Nominatim NICHT, + * `"Pascalstr. 8, Ingolstadt"` dagegen schon. Genau daran blieb die Karte auf + * der realen Instanz leer, während dieselbe Tankstelle in der Testinstanz + * (dort steht die Anschrift getrennt daneben) gefunden wurde. */ export function tankstelleKandidaten( name: string | null | undefined, adresse: string | null | undefined, ): string[] { - return [(adresse ?? "").trim(), (name ?? "").trim()].filter(Boolean) + const n = (name ?? "").trim() + const marke = markeErkennen(n) + // Nur abschneiden, wenn die Marke wirklich vorn steht und noch etwas folgt. + const ohneMarke = + marke && n.toLowerCase().startsWith(marke.toLowerCase()) + ? n.slice(marke.length).replace(/^[ ,]+/, "").trim() + : "" + const liste = [(adresse ?? "").trim(), n, ohneMarke].filter(Boolean) + return [...new Set(liste)] } /** Der erste Suchbegriff - leer heißt: der Beleg nennt gar nichts. */ @@ -90,16 +152,19 @@ export function tankstelleSuchtext( } /** - * Das Ziel für den Kartendienst. Hier gehören Name UND Anschrift zusammen: - * Apple Karten und Google Maps kennen Betriebsnamen und treffen damit die - * Zapfsäule statt des Nachbargebäudes - beide sind darin deutlich besser als - * Nominatims Freitextsuche. + * Das Ziel für den Kartendienst: Kopfmarke UND Anschrift. Apple Karten und + * Google Maps kennen Tankstellenmarken und treffen damit die Zapfsäule statt + * des Nachbargebäudes - beide sind darin deutlich besser als Nominatims + * Freitextsuche. Die Firmierung des Betreibers hilft dagegen nirgends; ohne + * erkennbare Marke geht die Anschrift allein hin. */ export function tankstelleZiel( name: string | null | undefined, adresse: string | null | undefined, ): string { - return [(name ?? "").trim(), (adresse ?? "").trim()].filter(Boolean).join(", ") + const a = (adresse ?? "").trim() + if (!a) return (name ?? "").trim() + return [markeErkennen(name) ?? "", a].filter(Boolean).join(", ") } /** Apple-Gerät? Dort ist Karten der Standarddienst. */ @@ -109,6 +174,14 @@ function apfelgeraet(): boolean { return /iPhone|iPad|iPod|Macintosh|Mac OS X/i.test(kennung) } +/** + * "route" berechnet den Weg dorthin, "zeigen" setzt nur eine Nadel. Der + * Tankstellen-Verweis im Einzelbeleg will das Zweite (Vorgabe des Eigentümers, + * 04.09.2026): erst sehen, wo die Tankstelle liegt - eine Route erwartet dort + * niemand, und sie ist auf dem Telefon nur einen weiteren Tipp entfernt. + */ +export type Kartenmodus = "route" | "zeigen" + /** * Verweis in den Kartendienst - `null`, wenn nichts bekannt ist, wohin. * @@ -119,15 +192,25 @@ export function kartendienstUrl( name: string | null | undefined, adresse: string | null | undefined, pos?: Koordinate | null, + modus: Kartenmodus = "route", ): string | null { const beschriftung = (name ?? "").trim() const ziel = pos ? `${pos.lat},${pos.lon}` : tankstelleZiel(name, adresse) if (!ziel) return null if (apfelgeraet()) { + if (modus === "zeigen") { + // Mit Koordinaten der Punkt selbst, sonst der Suchtext - beides zeigt + // Karten nur an, ohne eine Route zu berechnen. + return pos + ? `https://maps.apple.com/?ll=${ziel}&q=${encodeURIComponent(beschriftung || "Ziel")}` + : `https://maps.apple.com/?q=${encodeURIComponent(ziel)}` + } const q = pos && beschriftung ? `&q=${encodeURIComponent(beschriftung)}` : "" return `https://maps.apple.com/?daddr=${encodeURIComponent(ziel)}${q}` } - return `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(ziel)}` + return modus === "zeigen" + ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(ziel)}` + : `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(ziel)}` } /** Wie oben, aber für einen reinen Punkt (Fahrzeugstandort, Autohaus). */ diff --git a/companion-app/src/screens/Einrichtung.tsx b/companion-app/src/screens/Einrichtung.tsx index 2c40d07..f4e245b 100644 --- a/companion-app/src/screens/Einrichtung.tsx +++ b/companion-app/src/screens/Einrichtung.tsx @@ -12,13 +12,28 @@ import { useState } from "react" import { ActionButton, Feld, Tile } from "@audi-dash/ui" import { ApiFehler, HassRest, basisUrlNormalisieren, zugangSpeichern } from "../api" +import { QrScanner, type QrFund } from "./QrScanner" import { zugangMerken } from "./zugang" +/* Der Scanner hat nur hier einen Platz: er hilft bei der Ersteinrichtung, und + danach gibt es nichts mehr abzutippen. Angeboten wird er nur, wenn das + Gerät überhaupt eine Kamera an die App durchreicht - am Rechner wäre er ein + Knopf, der zwangsläufig in eine Fehlermeldung läuft. */ +const kameraVorhanden = () => + typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia + export function Einrichtung({ fertig }: { fertig: () => void }) { const [adresse, setzeAdresse] = useState("") const [token, setzeToken] = useState("") const [laeuft, setzeLaeuft] = useState(false) const [fehler, setzeFehler] = useState(null) + const [scannerOffen, setzeScannerOffen] = useState(false) + + const scanUebernehmen = (fund: QrFund) => { + setzeFehler(null) + if (fund.art === "adresse") setzeAdresse(fund.wert) + else setzeToken(fund.wert) + } const verbinden = async () => { setzeFehler(null) @@ -92,6 +107,12 @@ export function Einrichtung({ fertig }: { fertig: () => void }) { + {kameraVorhanden() && ( +
+ setzeScannerOffen(true)}>QR-Code scannen +
+ )} + {fehler && (

{fehler} @@ -99,11 +120,17 @@ export function Einrichtung({ fertig }: { fertig: () => void }) { )}

- void verbinden()} disabled={laeuft}> + void verbinden()} disabled={laeuft}> {laeuft ? "Verbinde …" : "Verbinden"}
+ setzeScannerOffen(false)} + gefunden={scanUebernehmen} + /> +

Den Token erzeugst du in Home Assistant unter „Profil → Sicherheit → Langlebige Zugriffstoken". Deine Daten bleiben auf deinem Server. diff --git a/companion-app/src/screens/Karte.tsx b/companion-app/src/screens/Karte.tsx index 7af1ecb..3a932a6 100644 --- a/companion-app/src/screens/Karte.tsx +++ b/companion-app/src/screens/Karte.tsx @@ -140,8 +140,12 @@ export function Karte({ * Knopf, der wieder darauf zentriert. So zeichnet das Panel den Einzelbeleg * (tankstelleAufKarteZeigen/mapBoxMitZentrieren); die App zeigte hier bis * zum 04.09.2026 nur den schwarzen Startpunkt ohne Namen. + * + * Die beiden Zeilen kommen fertig herein (tankstelleZeilen) statt als Name + * und Anschrift: dann steht im Popup dasselbe wie in der Wertezeile darunter, + * und diese Komponente muss nichts über Tankstellen wissen. */ - nadel?: { name?: string | null; adresse?: string | null } + nadel?: { oben: string; unten?: string } }) { const behaelter = useRef(null) const karteRef = useRef(null) @@ -170,7 +174,7 @@ export function Karte({ iconAnchor: [15, 31], }) L.marker([start.lat, start.lon], { icon: symbol }) - .bindPopup([nadel.name, nadel.adresse].filter(Boolean).join("
")) + .bindPopup([nadel.oben, nadel.unten].filter(Boolean).join("
")) .addTo(karte) } else { // Deckungsgleich mit initMap() im Panel: Start schwarz mit weißem Ring, diff --git a/companion-app/src/screens/QrScanner.test.ts b/companion-app/src/screens/QrScanner.test.ts new file mode 100644 index 0000000..2364a33 --- /dev/null +++ b/companion-app/src/screens/QrScanner.test.ts @@ -0,0 +1,43 @@ +/** + * Tests zur Deutung des gelesenen QR-Codes. + * + * Der Scanner selbst (Kamera, Bildtakt) laesst sich hier nicht pruefen - was + * sich pruefen laesst, ist die eine Entscheidung, die falsch ausgehen kann: + * in welches Feld der gelesene Text gehoert. + */ + +import { describe, expect, it } from "vitest" + +import { qrFundDeuten } from "./QrScanner" + +describe("qrFundDeuten", () => { + it("haelt einen langen zusammenhaengenden Text fuer den Token", () => { + const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + "a".repeat(60) + ".signatur" + expect(qrFundDeuten(token)).toEqual({ art: "token", wert: token }) + }) + + it("erkennt eine Adresse und legt sie ins andere Feld", () => { + expect(qrFundDeuten("https://datametric360.de")).toEqual({ + art: "adresse", + wert: "https://datametric360.de", + }) + expect(qrFundDeuten("http://192.168.1.20:8123")).toEqual({ + art: "adresse", + wert: "http://192.168.1.20:8123", + }) + }) + + it("uebernimmt nichts, was kein Token sein kann", () => { + // Lieber gar nichts als das falsche Feld gefuellt: ein WLAN-Code, eine + // Visitenkarte oder ein zu kurzer Text sind keine Zugangsdaten. + expect(qrFundDeuten("WIFI:S=Heimnetz;T=WPA;P=geheim;;")).toBeNull() + expect(qrFundDeuten("kurz")).toBeNull() + expect(qrFundDeuten("")).toBeNull() + expect(qrFundDeuten(" ")).toBeNull() + }) + + it("schneidet Leerraum ab", () => { + const token = "b".repeat(50) + expect(qrFundDeuten(" " + token + " ")).toEqual({ art: "token", wert: token }) + }) +}) diff --git a/companion-app/src/screens/QrScanner.tsx b/companion-app/src/screens/QrScanner.tsx new file mode 100644 index 0000000..450a5ea --- /dev/null +++ b/companion-app/src/screens/QrScanner.tsx @@ -0,0 +1,143 @@ +/** + * QR-Code scannen, um den Zugangs-Token nicht abtippen zu müssen. + * + * WARUM KEIN NATIVES SCANNER-PLUGIN + * --------------------------------- + * `@capacitor-mlkit/barcode-scanning` wäre der bequemere Weg (native + * Scanner-Oberfläche, schneller), zieht aber GoogleMLKit über CocoaPods nach - + * mehrere Dutzend Megabyte und ein `pod install`, das auf dem Mac gelingen + * muss. Genau der Xcode-Lauf, der die App überhaupt erst wieder installierbar + * macht (Abschnitt CD), darf daran nicht scheitern. jsQR sind 30 KB + * JavaScript, kein Pod, kein nativer Code - der einzige native Anteil ist + * `NSCameraUsageDescription` in der Info.plist, und den setzt + * `ios-signieren.sh` wie schon den Standort- und die Kalender-Schlüssel. + * + * WKWebView liefert `getUserMedia` seit iOS 14.3 aus; die App läuft unter + * `capacitor://localhost` und damit in einem sicheren Kontext. + * + * WAS IM CODE STEHT + * ----------------- + * Home Assistant zeigt den Token beim Anlegen selbst als QR-Code an, und der + * trägt **nur den Token**, nicht die Adresse der Instanz (deshalb ist die + * eigene QR-Seite unter `/local/` im Umsetzungsplan ersatzlos entfallen). + * Steht im Code trotzdem eine http-Adresse, ist es offensichtlich die + * Server-Adresse - dann wandert sie in das andere Feld, statt als Token + * abgelehnt zu werden. + */ + +import { useEffect, useRef, useState } from "react" + +import { ActionButton, Sheet } from "@audi-dash/ui" + +export type QrFund = { art: "token" | "adresse"; wert: string } + +/** Entscheidet, wofür der gelesene Text taugt. */ +export function qrFundDeuten(roh: string): QrFund | null { + const t = (roh ?? "").trim() + if (!t) return null + if (/^https?:\/\//i.test(t)) return { art: "adresse", wert: t } + // Ein Langzeit-Token von Home Assistant ist ein JWT: drei mit Punkt + // getrennte Teile, durchgehend URL-sicheres Base64. Kürzeres kann kein + // Token sein - dann lieber nichts übernehmen als ein falsches Feld füllen. + if (t.length < 40 || /\s/.test(t)) return null + return { art: "token", wert: t } +} + +/** Wie oft ein Bild ausgewertet wird. Öfter kostet Akku, ohne schneller zu treffen. */ +const TAKT_MS = 250 + +export function QrScanner({ + offen, + schliessen, + gefunden, +}: { + offen: boolean + schliessen: () => void + gefunden: (fund: QrFund) => void +}) { + const videoRef = useRef(null) + const [fehler, setzeFehler] = useState(null) + + useEffect(() => { + if (!offen) return + let abgebrochen = false + let strom: MediaStream | null = null + let takt: ReturnType | null = null + + const starten = async () => { + if (!navigator.mediaDevices?.getUserMedia) { + setzeFehler("Dieses Gerät stellt der App keine Kamera zur Verfügung.") + return + } + try { + strom = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: "environment" }, + audio: false, + }) + } catch { + setzeFehler( + "Kein Zugriff auf die Kamera. In den Einstellungen des Geräts unter „DataMetric360“ freigeben.", + ) + return + } + if (abgebrochen) { + strom.getTracks().forEach((s) => s.stop()) + return + } + const video = videoRef.current + if (!video) return + video.srcObject = strom + await video.play().catch(() => undefined) + + const { default: jsQR } = await import("jsqr") + const leinwand = document.createElement("canvas") + const stift = leinwand.getContext("2d", { willReadFrequently: true }) + if (!stift) return + + takt = setInterval(() => { + if (!video.videoWidth) return + leinwand.width = video.videoWidth + leinwand.height = video.videoHeight + stift.drawImage(video, 0, 0, leinwand.width, leinwand.height) + const bild = stift.getImageData(0, 0, leinwand.width, leinwand.height) + const treffer = jsQR(bild.data, bild.width, bild.height) + if (!treffer) return + const fund = qrFundDeuten(treffer.data) + if (!fund) { + setzeFehler("Dieser Code enthält weder einen Token noch eine Adresse.") + return + } + gefunden(fund) + schliessen() + }, TAKT_MS) + } + + void starten() + return () => { + abgebrochen = true + if (takt) clearInterval(takt) + // Ohne das bleibt die Kameraleuchte an, auch wenn das Blatt zu ist. + strom?.getTracks().forEach((s) => s.stop()) + } + }, [offen, gefunden, schliessen]) + + return ( + +

+
+ {fehler && ( +

+ {fehler} +

+ )} + Abbrechen + + ) +} diff --git a/companion-app/src/screens/TankDetail.tsx b/companion-app/src/screens/TankDetail.tsx index e668ee7..1d3d0cf 100644 --- a/companion-app/src/screens/TankDetail.tsx +++ b/companion-app/src/screens/TankDetail.tsx @@ -156,7 +156,7 @@ function Tankstellenzeile({ adresse: string | null | undefined }) { const { oben, unten } = tankstelleZeilen(name, adresse) - const url = kartendienstUrl(name, adresse) + const url = kartendienstUrl(name, adresse, null, "zeigen") const inhalt = ( <> diff --git a/companion-app/src/screens/TankstellenKarte.tsx b/companion-app/src/screens/TankstellenKarte.tsx index f36c435..f06b054 100644 --- a/companion-app/src/screens/TankstellenKarte.tsx +++ b/companion-app/src/screens/TankstellenKarte.tsx @@ -25,7 +25,7 @@ import { useEffect, useState } from "react" import { adresseAufloesen } from "../daten/geokodierung" import type { Koordinate } from "../daten/geokodierung" -import { tankstelleKandidaten } from "../daten/tankstelle" +import { tankstelleKandidaten, tankstelleZeilen } from "../daten/tankstelle" import { Leerzustand } from "./bausteine" import { Karte } from "./Karte" @@ -68,8 +68,7 @@ export function TankstellenKarte({ // Wie im Panel (tankstelleAufKarteZeigen): die CI-Nadel mit Name und // Anschrift im Popup, dazu der Knopf, der wieder darauf zentriert. - if (position) - return + if (position) return // Solange die Auflösung läuft: eine leere Kartenfläche, wie im Panel (die // Karte steht dort schon, die Nadel kommt nach). Bewusst nicht die // Ladeanzeige der Seite - die meldet „der Bildschirm ist noch nicht da". diff --git a/companion-app/src/stile/screens.css b/companion-app/src/stile/screens.css index 7628ee7..0b36b18 100644 --- a/companion-app/src/stile/screens.css +++ b/companion-app/src/stile/screens.css @@ -2095,3 +2095,31 @@ button.dm-listenzeile { .dm-wertzeile__warnung { color: var(--warn); } + +/* QR-Scanner der Ersteinrichtung. + + Das Bild bleibt quadratisch und beschnitten (object-fit: cover): ein QR-Code + wird ohnehin mittig in den Rahmen gehalten, und ein hochkant gezerrtes + Kamerabild macht das Zielen schwerer statt leichter. */ +.dm-qr { + position: relative; + width: 100%; + aspect-ratio: 1 / 1; + border-radius: var(--r-tile); + overflow: hidden; + background: #000; +} +.dm-qr__bild { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.dm-qr__rahmen { + position: absolute; + inset: 18%; + border: 2px solid var(--fg); + border-radius: 12px; + opacity: 0.85; + pointer-events: none; +} diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 377e27e..969d241 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.9.4.5","sha256":"ffea00a94ac842d8b6b233ebd91a1a97252c4b7b6a952c336ca7a6218eceef1e","bytes":270888,"gebaut":"2026-09-04T07:57:27Z"} \ No newline at end of file +{"version":"2026.9.4.8","sha256":"d6fe041f31480ff67d9b39884e9ef8b5ac5795d8575716d4638a9f557198bc49","bytes":319995,"gebaut":"2026-09-04T09:50:03Z"} \ 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 8855430..3fad36b 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 639cae4..726f102 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js @@ -1236,6 +1236,38 @@ async function adresseAufloesen(adresse) { Erste Zeile Tankstelle und Strasse, zweite Zeile der Ort (Vorgabe des Eigentuemers, 04.09.2026). Ohne Komma bleibt alles in der ersten Zeile - lieber ein langer erster Eintrag als ein erfundener Ort. */ +/* Kopfmarken des deutschsprachigen Marktes. WORTGLEICH mit _MARKEN in + shell_beleg_parser.py und MARKEN in companion-app/src/daten/tankstelle.ts - + der Parser erkennt die Marke beim Einlesen, diese Liste erkennt sie + nachtraeglich in dem, was gespeichert ist. Reihenfolge zaehlt: + "TotalEnergies" muss vor "Total" stehen. */ +const MARKEN = [ + "TotalEnergies", "Total", "Aral", "Shell", "Eni", "Agip", "Esso", "OMV", + "Avia", "Turmöl", "Turmoel", "Orlen", "Tamoil", "Westfalen", "Allguth", + "Classic", "Sprint", "Raiffeisen", "BayWa", "Elan", "HEM", "Star", "JET", + "bft", "BP", "Q1", +]; + +/* Die Kopfmarke aus dem gespeicherten Stationsnamen - null, wenn keine + bekannte darin steht. + + `station_name` traegt je nach Alter des Belegs Verschiedenes: seit dem + 16.08.2026 setzt der Parser "Marke, Strasse, Ort" zusammen, aeltere Belege + und jeder ohne erkennbare Marke tragen dort die Firmierung des Betreibers + ("A. Zrenner GmbH"). Die ist nach aussen nicht sichtbar und gehoert nicht in + die Anzeige (Vorgabe des Eigentuemers, 04.09.2026). + + Wortgrenzen sind Absicht: "Star" darf nicht in "Starnberg" treffen. */ +function markeErkennen(name) { + const roh = (name || "").trim(); + if (!roh) return null; + for (const marke of MARKEN) { + const muster = new RegExp("\\b" + marke.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\b", "i"); + if (muster.test(roh)) return marke; + } + return null; +} + function adresseTeilen(adresse) { const roh = (adresse || "").trim(); if (!roh) return { strasse: "", ort: "" }; @@ -1244,9 +1276,14 @@ function adresseTeilen(adresse) { return { strasse: roh.slice(0, komma).trim(), ort: roh.slice(komma + 1).trim() }; } +/* Oben Kopfmarke und Strasse mit Hausnummer, unten Postleitzahl und Ort. + Ohne erkennbare Marke steht oben nur die Strasse - die Firmierung des + Betreibers tritt NICHT an ihre Stelle. Ohne Anschrift bleibt der + gespeicherte Name die einzige Auskunft und wird unveraendert gezeigt. */ function tankstelleZeilen(name, adresse) { const { strasse, ort } = adresseTeilen(adresse); - const oben = [(name || "").trim(), strasse].filter(Boolean).join(", "); + if (!strasse && !ort) return { oben: (name || "").trim() || "unbekannt", unten: "" }; + const oben = [markeErkennen(name) || "", strasse].filter(Boolean).join(", "); return { oben: oben || "unbekannt", unten: ort }; } @@ -1255,8 +1292,20 @@ function tankstelleZeilen(name, adresse) { gemessen: "Pascalstr. 8, 85057 Ingolstadt" findet Nominatim, mit dem Betreibernamen davor nicht. Der Name ALLEIN ist trotzdem wertvoll ("Shell, SOELDEN" findet es), deshalb der zweite Versuch. */ +/* Dritter Versuch: der Name OHNE die fuehrende Marke. Seit dem 16.08.2026 + setzt der Belegparser den Namen als "Marke, Strasse, Ort" zusammen; fehlt + darin die Postleitzahl, kippt die Marke die Suche. Am 04.09.2026 gemessen: + "Shell, Pascalstr. 8, Ingolstadt" findet Nominatim NICHT, + "Pascalstr. 8, Ingolstadt" dagegen schon. Genau daran blieb die Karte auf + der realen Instanz leer. */ function tankstelleKandidaten(name, adresse) { - return [(adresse || "").trim(), (name || "").trim()].filter(Boolean); + const n = (name || "").trim(); + const marke = markeErkennen(n); + const ohneMarke = + marke && n.toLowerCase().startsWith(marke.toLowerCase()) + ? n.slice(marke.length).replace(/^[ ,]+/, "").trim() + : ""; + return [...new Set([(adresse || "").trim(), n, ohneMarke].filter(Boolean))]; } /** Der erste Suchbegriff - leer heisst: der Beleg nennt gar nichts. */ @@ -1268,7 +1317,9 @@ function tankstelleSuchtext(name, adresse) { Apple Karten und Google Maps kennen Betriebsnamen und treffen damit die Zapfsaeule statt des Nachbargebaeudes. */ function tankstelleZiel(name, adresse) { - return [(name || "").trim(), (adresse || "").trim()].filter(Boolean).join(", "); + const a = (adresse || "").trim(); + if (!a) return (name || "").trim(); + return [markeErkennen(name) || "", a].filter(Boolean).join(", "); } /* Die Kandidaten der Reihe nach nachschlagen, erster Treffer gewinnt. */ @@ -1286,15 +1337,26 @@ function apfelgeraet() { return /iPhone|iPad|iPod|Macintosh|Mac OS X/i.test(kennung); } -function kartendienstUrl(name, adresse, pos) { +/* modus "route" berechnet den Weg, "zeigen" setzt nur eine Nadel. Der + Tankstellen-Verweis im Einzelbeleg will das Zweite (Vorgabe des + Eigentuemers, 04.09.2026): erst sehen, wo sie liegt - eine Route erwartet + dort niemand, und sie ist im Kartendienst einen Tipp entfernt. */ +function kartendienstUrl(name, adresse, pos, modus = "route") { const beschriftung = (name || "").trim(); const ziel = pos ? `${pos.lat},${pos.lon}` : tankstelleZiel(name, adresse); if (!ziel) return null; if (apfelgeraet()) { + if (modus === "zeigen") { + return pos + ? `https://maps.apple.com/?ll=${ziel}&q=${encodeURIComponent(beschriftung || "Ziel")}` + : `https://maps.apple.com/?q=${encodeURIComponent(ziel)}`; + } const q = pos && beschriftung ? `&q=${encodeURIComponent(beschriftung)}` : ""; return `https://maps.apple.com/?daddr=${encodeURIComponent(ziel)}${q}`; } - return `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(ziel)}`; + return modus === "zeigen" + ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(ziel)}` + : `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(ziel)}`; } function kartendienstUrlZuPunkt(pos) { @@ -1308,8 +1370,12 @@ function tankstelleAufKarteZeigen(karte, pos, f) { if (!karte || !window.L || !pos) return; karte.setView([pos.lat, pos.lon], 14); const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `
${tankstellenMarkerSVG(32)}
`, iconSize: [32, 32], iconAnchor: [15, 31] }); + // Dieselben zwei Zeilen wie in der Wertezeile darunter (tankstelleZeilen) - + // die Nadel soll nicht die Firmierung des Betreibers nennen, wenn die Zeile + // die Kopfmarke zeigt. + const zeilen = tankstelleZeilen(f.station_name, f.station_address); window.L.marker([pos.lat, pos.lon], { icon }).addTo(karte) - .bindPopup((f.station_name || "") + "
" + (f.station_address || "")); + .bindPopup(esc(zeilen.oben) + (zeilen.unten ? "
" + esc(zeilen.unten) : "")); // Position merken und den Knopf scharf schalten - er kann erst jetzt etwas // Sinnvolles tun (bei Adressauflösung kommt das hier verzögert, siehe // Aufrufstelle in initMap). @@ -4542,7 +4608,7 @@ function belegKarte(f) { ein Verweis, der nirgendwohin fuehrt, waere schlechter als keiner. */ function tankstellenZeile(f) { const { oben, unten } = tankstelleZeilen(f.station_name, f.station_address); - const url = kartendienstUrl(f.station_name, f.station_address); + const url = kartendienstUrl(f.station_name, f.station_address, null, "zeigen"); const inhalt = `${esc(oben)}${unten ? `${esc(unten)}` : ""}` + (url ? `` : ""); diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index d761343..452fb8e 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.4.5", + "version": "2026.9.4.8", "documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md", "issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues", "codeowners": [ diff --git a/package-lock.json b/package-lock.json index 72c80f2..0fd6348 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@capacitor/preferences": "^8.0.1", "@capacitor/share": "^8.0.1", "@capgo/capacitor-updater": "^8.51.14", + "jsqr": "^1.4.0", "leaflet": "^1.9.4", "react": "^18.3.1", "react-dom": "^18.3.1" @@ -3005,6 +3006,12 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", + "license": "Apache-2.0" + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",