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 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 13:08:10 +02:00
parent 6fb8375ad6
commit d6f7993504
16 changed files with 764 additions and 55 deletions
+161 -2
View File
@@ -7,8 +7,10 @@ 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; dazu Debug-Mode, eine Version-Kachel, das Knopfpaar nach
HIG und Bilder ohne Verzoegerung, `2026.9.4.11`,
Abschnitte CC bis CF. Davor: Geraetezeit statt Ankunftszeit - die Wurzel hinter der 3-km-Fahrt:
HIG und Bilder ohne Verzoegerung; dazu eine Musterseite, die die App ohne Token
und ohne Netz rendert; dazu der QR-Code aus einem gespeicherten Bild, ein stiller
Debug-Knopf und der HA-Zugang bei den Zugaengen, `2026.9.4.12`,
Abschnitte CC bis CH. Davor: Geraetezeit statt Ankunftszeit - die Wurzel hinter der 3-km-Fahrt:
das Fahrtfenster stand in Geraetezeit, der Verlauf war nach Ankunftszeit sortiert. Dazu die neun
Paritaetsbefunde und die verunreinigte Batteriehistorie, `2026.9.4.1`, Abschnitt CB. Davor: Der Regler stellt den Schlaf-Timeout des Dongles, flespi lesend und
schreibend, die eigene Warteschlange wieder entfallen, `2026.9.3.10`, Abschnitt BZ. Davor: Zugaenge: die Dienste-Token aus App und Panel bedienbar, der Wert kommt nie
@@ -9942,3 +9944,160 @@ ist ausgeschlossen. Abgedeckt sind sie durch `tsc`, den
Alle-Seiten-Rendertest und die Tatsache, dass das Panel dieselbe Struktur aus
denselben Backend-Daten baut - der Zweig MIT Datensatz ist in der App damit
strukturell, nicht optisch belegt.
## CG. Eine Musterseite loest den wiederkehrenden Pruefblocker (2026.9.4.12)
**Das Problem, das seit Wochen wiederkehrt:** die App zeigt ohne gespeicherten
Zugang gar nichts, jede OPTISCHE Pruefung haengt damit an einem Zugangstoken -
und fast jeder Fehler, den dieses Projekt findet, ist nur am gerenderten Bild
sichtbar (CSS-Spezifitaet, Flex-Basis in Spalten, ein unsichtbarer Rahmen).
`tsc` und die Tests finden diese Klasse nachweislich nicht (Abschnitt S).
**`companion-app/muster/`** mountet dieselben Bildschirme mit demselben
Stylesheet, aber mit `beispielApi()` aus den Tests: kein Netz, kein Token,
keine echten Daten - und trotzdem echtes CSS. Gebaut wird sie ueber eine
**eigene** Konfiguration (`vite.muster.config.ts`) nach `dist-muster/`; der
normale `npm run build` fasst sie nicht an, sie liegt also auch nicht in der
`.ipa`. Aufruf mit `?seite=<name>` fuer jeden Namen aus `TITEL`.
Ausgeliefert wird sie bei Bedarf in den Testcontainer:
```bash
npx vite build --config vite.muster.config.ts
MSYS_NO_PATHCONV=1 docker cp companion-app/dist-muster/. audi_ha_test:/config/www/dmmuster/
# http://localhost:18123/local/dmmuster/index.html?seite=einst
```
### Was sie sofort gefunden hat
**Der Zielrahmen des QR-Scanners war unsichtbar.** `border: 2px solid var(--fg)`
liegt auf dem Kamerabild - und das ist dunkel. Im Tagmodus ist `--fg` fast
schwarz, der Rahmen also nicht zu sehen. Jetzt fest weiss mit einem dunklen
Saum darum, damit er auch vor einer hellen Wand ablesbar bleibt. Genau die
Sorte Fehler, die kein Test findet.
### Was sie bestaetigt hat
- **Letzter Datensatz**: Geraet, Ankunft, Geraetezeit mit Rueckstand
(40.686 s), Werte samt Einheit, Fussnote - erst nach dem Aufklappen.
- **Version**: „Installiert 2026.9.4.11 / Home-Assistant-Integration" und der
Update-Teil in EINER Kachel.
- **Knopfpaar**: „Verwerfen" und „Speichern" exakt **148 x 50 px** beide,
nebeneinander, der bestaetigende gefuellt und rechts.
- Zum ersten Mal live in der App gesehen: der Schalter ist **gruen** und der
Nacht/Tag-Umschalter zeigt die gefuellte Bahn - beides Korrekturen aus
Abschnitt AL, bis dahin nur strukturell belegt.
### Die Bild-Verzoegerung, gemessen
Am selben Bild (`seitenansicht.webp`, 1,2 MB, 2000x1125) in der Musterseite:
| Fall | Dauer bis das Bild fertig ist |
|---|---|
| frisch geholt und dekodiert | 28,7 ms |
| **im HTTP-Zwischenspeicher, erstes Dekodieren** | **14,3 ms** |
| nach `decode()` vorab (was `bilderVorladen()` hinterlaesst) | **0,1 ms** |
Die mittlere Zeile ist genau der gemeldete Menuewechsel: die Datei liegt im
Cache, das Dekodieren kostet trotzdem. Das Vorladen entfernt es vollstaendig.
**Der Preis, und er gehoert genannt:** die vier vorhandenen Fotos sind zusammen
**5,3 MB**, und `bildVersion` haengt an `Date.now()` - die Adresse aendert sich
also bei JEDEM App-Start, der HTTP-Zwischenspeicher greift zwischen zwei Starts
nie. Bisher zahlte man das nur fuer Bilder, die man ansieht; mit dem Vorladen
fuer alle. **Offen, dem Eigentuemer vorgelegt:** entweder so lassen, oder nur
das Uebersichtsbild vorladen, oder - die eigentliche Loesung - den
Cache-Brecher an eine echte Fotoversion aus dem Backend haengen statt an die
Startzeit, dann faellt der wiederholte Abruf ganz weg.
### Nicht geprueft
Der Kamerapfad des Scanners (die Vorschau gibt keine Kamera heraus) und das
Vorladen in der App selbst - `bildUrl()` braucht dafuer einen gespeicherten
Zugang, den die Musterseite bewusst nicht hat. Im Panel ist das Vorladen live
belegt (alle neun Plaetze in `performance.getEntriesByType`), und beide
Codebasen fuehren denselben Ablauf.
## CH. Der QR-Code darf auch aus einem gespeicherten Bild kommen (2026.9.4.12)
### Ohne Kamera war der Scanner eine Sackgasse
Vorgabe des Eigentuemers: ein Knopf im Scanner-Blatt, der ein **gespeichertes
Bild** des QR-Codes einliest. Der Anlass war die Testumgebung ohne Kamera, aber
der Fall ist auch auf dem Telefon der haeufigere: der Token entsteht in Home
Assistant am **Rechner**, und ein Bildschirmfoto davon liegt danach ohnehin auf
dem Geraet. Das Telefon vor den Monitor zu halten ist der Umweg.
`qrAusDatei()` bekommt dieselben Bilddaten wie der Kamerapfad und benutzt
denselben Leser (jsQR) - es ist eine zweite Quelle, kein zweiter Mechanismus.
Zwei Unterschiede mit Grund: das Bild wird auf `MAX_KANTE = 2000` verkleinert
(ein Handyfoto hat leicht 4000 px, und jsQR laeuft Pixel fuer Pixel darueber),
und es wird mit `inversionAttempts: "attemptBoth"` gelesen - ein Bildschirmfoto
aus dem Dunkelmodus zeigt den Code hell auf dunkel.
Ohne Kamera verschwindet ausserdem das schwarze Quadrat: es waere totes Beiwerk,
und die Flaeche gehoert dann dem Weg, der noch funktioniert.
### Der Fund, den erst die Musterseite gezeigt hat: `decode()` bleibt stehen
Die erste Fassung dekodierte mit `new Image()` plus `await bild.decode()`. Am
04.09.2026 an der Musterseite gemessen: bei einem **GIF** loest `decode()` in
Chromium **nie** auf - das Bild war laengst geladen (`naturalWidth` 294), das
Versprechen blieb offen. Folge: das Blatt haette ohne Fehlermeldung, ohne
Kreisel und ohne Absage ewig gewartet.
Jetzt `createImageBitmap(datei)` (der dafuer vorgesehene Weg, nimmt einen Blob
direkt), und als Rueckfall bewusst das **`load`-Ereignis** statt `decode()`.
Genau die Fehlerklasse, fuer die die Musterseite gebaut wurde: `tsc` und 215
Tests waren gruen, der Fehler stand trotzdem drin - er zeigt sich nur beim
Ausfuehren, und ausfuehren liess sich der Bildschirm bisher nur mit einem Token.
### Die Musterseite kann jetzt auch die Ersteinrichtung
`?seite=einrichtung` mountet den Willkommensbildschirm. Er ist keine Route (er
steht vor der Anmeldung) und brauchte deshalb einen eigenen Zweig in
`muster/muster.tsx`.
**Damit ist der ganze Weg nachgewiesen, ohne einen echten Token anzufassen** -
gepruefte QR-Codes waren selbst erzeugte (die im Repo liegende MIT-Bibliothek
`homeassistant/www/dm360-qrcode-lib.js` erzeugt sie, in den Container kopiert
und ueber `/local/` geladen):
| Eingespieltes Bild | Ergebnis |
|---|---|
| QR mit einem Text in JWT-Form | steht **zeichengleich** im Token-Feld, Blatt schliesst, Adressfeld unberuehrt |
| QR mit `http://muster.local:8123` | landet im **Adressfeld**, nicht im Token-Feld |
| graues Bild ohne Code | „In diesem Bild ist kein QR-Code zu erkennen", Blatt bleibt offen |
### Debug-Mode ist nur noch ein stiller Knopf
Vorgabe des Eigentuemers: „moeglichst unauffaellig". Kachel, Ueberschrift und
der erklaerende Satz sind entfallen; es bleibt ein Textknopf am Seitenende
(`.debugknopf` / `.dm-debugknopf`, wortgleich in beiden Codebasen): kein
Kachelgrund, kein Rahmen, 12,5 px in der Drittfarbe. Die 44 px Trefferhoehe
kommen aus dem Innenabstand - die Flaeche stimmt, ohne dass der Knopf gross
aussieht. Live gemessen: Panel 45 px, `rgb(107,107,112)`, durchsichtig, letztes
Element der Seite; App 12,5 px, `rgb(142,142,147)`.
### Der Zugang zu Home Assistant steht jetzt bei den Zugaengen
Ebenfalls Vorgabe: die eigene Kachel „Zugang" mit „Verbindung trennen" wandert
in den Debug-Mode, und zwar **in** die Kachel „Zugaenge". Das ist dieselbe Sorte
Angabe wie die Dienste-Token, und eine Verbindung wird ungefaehr so oft getrennt,
wie ein Token getauscht wird - also fast nie. Als Akkordeon „Home Assistant" mit
der Server-Adresse (aus `zugangLesenSynchron()`) und dem Trennen-Knopf; die
Kachel wird deshalb jetzt immer gezeigt, nicht mehr nur bei vorhandenen
Dienste-Token.
Ohne Entsprechung im Panel, und zwar notwendigerweise: es laeuft INNERHALB von
Home Assistant und kennt weder Adresse noch Token.
### Merkposten: die App am Entwicklungsserver anmelden
Gemeldet als „die Adresse stimmt nicht". Aus dem Dev-Server (`:5173`) heraus ist
`http://localhost:18123` **fremder Ursprung**, der Preflight endet in 403 - die
App meldet daraufhin voellig zu Recht „Der Server ist unter dieser Adresse nicht
erreichbar". Einzutragen ist die **eigene** Adresse `http://localhost:5173`; der
Vite-Proxy reicht `/api` serverseitig an `:18123` weiter (Abschnitt AK). Nach der
Korrektur stand die Uebersicht mit echten Daten.
+2
View File
@@ -1,5 +1,7 @@
node_modules/
dist/
# Bau der Musterseite (vite.muster.config.ts) - reines Ergebnis, nie in der .ipa.
dist-muster/
*.local
.DS_Store
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de" data-theme="nacht">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>DataMetric360 Musterseite</title>
</head>
<body>
<div id="wurzel" class="ads-root"></div>
<script type="module" src="./muster.tsx"></script>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
/**
* Musterseite: die Oberflaeche der App mit Beispieldaten, ohne Server.
*
* WOFUER
* ------
* Die App braucht sonst einen gespeicherten Zugang, um ueberhaupt etwas
* anzuzeigen - und damit war jede OPTISCHE Pruefung an ein Zugangstoken
* gebunden. Genau daran ist in diesem Projekt wiederholt die Verifikation
* haengengeblieben, obwohl fast jeder gefundene Fehler (CSS-Spezifitaet,
* Flex-Basis in Spalten, unsichtbare Rahmen) nur am gerenderten Bild
* sichtbar war und von tsc und den Tests nicht gefunden werden konnte.
*
* Diese Seite mountet dieselben Bildschirme mit demselben Stylesheet, aber
* mit `beispielApi()` aus den Tests. Kein Netz, kein Token, keine echten
* Daten - und trotzdem echtes CSS.
*
* NICHT TEIL DER APP
* ------------------
* Gebaut wird sie ueber `vite.muster.config.ts` nach `dist-muster/`. Der
* normale `npm run build` fasst sie nicht an, sie liegt also auch nicht in
* der .ipa.
*
* Aufruf: `?seite=einst` (Vorgabe), jeder Name aus navigation.ts TITEL.
* Dazu `?seite=einrichtung` fuer den Willkommensbildschirm - der ist keine
* Route (er steht VOR der Anmeldung) und braucht deshalb einen eigenen Zweig.
*/
import { StrictMode, useState } from "react"
import { createRoot } from "react-dom/client"
import "@audi-dash/ui/styles.css"
import "../src/stile/grundlage.css"
import "../src/stile/audi-schrift.css"
import "../src/stile/rahmen.css"
import "../src/stile/screens.css"
import { DatenAnbieter } from "../src/daten/DatenKontext"
import { Shell } from "../src/Shell"
import type { SeitenName } from "../src/navigation"
import { TITEL } from "../src/navigation"
import { Einrichtung } from "../src/screens/Einrichtung"
import { SeiteFuer } from "../src/screens/register"
import { beispielApi } from "../src/tests/beispieldaten"
function Muster() {
const gewaehlt = new URLSearchParams(location.search).get("seite") || "einst"
const start = gewaehlt as SeitenName
const [route, setzeRoute] = useState<{ name: SeitenName; id?: string }>({ name: start })
const geheZu = (name: SeitenName, id?: string) =>
setzeRoute(id === undefined ? { name } : { name, id })
// Der Willkommensbildschirm haengt an keinem Zugang und an keiner Route.
if (gewaehlt === "einrichtung") return <Einrichtung fertig={() => {}} />
return (
<DatenAnbieter api={beispielApi()}>
<Shell route={route} geheZu={geheZu} tabBeschriftung>
<SeiteFuer
route={route}
geheZu={geheZu}
tabBeschriftung
setzeTabBeschriftung={() => {}}
beiAbmeldung={() => {}}
/>
</Shell>
</DatenAnbieter>
)
}
// Zum schnellen Springen zwischen den Seiten, ohne die Adresse zu tippen.
;(window as unknown as { SEITEN: string[] }).SEITEN = Object.keys(TITEL)
const wurzel = document.getElementById("wurzel")
if (!wurzel) throw new Error("Wurzelelement #wurzel fehlt")
createRoot(wurzel).render(
<StrictMode>
<Muster />
</StrictMode>,
)
+1
View File
@@ -39,6 +39,7 @@
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^29.1.1",
"qrcode": "^1.5.4",
"typescript": "^5.7.2",
"vite": "^6.0.7",
"vitest": "^2.1.8",
+32 -34
View File
@@ -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 &amp;
Dienste Audi Dashboard Konfigurieren.
</span>
</Tile>
)}
</Tile>
{dongle?.aktiv && (
<Tile>
<span className="ads-eyebrow">Dongle</span>
+140 -6
View File
@@ -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>
)
}
+6 -1
View File
@@ -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()
})
})
+26 -1
View File
@@ -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);
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Bau der Musterseite (siehe muster/muster.tsx).
*
* Bewusst eine EIGENE Konfiguration mit eigenem Ausgabeordner: die Seite darf
* nicht in `dist/` landen, weil `dist/` die Huelle der iOS-App ist. Was dort
* liegt, wird mit ausgeliefert.
*/
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
export default defineConfig({
root: "muster",
base: "./",
plugins: [react()],
define: { __APP_VERSION__: JSON.stringify("muster") },
build: { outDir: "../dist-muster", emptyOutDir: true },
})
@@ -1 +1 @@
{"version":"2026.9.4.11","sha256":"937302e85e87c62326c4f7013cb026f50243b8cc5d521d752b5a3708120d2b91","bytes":320679,"gebaut":"2026-09-04T10:13:31Z"}
{"version":"2026.9.4.12","sha256":"bb66727726eac2e6be98dc1132bd3b532c8c6b9525f9885020a65d99d2d7fc55","bytes":321283,"gebaut":"2026-09-04T11:05:31Z"}
@@ -3442,15 +3442,13 @@ function vEinst() {
</div>
<!-- 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). -->
<div class="tile"><span class="label">Debug-Mode</span>
<span class="label" style="margin-top:8px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
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.</span>
<button class="aktion ${debugOffen ? "primaer" : ""}" style="margin-top:16px" data-debug-auf>${debugOffen ? "Fertig" : "Öffnen"}</button>
</div>
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"). Wer ihn braucht, weiss was er tut; wer
ihn nicht braucht, soll nicht darueber stolpern. -->
<button class="debugknopf" data-debug-auf>${debugOffen ? "Debug-Mode schließen" : "Debug-Mode"}</button>
${debugOffen ? `
${ZUGAENGE.length ? `<div class="tile"><span class="label">Zugänge</span>
${ZUGAENGE.map((z, i) => `
@@ -1671,3 +1671,24 @@ img.carfix.klein { aspect-ratio: auto; height: auto; }
flex: 1 1 0;
margin-top: 0;
}
/* 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, damit die
Flaeche stimmt, ohne dass der Knopf gross aussieht. */
.debugknopf {
display: block;
width: 100%;
margin-top: var(--sp-4);
padding: 15px 0;
border: 0;
background: none;
font: inherit;
font-size: 12.5px;
color: var(--fg3);
text-align: center;
cursor: pointer;
}
.debugknopf:active {
color: var(--fg2);
}
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.9.4.11",
"version": "2026.9.4.12",
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
"codeowners": [
+260
View File
@@ -40,6 +40,7 @@
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^29.1.1",
"qrcode": "^1.5.4",
"typescript": "^5.7.2",
"vite": "^6.0.7",
"vitest": "^2.1.8",
@@ -2337,6 +2338,16 @@
"node": ">=8"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001809",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
@@ -2411,6 +2422,49 @@
"node": ">=18"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/cliui/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2537,6 +2591,16 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -2575,6 +2639,13 @@
"node": ">=6"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"dev": true,
"license": "MIT"
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
@@ -2740,6 +2811,20 @@
}
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fix-dts-default-cjs-exports": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz",
@@ -2792,6 +2877,16 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
@@ -3058,6 +3153,19 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -3269,6 +3377,45 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -3289,6 +3436,16 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -3407,6 +3564,16 @@
"node": ">=10.4.0"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
@@ -3529,6 +3696,24 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -3601,6 +3786,16 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -3611,6 +3806,13 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"dev": true,
"license": "ISC"
},
"node_modules/resolve-from": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
@@ -3747,6 +3949,13 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"dev": true,
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -6055,6 +6264,13 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"dev": true,
"license": "ISC"
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -6171,6 +6387,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"dev": true,
"license": "ISC"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -6178,6 +6401,43 @@
"dev": true,
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yauzl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",