Native iOS-Huelle in Betrieb, drei Darstellungsfehler behoben

Xcode ist vorhanden, die Huelle liess sich also wirklich bauen statt nur
vorzubereiten: Capacitor 8 fuer iOS und Android, Build fuer den Simulator
erfolgreich, App laeuft und zeigt echte Daten vom Server.

CapacitorHttp eingeschaltet. Das ist keine Feinheit: die native Huelle
liefert die Oberflaeche unter eigenem Ursprung aus, jede Anfrage an Home
Assistant ist damit ursprungsuebergreifend, und die WebView lehnt sie ohne
CORS-Freigabe ab. Nativ gestellte Anfragen kennen keine CORS-Pruefung -
die App braucht dadurch keine CORS-Einstellung am Server.

Drei Fehler, die erst die Bildschirmfotos zeigten:

1. Der Fahrzeug-Block auf der Uebersicht war unsichtbar. Ursache war der
   Flexbox-Fallstrick: "overflow: hidden" setzt die automatische Mindesthoehe
   auf 0, und sobald die Seite laenger ist als der Bildschirm, quetscht
   flex-shrink den Block auf Hoehe 0. Modellname, Typenschild und Kennzeichen
   waren damit schlicht weg.
2. Neben dem Typenschild stand der Modellname noch einmal komplett, obwohl
   das Schild ihn bereits zeigt. Das alte Panel loest das laengst richtig -
   nur der Zusatz gehoert daneben. Nachgezogen, samt Entdopplung, wenn die
   Ausfuehrung schon im Namen steckt.
3. In der Fotogalerie liefen die Dateinamen ueber den Rand der Miniaturen.

Dazu: Datumszeile in Listen bricht um statt abzuschneiden, Statistik nutzt
auf der Flaeche mehrere Spalten.

Im Backend war das Kilometerstand-Screening kaputt: es rief urlopen direkt
auf, was Home Assistant seit 2026.8 als blockierenden Aufruf abbricht. Jede
Fahrt blieb dadurch ohne Strecke - sichtbar nur als Warnung im Protokoll.
Laeuft jetzt ueber task.executor und rechnet an der Testinstanz wieder echte
Strecken aus dem Verlauf.

Die CORS-Freigabe fuer die Web-Fassung ist in der Testkonfiguration
hinterlegt. Sie greift in Home Assistant 2026.8 allerdings nicht - deshalb
wird die App aus Home Assistant selbst ausgeliefert (gleicher Ursprung, kein
CORS), was ohnehin der geplante Weg ist.

