Die .ipa war nicht installierbar, plus QR-Scanner (2026.9.4.8)

Der Erweiterung fehlte CFBundleVersion - eine Pflichtangabe, ohne die iOS
die Installation der ganzen App verweigert ("kann nicht installiert werden").
Verursacht von der Versionsangleichung vom Vortag: das Skript reichte den
Platzhalter $(CURRENT_PROJECT_VERSION) statt einer Zahl weiter, und das per
Skript angelegte Ziel der Erweiterung kennt diese Build-Einstellung nicht.
An den entpackten .ipa nachgemessen, 2026.9.3.3 gegen 2026.9.4.5.

ios-signieren.sh reicht nur noch echte Zahlen weiter, das Gegenlesen der
fertigen .ipa bricht ab statt zu warnen, und abgelegt wird erst danach -
eine Datei, die die Pruefung nicht besteht, darf nicht in auslieferung/
landen.

Dazu:
- Kopfmarke statt Firmierung des Betreibers in der Tankstellen-Zeile
- dritter Suchbegriff: der Name ohne fuehrende Marke. Gemessen: Nominatim
  findet "Shell, Pascalstr. 8, Ingolstadt" nicht, "Pascalstr. 8, Ingolstadt"
  schon - daran blieb die Belegkarte der realen Instanz leer
- der Tankstellen-Verweis zeigt die Tankstelle, statt eine Route zu rechnen
- Cache-Brecher auf .ipa, Symbolen und manifest.plist
- QR-Scanner fuer den Zugangs-Token bei der Ersteinrichtung (jsQR statt
  GoogleMLKit, damit der Xcode-Lauf kein pod install riskiert)

