Compare commits

...

2 Commits

Author SHA1 Message Date
Paul Nothaft f7f1a238e5 Stand nach der nativen Huelle in AGENTS.md und Plan nachziehen 2026-08-11 12:44:32 +02:00
Paul Nothaft 4395ff6ac6 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.
2026-08-11 12:44:11 +02:00
14 changed files with 1394 additions and 63 deletions
+21 -2
View File
@@ -1,6 +1,6 @@
# AGENTS.md — Project state, review findings, open items, and working rules
**Last updated: 2026-08-11** (after building the app — phases 1, 2, 410 of `UMSETZUNGSPLAN.md`). This file is the entry point for every new agent
**Last updated: 2026-08-11** (after building the app and the native iOS hull — phases 1, 2, 411 of `UMSETZUNGSPLAN.md`). This file is the entry point for every new agent
session: what this repo is, what is finished, what is missing, and how to work here. Detail lives in
the linked documents — this file points, it does not duplicate.
@@ -80,7 +80,7 @@ assets. Never mix these the other way around.
| `homeassistant/` | HA panel (`panel_custom`): pyscript backend + vanilla-JS frontend | ✅ **finished, in use** — to be replaced by the app |
| `testumgebung/` | Script that rebuilds a throwaway Home Assistant with the real backend | ✅ new, reproducible |
| `design-system/` | React component library `@audi-dash/ui`, brand-free, feeds Claude Design | ✅ done as a kit (20 components, 1,690 lines) |
| `companion-app/` | **DataMetric360** — successor app (PWA today, Capacitor iOS/Android later, HA iframe); will **replace** the panel | ✅ **all 21 screens built and tested**; PWA works; Capacitor hull pending |
| `companion-app/` | **DataMetric360** — successor app (web, PWA, native iOS/Android via Capacitor, HA iframe); will **replace** the panel | ✅ **all 21 screens built and tested**; runs natively on iOS with real data |
| `design/` | Export of the Claude Design draft for DataMetric360 | 🚧 interim and now behind the code — screens were derived from the old panel instead (owner's decision) |
Root files: `dashboard-muster*.html` = original static prototype (superseded, reference only),
@@ -193,6 +193,25 @@ Three defects that only surfaced by running against a real Home Assistant, not b
Also: two TypeScript parameter properties in the data layer broke Node's strip-only mode, which is
what the smoke scripts run on — rewritten as plain fields.
Three more that only screenshots revealed — nothing failed, the pixels were simply wrong:
4. **The vehicle block on the home screen was invisible.** `overflow: hidden` sets a flex item's
automatic minimum size to 0, so once the page was taller than the screen, flex-shrink squashed
the block to zero height and took model name, badge and plate with it. Fixed with
`.dm-inhalt > * { flex: none }`.
5. **The model name appeared twice** — once as the badge image, once as text beside it.
6. **Filenames overflowed the gallery thumbnails.**
And one in the backend, found by watching the log against a current Home Assistant: the odometer
screening called `urlopen` directly, which HA aborts as a blocking call since 2026.8. Every trip
stayed without a distance, visible only as a warning. Now runs through `task.executor`.
**CORS is a real constraint for this app.** `cors_allowed_origins` did not take effect on HA
2026.8 (preflight 403 even same-origin). Two consequences, both handled: the web build is served
from Home Assistant itself (`/local/dm360/`, same origin — the planned deployment anyway), and the
native hull enables `CapacitorHttp`, which routes fetch through native HTTP where CORS does not
apply. Verify this again at commissioning if the app ever moves to a separate hostname.
**A self-written QR encoder was discarded.** It disagreed with a reference implementation on 1239
of 3249 modules — the code would have been unreadable. `homeassistant/www/dm360-qr.html` now uses a
vendored MIT library served from Home Assistant itself, which satisfies the actual requirement
+7 -4
View File
@@ -52,7 +52,7 @@ KI-Modell oder in einer frischen Session ohne Vorwissen abgearbeitet werden kann
| 7 | Alle Screens umsetzen | 3, 6 | nein |
| 8 | Audi-Assets einbauen | 7 | nein |
| 9 | Tests (authentifiziert + Komponenten) | 6 | nein |
| 10 | PWA + Capacitor/iOS-Sideload | 7 | Mac mit Xcode, iPhone |
| 10 | PWA + Capacitor/iOS-Sideload | 7 | erledigt bis auf das Signieren aufs Geraet |
| 11 | HA-Einbettung, Ablösung + Archivierung des Panels | 710 | HA-Produktivinstanz |
| 12 | Externer Zugriff: Cloudflare Tunnel + Reverse Proxy | teils unabhängig | Domain/DNS, Besitzer |
| 13 | FMM003: MQTT, Zertifikate, Mapping, Fahrterkennung | Hardware | **ja: FMM003 verbaut** |
@@ -416,10 +416,13 @@ nicht ein Riesencommit.
---
## Phase 10 — PWA und Capacitor 🟡 STUFE 1 ERLEDIGT (2026-08-11)
## Phase 10 — PWA und Capacitor ERLEDIGT (2026-08-11)
> PWA fertig. Capacitor-Konfiguration, Secure-Storage-Adapter und die QR-Seite
> liegen bereit; die Hülle selbst braucht einen Mac mit Xcode und ein Gerät.
> PWA fertig. Native Hülle gebaut und in Betrieb: Capacitor 8 für iOS und
> Android, Build im Simulator (iPhone 17 Pro, iOS 26.4) erfolgreich, App zeigt
> echte Daten. `CapacitorHttp` umgeht die CORS-Beschränkung der WebView.
> Offen bleibt allein das Signieren aufs eigene Gerät — das braucht das
> angeschlossene iPhone und die Apple-ID des Besitzers.
**Ziel:** Die App läuft auf dem iPhone. Zwei Stufen — erst PWA (sofort nutzbar), dann Capacitor
(Keychain + QR-Scan).
+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