QR-Code aus gespeichertem Bild, stiller Debug-Knopf, HA-Zugang bei den Zugaengen
Der Scanner liest den QR jetzt auch aus einer gespeicherten Bilddatei - in der Testumgebung ohne Kamera der einzige Weg, auf dem Telefon der kuerzere (der Token entsteht am Rechner, das Bildschirmfoto liegt ohnehin auf dem Geraet). Gleicher Leser, gleiche Deutung, nur eine andere Quelle; ohne Kamera entfaellt das schwarze Quadrat. Dabei ein echter Fehler in der ersten Fassung, gefunden an der Musterseite: img.decode() loest bei einem GIF in Chromium nie auf - das Blatt haette ohne Meldung ewig gewartet. Jetzt createImageBitmap, als Rueckfall bewusst das load-Ereignis statt decode(). Debug-Mode ist nur noch ein stiller Textknopf am Seitenende, ohne Kachel und ohne Erlaeuterung; der Zugang zu Home Assistant steht als Akkordeon in der Kachel "Zugaenge" darin. Beides auf Vorgabe des Eigentuemers. Die Musterseite kann jetzt auch die Ersteinrichtung (?seite=einrichtung) - damit ist der ganze Weg ohne echten Token nachgewiesen: selbst erzeugter QR mit Token-Text landet zeichengleich im Feld, einer mit http-Adresse im Adressfeld, ein Bild ohne Code meldet das sauber. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -22,7 +22,7 @@ import { BILDPLAETZE, SEITE_WINTER, markenlogoUrl } from "./bilder"
|
||||
import { DatensatzPopup } from "./DatensatzPopup"
|
||||
import { HistorienImport } from "./HistorienImport"
|
||||
import type { ServicebuchEintrag } from "./Service"
|
||||
import { zugangVergessen } from "./zugang"
|
||||
import { zugangLesenSynchron, zugangVergessen } from "./zugang"
|
||||
|
||||
export function Einstellungen({
|
||||
tabBeschriftung,
|
||||
@@ -857,42 +857,41 @@ export function Einstellungen({
|
||||
!updateNeustartLaeuft && <p className="dm-fehler">{integrationUpdate.fehler}</p>}
|
||||
</Tile>
|
||||
|
||||
{/* Ohne Entsprechung im Panel - und zwar notwendigerweise: das Panel
|
||||
laeuft innerhalb von Home Assistant und kennt keine Serveradresse und
|
||||
kein Token, die man wechseln koennte. Bleibt deshalb bestehen (wie
|
||||
der Willkommensbildschirm). Die beiden reinen Zaehlzeilen darueber
|
||||
(„Fahrten gespeichert"/„Tankvorgaenge gespeichert") sind entfallen -
|
||||
die waren Zusatz ohne Panel-Vorbild. */}
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Zugang</span>
|
||||
<ActionButton variant="destructive" onClick={() => void abmelden()}>
|
||||
Verbindung trennen
|
||||
</ActionButton>
|
||||
</Tile>
|
||||
|
||||
{/* Debug-Mode: Zugaenge, Dongle und der letzte Datensatz des Geraets.
|
||||
Drei Kacheln, die im Alltag nichts zu suchen haben und beim Suchen
|
||||
eines Fehlers alles - deshalb zusammen, zugeklappt und ganz unten
|
||||
(Vorgabe des Eigentuemers, 04.09.2026). */}
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Debug-Mode</span>
|
||||
<p className="dm-erklaerung">
|
||||
Zugangs-Token, die Einstellungen des Dongles und der letzte Datensatz, den das Gerät
|
||||
gemeldet hat. Für die Fehlersuche, nicht für den Alltag.
|
||||
</p>
|
||||
<ActionButton
|
||||
variant={debugOffen ? "primary" : "default"}
|
||||
onClick={() => setzeDebugOffen(!debugOffen)}
|
||||
>
|
||||
{debugOffen ? "Fertig" : "Öffnen"}
|
||||
</ActionButton>
|
||||
</Tile>
|
||||
eines Fehlers alles - deshalb zusammen, zugeklappt und ganz unten.
|
||||
|
||||
Bewusst KEINE Kachel und keine Erlaeuterung, sondern ein stiller
|
||||
Textknopf am Seitenende (Vorgabe des Eigentuemers, 04.09.2026:
|
||||
„moeglichst unauffaellig"). */}
|
||||
<button type="button" className="dm-debugknopf" onClick={() => setzeDebugOffen(!debugOffen)}>
|
||||
{debugOffen ? "Debug-Mode schließen" : "Debug-Mode"}
|
||||
</button>
|
||||
|
||||
{debugOffen && (
|
||||
<>
|
||||
{zugaenge.length > 0 && (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Zugänge</span>
|
||||
{/* Der Zugang zu Home Assistant selbst steht hier mit drin (Vorgabe des
|
||||
Eigentuemers, 04.09.2026): es ist dieselbe Sorte Angabe wie die
|
||||
Dienste-Token, und getrennt wird eine Verbindung ungefaehr so oft,
|
||||
wie ein Token getauscht wird - also fast nie. Ohne Entsprechung im
|
||||
Panel, und zwar notwendigerweise: das Panel laeuft INNERHALB von
|
||||
Home Assistant und kennt weder Adresse noch Token. */}
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Zugänge</span>
|
||||
<Accordion
|
||||
title="Home Assistant"
|
||||
summary={<span className="dm-token-gesetzt">verbunden</span>}
|
||||
>
|
||||
<p className="dm-erklaerung">
|
||||
Adresse und Zugangs-Token dieser Instanz. „Verbindung trennen“ löscht beides vom
|
||||
Gerät — die App fragt beim nächsten Start wieder danach, die Daten auf dem Server
|
||||
bleiben unberührt.
|
||||
</p>
|
||||
<Wertzeile label="Server" wert={zugangLesenSynchron()?.basisUrl ?? "—"} />
|
||||
<ActionButton variant="destructive" onClick={() => void abmelden()}>
|
||||
Verbindung trennen
|
||||
</ActionButton>
|
||||
</Accordion>
|
||||
{/* Aufklappbar, weil die Token im Alltag nichts zu suchen haben —
|
||||
man fasst sie an, wenn einer abgelaufen ist, und sonst nie. */}
|
||||
{zugaenge.map((z) => (
|
||||
@@ -947,8 +946,7 @@ export function Einstellungen({
|
||||
Dieselben Felder gibt es unter Einstellungen → Geräte &
|
||||
Dienste → Audi Dashboard → Konfigurieren.
|
||||
</span>
|
||||
</Tile>
|
||||
)}
|
||||
</Tile>
|
||||
{dongle?.aktiv && (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Dongle</span>
|
||||
|
||||
@@ -23,6 +23,15 @@
|
||||
* 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.
|
||||
*
|
||||
* ZWEITER WEG: EIN GESPEICHERTES BILD
|
||||
* -----------------------------------
|
||||
* Der QR-Code steht in Home Assistant auf demselben Bildschirm, auf dem der
|
||||
* Token entsteht - und das ist beim Einrichten meist der Rechner, nicht das
|
||||
* Telefon. Ein abfotografierter oder als Bildschirmfoto gesicherter Code ist
|
||||
* dann der kuerzere Weg, als das Telefon vor den Monitor zu halten - und ohne
|
||||
* Kamera (Simulator, Testumgebung) der einzige. jsQR bekommt dafuer dieselben
|
||||
* Bilddaten wie aus dem Kamerabild: derselbe Leser, nur eine andere Quelle.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
@@ -43,6 +52,78 @@ export function qrFundDeuten(roh: string): QrFund | null {
|
||||
return { art: "token", wert: t }
|
||||
}
|
||||
|
||||
/**
|
||||
* Laengste Kante, auf die ein Foto vor dem Lesen verkleinert wird. Ein
|
||||
* Handyfoto hat leicht 4000 px, und jsQR laeuft darueber Pixel fuer Pixel;
|
||||
* ein QR-Code bleibt auch bei 2000 px klar genug. Deutlich kleiner waere
|
||||
* riskant - dann verschwimmen die Module eines nicht formatfuellend
|
||||
* aufgenommenen Codes.
|
||||
*/
|
||||
const MAX_KANTE = 2000
|
||||
|
||||
/**
|
||||
* Dekodiert die Datei zu etwas, das auf eine Leinwand gezeichnet werden kann.
|
||||
*
|
||||
* `createImageBitmap` ist dafuer der vorgesehene Weg und nimmt einen Blob
|
||||
* direkt. Der Ruckfall ist bewusst das `load`-Ereignis und NICHT `decode()`:
|
||||
* am 04.09.2026 an der Musterseite gemessen, dass `decode()` bei einem GIF in
|
||||
* Chromium nie aufloest - das Bild war laengst geladen (294x294), das
|
||||
* Versprechen blieb offen. Das Blatt haette dann ohne Fehlermeldung ewig
|
||||
* gewartet, und genau das ist schlimmer als eine klare Absage.
|
||||
*/
|
||||
async function bildDekodieren(datei: File): Promise<CanvasImageSource & { width: number; height: number }> {
|
||||
if (typeof createImageBitmap === "function") {
|
||||
try {
|
||||
return await createImageBitmap(datei)
|
||||
} catch {
|
||||
// Weiter mit dem Rueckfall - manche Formate kennt nur der <img>-Pfad.
|
||||
}
|
||||
}
|
||||
const url = URL.createObjectURL(datei)
|
||||
try {
|
||||
const bild = new Image()
|
||||
await new Promise<void>((fertig, fehler) => {
|
||||
bild.onload = () => fertig()
|
||||
bild.onerror = () => fehler(new Error("Bild nicht lesbar"))
|
||||
bild.src = url
|
||||
})
|
||||
return Object.assign(bild, { width: bild.naturalWidth, height: bild.naturalHeight })
|
||||
} finally {
|
||||
// Ohne das haelt der Browser die Datei bis zum Neuladen im Speicher.
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest den QR-Code aus einer gespeicherten Bilddatei - `null`, wenn keiner
|
||||
* darin steht. Wirft, wenn der Browser das Bildformat gar nicht dekodieren
|
||||
* kann (HEIC am Rechner ist der praktische Fall).
|
||||
*/
|
||||
export async function qrAusDatei(datei: File): Promise<string | null> {
|
||||
const bild = await bildDekodieren(datei)
|
||||
try {
|
||||
const faktor = Math.min(1, MAX_KANTE / Math.max(bild.width, bild.height))
|
||||
const breite = Math.max(1, Math.round(bild.width * faktor))
|
||||
const hoehe = Math.max(1, Math.round(bild.height * faktor))
|
||||
const leinwand = document.createElement("canvas")
|
||||
leinwand.width = breite
|
||||
leinwand.height = hoehe
|
||||
const stift = leinwand.getContext("2d", { willReadFrequently: true })
|
||||
if (!stift) return null
|
||||
stift.drawImage(bild, 0, 0, breite, hoehe)
|
||||
const { default: jsQR } = await import("jsqr")
|
||||
const daten = stift.getImageData(0, 0, breite, hoehe)
|
||||
// attemptBoth, anders als im Kamerabild: ein Bildschirmfoto aus dem
|
||||
// Dunkelmodus zeigt den Code hell auf dunkel, also invertiert.
|
||||
const treffer = jsQR(daten.data, daten.width, daten.height, {
|
||||
inversionAttempts: "attemptBoth",
|
||||
})
|
||||
return treffer ? treffer.data : null
|
||||
} finally {
|
||||
if ("close" in bild && typeof bild.close === "function") bild.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Wie oft ein Bild ausgewertet wird. Öfter kostet Akku, ohne schneller zu treffen. */
|
||||
const TAKT_MS = 250
|
||||
|
||||
@@ -56,7 +137,11 @@ export function QrScanner({
|
||||
gefunden: (fund: QrFund) => void
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const dateiRef = useRef<HTMLInputElement | null>(null)
|
||||
const [fehler, setzeFehler] = useState<string | null>(null)
|
||||
// Ohne Kamera waere das schwarze Quadrat totes Beiwerk - dann bleibt der
|
||||
// Weg ueber die Bilddatei uebrig, und der soll die Flaeche haben.
|
||||
const [kameraAus, setzeKameraAus] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!offen) return
|
||||
@@ -66,6 +151,7 @@ export function QrScanner({
|
||||
|
||||
const starten = async () => {
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
setzeKameraAus(true)
|
||||
setzeFehler("Dieses Gerät stellt der App keine Kamera zur Verfügung.")
|
||||
return
|
||||
}
|
||||
@@ -75,6 +161,7 @@ export function QrScanner({
|
||||
audio: false,
|
||||
})
|
||||
} catch {
|
||||
setzeKameraAus(true)
|
||||
setzeFehler(
|
||||
"Kein Zugriff auf die Kamera. In den Einstellungen des Geräts unter „DataMetric360“ freigeben.",
|
||||
)
|
||||
@@ -121,23 +208,70 @@ export function QrScanner({
|
||||
}
|
||||
}, [offen, gefunden, schliessen])
|
||||
|
||||
const bildLesen = async (datei: File | undefined) => {
|
||||
if (!datei) return
|
||||
setzeFehler(null)
|
||||
let text: string | null
|
||||
try {
|
||||
text = await qrAusDatei(datei)
|
||||
} catch {
|
||||
setzeFehler(
|
||||
"Dieses Bildformat kann der Browser nicht lesen. Bitte als JPEG, PNG oder WebP sichern.",
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!text) {
|
||||
setzeFehler(
|
||||
"In diesem Bild ist kein QR-Code zu erkennen. Am besten den Code formatfüllend aufnehmen.",
|
||||
)
|
||||
return
|
||||
}
|
||||
const fund = qrFundDeuten(text)
|
||||
if (!fund) {
|
||||
setzeFehler("Dieser Code enthält weder einen Token noch eine Adresse.")
|
||||
return
|
||||
}
|
||||
gefunden(fund)
|
||||
schliessen()
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={offen}
|
||||
onClose={schliessen}
|
||||
title="QR-Code scannen"
|
||||
description="Den QR-Code aus Home Assistant in den Rahmen halten."
|
||||
description="Den QR-Code aus Home Assistant in den Rahmen halten - oder ein gespeichertes Bild davon auswählen."
|
||||
>
|
||||
<div className="dm-qr">
|
||||
<video ref={videoRef} className="dm-qr__bild" muted playsInline />
|
||||
<span className="dm-qr__rahmen" aria-hidden="true" />
|
||||
</div>
|
||||
{!kameraAus && (
|
||||
<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>
|
||||
<input
|
||||
ref={dateiRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const datei = e.target.files?.[0]
|
||||
// Zuruecksetzen, damit dasselbe Bild ein zweites Mal gewaehlt werden
|
||||
// kann - sonst feuert onChange beim gleichen Namen nicht.
|
||||
e.target.value = ""
|
||||
void bildLesen(datei)
|
||||
}}
|
||||
/>
|
||||
<div className="dm-speicherleiste dm-speicherleiste--kachel">
|
||||
<ActionButton onClick={schliessen}>Abbrechen</ActionButton>
|
||||
<ActionButton variant="primary" onClick={() => dateiRef.current?.click()}>
|
||||
Bild auswählen
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,8 +50,12 @@ describe("Einstellungen: Debug-Mode", () => {
|
||||
await waitFor(() => expect(screen.getByText("Debug-Mode")).toBeTruthy())
|
||||
// Zugeklappt: die Kacheln sind nicht bloss unsichtbar, sie stehen nicht da.
|
||||
expect(screen.queryByText("Letzter Datensatz")).toBeNull()
|
||||
// Der Zugang zu Home Assistant steht ebenfalls erst im Debug-Mode.
|
||||
expect(screen.queryByText("Verbindung trennen")).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByText("Öffnen"))
|
||||
// Der stille Textknopf am Seitenende ist die einzige Beschriftung -
|
||||
// Ueberschrift und Erlaeuterung sind entfallen (Vorgabe 04.09.2026).
|
||||
fireEvent.click(screen.getByText("Debug-Mode"))
|
||||
await waitFor(() => expect(screen.getByText("Letzter Datensatz")).toBeTruthy())
|
||||
|
||||
// Der gefuellte Zweig, nicht der Leerzustand: Geraet, Werte und der
|
||||
@@ -60,6 +64,7 @@ describe("Einstellungen: Debug-Mode", () => {
|
||||
expect(screen.getByText("CAN fuel volume")).toBeTruthy()
|
||||
expect(screen.getByText("7 liters")).toBeTruthy()
|
||||
expect(container.textContent).toContain("Rückstand")
|
||||
expect(screen.getByText("Verbindung trennen")).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2118,11 +2118,36 @@ button.dm-listenzeile {
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
/* Fest weiss, NICHT var(--fg): der Rahmen liegt auf dem Kamerabild, und das
|
||||
ist dunkel. Im Tagmodus waere --fg fast schwarz - der Rahmen war dort
|
||||
unsichtbar (am gerenderten Blatt gesehen, 04.09.2026). Der Schatten haelt
|
||||
ihn auch vor einer hellen Wand ablesbar. */
|
||||
.dm-qr__rahmen {
|
||||
position: absolute;
|
||||
inset: 18%;
|
||||
border: 2px solid var(--fg);
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
border-radius: 12px;
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Wortgleich zu .debugknopf im Panel: der stille Knopf zum Debug-Mode am
|
||||
Seitenende. Kein Kachelgrund, keine Ueberschrift, Zweitfarbe - er soll
|
||||
gefunden werden koennen, ohne sich anzubieten. Die 44px Trefferhoehe
|
||||
kommen aus dem Innenabstand. */
|
||||
.dm-debugknopf {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 15px 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
color: var(--fg3);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dm-debugknopf:active {
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user