214/214 Tests, Panel als Modul geparst, live gegen audi_ha_test geprueft.
This commit is contained in:
2026-09-04 11:51:44 +02:00
parent d819f2e285
commit 63a45d4ee2
20 changed files with 788 additions and 56 deletions
+218 -3
View File
@@ -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 `<Marke>, <Strasse>` und unten `<PLZ> <Ort>`; 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 `<string>$BASIS/App.ipa</string>`
**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=<Version>`; 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 `<string>$BASIS/App.ipa</string>` 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=<Version>`; 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.
+1 -1
View File
@@ -15,7 +15,7 @@
<main>
<h1>DataMetric360</h1>
<p>Version 2026.9.4.5</p>
<a href="itms-services://?action=download-manifest&amp;url=https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/manifest.plist">Installieren</a>
<a href="itms-services://?action=download-manifest&amp;url=https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/manifest.plist?v=2026.9.4.5">Installieren</a>
<p>In Safari oeffnen. Nach dem Antippen fragt iOS einmal nach,<br>danach erscheint die App auf dem Startbildschirm.</p>
</main>
</html>
+3 -3
View File
@@ -9,15 +9,15 @@
<array>
<dict>
<key>kind</key><string>software-package</string>
<key>url</key><string>https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/App.ipa</string>
<key>url</key><string>https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/App.ipa?v=2026.9.4.5</string>
</dict>
<dict>
<key>kind</key><string>display-image</string>
<key>url</key><string>https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/symbol-192.png</string>
<key>url</key><string>https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/symbol-192.png?v=2026.9.4.5</string>
</dict>
<dict>
<key>kind</key><string>full-size-image</string>
<key>url</key><string>https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/symbol-512.png</string>
<key>url</key><string>https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/symbol-512.png?v=2026.9.4.5</string>
</dict>
</array>
<key>metadata</key>
+1
View File
@@ -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"
+10 -5
View File
@@ -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" <<PLISTENDE
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -72,15 +77,15 @@ cat > "$ZIEL/manifest.plist" <<PLISTENDE
<array>
<dict>
<key>kind</key><string>software-package</string>
<key>url</key><string>$BASIS/App.ipa</string>
<key>url</key><string>$BASIS/App.ipa?v=$VERSION</string>
</dict>
<dict>
<key>kind</key><string>display-image</string>
<key>url</key><string>$BASIS/symbol-192.png</string>
<key>url</key><string>$BASIS/symbol-192.png?v=$VERSION</string>
</dict>
<dict>
<key>kind</key><string>full-size-image</string>
<key>url</key><string>$BASIS/symbol-512.png</string>
<key>url</key><string>$BASIS/symbol-512.png?v=$VERSION</string>
</dict>
</array>
<key>metadata</key>
@@ -116,7 +121,7 @@ cat > "$ZIEL/index.html" <<HTMLENDE
<main>
<h1>DataMetric360</h1>
<p>Version $VERSION</p>
<a href="itms-services://?action=download-manifest&amp;url=$BASIS/manifest.plist">Installieren</a>
<a href="itms-services://?action=download-manifest&amp;url=$BASIS/manifest.plist?v=$VERSION">Installieren</a>
<p>In Safari oeffnen. Nach dem Antippen fragt iOS einmal nach,<br>danach erscheint die App auf dem Startbildschirm.</p>
</main>
</html>
@@ -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"
+49 -5
View File
@@ -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,
+79 -12
View File
@@ -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",
)
})
})
+94 -11
View File
@@ -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). */
+28 -1
View File
@@ -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<string | null>(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 }) {
</Feld>
</Tile>
{kameraVorhanden() && (
<div className="dm-einrichtung__knoepfe">
<ActionButton onClick={() => setzeScannerOffen(true)}>QR-Code scannen</ActionButton>
</div>
)}
{fehler && (
<p className="dm-einrichtung__fehler" role="alert">
{fehler}
@@ -99,11 +120,17 @@ export function Einrichtung({ fertig }: { fertig: () => void }) {
)}
<div className="dm-einrichtung__knoepfe">
<ActionButton onClick={() => void verbinden()} disabled={laeuft}>
<ActionButton variant="primary" onClick={() => void verbinden()} disabled={laeuft}>
{laeuft ? "Verbinde …" : "Verbinden"}
</ActionButton>
</div>
<QrScanner
offen={scannerOffen}
schliessen={() => setzeScannerOffen(false)}
gefunden={scanUebernehmen}
/>
<p className="dm-einrichtung__hinweis">
Den Token erzeugst du in Home Assistant unter Profil Sicherheit Langlebige
Zugriffstoken". Deine Daten bleiben auf deinem Server.
+6 -2
View File
@@ -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<HTMLDivElement | null>(null)
const karteRef = useRef<import("leaflet").Map | null>(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("<br>"))
.bindPopup([nadel.oben, nadel.unten].filter(Boolean).join("<br>"))
.addTo(karte)
} else {
// Deckungsgleich mit initMap() im Panel: Start schwarz mit weißem Ring,
@@ -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 })
})
})
+143
View File
@@ -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<HTMLVideoElement | null>(null)
const [fehler, setzeFehler] = useState<string | null>(null)
useEffect(() => {
if (!offen) return
let abgebrochen = false
let strom: MediaStream | null = null
let takt: ReturnType<typeof setInterval> | 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 (
<Sheet
open={offen}
onClose={schliessen}
title="QR-Code scannen"
description="Den QR-Code aus Home Assistant in den Rahmen halten."
>
<div className="dm-qr">
<video ref={videoRef} className="dm-qr__bild" muted playsInline />
<span className="dm-qr__rahmen" aria-hidden="true" />
</div>
{fehler && (
<p className="dm-fehler" role="alert">
{fehler}
</p>
)}
<ActionButton onClick={schliessen}>Abbrechen</ActionButton>
</Sheet>
)
}
+1 -1
View File
@@ -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 = (
<>
<span className="dm-tankstellenzeile__ort">
@@ -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 <Karte start={position} nadel={{ name: name ?? null, adresse: adresse ?? null }} />
if (position) return <Karte start={position} nadel={tankstelleZeilen(name, adresse)} />
// 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".
+28
View File
@@ -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;
}
@@ -1 +1 @@
{"version":"2026.9.4.5","sha256":"ffea00a94ac842d8b6b233ebd91a1a97252c4b7b6a952c336ca7a6218eceef1e","bytes":270888,"gebaut":"2026-09-04T07:57:27Z"}
{"version":"2026.9.4.8","sha256":"d6fe041f31480ff67d9b39884e9ef8b5ac5795d8575716d4638a9f557198bc49","bytes":319995,"gebaut":"2026-09-04T09:50:03Z"}
@@ -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: `<div class="fahrzeug-pin">${tankstellenMarkerSVG(32)}</div>`, 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 || "") + "<br>" + (f.station_address || ""));
.bindPopup(esc(zeilen.oben) + (zeilen.unten ? "<br>" + 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 =
`<span class="tankstellenzeile-ort">${esc(oben)}${unten ? `<small>${esc(unten)}</small>` : ""}</span>` +
(url ? `<svg class="chev" viewBox="0 0 6 10" aria-hidden="true"><polyline points="1,1 5,5 1,9"/></svg>` : "");
@@ -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": [
+7
View File
@@ -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",