Die erzeugten Ordner ios/ und android/ bleiben ungetrackt; sie entstehen
jederzeit neu aus dem Webbuendel.
This commit is contained in:
Paul Nothaft
2026-08-11 12:44:11 +02:00
parent 5e4ef2a327
commit 4395ff6ac6
12 changed files with 1366 additions and 57 deletions
+6
View File
@@ -2,3 +2,9 @@ node_modules/
dist/ dist/
*.local *.local
.DS_Store .DS_Store
# Native Huellen: von `npx cap add` erzeugt, aus dem Webbuendel jederzeit
# wiederherstellbar. Die Projektdateien selbst (capacitor.config.ts) sind
# getrackt.
ios/
android/
+17 -24
View File
@@ -1,33 +1,15 @@
import type { CapacitorConfig } from "@capacitor/cli"
/** /**
* Capacitor-Konfiguration für die native Hülle. * Capacitor-Konfiguration für die native Hülle.
* *
* **Noch nicht in Betrieb.** Die Pakete sind bewusst nicht installiert; diese
* Datei liegt fertig da, damit die Inbetriebnahme nur noch aus den Befehlen in
* `../UMSETZUNGSPLAN.md` Phase 10 Stufe 2 besteht:
*
* npm i -D @capacitor/cli
* npm i @capacitor/core @capacitor/ios @capacitor/android
* npx cap add ios && npx cap add android
* npm run build && npx cap sync
* npx cap open ios # in Xcode signieren und aufs Gerät bauen
*
* Nur Sideload, nie App Store: die Audi-Schrift und die Typenschilder sind * Nur Sideload, nie App Store: die Audi-Schrift und die Typenschilder sind
* ausschließlich für diese private Installation freigegeben. * ausschließlich für diese eine private Installation freigegeben.
* *
* Der Typ ist absichtlich nicht aus @capacitor/cli importiert — das Paket * Ablauf: `npm run build && npx cap sync`, dann `npx cap open ios` und in
* fehlt ja noch, und ein toter Import würde die Typprüfung brechen. * Xcode signieren. Siehe `../UMSETZUNGSPLAN.md` Phase 10.
*/ */
const konfiguration: CapacitorConfig = {
interface CapacitorKonfiguration {
appId: string
appName: string
webDir: string
server?: { androidScheme?: string; iosScheme?: string; cleartext?: boolean }
ios?: { contentInset?: string; backgroundColor?: string }
android?: { backgroundColor?: string }
}
const konfiguration: CapacitorKonfiguration = {
appId: "app.datametric360", appId: "app.datametric360",
appName: "DataMetric360", appName: "DataMetric360",
webDir: "dist", webDir: "dist",
@@ -46,6 +28,17 @@ const konfiguration: CapacitorKonfiguration = {
android: { android: {
backgroundColor: "#161b23", backgroundColor: "#161b23",
}, },
plugins: {
// Leitet fetch() über die native HTTP-Schicht statt über die WebView.
//
// Das ist hier nicht Feinschliff, sondern notwendig: Die native Hülle
// liefert die Oberfläche unter einem eigenen Ursprung aus, jede Anfrage an
// Home Assistant ist damit ursprungsübergreifend, und die WebView lehnt sie
// ohne CORS-Freigabe ab. Nativ gestellte Anfragen kennen keine
// CORS-Prüfung — die App braucht dadurch keinerlei CORS-Einstellung am
// Server. Die WebSocket-Verbindung läuft unberührt weiter.
CapacitorHttp: { enabled: true },
},
} }
export default konfiguration export default konfiguration
+5
View File
@@ -14,12 +14,17 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@aparajita/capacitor-secure-storage": "^8.0.0",
"@audi-dash/ui": "*", "@audi-dash/ui": "*",
"@capacitor/android": "^8.5.0",
"@capacitor/core": "^8.5.0",
"@capacitor/ios": "^8.5.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"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/cli": "^8.5.0",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/leaflet": "^1.9.12", "@types/leaflet": "^1.9.12",
"@types/node": "^24.13.3", "@types/node": "^24.13.3",
+4 -1
View File
@@ -23,13 +23,16 @@ export function Bild({
groesse?: "default" | "mini" | "round" groesse?: "default" | "mini" | "round"
}) { }) {
const url = bildUrl(datei) const url = bildUrl(datei)
// Der Dateiname hilft nur, wenn er lesbar ist — in der 78px breiten
// Miniaturansicht liefe er über den Rand hinaus.
const mitDateiname = groesse !== "mini" && groesse !== "round"
return ( return (
<ImagePlaceholder <ImagePlaceholder
{...(url ? { src: url } : {})} {...(url ? { src: url } : {})}
{...(groesse ? { size: groesse } : {})} {...(groesse ? { size: groesse } : {})}
{...(mitDateiname && datei ? { hint: datei } : {})}
alt={beschreibung} alt={beschreibung}
label={name} label={name}
hint={datei ?? undefined}
/> />
) )
} }
+3 -3
View File
@@ -31,7 +31,7 @@ export function Statistik() {
) )
return ( return (
<> <div className="dm-raster">
<Tile> <Tile>
<span className="ads-eyebrow">Distanz und Fahrzeit {a.jahr}</span> <span className="ads-eyebrow">Distanz und Fahrzeit {a.jahr}</span>
<Fig value={de(a.kmJahr)} unit="km" size={40} /> <Fig value={de(a.kmJahr)} unit="km" size={40} />
@@ -74,7 +74,7 @@ export function Statistik() {
/> />
</Tile> </Tile>
<Tile> <Tile className="dm-raster__breit">
<span className="ads-eyebrow">Langzeitverbrauch</span> <span className="ads-eyebrow">Langzeitverbrauch</span>
<Fig value={deOderStrich(a.langzeit.wert, 1)} unit="l/100 km" size={40} /> <Fig value={deOderStrich(a.langzeit.wert, 1)} unit="l/100 km" size={40} />
<Werteliste <Werteliste
@@ -150,6 +150,6 @@ export function Statistik() {
} }
/> />
</Tile> </Tile>
</> </div>
) )
} }
+18 -11
View File
@@ -17,23 +17,30 @@ const SCHILDER = import.meta.glob<string>("../assets/audi/typenschilder/*.svg",
query: "?url", query: "?url",
}) })
/** Modellname aus dem Profil → Dateikürzel des Typenschilds. */ /**
export function schildKuerzel(titel: string): string | null { * Erkennt aus dem Modellnamen das passende Typenschild und trennt ab, was
const norm = titel.toLowerCase().replace(/\s+/g, " ").trim() * darüber hinaus im Namen steht.
const treffer = /\b(rs)\s?([34567])\b/.exec(norm) ?? /\b(s)\s?(q7|3)\b/.exec(norm) *
if (!treffer) return null * Das ist die Regel aus dem alten Panel (`badge()`): Der Modellname selbst
const [, reihe, zahl] = treffer * steckt schon im Schild — daneben gehört nur, was danach kommt („Avant
return `${reihe}${zahl}`.replace("sq7", "sq7") * competition"). Sonst stünde „RS 4" zweimal da.
*/
export function schildErkennen(
titel: string,
): { kuerzel: string; zusatz: string } | null {
const treffer = /^\s*(RS\s?[34567]|S\s?Q7|S\s?3)\b/i.exec(titel)
if (!treffer?.[1]) return null
const kuerzel = treffer[1].replace(/\s+/g, "").toLowerCase()
return { kuerzel, zusatz: titel.slice(treffer[0].length).trim() }
} }
export function Typenschild({ modell, hoehe = 22 }: { modell: string; hoehe?: number }) { export function Typenschild({ modell, hoehe = 22 }: { modell: string; hoehe?: number }) {
const [theme] = useTheme() const [theme] = useTheme()
const kuerzel = schildKuerzel(modell) const erkannt = schildErkennen(modell)
if (!kuerzel) return null if (!erkannt) return null
const variante = theme === "nacht" ? "negative" : "positive" const variante = theme === "nacht" ? "negative" : "positive"
const pfad = `../assets/audi/typenschilder/${kuerzel}-${variante}.svg` const quelle = SCHILDER[`../assets/audi/typenschilder/${erkannt.kuerzel}-${variante}.svg`]
const quelle = SCHILDER[pfad]
if (!quelle) return null if (!quelle) return null
return <img src={quelle} alt={modell} height={hoehe} style={{ height: hoehe, width: "auto" }} /> return <img src={quelle} alt={modell} height={hoehe} style={{ height: hoehe, width: "auto" }} />
+29 -6
View File
@@ -15,7 +15,7 @@ import { datum, datumZeit, dauer, de, deOderStrich, eur } from "../format"
import type { SeitenName } from "../navigation" import type { SeitenName } from "../navigation"
import { Leerzustand, NaviKachel, Wertzeile, Werteliste } from "./bausteine" import { Leerzustand, NaviKachel, Wertzeile, Werteliste } from "./bausteine"
import { Bild } from "./Bild" import { Bild } from "./Bild"
import { Typenschild } from "./Typenschild" import { Typenschild, schildErkennen } from "./Typenschild"
export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) { export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { fahrzeug, einstellungen, fahrten, tankvorgaenge, jetztAktualisieren, statusStand } = const { fahrzeug, einstellungen, fahrten, tankvorgaenge, jetztAktualisieren, statusStand } =
@@ -24,6 +24,22 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
if (!fahrzeug || !einstellungen) return null if (!fahrzeug || !einstellungen) return null
// Der Zusatz stammt aus dem Modellnamen selbst (alles nach dem Typenschild)
// plus der eigens gepflegten Ausführung — so macht es auch das alte Panel.
const schild = schildErkennen(einstellungen.fahrzeugtitel)
const ausfuehrung = einstellungen.ausfuehrung.trim()
// Die Ausführung steht oft schon im Modellnamen („RS 4 Avant competition"
// plus Ausführung „competition") — dann nicht doppelt anhängen.
const zusatz = [
schild?.zusatz,
ausfuehrung && !schild?.zusatz.toLowerCase().includes(ausfuehrung.toLowerCase())
? ausfuehrung
: "",
]
.filter(Boolean)
.join(" ")
.trim()
const letzteFahrt = fahrten const letzteFahrt = fahrten
.slice() .slice()
.sort((a, b) => new Date(b.ts_start).getTime() - new Date(a.ts_start).getTime())[0] .sort((a, b) => new Date(b.ts_start).getTime() - new Date(a.ts_start).getTime())[0]
@@ -59,11 +75,18 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
/> />
<div className="dm-szene__text"> <div className="dm-szene__text">
<div className="dm-szene__marke"> <div className="dm-szene__marke">
<Typenschild modell={einstellungen.fahrzeugtitel} hoehe={20} /> <Typenschild modell={einstellungen.fahrzeugtitel} hoehe={26} />
<div className="ads-title"> {/* Neben dem Schild steht nur, was im Namen darüber hinausgeht —
{einstellungen.fahrzeugtitel}{" "} das Modell selbst zeigt bereits das Schild. Ohne erkanntes
{einstellungen.fahrzeugzusatz && ( Schild tritt der volle Name an dessen Stelle. */}
<span className="ads-sport">{einstellungen.fahrzeugzusatz}</span> <div className="ads-title dm-szene__zusatz">
{schild ? (
zusatz && <span className="ads-sport">{zusatz}</span>
) : (
<>
{einstellungen.fahrzeugtitel}{" "}
{zusatz && <span className="ads-sport">{zusatz}</span>}
</>
)} )}
</div> </div>
</div> </div>
+9
View File
@@ -61,6 +61,15 @@
gap: var(--sp-3); gap: var(--sp-3);
} }
/* Inhalte dürfen nie gestaucht werden — die Fläche scrollt stattdessen.
Ohne das quetscht Flex jeden Block mit `overflow: hidden` auf Höhe 0,
sobald die Seite länger ist als der Bildschirm: `overflow: hidden` setzt
die automatische Mindesthöhe auf 0, und dann greift flex-shrink ungebremst.
Genau so verschwand das Fahrzeugbild samt Modellname und Kennzeichen. */
.dm-inhalt > * {
flex: none;
}
.dm-inhalt--breit { .dm-inhalt--breit {
padding: var(--sp-5) 32px 32px; padding: var(--sp-5) 32px 32px;
} }
+28 -3
View File
@@ -421,9 +421,10 @@
font-size: 12px; font-size: 12px;
font-weight: 300; font-weight: 300;
color: var(--fg3); color: var(--fg3);
overflow: hidden; /* Umbrechen statt abschneiden: Datum, Uhrzeit und Dauer sind alle drei
text-overflow: ellipsis; wichtig, und "11.08.2026 · 12:27 Uhr · 3…" verliert genau die Angabe,
white-space: nowrap; die man sucht. */
line-height: 1.4;
} }
/* -------------------------------------------------------- Detailseiten */ /* -------------------------------------------------------- Detailseiten */
@@ -568,3 +569,27 @@
background: var(--canvas); background: var(--canvas);
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
} }
.dm-szene__zusatz {
min-width: 0;
}
/* Der Zusatz neben dem Typenschild darf umbrechen — abgeschnittener Text mit
Auslassungspunkten sieht nach Fehler aus, ein Umbruch nicht. */
.dm-szene__zusatz {
min-width: 0;
white-space: normal;
overflow: visible;
text-overflow: clip;
font-size: 19px;
line-height: 1.2;
}
/* Die Galerie richtet sich nach der festen Kachelbreite der Bibliothek (78px),
damit die Kacheln nicht auseinandergezogen werden und ihre Beschriftung
innerhalb des Rahmens bleibt. */
.dm-galerie {
grid-template-columns: repeat(auto-fill, 78px);
justify-content: space-between;
gap: var(--sp-3);
}
@@ -51,10 +51,19 @@ def _token_lesen():
def historie_abfragen(entity_id, start_iso, end_iso): def historie_abfragen(entity_id, start_iso, end_iso):
"""urllib.request.urlopen läuft direkt, nicht über task.executor - anders """Fragt die Verlaufsdaten des Kilometerstand-Sensors über die REST-API ab.
als ein bloßes open() ist das ein modulqualifizierter Aufruf einer echten
externen Funktion, der an der Testinstanz ohne Fehler durchlief. Blockiert Beide blockierenden Schritte laufen über task.executor. Ein früherer Stand
kurz, läuft nur beim Fahrtabschluss-Screening, nicht bei jedem Tick.""" rief urlopen() direkt auf - das lief jahrelang durch, bis Home Assistant
anfing, blockierende Aufrufe im Ereignisschleifen-Thread zu erkennen und
abzubrechen (real beobachtet ab 2026.8: "Caught blocking call to
putrequest ... inside the event loop"). Seitdem schlug jedes Screening
fehl, und manuell angelegte Fahrten blieben ohne Strecke - sichtbar nur
als Warnung im Protokoll, weil der Aufrufer den Fehler bewusst abfängt.
task.executor braucht echte externe Funktionen; urllib.request.urlopen und
die read-Methode der Antwort sind genau das (dieselbe Einschränkung wie bei
io.open in profil.py)."""
token = _token_lesen() token = _token_lesen()
start_enc = urllib.parse.quote(start_iso) start_enc = urllib.parse.quote(start_iso)
end_enc = urllib.parse.quote(end_iso) end_enc = urllib.parse.quote(end_iso)
@@ -63,10 +72,10 @@ def historie_abfragen(entity_id, start_iso, end_iso):
f"?filter_entity_id={entity_id}&end_time={end_enc}" f"?filter_entity_id={entity_id}&end_time={end_enc}"
) )
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
resp = urllib.request.urlopen(req, timeout=10) resp = task.executor(urllib.request.urlopen, req, timeout=10)
rohtext = resp.read().decode("utf-8") rohdaten = task.executor(resp.read)
resp.close() resp.close()
daten = json.loads(rohtext) daten = json.loads(rohdaten.decode("utf-8"))
return daten[0] if daten else [] return daten[0] if daten else []
+1220 -2
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -6,6 +6,17 @@
default_config: default_config:
# Die App laeuft unter einer anderen Adresse als Home Assistant. Ohne diese
# Freigabe lehnt der Browser jede Anfrage ab, noch bevor sie den Server
# erreicht (CORS). Gilt genauso im Echtbetrieb - siehe
# ../homeassistant/REVERSE_PROXY.md.
http:
cors_allowed_origins:
- http://localhost:4173 # Vorschau des Produktionsbuendels
- http://localhost:5173 # Entwicklungsserver
- capacitor://localhost # native Huelle iOS
- ionic://localhost # native Huelle Android
pyscript: pyscript:
allow_all_imports: true allow_all_imports: true