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
|
**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
|
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`,
|
Speichern-Knopf, und die Share-Erweiterung trug eine andere Versionsnummer als die App;
|
||||||
Abschnitt CC. Davor: Geraetezeit statt Ankunftszeit - die Wurzel hinter der 3-km-Fahrt:
|
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
|
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
|
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
|
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)
|
- [x] Authenticated smoke tests of the data layer (reads, service calls, queue round-trip)
|
||||||
against `audi_ha_test` with a real token
|
against `audi_ha_test` with a real token
|
||||||
- [x] Offline UX per design brief (offline marker, visible pending queue)
|
- [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
|
2026-08-11 bewusst weggelassen ("nur wenn es einfach bleibt"), der Owner will das jetzt doch als
|
||||||
Alternative zum manuellen Einfügen.
|
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
|
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
|
scheitern: `cors_allowed_origins` in der `configuration.yaml` blieb wirkungslos
|
||||||
(Preflight weiterhin 403), der eingetragene Block ist deshalb wieder entfernt.
|
(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>
|
<main>
|
||||||
<h1>DataMetric360</h1>
|
<h1>DataMetric360</h1>
|
||||||
<p>Version 2026.9.4.5</p>
|
<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>
|
<p>In Safari oeffnen. Nach dem Antippen fragt iOS einmal nach,<br>danach erscheint die App auf dem Startbildschirm.</p>
|
||||||
</main>
|
</main>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,15 +9,15 @@
|
|||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
<key>kind</key><string>software-package</string>
|
<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>
|
||||||
<dict>
|
<dict>
|
||||||
<key>kind</key><string>display-image</string>
|
<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>
|
||||||
<dict>
|
<dict>
|
||||||
<key>kind</key><string>full-size-image</string>
|
<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>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>metadata</key>
|
<key>metadata</key>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"@capacitor/preferences": "^8.0.1",
|
"@capacitor/preferences": "^8.0.1",
|
||||||
"@capacitor/share": "^8.0.1",
|
"@capacitor/share": "^8.0.1",
|
||||||
"@capgo/capacitor-updater": "^8.51.14",
|
"@capgo/capacitor-updater": "^8.51.14",
|
||||||
|
"jsqr": "^1.4.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^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-192.png" "$ZIEL/symbol-192.png"
|
||||||
cp "$APP/public/symbol-512.png" "$ZIEL/symbol-512.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
|
cat > "$ZIEL/manifest.plist" <<PLISTENDE
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<!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>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
<key>kind</key><string>software-package</string>
|
<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>
|
||||||
<dict>
|
<dict>
|
||||||
<key>kind</key><string>display-image</string>
|
<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>
|
||||||
<dict>
|
<dict>
|
||||||
<key>kind</key><string>full-size-image</string>
|
<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>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>metadata</key>
|
<key>metadata</key>
|
||||||
@@ -116,7 +121,7 @@ cat > "$ZIEL/index.html" <<HTMLENDE
|
|||||||
<main>
|
<main>
|
||||||
<h1>DataMetric360</h1>
|
<h1>DataMetric360</h1>
|
||||||
<p>Version $VERSION</p>
|
<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>
|
<p>In Safari oeffnen. Nach dem Antippen fragt iOS einmal nach,<br>danach erscheint die App auf dem Startbildschirm.</p>
|
||||||
</main>
|
</main>
|
||||||
</html>
|
</html>
|
||||||
@@ -133,4 +138,4 @@ echo
|
|||||||
# hier zusaetzlich der fertige Link zum Einfuegen in die Safari-Adresszeile;
|
# hier zusaetzlich der fertige Link zum Einfuegen in die Safari-Adresszeile;
|
||||||
# der funktioniert unabhaengig davon, ob eine Seite drumherum existiert.
|
# der funktioniert unabhaengig davon, ob eine Seite drumherum existiert.
|
||||||
echo "Ohne Seite (z. B. aus dem Repository heraus) direkt in Safari einfuegen:"
|
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
|
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.
|
# Share-Erweiterung ins Xcode-Projekt haengen.
|
||||||
#
|
#
|
||||||
# Aus demselben Grund wie die Team-Kennung und der Standort-Schluessel oben:
|
# 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"
|
/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string $VERSION" "$PLIST"
|
||||||
fi
|
fi
|
||||||
BUILDNR="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$PLIST" 2>/dev/null || echo 1)"
|
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.
|
# Die Share-Erweiterung MUSS dieselben beiden Nummern tragen wie die App.
|
||||||
#
|
#
|
||||||
@@ -265,10 +293,6 @@ xcodebuild -exportArchive \
|
|||||||
-exportPath "$AUSGABE" \
|
-exportPath "$AUSGABE" \
|
||||||
-allowProvisioningUpdates
|
-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.
|
# Am FERTIGEN Buendel gegenlesen, nicht am Projekt.
|
||||||
#
|
#
|
||||||
@@ -280,7 +304,7 @@ cp "$AUSGABE/App.ipa" "$APP/auslieferung/App.ipa"
|
|||||||
# Teilen-Blatt).
|
# Teilen-Blatt).
|
||||||
echo "==> Fertige .ipa gegenlesen"
|
echo "==> Fertige .ipa gegenlesen"
|
||||||
PRUEF="$(mktemp -d)"
|
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"
|
ERW="$PRUEF/Payload/App.app/PlugIns/DataMetric360Share.appex"
|
||||||
if [ ! -x "$ERW/DataMetric360Share" ] && [ ! -f "$ERW/DataMetric360Share" ]; then
|
if [ ! -x "$ERW/DataMetric360Share" ] && [ ! -f "$ERW/DataMetric360Share" ]; then
|
||||||
echo " WARNUNG: die Erweiterung hat kein Programm - sie wird nichts tun."
|
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
|
else
|
||||||
echo " Erweiterung und App tragen beide $APP_V"
|
echo " Erweiterung und App tragen beide $APP_V"
|
||||||
fi
|
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
|
for ZIEL in "$PRUEF/Payload/App.app" "$ERW"; do
|
||||||
if ! codesign -d --entitlements :- "$ZIEL" 2>/dev/null | grep -q "group.app.datametric360"; then
|
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."
|
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
|
fi
|
||||||
rm -rf "$PRUEF"
|
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.
|
# Auslieferung ueber die Luft gleich mitschreiben.
|
||||||
#
|
#
|
||||||
# manifest.plist und die Installationsseite hingen bisher an einem zweiten,
|
# manifest.plist und die Installationsseite hingen bisher an einem zweiten,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"
|
|||||||
import {
|
import {
|
||||||
adresseTeilen,
|
adresseTeilen,
|
||||||
kartendienstUrl,
|
kartendienstUrl,
|
||||||
|
markeErkennen,
|
||||||
tankstelleKandidaten,
|
tankstelleKandidaten,
|
||||||
tankstelleSuchtext,
|
tankstelleSuchtext,
|
||||||
tankstelleZeilen,
|
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", () => {
|
describe("tankstelleZeilen", () => {
|
||||||
it("setzt Name und Straße in die erste Zeile, den Ort in die zweite", () => {
|
it("setzt Kopfmarke und Strasse in die erste Zeile, PLZ und Ort in die zweite", () => {
|
||||||
expect(tankstelleZeilen("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt")).toEqual({
|
expect(tankstelleZeilen("Shell, SÖLDEN", "Pascalstr. 8, 85057 Ingolstadt")).toEqual({
|
||||||
oben: "A. Zrenner GmbH, Pascalstr. 8",
|
oben: "Shell, Pascalstr. 8",
|
||||||
unten: "85057 Ingolstadt",
|
unten: "85057 Ingolstadt",
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it("kommt ohne Anschrift aus", () => {
|
it("laesst den Betreibernamen weg - nach aussen sichtbar ist die Kopfmarke", () => {
|
||||||
expect(tankstelleZeilen("Testtankstelle", null)).toEqual({
|
expect(tankstelleZeilen("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt")).toEqual({
|
||||||
oben: "Testtankstelle",
|
oben: "Pascalstr. 8",
|
||||||
unten: "",
|
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", () => {
|
it("kommt ohne Namen aus", () => {
|
||||||
expect(tankstelleZeilen("", "Pascalstr. 8, 85057 Ingolstadt")).toEqual({
|
expect(tankstelleZeilen("", "Pascalstr. 8, 85057 Ingolstadt")).toEqual({
|
||||||
oben: "Pascalstr. 8",
|
oben: "Pascalstr. 8",
|
||||||
@@ -88,15 +119,37 @@ describe("tankstelleKandidaten", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("laesst leere Teile weg", () => {
|
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([])
|
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", () => {
|
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(
|
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", () => {
|
describe("kartendienstUrl", () => {
|
||||||
it("nimmt auf dem iPhone Apple Karten", () => {
|
it("nimmt auf dem iPhone Apple Karten", () => {
|
||||||
alsGeraet("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)")
|
alsGeraet("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)")
|
||||||
expect(kartendienstUrl("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt")).toBe(
|
expect(kartendienstUrl("Shell, SÖLDEN", "Pascalstr. 8, 85057 Ingolstadt")).toBe(
|
||||||
"https://maps.apple.com/?daddr=A.%20Zrenner%20GmbH%2C%20Pascalstr.%208%2C%2085057%20Ingolstadt",
|
"https://maps.apple.com/?daddr=Shell%2C%20Pascalstr.%208%2C%2085057%20Ingolstadt",
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -148,4 +201,18 @@ describe("kartendienstUrl", () => {
|
|||||||
alsGeraet("Mozilla/5.0 (iPhone)")
|
alsGeraet("Mozilla/5.0 (iPhone)")
|
||||||
expect(kartendienstUrl(null, null)).toBeNull()
|
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
|
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. */
|
/** `"Pascalstr. 8, 85057 Ingolstadt"` → Straße und Ort. */
|
||||||
export function adresseTeilen(adresse: string | null | undefined): {
|
export function adresseTeilen(adresse: string | null | undefined): {
|
||||||
strasse: string
|
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
|
* Die beiden Zeilen der Tankstellen-Angabe (Vorgabe des Eigentümers):
|
||||||
* Name noch Anschrift im Beleg, heißt es dort "unbekannt" - dieselbe Auskunft,
|
* oben Kopfmarke und Straße mit Hausnummer, unten Postleitzahl und Ort.
|
||||||
* die die Zeile vorher gab.
|
*
|
||||||
|
* 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(
|
export function tankstelleZeilen(
|
||||||
name: string | null | undefined,
|
name: string | null | undefined,
|
||||||
adresse: string | null | undefined,
|
adresse: string | null | undefined,
|
||||||
): { oben: string; unten: string } {
|
): { oben: string; unten: string } {
|
||||||
const { strasse, ort } = adresseTeilen(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 }
|
return { oben: oben || "unbekannt", unten: ort }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,12 +119,28 @@ export function tankstelleZeilen(
|
|||||||
*
|
*
|
||||||
* Der Name allein ist trotzdem wertvoll: `"Shell, SÖLDEN"` findet Nominatim
|
* Der Name allein ist trotzdem wertvoll: `"Shell, SÖLDEN"` findet Nominatim
|
||||||
* (46.9756/11.0111) — deshalb der zweite Versuch statt gar keinem.
|
* (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(
|
export function tankstelleKandidaten(
|
||||||
name: string | null | undefined,
|
name: string | null | undefined,
|
||||||
adresse: string | null | undefined,
|
adresse: string | null | undefined,
|
||||||
): string[] {
|
): 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. */
|
/** 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:
|
* Das Ziel für den Kartendienst: Kopfmarke UND Anschrift. Apple Karten und
|
||||||
* Apple Karten und Google Maps kennen Betriebsnamen und treffen damit die
|
* Google Maps kennen Tankstellenmarken und treffen damit die Zapfsäule statt
|
||||||
* Zapfsäule statt des Nachbargebäudes - beide sind darin deutlich besser als
|
* des Nachbargebäudes - beide sind darin deutlich besser als Nominatims
|
||||||
* Nominatims Freitextsuche.
|
* Freitextsuche. Die Firmierung des Betreibers hilft dagegen nirgends; ohne
|
||||||
|
* erkennbare Marke geht die Anschrift allein hin.
|
||||||
*/
|
*/
|
||||||
export function tankstelleZiel(
|
export function tankstelleZiel(
|
||||||
name: string | null | undefined,
|
name: string | null | undefined,
|
||||||
adresse: string | null | undefined,
|
adresse: string | null | undefined,
|
||||||
): string {
|
): 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. */
|
/** 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)
|
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.
|
* Verweis in den Kartendienst - `null`, wenn nichts bekannt ist, wohin.
|
||||||
*
|
*
|
||||||
@@ -119,15 +192,25 @@ export function kartendienstUrl(
|
|||||||
name: string | null | undefined,
|
name: string | null | undefined,
|
||||||
adresse: string | null | undefined,
|
adresse: string | null | undefined,
|
||||||
pos?: Koordinate | null,
|
pos?: Koordinate | null,
|
||||||
|
modus: Kartenmodus = "route",
|
||||||
): string | null {
|
): string | null {
|
||||||
const beschriftung = (name ?? "").trim()
|
const beschriftung = (name ?? "").trim()
|
||||||
const ziel = pos ? `${pos.lat},${pos.lon}` : tankstelleZiel(name, adresse)
|
const ziel = pos ? `${pos.lat},${pos.lon}` : tankstelleZiel(name, adresse)
|
||||||
if (!ziel) return null
|
if (!ziel) return null
|
||||||
if (apfelgeraet()) {
|
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)}` : ""
|
const q = pos && beschriftung ? `&q=${encodeURIComponent(beschriftung)}` : ""
|
||||||
return `https://maps.apple.com/?daddr=${encodeURIComponent(ziel)}${q}`
|
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). */
|
/** 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 { ActionButton, Feld, Tile } from "@audi-dash/ui"
|
||||||
|
|
||||||
import { ApiFehler, HassRest, basisUrlNormalisieren, zugangSpeichern } from "../api"
|
import { ApiFehler, HassRest, basisUrlNormalisieren, zugangSpeichern } from "../api"
|
||||||
|
import { QrScanner, type QrFund } from "./QrScanner"
|
||||||
import { zugangMerken } from "./zugang"
|
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 }) {
|
export function Einrichtung({ fertig }: { fertig: () => void }) {
|
||||||
const [adresse, setzeAdresse] = useState("")
|
const [adresse, setzeAdresse] = useState("")
|
||||||
const [token, setzeToken] = useState("")
|
const [token, setzeToken] = useState("")
|
||||||
const [laeuft, setzeLaeuft] = useState(false)
|
const [laeuft, setzeLaeuft] = useState(false)
|
||||||
const [fehler, setzeFehler] = useState<string | null>(null)
|
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 () => {
|
const verbinden = async () => {
|
||||||
setzeFehler(null)
|
setzeFehler(null)
|
||||||
@@ -92,6 +107,12 @@ export function Einrichtung({ fertig }: { fertig: () => void }) {
|
|||||||
</Feld>
|
</Feld>
|
||||||
</Tile>
|
</Tile>
|
||||||
|
|
||||||
|
{kameraVorhanden() && (
|
||||||
|
<div className="dm-einrichtung__knoepfe">
|
||||||
|
<ActionButton onClick={() => setzeScannerOffen(true)}>QR-Code scannen</ActionButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{fehler && (
|
{fehler && (
|
||||||
<p className="dm-einrichtung__fehler" role="alert">
|
<p className="dm-einrichtung__fehler" role="alert">
|
||||||
{fehler}
|
{fehler}
|
||||||
@@ -99,11 +120,17 @@ export function Einrichtung({ fertig }: { fertig: () => void }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="dm-einrichtung__knoepfe">
|
<div className="dm-einrichtung__knoepfe">
|
||||||
<ActionButton onClick={() => void verbinden()} disabled={laeuft}>
|
<ActionButton variant="primary" onClick={() => void verbinden()} disabled={laeuft}>
|
||||||
{laeuft ? "Verbinde …" : "Verbinden"}
|
{laeuft ? "Verbinde …" : "Verbinden"}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<QrScanner
|
||||||
|
offen={scannerOffen}
|
||||||
|
schliessen={() => setzeScannerOffen(false)}
|
||||||
|
gefunden={scanUebernehmen}
|
||||||
|
/>
|
||||||
|
|
||||||
<p className="dm-einrichtung__hinweis">
|
<p className="dm-einrichtung__hinweis">
|
||||||
Den Token erzeugst du in Home Assistant unter „Profil → Sicherheit → Langlebige
|
Den Token erzeugst du in Home Assistant unter „Profil → Sicherheit → Langlebige
|
||||||
Zugriffstoken". Deine Daten bleiben auf deinem Server.
|
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
|
* Knopf, der wieder darauf zentriert. So zeichnet das Panel den Einzelbeleg
|
||||||
* (tankstelleAufKarteZeigen/mapBoxMitZentrieren); die App zeigte hier bis
|
* (tankstelleAufKarteZeigen/mapBoxMitZentrieren); die App zeigte hier bis
|
||||||
* zum 04.09.2026 nur den schwarzen Startpunkt ohne Namen.
|
* 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 behaelter = useRef<HTMLDivElement | null>(null)
|
||||||
const karteRef = useRef<import("leaflet").Map | null>(null)
|
const karteRef = useRef<import("leaflet").Map | null>(null)
|
||||||
@@ -170,7 +174,7 @@ export function Karte({
|
|||||||
iconAnchor: [15, 31],
|
iconAnchor: [15, 31],
|
||||||
})
|
})
|
||||||
L.marker([start.lat, start.lon], { icon: symbol })
|
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)
|
.addTo(karte)
|
||||||
} else {
|
} else {
|
||||||
// Deckungsgleich mit initMap() im Panel: Start schwarz mit weißem Ring,
|
// 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
|
adresse: string | null | undefined
|
||||||
}) {
|
}) {
|
||||||
const { oben, unten } = tankstelleZeilen(name, adresse)
|
const { oben, unten } = tankstelleZeilen(name, adresse)
|
||||||
const url = kartendienstUrl(name, adresse)
|
const url = kartendienstUrl(name, adresse, null, "zeigen")
|
||||||
const inhalt = (
|
const inhalt = (
|
||||||
<>
|
<>
|
||||||
<span className="dm-tankstellenzeile__ort">
|
<span className="dm-tankstellenzeile__ort">
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { useEffect, useState } from "react"
|
|||||||
|
|
||||||
import { adresseAufloesen } from "../daten/geokodierung"
|
import { adresseAufloesen } from "../daten/geokodierung"
|
||||||
import type { Koordinate } 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 { Leerzustand } from "./bausteine"
|
||||||
import { Karte } from "./Karte"
|
import { Karte } from "./Karte"
|
||||||
|
|
||||||
@@ -68,8 +68,7 @@ export function TankstellenKarte({
|
|||||||
|
|
||||||
// Wie im Panel (tankstelleAufKarteZeigen): die CI-Nadel mit Name und
|
// Wie im Panel (tankstelleAufKarteZeigen): die CI-Nadel mit Name und
|
||||||
// Anschrift im Popup, dazu der Knopf, der wieder darauf zentriert.
|
// Anschrift im Popup, dazu der Knopf, der wieder darauf zentriert.
|
||||||
if (position)
|
if (position) return <Karte start={position} nadel={tankstelleZeilen(name, adresse)} />
|
||||||
return <Karte start={position} nadel={{ name: name ?? null, adresse: adresse ?? null }} />
|
|
||||||
// Solange die Auflösung läuft: eine leere Kartenfläche, wie im Panel (die
|
// 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
|
// Karte steht dort schon, die Nadel kommt nach). Bewusst nicht die
|
||||||
// Ladeanzeige der Seite - die meldet „der Bildschirm ist noch nicht da".
|
// Ladeanzeige der Seite - die meldet „der Bildschirm ist noch nicht da".
|
||||||
|
|||||||
@@ -2095,3 +2095,31 @@ button.dm-listenzeile {
|
|||||||
.dm-wertzeile__warnung {
|
.dm-wertzeile__warnung {
|
||||||
color: var(--warn);
|
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
|
Erste Zeile Tankstelle und Strasse, zweite Zeile der Ort (Vorgabe des
|
||||||
Eigentuemers, 04.09.2026). Ohne Komma bleibt alles in der ersten Zeile -
|
Eigentuemers, 04.09.2026). Ohne Komma bleibt alles in der ersten Zeile -
|
||||||
lieber ein langer erster Eintrag als ein erfundener Ort. */
|
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) {
|
function adresseTeilen(adresse) {
|
||||||
const roh = (adresse || "").trim();
|
const roh = (adresse || "").trim();
|
||||||
if (!roh) return { strasse: "", ort: "" };
|
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() };
|
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) {
|
function tankstelleZeilen(name, adresse) {
|
||||||
const { strasse, ort } = adresseTeilen(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 };
|
return { oben: oben || "unbekannt", unten: ort };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1255,8 +1292,20 @@ function tankstelleZeilen(name, adresse) {
|
|||||||
gemessen: "Pascalstr. 8, 85057 Ingolstadt" findet Nominatim, mit dem
|
gemessen: "Pascalstr. 8, 85057 Ingolstadt" findet Nominatim, mit dem
|
||||||
Betreibernamen davor nicht. Der Name ALLEIN ist trotzdem wertvoll
|
Betreibernamen davor nicht. Der Name ALLEIN ist trotzdem wertvoll
|
||||||
("Shell, SOELDEN" findet es), deshalb der zweite Versuch. */
|
("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) {
|
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. */
|
/** 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
|
Apple Karten und Google Maps kennen Betriebsnamen und treffen damit die
|
||||||
Zapfsaeule statt des Nachbargebaeudes. */
|
Zapfsaeule statt des Nachbargebaeudes. */
|
||||||
function tankstelleZiel(name, adresse) {
|
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. */
|
/* 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);
|
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 beschriftung = (name || "").trim();
|
||||||
const ziel = pos ? `${pos.lat},${pos.lon}` : tankstelleZiel(name, adresse);
|
const ziel = pos ? `${pos.lat},${pos.lon}` : tankstelleZiel(name, adresse);
|
||||||
if (!ziel) return null;
|
if (!ziel) return null;
|
||||||
if (apfelgeraet()) {
|
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)}` : "";
|
const q = pos && beschriftung ? `&q=${encodeURIComponent(beschriftung)}` : "";
|
||||||
return `https://maps.apple.com/?daddr=${encodeURIComponent(ziel)}${q}`;
|
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) {
|
function kartendienstUrlZuPunkt(pos) {
|
||||||
@@ -1308,8 +1370,12 @@ function tankstelleAufKarteZeigen(karte, pos, f) {
|
|||||||
if (!karte || !window.L || !pos) return;
|
if (!karte || !window.L || !pos) return;
|
||||||
karte.setView([pos.lat, pos.lon], 14);
|
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] });
|
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)
|
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
|
// Position merken und den Knopf scharf schalten - er kann erst jetzt etwas
|
||||||
// Sinnvolles tun (bei Adressauflösung kommt das hier verzögert, siehe
|
// Sinnvolles tun (bei Adressauflösung kommt das hier verzögert, siehe
|
||||||
// Aufrufstelle in initMap).
|
// Aufrufstelle in initMap).
|
||||||
@@ -4542,7 +4608,7 @@ function belegKarte(f) {
|
|||||||
ein Verweis, der nirgendwohin fuehrt, waere schlechter als keiner. */
|
ein Verweis, der nirgendwohin fuehrt, waere schlechter als keiner. */
|
||||||
function tankstellenZeile(f) {
|
function tankstellenZeile(f) {
|
||||||
const { oben, unten } = tankstelleZeilen(f.station_name, f.station_address);
|
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 =
|
const inhalt =
|
||||||
`<span class="tankstellenzeile-ort">${esc(oben)}${unten ? `<small>${esc(unten)}</small>` : ""}</span>` +
|
`<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>` : "");
|
(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",
|
"domain": "audi_dashboard",
|
||||||
"name": "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",
|
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
|
||||||
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
|
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
|
||||||
"codeowners": [
|
"codeowners": [
|
||||||
|
|||||||
Generated
+7
@@ -26,6 +26,7 @@
|
|||||||
"@capacitor/preferences": "^8.0.1",
|
"@capacitor/preferences": "^8.0.1",
|
||||||
"@capacitor/share": "^8.0.1",
|
"@capacitor/share": "^8.0.1",
|
||||||
"@capgo/capacitor-updater": "^8.51.14",
|
"@capgo/capacitor-updater": "^8.51.14",
|
||||||
|
"jsqr": "^1.4.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
@@ -3005,6 +3006,12 @@
|
|||||||
"graceful-fs": "^4.1.6"
|
"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": {
|
"node_modules/kleur": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user