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

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

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

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

214/214 Tests, Panel als Modul geparst, live gegen audi_ha_test geprueft.
This commit is contained in:
2026-09-04 11:51:44 +02:00
parent d819f2e285
commit 63a45d4ee2
20 changed files with 788 additions and 56 deletions
+79 -12
View File
@@ -11,6 +11,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"
import {
adresseTeilen,
kartendienstUrl,
markeErkennen,
tankstelleKandidaten,
tankstelleSuchtext,
tankstelleZeilen,
@@ -50,21 +51,51 @@ describe("adresseTeilen", () => {
})
})
describe("markeErkennen", () => {
it("findet die Kopfmarke im gespeicherten Namen", () => {
expect(markeErkennen("Shell, SÖLDEN")).toBe("Shell")
expect(markeErkennen("ARAL Station")).toBe("Aral")
})
it("haelt die Firmierung des Betreibers NICHT fuer eine Marke", () => {
// Die vier Belege der Testinstanz mit Anschrift tragen genau das.
expect(markeErkennen("A. Zrenner GmbH")).toBeNull()
expect(markeErkennen("Hermann Mogler Mineralölg. GmbH")).toBeNull()
expect(markeErkennen("TC Sengül GmbH")).toBeNull()
})
it("achtet auf Wortgrenzen", () => {
// "Star" ist eine Marke, "Starnberg" ist keine.
expect(markeErkennen("Tankstelle Starnberg")).toBeNull()
expect(markeErkennen("STAR Tankstelle")).toBe("Star")
})
it("nimmt TotalEnergies vor Total", () => {
expect(markeErkennen("TotalEnergies Station")).toBe("TotalEnergies")
})
})
describe("tankstelleZeilen", () => {
it("setzt Name und Straße in die erste Zeile, den Ort in die zweite", () => {
expect(tankstelleZeilen("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt")).toEqual({
oben: "A. Zrenner GmbH, Pascalstr. 8",
it("setzt Kopfmarke und Strasse in die erste Zeile, PLZ und Ort in die zweite", () => {
expect(tankstelleZeilen("Shell, SÖLDEN", "Pascalstr. 8, 85057 Ingolstadt")).toEqual({
oben: "Shell, Pascalstr. 8",
unten: "85057 Ingolstadt",
})
})
it("kommt ohne Anschrift aus", () => {
expect(tankstelleZeilen("Testtankstelle", null)).toEqual({
oben: "Testtankstelle",
unten: "",
it("laesst den Betreibernamen weg - nach aussen sichtbar ist die Kopfmarke", () => {
expect(tankstelleZeilen("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt")).toEqual({
oben: "Pascalstr. 8",
unten: "85057 Ingolstadt",
})
})
it("zeigt ohne Anschrift den gespeicherten Namen unveraendert", () => {
// Sonst hiesse eine bekannte Tankstelle plötzlich "unbekannt".
expect(tankstelleZeilen("Shell, SÖLDEN", null)).toEqual({ oben: "Shell, SÖLDEN", unten: "" })
expect(tankstelleZeilen("Testtankstelle", null)).toEqual({ oben: "Testtankstelle", unten: "" })
})
it("kommt ohne Namen aus", () => {
expect(tankstelleZeilen("", "Pascalstr. 8, 85057 Ingolstadt")).toEqual({
oben: "Pascalstr. 8",
@@ -88,15 +119,37 @@ describe("tankstelleKandidaten", () => {
})
it("laesst leere Teile weg", () => {
expect(tankstelleKandidaten("Shell, SÖLDEN", null)).toEqual(["Shell, SÖLDEN"])
// Der Ort allein ist der letzte Versuch, er laeuft nur, wenn die beiden
// davor nichts gefunden haben - schaden kann er dann nicht.
expect(tankstelleKandidaten("Shell, SÖLDEN", null)).toEqual(["Shell, SÖLDEN", "SÖLDEN"])
expect(tankstelleKandidaten(null, null)).toEqual([])
})
it("versucht zuletzt den Namen ohne die fuehrende Marke", () => {
// Der Fall der realen Instanz: der Parser hat "Marke, Strasse, Ort" in den
// Namen geschrieben, eine Postleitzahl fehlt. Gemessen am 04.09.2026:
// mit "Shell," davor findet Nominatim nichts, ohne findet es die Strasse.
expect(tankstelleKandidaten("Shell, Pascalstr. 8, Ingolstadt", null)).toEqual([
"Shell, Pascalstr. 8, Ingolstadt",
"Pascalstr. 8, Ingolstadt",
])
})
it("schneidet nichts ab, wenn die Marke nicht vorn steht", () => {
expect(tankstelleKandidaten("Tankstelle Shell", null)).toEqual(["Tankstelle Shell"])
})
})
describe("tankstelleZiel", () => {
it("nimmt fuer den Kartendienst Name und Anschrift zusammen", () => {
it("nimmt fuer den Kartendienst Kopfmarke und Anschrift zusammen", () => {
expect(tankstelleZiel("Shell, SÖLDEN", "Pascalstr. 8, 85057 Ingolstadt")).toBe(
"Shell, Pascalstr. 8, 85057 Ingolstadt",
)
})
it("schickt ohne erkennbare Marke die Anschrift allein hin", () => {
expect(tankstelleZiel("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt")).toBe(
"A. Zrenner GmbH, Pascalstr. 8, 85057 Ingolstadt",
"Pascalstr. 8, 85057 Ingolstadt",
)
})
})
@@ -122,8 +175,8 @@ describe("tankstelleSuchtext", () => {
describe("kartendienstUrl", () => {
it("nimmt auf dem iPhone Apple Karten", () => {
alsGeraet("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)")
expect(kartendienstUrl("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt")).toBe(
"https://maps.apple.com/?daddr=A.%20Zrenner%20GmbH%2C%20Pascalstr.%208%2C%2085057%20Ingolstadt",
expect(kartendienstUrl("Shell, SÖLDEN", "Pascalstr. 8, 85057 Ingolstadt")).toBe(
"https://maps.apple.com/?daddr=Shell%2C%20Pascalstr.%208%2C%2085057%20Ingolstadt",
)
})
@@ -148,4 +201,18 @@ describe("kartendienstUrl", () => {
alsGeraet("Mozilla/5.0 (iPhone)")
expect(kartendienstUrl(null, null)).toBeNull()
})
it("zeigt im Modus \"zeigen\" nur die Tankstelle, ohne Route", () => {
alsGeraet("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)")
const url = kartendienstUrl("Shell, SÖLDEN", "Pascalstr. 8, 85057 Ingolstadt", null, "zeigen")
expect(url).toContain("maps.apple.com/?q=")
expect(url).not.toContain("daddr")
})
it("nimmt bei Google die Suche statt der Routenplanung", () => {
alsGeraet("Mozilla/5.0 (X11; Linux x86_64)")
expect(kartendienstUrl(null, "Pascalstr. 8, 85057 Ingolstadt", null, "zeigen")).toBe(
"https://www.google.com/maps/search/?api=1&query=Pascalstr.%208%2C%2085057%20Ingolstadt",
)
})
})
+94 -11
View File
@@ -34,6 +34,44 @@ export interface Koordinate {
lon: number
}
/* Kopfmarken des deutschsprachigen Marktes.
WORTGLEICH mit `_MARKEN` in custom_components/audi_dashboard/shell_beleg_parser.py
(und mit MARKEN im Panel) - der Parser erkennt die Marke beim Einlesen eines
Belegs, diese Liste erkennt sie nachträglich in dem, was gespeichert ist.
Laufen die beiden auseinander, zeigt die App eine andere Marke als der
Beleg. Reihenfolge zählt: "TotalEnergies" muss vor "Total" stehen. */
const MARKEN = [
"TotalEnergies", "Total", "Aral", "Shell", "Eni", "Agip", "Esso", "OMV",
"Avia", "Turmöl", "Turmoel", "Orlen", "Tamoil", "Westfalen", "Allguth",
"Classic", "Sprint", "Raiffeisen", "BayWa", "Elan", "HEM", "Star", "JET",
"bft", "BP", "Q1",
]
/**
* Die Kopfmarke aus dem gespeicherten Stationsnamen - `null`, wenn keine
* bekannte darin steht.
*
* WARUM ÜBERHAUPT NACHTRÄGLICH: `station_name` trägt je nach Alter des Belegs
* Verschiedenes. Seit dem 16.08.2026 setzt der Parser "Marke, Straße, Ort"
* zusammen ("Shell, SÖLDEN"); ältere Belege - und jeder ohne erkennbare
* Marke - tragen dort die Firmierung des Betreibers ("A. Zrenner GmbH",
* "Hermann Mogler Mineralölg. GmbH"). Die ist nach außen nicht sichtbar und
* gehört deshalb nicht in die Anzeige (Vorgabe des Eigentümers, 04.09.2026);
* sichtbar ist die Kopfmarke.
*
* Wortgrenzen sind Absicht: "Star" darf nicht in "Starnberg" treffen.
*/
export function markeErkennen(name: string | null | undefined): string | null {
const roh = (name ?? "").trim()
if (!roh) return null
for (const marke of MARKEN) {
const muster = new RegExp(`\\b${marke.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i")
if (muster.test(roh)) return marke
}
return null
}
/** `"Pascalstr. 8, 85057 Ingolstadt"` → Straße und Ort. */
export function adresseTeilen(adresse: string | null | undefined): {
strasse: string
@@ -47,16 +85,24 @@ export function adresseTeilen(adresse: string | null | undefined): {
}
/**
* Die beiden Zeilen der Tankstellen-Angabe. `oben` ist nie leer: steht weder
* Name noch Anschrift im Beleg, heißt es dort "unbekannt" - dieselbe Auskunft,
* die die Zeile vorher gab.
* Die beiden Zeilen der Tankstellen-Angabe (Vorgabe des Eigentümers):
* oben Kopfmarke und Straße mit Hausnummer, unten Postleitzahl und Ort.
*
* Ohne erkennbare Marke steht oben nur die Straße - die Firmierung des
* Betreibers tritt NICHT an ihre Stelle, sie ist nach außen unsichtbar.
* Trägt der Beleg gar keine Anschrift, bleibt der gespeicherte Name die
* einzige Auskunft und wird unverändert gezeigt ("Shell, SÖLDEN"); ihn dann
* wegzulassen hieße, eine bekannte Tankstelle "unbekannt" zu nennen.
*
* `oben` ist nie leer: nennt der Beleg nichts, heißt es dort "unbekannt".
*/
export function tankstelleZeilen(
name: string | null | undefined,
adresse: string | null | undefined,
): { oben: string; unten: string } {
const { strasse, ort } = adresseTeilen(adresse)
const oben = [(name ?? "").trim(), strasse].filter(Boolean).join(", ")
if (!strasse && !ort) return { oben: (name ?? "").trim() || "unbekannt", unten: "" }
const oben = [markeErkennen(name) ?? "", strasse].filter(Boolean).join(", ")
return { oben: oben || "unbekannt", unten: ort }
}
@@ -73,12 +119,28 @@ export function tankstelleZeilen(
*
* Der Name allein ist trotzdem wertvoll: `"Shell, SÖLDEN"` findet Nominatim
* (46.9756/11.0111) — deshalb der zweite Versuch statt gar keinem.
*
* Und als dritter Versuch der Name OHNE die führende Marke. Seit dem 16.08.2026
* setzt der Belegparser den Namen als „Marke, Straße, Ort" zusammen; steht darin
* keine Postleitzahl, kippt die Marke die Suche. Am 04.09.2026 gemessen:
* `"Shell, Pascalstr. 8, Ingolstadt"` findet Nominatim NICHT,
* `"Pascalstr. 8, Ingolstadt"` dagegen schon. Genau daran blieb die Karte auf
* der realen Instanz leer, während dieselbe Tankstelle in der Testinstanz
* (dort steht die Anschrift getrennt daneben) gefunden wurde.
*/
export function tankstelleKandidaten(
name: string | null | undefined,
adresse: string | null | undefined,
): string[] {
return [(adresse ?? "").trim(), (name ?? "").trim()].filter(Boolean)
const n = (name ?? "").trim()
const marke = markeErkennen(n)
// Nur abschneiden, wenn die Marke wirklich vorn steht und noch etwas folgt.
const ohneMarke =
marke && n.toLowerCase().startsWith(marke.toLowerCase())
? n.slice(marke.length).replace(/^[ ,]+/, "").trim()
: ""
const liste = [(adresse ?? "").trim(), n, ohneMarke].filter(Boolean)
return [...new Set(liste)]
}
/** Der erste Suchbegriff - leer heißt: der Beleg nennt gar nichts. */
@@ -90,16 +152,19 @@ export function tankstelleSuchtext(
}
/**
* Das Ziel für den Kartendienst. Hier gehören Name UND Anschrift zusammen:
* Apple Karten und Google Maps kennen Betriebsnamen und treffen damit die
* Zapfsäule statt des Nachbargebäudes - beide sind darin deutlich besser als
* Nominatims Freitextsuche.
* Das Ziel für den Kartendienst: Kopfmarke UND Anschrift. Apple Karten und
* Google Maps kennen Tankstellenmarken und treffen damit die Zapfsäule statt
* des Nachbargebäudes - beide sind darin deutlich besser als Nominatims
* Freitextsuche. Die Firmierung des Betreibers hilft dagegen nirgends; ohne
* erkennbare Marke geht die Anschrift allein hin.
*/
export function tankstelleZiel(
name: string | null | undefined,
adresse: string | null | undefined,
): string {
return [(name ?? "").trim(), (adresse ?? "").trim()].filter(Boolean).join(", ")
const a = (adresse ?? "").trim()
if (!a) return (name ?? "").trim()
return [markeErkennen(name) ?? "", a].filter(Boolean).join(", ")
}
/** Apple-Gerät? Dort ist Karten der Standarddienst. */
@@ -109,6 +174,14 @@ function apfelgeraet(): boolean {
return /iPhone|iPad|iPod|Macintosh|Mac OS X/i.test(kennung)
}
/**
* "route" berechnet den Weg dorthin, "zeigen" setzt nur eine Nadel. Der
* Tankstellen-Verweis im Einzelbeleg will das Zweite (Vorgabe des Eigentümers,
* 04.09.2026): erst sehen, wo die Tankstelle liegt - eine Route erwartet dort
* niemand, und sie ist auf dem Telefon nur einen weiteren Tipp entfernt.
*/
export type Kartenmodus = "route" | "zeigen"
/**
* Verweis in den Kartendienst - `null`, wenn nichts bekannt ist, wohin.
*
@@ -119,15 +192,25 @@ export function kartendienstUrl(
name: string | null | undefined,
adresse: string | null | undefined,
pos?: Koordinate | null,
modus: Kartenmodus = "route",
): string | null {
const beschriftung = (name ?? "").trim()
const ziel = pos ? `${pos.lat},${pos.lon}` : tankstelleZiel(name, adresse)
if (!ziel) return null
if (apfelgeraet()) {
if (modus === "zeigen") {
// Mit Koordinaten der Punkt selbst, sonst der Suchtext - beides zeigt
// Karten nur an, ohne eine Route zu berechnen.
return pos
? `https://maps.apple.com/?ll=${ziel}&q=${encodeURIComponent(beschriftung || "Ziel")}`
: `https://maps.apple.com/?q=${encodeURIComponent(ziel)}`
}
const q = pos && beschriftung ? `&q=${encodeURIComponent(beschriftung)}` : ""
return `https://maps.apple.com/?daddr=${encodeURIComponent(ziel)}${q}`
}
return `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(ziel)}`
return modus === "zeigen"
? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(ziel)}`
: `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(ziel)}`
}
/** Wie oben, aber für einen reinen Punkt (Fahrzeugstandort, Autohaus). */
+28 -1
View File
@@ -12,13 +12,28 @@ import { useState } from "react"
import { ActionButton, Feld, Tile } from "@audi-dash/ui"
import { ApiFehler, HassRest, basisUrlNormalisieren, zugangSpeichern } from "../api"
import { QrScanner, type QrFund } from "./QrScanner"
import { zugangMerken } from "./zugang"
/* Der Scanner hat nur hier einen Platz: er hilft bei der Ersteinrichtung, und
danach gibt es nichts mehr abzutippen. Angeboten wird er nur, wenn das
Gerät überhaupt eine Kamera an die App durchreicht - am Rechner wäre er ein
Knopf, der zwangsläufig in eine Fehlermeldung läuft. */
const kameraVorhanden = () =>
typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia
export function Einrichtung({ fertig }: { fertig: () => void }) {
const [adresse, setzeAdresse] = useState("")
const [token, setzeToken] = useState("")
const [laeuft, setzeLaeuft] = useState(false)
const [fehler, setzeFehler] = useState<string | null>(null)
const [scannerOffen, setzeScannerOffen] = useState(false)
const scanUebernehmen = (fund: QrFund) => {
setzeFehler(null)
if (fund.art === "adresse") setzeAdresse(fund.wert)
else setzeToken(fund.wert)
}
const verbinden = async () => {
setzeFehler(null)
@@ -92,6 +107,12 @@ export function Einrichtung({ fertig }: { fertig: () => void }) {
</Feld>
</Tile>
{kameraVorhanden() && (
<div className="dm-einrichtung__knoepfe">
<ActionButton onClick={() => setzeScannerOffen(true)}>QR-Code scannen</ActionButton>
</div>
)}
{fehler && (
<p className="dm-einrichtung__fehler" role="alert">
{fehler}
@@ -99,11 +120,17 @@ export function Einrichtung({ fertig }: { fertig: () => void }) {
)}
<div className="dm-einrichtung__knoepfe">
<ActionButton onClick={() => void verbinden()} disabled={laeuft}>
<ActionButton variant="primary" onClick={() => void verbinden()} disabled={laeuft}>
{laeuft ? "Verbinde …" : "Verbinden"}
</ActionButton>
</div>
<QrScanner
offen={scannerOffen}
schliessen={() => setzeScannerOffen(false)}
gefunden={scanUebernehmen}
/>
<p className="dm-einrichtung__hinweis">
Den Token erzeugst du in Home Assistant unter Profil Sicherheit Langlebige
Zugriffstoken". Deine Daten bleiben auf deinem Server.
+6 -2
View File
@@ -140,8 +140,12 @@ export function Karte({
* Knopf, der wieder darauf zentriert. So zeichnet das Panel den Einzelbeleg
* (tankstelleAufKarteZeigen/mapBoxMitZentrieren); die App zeigte hier bis
* zum 04.09.2026 nur den schwarzen Startpunkt ohne Namen.
*
* Die beiden Zeilen kommen fertig herein (tankstelleZeilen) statt als Name
* und Anschrift: dann steht im Popup dasselbe wie in der Wertezeile darunter,
* und diese Komponente muss nichts über Tankstellen wissen.
*/
nadel?: { name?: string | null; adresse?: string | null }
nadel?: { oben: string; unten?: string }
}) {
const behaelter = useRef<HTMLDivElement | null>(null)
const karteRef = useRef<import("leaflet").Map | null>(null)
@@ -170,7 +174,7 @@ export function Karte({
iconAnchor: [15, 31],
})
L.marker([start.lat, start.lon], { icon: symbol })
.bindPopup([nadel.name, nadel.adresse].filter(Boolean).join("<br>"))
.bindPopup([nadel.oben, nadel.unten].filter(Boolean).join("<br>"))
.addTo(karte)
} else {
// Deckungsgleich mit initMap() im Panel: Start schwarz mit weißem Ring,
@@ -0,0 +1,43 @@
/**
* Tests zur Deutung des gelesenen QR-Codes.
*
* Der Scanner selbst (Kamera, Bildtakt) laesst sich hier nicht pruefen - was
* sich pruefen laesst, ist die eine Entscheidung, die falsch ausgehen kann:
* in welches Feld der gelesene Text gehoert.
*/
import { describe, expect, it } from "vitest"
import { qrFundDeuten } from "./QrScanner"
describe("qrFundDeuten", () => {
it("haelt einen langen zusammenhaengenden Text fuer den Token", () => {
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + "a".repeat(60) + ".signatur"
expect(qrFundDeuten(token)).toEqual({ art: "token", wert: token })
})
it("erkennt eine Adresse und legt sie ins andere Feld", () => {
expect(qrFundDeuten("https://datametric360.de")).toEqual({
art: "adresse",
wert: "https://datametric360.de",
})
expect(qrFundDeuten("http://192.168.1.20:8123")).toEqual({
art: "adresse",
wert: "http://192.168.1.20:8123",
})
})
it("uebernimmt nichts, was kein Token sein kann", () => {
// Lieber gar nichts als das falsche Feld gefuellt: ein WLAN-Code, eine
// Visitenkarte oder ein zu kurzer Text sind keine Zugangsdaten.
expect(qrFundDeuten("WIFI:S=Heimnetz;T=WPA;P=geheim;;")).toBeNull()
expect(qrFundDeuten("kurz")).toBeNull()
expect(qrFundDeuten("")).toBeNull()
expect(qrFundDeuten(" ")).toBeNull()
})
it("schneidet Leerraum ab", () => {
const token = "b".repeat(50)
expect(qrFundDeuten(" " + token + " ")).toEqual({ art: "token", wert: token })
})
})
+143
View File
@@ -0,0 +1,143 @@
/**
* QR-Code scannen, um den Zugangs-Token nicht abtippen zu müssen.
*
* WARUM KEIN NATIVES SCANNER-PLUGIN
* ---------------------------------
* `@capacitor-mlkit/barcode-scanning` wäre der bequemere Weg (native
* Scanner-Oberfläche, schneller), zieht aber GoogleMLKit über CocoaPods nach -
* mehrere Dutzend Megabyte und ein `pod install`, das auf dem Mac gelingen
* muss. Genau der Xcode-Lauf, der die App überhaupt erst wieder installierbar
* macht (Abschnitt CD), darf daran nicht scheitern. jsQR sind 30 KB
* JavaScript, kein Pod, kein nativer Code - der einzige native Anteil ist
* `NSCameraUsageDescription` in der Info.plist, und den setzt
* `ios-signieren.sh` wie schon den Standort- und die Kalender-Schlüssel.
*
* WKWebView liefert `getUserMedia` seit iOS 14.3 aus; die App läuft unter
* `capacitor://localhost` und damit in einem sicheren Kontext.
*
* WAS IM CODE STEHT
* -----------------
* Home Assistant zeigt den Token beim Anlegen selbst als QR-Code an, und der
* trägt **nur den Token**, nicht die Adresse der Instanz (deshalb ist die
* eigene QR-Seite unter `/local/` im Umsetzungsplan ersatzlos entfallen).
* Steht im Code trotzdem eine http-Adresse, ist es offensichtlich die
* Server-Adresse - dann wandert sie in das andere Feld, statt als Token
* abgelehnt zu werden.
*/
import { useEffect, useRef, useState } from "react"
import { ActionButton, Sheet } from "@audi-dash/ui"
export type QrFund = { art: "token" | "adresse"; wert: string }
/** Entscheidet, wofür der gelesene Text taugt. */
export function qrFundDeuten(roh: string): QrFund | null {
const t = (roh ?? "").trim()
if (!t) return null
if (/^https?:\/\//i.test(t)) return { art: "adresse", wert: t }
// Ein Langzeit-Token von Home Assistant ist ein JWT: drei mit Punkt
// getrennte Teile, durchgehend URL-sicheres Base64. Kürzeres kann kein
// Token sein - dann lieber nichts übernehmen als ein falsches Feld füllen.
if (t.length < 40 || /\s/.test(t)) return null
return { art: "token", wert: t }
}
/** Wie oft ein Bild ausgewertet wird. Öfter kostet Akku, ohne schneller zu treffen. */
const TAKT_MS = 250
export function QrScanner({
offen,
schliessen,
gefunden,
}: {
offen: boolean
schliessen: () => void
gefunden: (fund: QrFund) => void
}) {
const videoRef = useRef<HTMLVideoElement | null>(null)
const [fehler, setzeFehler] = useState<string | null>(null)
useEffect(() => {
if (!offen) return
let abgebrochen = false
let strom: MediaStream | null = null
let takt: ReturnType<typeof setInterval> | null = null
const starten = async () => {
if (!navigator.mediaDevices?.getUserMedia) {
setzeFehler("Dieses Gerät stellt der App keine Kamera zur Verfügung.")
return
}
try {
strom = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment" },
audio: false,
})
} catch {
setzeFehler(
"Kein Zugriff auf die Kamera. In den Einstellungen des Geräts unter „DataMetric360“ freigeben.",
)
return
}
if (abgebrochen) {
strom.getTracks().forEach((s) => s.stop())
return
}
const video = videoRef.current
if (!video) return
video.srcObject = strom
await video.play().catch(() => undefined)
const { default: jsQR } = await import("jsqr")
const leinwand = document.createElement("canvas")
const stift = leinwand.getContext("2d", { willReadFrequently: true })
if (!stift) return
takt = setInterval(() => {
if (!video.videoWidth) return
leinwand.width = video.videoWidth
leinwand.height = video.videoHeight
stift.drawImage(video, 0, 0, leinwand.width, leinwand.height)
const bild = stift.getImageData(0, 0, leinwand.width, leinwand.height)
const treffer = jsQR(bild.data, bild.width, bild.height)
if (!treffer) return
const fund = qrFundDeuten(treffer.data)
if (!fund) {
setzeFehler("Dieser Code enthält weder einen Token noch eine Adresse.")
return
}
gefunden(fund)
schliessen()
}, TAKT_MS)
}
void starten()
return () => {
abgebrochen = true
if (takt) clearInterval(takt)
// Ohne das bleibt die Kameraleuchte an, auch wenn das Blatt zu ist.
strom?.getTracks().forEach((s) => s.stop())
}
}, [offen, gefunden, schliessen])
return (
<Sheet
open={offen}
onClose={schliessen}
title="QR-Code scannen"
description="Den QR-Code aus Home Assistant in den Rahmen halten."
>
<div className="dm-qr">
<video ref={videoRef} className="dm-qr__bild" muted playsInline />
<span className="dm-qr__rahmen" aria-hidden="true" />
</div>
{fehler && (
<p className="dm-fehler" role="alert">
{fehler}
</p>
)}
<ActionButton onClick={schliessen}>Abbrechen</ActionButton>
</Sheet>
)
}
+1 -1
View File
@@ -156,7 +156,7 @@ function Tankstellenzeile({
adresse: string | null | undefined
}) {
const { oben, unten } = tankstelleZeilen(name, adresse)
const url = kartendienstUrl(name, adresse)
const url = kartendienstUrl(name, adresse, null, "zeigen")
const inhalt = (
<>
<span className="dm-tankstellenzeile__ort">
@@ -25,7 +25,7 @@ import { useEffect, useState } from "react"
import { adresseAufloesen } from "../daten/geokodierung"
import type { Koordinate } from "../daten/geokodierung"
import { tankstelleKandidaten } from "../daten/tankstelle"
import { tankstelleKandidaten, tankstelleZeilen } from "../daten/tankstelle"
import { Leerzustand } from "./bausteine"
import { Karte } from "./Karte"
@@ -68,8 +68,7 @@ export function TankstellenKarte({
// Wie im Panel (tankstelleAufKarteZeigen): die CI-Nadel mit Name und
// Anschrift im Popup, dazu der Knopf, der wieder darauf zentriert.
if (position)
return <Karte start={position} nadel={{ name: name ?? null, adresse: adresse ?? null }} />
if (position) return <Karte start={position} nadel={tankstelleZeilen(name, adresse)} />
// Solange die Auflösung läuft: eine leere Kartenfläche, wie im Panel (die
// Karte steht dort schon, die Nadel kommt nach). Bewusst nicht die
// Ladeanzeige der Seite - die meldet „der Bildschirm ist noch nicht da".
+28
View File
@@ -2095,3 +2095,31 @@ button.dm-listenzeile {
.dm-wertzeile__warnung {
color: var(--warn);
}
/* QR-Scanner der Ersteinrichtung.
Das Bild bleibt quadratisch und beschnitten (object-fit: cover): ein QR-Code
wird ohnehin mittig in den Rahmen gehalten, und ein hochkant gezerrtes
Kamerabild macht das Zielen schwerer statt leichter. */
.dm-qr {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
border-radius: var(--r-tile);
overflow: hidden;
background: #000;
}
.dm-qr__bild {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.dm-qr__rahmen {
position: absolute;
inset: 18%;
border: 2px solid var(--fg);
border-radius: 12px;
opacity: 0.85;
pointer-events: none;
}