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/
*.local
.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.
*
* **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
* 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
* fehlt ja noch, und ein toter Import würde die Typprüfung brechen.
* Ablauf: `npm run build && npx cap sync`, dann `npx cap open ios` und in
* Xcode signieren. Siehe `../UMSETZUNGSPLAN.md` Phase 10.
*/
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 = {
const konfiguration: CapacitorConfig = {
appId: "app.datametric360",
appName: "DataMetric360",
webDir: "dist",
@@ -46,6 +28,17 @@ const konfiguration: CapacitorKonfiguration = {
android: {
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
+5
View File
@@ -14,12 +14,17 @@
"test": "vitest run"
},
"dependencies": {
"@aparajita/capacitor-secure-storage": "^8.0.0",
"@audi-dash/ui": "*",
"@capacitor/android": "^8.5.0",
"@capacitor/core": "^8.5.0",
"@capacitor/ios": "^8.5.0",
"leaflet": "^1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@capacitor/cli": "^8.5.0",
"@testing-library/react": "^16.3.2",
"@types/leaflet": "^1.9.12",
"@types/node": "^24.13.3",
+4 -1
View File
@@ -23,13 +23,16 @@ export function Bild({
groesse?: "default" | "mini" | "round"
}) {
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 (
<ImagePlaceholder
{...(url ? { src: url } : {})}
{...(groesse ? { size: groesse } : {})}
{...(mitDateiname && datei ? { hint: datei } : {})}
alt={beschreibung}
label={name}
hint={datei ?? undefined}
/>
)
}
+3 -3
View File
@@ -31,7 +31,7 @@ export function Statistik() {
)
return (
<>
<div className="dm-raster">
<Tile>
<span className="ads-eyebrow">Distanz und Fahrzeit {a.jahr}</span>
<Fig value={de(a.kmJahr)} unit="km" size={40} />
@@ -74,7 +74,7 @@ export function Statistik() {
/>
</Tile>
<Tile>
<Tile className="dm-raster__breit">
<span className="ads-eyebrow">Langzeitverbrauch</span>
<Fig value={deOderStrich(a.langzeit.wert, 1)} unit="l/100 km" size={40} />
<Werteliste
@@ -150,6 +150,6 @@ export function Statistik() {
}
/>
</Tile>
</>
</div>
)
}
+18 -11
View File
@@ -17,23 +17,30 @@ const SCHILDER = import.meta.glob<string>("../assets/audi/typenschilder/*.svg",
query: "?url",
})
/** Modellname aus dem Profil → Dateikürzel des Typenschilds. */
export function schildKuerzel(titel: string): string | null {
const norm = titel.toLowerCase().replace(/\s+/g, " ").trim()
const treffer = /\b(rs)\s?([34567])\b/.exec(norm) ?? /\b(s)\s?(q7|3)\b/.exec(norm)
if (!treffer) return null
const [, reihe, zahl] = treffer
return `${reihe}${zahl}`.replace("sq7", "sq7")
/**
* Erkennt aus dem Modellnamen das passende Typenschild und trennt ab, was
* darüber hinaus im Namen steht.
*
* Das ist die Regel aus dem alten Panel (`badge()`): Der Modellname selbst
* steckt schon im Schild — daneben gehört nur, was danach kommt („Avant
* 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 }) {
const [theme] = useTheme()
const kuerzel = schildKuerzel(modell)
if (!kuerzel) return null
const erkannt = schildErkennen(modell)
if (!erkannt) return null
const variante = theme === "nacht" ? "negative" : "positive"
const pfad = `../assets/audi/typenschilder/${kuerzel}-${variante}.svg`
const quelle = SCHILDER[pfad]
const quelle = SCHILDER[`../assets/audi/typenschilder/${erkannt.kuerzel}-${variante}.svg`]
if (!quelle) return null
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 { Leerzustand, NaviKachel, Wertzeile, Werteliste } from "./bausteine"
import { Bild } from "./Bild"
import { Typenschild } from "./Typenschild"
import { Typenschild, schildErkennen } from "./Typenschild"
export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
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
// 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
.slice()
.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__marke">
<Typenschild modell={einstellungen.fahrzeugtitel} hoehe={20} />
<div className="ads-title">
{einstellungen.fahrzeugtitel}{" "}
{einstellungen.fahrzeugzusatz && (
<span className="ads-sport">{einstellungen.fahrzeugzusatz}</span>
<Typenschild modell={einstellungen.fahrzeugtitel} hoehe={26} />
{/* Neben dem Schild steht nur, was im Namen darüber hinausgeht —
das Modell selbst zeigt bereits das Schild. Ohne erkanntes
Schild tritt der volle Name an dessen Stelle. */}
<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>
+9
View File
@@ -61,6 +61,15 @@
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 {
padding: var(--sp-5) 32px 32px;
}
+28 -3
View File
@@ -421,9 +421,10 @@
font-size: 12px;
font-weight: 300;
color: var(--fg3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* Umbrechen statt abschneiden: Datum, Uhrzeit und Dauer sind alle drei
wichtig, und "11.08.2026 · 12:27 Uhr · 3…" verliert genau die Angabe,
die man sucht. */
line-height: 1.4;
}
/* -------------------------------------------------------- Detailseiten */
@@ -568,3 +569,27 @@
background: var(--canvas);
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):
"""urllib.request.urlopen läuft direkt, nicht über task.executor - anders
als ein bloßes open() ist das ein modulqualifizierter Aufruf einer echten
externen Funktion, der an der Testinstanz ohne Fehler durchlief. Blockiert
kurz, läuft nur beim Fahrtabschluss-Screening, nicht bei jedem Tick."""
"""Fragt die Verlaufsdaten des Kilometerstand-Sensors über die REST-API ab.
Beide blockierenden Schritte laufen über task.executor. Ein früherer Stand
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()
start_enc = urllib.parse.quote(start_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}"
)
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
resp = urllib.request.urlopen(req, timeout=10)
rohtext = resp.read().decode("utf-8")
resp = task.executor(urllib.request.urlopen, req, timeout=10)
rohdaten = task.executor(resp.read)
resp.close()
daten = json.loads(rohtext)
daten = json.loads(rohdaten.decode("utf-8"))
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:
# 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:
allow_all_imports: true