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:
@@ -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.
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<main>
|
||||
<h1>DataMetric360</h1>
|
||||
<p>Version 2026.9.4.5</p>
|
||||
<a href="itms-services://?action=download-manifest&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&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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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&url=$BASIS/manifest.plist">Installieren</a>
|
||||
<a href="itms-services://?action=download-manifest&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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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".
|
||||
|
||||
@@ -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"}
|
||||
Binary file not shown.
@@ -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": [
|
||||
|
||||
Generated
+7
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user