OTA-Updates für die iOS-App; HACS-Fehlannahme korrigiert

HACS kann laut eigener Dokumentation grundsätzlich nicht mit privaten
GitHub-Repositories arbeiten (hacs.xyz/docs/faq/private_repositories) - keine
Ausnahme für Tokens oder verbundene Konten. Meine frühere Annahme, HACS käme
damit zurecht, wenn es unter dem richtigen Konto angemeldet ist, war falsch.
Da das Repository aus Lizenzgründen privat bleiben muss (Audi-Hausschrift,
Typenschilder), ist install.ps1 damit nicht die Rückfallebene, sondern der
einzige Installationsweg - README, INSTALL.md, ANLEITUNG.md, install.ps1 und
VERSIONIERUNG.md korrigiert.

Oberflächen-Updates für die iOS-App laufen jetzt ohne Xcode:
@capgo/capacitor-updater eingebaut, ein Update-Abschnitt in den
Einstellungen lädt ein neues Bündel und tauscht die Oberfläche aus. Kein
Selbstlauf (autoUpdate: false) - nur auf Tastendruck, nie während der
Benutzung.

Das Bündel liegt in der Integration selbst
(custom_components/audi_dashboard/frontend/app/), nicht unter /local/: so
reist es bei jeder Installation automatisch mit, ohne zweiten
Auslieferungsweg. Gebaut von companion-app/scripts/ota-paket.ps1 (neuer
Befehl: npm run ota), gemeldet über sensor.audi_dashboard_app_version
(neues Feld daten.buendel).

Ein echter Bug beim Bauen gefunden: [IO.Compression.ZipFile]::CreateFrom-
Directory schreibt unter Windows PowerShell 5.1 Backslashes in die
Zip-Einträge - iOS hätte das Archiv falsch entpackt. Behoben, indem die
Einträge von Hand mit "/" geschrieben werden.

Rückfallebene: notifyAppReady() läuft erst, wenn React nachweislich
gerendert hat (App.tsx). Kommt diese Meldung nicht, rollt das Plugin nach
20 Sekunden von selbst auf das vorherige Bündel zurück.

Am laufenden Testcontainer verifiziert: die ausgelieferte Zip hasht exakt
auf den in bundle.json hinterlegten Wert, 13 Einträge, index.html in der
Wurzel, keine Backslashes, keine Beschädigung. tsc sauber, 117/117 Tests
(5 davon neu für buendelPasst() - dabei eine echte Lücke gefunden: die
Funktion hätte bei unbekannter eigener Version fälschlich ein Update
angeboten, jetzt genauso vorsichtig wie versionVergleichen).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-24 09:20:14 +02:00
co-authored by Claude Sonnet 5
parent d8b12da36d
commit 1354ca6b06
22 changed files with 695 additions and 99 deletions
+10
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"
import { DataMetricApi, zugangLesen } from "./api"
import { DatenAnbieter, useDaten } from "./daten/DatenKontext"
import { startklarMelden } from "./daten/ota"
import { Shell } from "./Shell"
import type { Route, SeitenName } from "./navigation"
import { useTabBeschriftung, useTheme } from "./theme"
@@ -65,6 +66,15 @@ function AngemeldeteApp({ beiAbmeldung }: { beiAbmeldung: () => void }) {
if (daten.verbindung === "nicht_angemeldet") beiAbmeldung()
}, [daten.verbindung, beiAbmeldung])
// Meldet dem OTA-Plugin, dass diese Fassung hochgekommen ist (siehe
// daten/ota.ts). Bewusst hier und nicht in main.tsx: erst wenn diese
// Komponente rendert, hat React tatsächlich etwas auf den Schirm gebracht.
// Ein Absturz vor diesem Punkt bleibt unbestätigt, und das Plugin rollt
// nach appReadyTimeout von selbst auf das vorherige Bündel zurück.
useEffect(() => {
void startklarMelden()
}, [])
return (
<Shell
route={route}
+17 -7
View File
@@ -14,7 +14,14 @@ export * from "./warteschlange.ts";
export * from "./ablageNativ.ts";
import { DIENST_DOMAIN, ENTITAETEN } from "./types.ts";
import type { Fahrt, Fahrzeugstatus, ImportErgebnis, Profil, Tankvorgang } from "./types.ts";
import type {
AppVersionAngabe,
Fahrt,
Fahrzeugstatus,
ImportErgebnis,
Profil,
Tankvorgang,
} from "./types.ts";
import { HassRest } from "./rest.ts";
import { HassLive } from "./live.ts";
import { Warteschlange } from "./warteschlange.ts";
@@ -172,16 +179,19 @@ export class DataMetricApi {
return this.rest.dienstAufrufen(DIENST_DOMAIN, "jetzt_aktualisieren");
}
/** Welchen Oberflächen-Stand das Backend ausliefert (Version der Integration, siehe
VERSIONIERUNG.md). `null`, wenn die Entität fehlt — etwa weil das Backend
älter ist als diese Funktion; dann wird nichts verglichen und nichts
/** Nutzlast von sensor.audi_dashboard_app_version: welchen Stand das
Backend ausliefert (`app`, siehe VERSIONIERUNG.md) und ob ein
OTA-Bündel bereitliegt (`buendel`, siehe daten/ota.ts). Beides kommt
aus derselben Entität und wird deshalb in einem Aufruf gelesen statt in
zweien. `null` bei jedem Fehler — fehlende Entität (älteres Backend),
Netzproblem, was auch immer; dann wird nichts verglichen und nichts
gemeldet, statt einen Fehlalarm auszulösen. */
async appVersionLesen(): Promise<string | null> {
async appVersionAngabeLesen(): Promise<AppVersionAngabe | null> {
try {
const zustand = await this.rest.zustandLesen<{ daten?: { app?: string | null } }>(
const zustand = await this.rest.zustandLesen<{ daten?: AppVersionAngabe }>(
ENTITAETEN.appVersion,
);
return zustand.attributes?.daten?.app ?? null;
return zustand.attributes?.daten ?? null;
} catch {
return null;
}
+22
View File
@@ -156,6 +156,28 @@ export interface ImportErgebnis {
meldung?: string;
}
/** Das OTA-Bündel, das die Integration mit ausliefert (siehe
custom_components/audi_dashboard/koordinator.py `_buendel_lesen()` und
companion-app/scripts/ota-paket.ps1). `null`, solange keine
bundle.json neben der Integration liegt — ein völlig normaler Zustand,
kein Fehler: wer nur das Panel nutzt, braucht kein App-Bündel. */
export interface Buendelangabe {
version: string;
sha256: string;
bytes: number;
gebaut: string;
/** Pfad relativ zur Home-Assistant-Basis-URL, z. B.
"/audi_dashboard_static/app/bundle.zip". Von der Integration
angehängt, steht nicht in bundle.json selbst. */
url: string;
}
/** Nutzlast von sensor.audi_dashboard_app_version. */
export interface AppVersionAngabe {
app: string | null;
buendel: Buendelangabe | null;
}
/* -------------------------------------------------- Entitäts-Verzeichnis */
/** Alle vom Backend veröffentlichten Entitäten an einer Stelle.
+13 -4
View File
@@ -22,6 +22,7 @@ import {
ApiFehler,
DataMetricApi,
ENTITAETEN,
type Buendelangabe,
type Fahrt,
type Fahrzeugstatus,
type Profil,
@@ -45,6 +46,10 @@ export interface DatenWert {
/** Ob diese App noch dem Stand entspricht, den das Backend ausliefert.
Siehe appVersion.ts und VERSIONIERUNG.md. */
versionsstand: Versionsstand
/** Das OTA-Bündel, das die Integration mitliefert (siehe daten/ota.ts),
oder null — kein Fehler, sondern der Zustand ohne Bündel oder ohne
Verbindung. */
otaBuendel: Buendelangabe | null
einstellungen: Einstellungen | null
fahrzeug: Fahrzeug | null
@@ -87,6 +92,7 @@ export function DatenAnbieter({
const [verbindung, setzeVerbindung] = useState<Verbindungszustand>("getrennt")
const [warteschlange, setzeWarteschlange] = useState<readonly WartenderAuftrag[]>([])
const [serverVersion, setzeServerVersion] = useState<string | null>(null)
const [otaBuendel, setzeOtaBuendel] = useState<Buendelangabe | null>(null)
// Damit profilSpeichern immer gegen den neuesten Rohstand arbeitet, auch
// wenn zwischendurch ein Push hereinkam.
@@ -109,10 +115,12 @@ export function DatenAnbieter({
setzeLadefehler(null)
setzeBereit(true)
// Bewusst außerhalb des Promise.all oben und ohne eigenes catch hier:
// appVersionLesen() schluckt seine Fehler selbst und liefert dann null.
// Ein fehlender Versionsvergleich darf die Erstladung nie aufhalten -
// er ist ein Hinweis, keine Betriebsvoraussetzung.
setzeServerVersion(await api.appVersionLesen())
// appVersionAngabeLesen() schluckt seine Fehler selbst und liefert dann
// null. Ein fehlender Versionsvergleich darf die Erstladung nie
// aufhalten - er ist ein Hinweis, keine Betriebsvoraussetzung.
const versionsangabe = await api.appVersionAngabeLesen()
setzeServerVersion(versionsangabe?.app ?? null)
setzeOtaBuendel(versionsangabe?.buendel ?? null)
} catch (fehler) {
// Kein Netz ist kein Ladefehler, solange schon Daten da sind — dann
// zeigt die App den zwischengespeicherten Stand mit Offline-Hinweis.
@@ -210,6 +218,7 @@ export function DatenAnbieter({
warteschlange,
statusStand,
versionsstand: versionVergleichen(eigeneVersion(), serverVersion).stand,
otaBuendel,
einstellungen,
fahrzeug,
fahrten,
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest"
import { buendelPasst } from "./ota"
import type { Buendelangabe } from "../api"
const buendel = (teil: Partial<Buendelangabe> = {}): Buendelangabe => ({
version: "2026.8.24.1",
sha256: "a".repeat(64),
bytes: 224186,
gebaut: "2026-08-24T00:00:00Z",
url: "/audi_dashboard_static/app/bundle.zip",
...teil,
})
/* buendelPasst() entscheidet, ob der Update-Knopf überhaupt erscheint - ein
Fehler hier zeigt entweder einen Knopf, der ins Leere läuft (kein Bündel
da), oder verbirgt ein echtes Update dauerhaft. */
describe("buendelPasst", () => {
it("lehnt ab, wenn kein Bündel vorliegt", () => {
expect(buendelPasst(null, "2026.8.23.2")).toBe(false)
})
it("lehnt ab, wenn Bündel- und eigene Version übereinstimmen", () => {
expect(buendelPasst(buendel({ version: "2026.8.24.1" }), "2026.8.24.1")).toBe(false)
})
it("akzeptiert bei abweichender Version", () => {
expect(buendelPasst(buendel({ version: "2026.8.24.2" }), "2026.8.24.1")).toBe(true)
})
it("lehnt ab, wenn die eigene Version unbekannt ist", () => {
// null heißt "diese App weiß nicht, welche Version sie ist" (z. B. im
// Dev-Server ohne eingebautes __APP_VERSION__) - dann lässt sich ein
// Update nicht sinnvoll anbieten, weil "abweichend" nicht feststellbar ist.
expect(buendelPasst(buendel(), null)).toBe(false)
})
it("lehnt ab, wenn dem Bündel Pflichtfelder fehlen", () => {
expect(buendelPasst(buendel({ url: "" }), "2026.8.23.2")).toBe(false)
expect(buendelPasst(buendel({ sha256: "" }), "2026.8.23.2")).toBe(false)
expect(buendelPasst(buendel({ version: "" }), "2026.8.23.2")).toBe(false)
})
})
+131
View File
@@ -0,0 +1,131 @@
/**
* Oberflächen-Updates ohne Xcode („OTA").
*
* Die native Hülle trägt ihre Oberfläche fest gebündelt — sie bleibt auf dem
* Stand vom Signieren, unbegrenzt. Diese Datei ist der Ausweg: die App lädt
* ein neues Bündel von der eigenen Home-Assistant-Instanz und tauscht es aus.
*
* WOHER DAS BÜNDEL KOMMT
* ----------------------
* Aus der Integration selbst: `custom_components/audi_dashboard/frontend/app/`
* enthält `bundle.zip` und `bundle.json`, ausgeliefert unter
* `/audi_dashboard_static/app/`. Damit reist das Bündel bei jedem
* HACS-Update mit, und es kann gar nicht zur Integration unpassend sein — es
* ist dieselbe Lieferung. Gebaut wird es von
* `companion-app/scripts/ota-paket.ps1`.
*
* Welche Fassung bereitliegt, sagt `sensor.audi_dashboard_app_version` im
* Attribut `daten.buendel`. Kein zweiter Abruf, keine zweite Quelle.
*
* WAS OTA NICHT KANN
* ------------------
* Nur Weboberfläche — HTML, CSS, JavaScript, Bilder. Alles Native (Plugins,
* Capacitor selbst, Berechtigungen, iOS-Einstellungen) braucht weiterhin
* Xcode. Deshalb setzt `resetWhenUpdate` in capacitor.config.ts nach einer
* frischen Xcode-Installation wieder auf das eingebaute Bündel zurück: sonst
* überdeckte ein älteres OTA-Bündel genau die native Änderung, für die man
* Xcode gebraucht hat.
*
* DIE RÜCKFALLEBENE
* -----------------
* `startklarMelden()` unten muss nach jedem Start laufen. Bleibt die Meldung
* aus (weil das neue Bündel gar nicht erst hochkommt), kehrt das Plugin nach
* `appReadyTimeout` von selbst zum vorherigen Bündel zurück. Ein kaputtes
* Bündel kann das Gerät deshalb nicht dauerhaft unbrauchbar machen — man
* landet wieder da, wo man vorher war.
*/
import { Capacitor } from "@capacitor/core"
import { CapacitorUpdater } from "@capgo/capacitor-updater"
import type { Buendelangabe } from "../api"
/** Läuft die App in der nativen Hülle? Nur dort gibt es etwas auszutauschen —
im Browser und im HA-Panel lädt ohnehin jeder Seitenaufruf den aktuellen
Stand. */
export function otaMoeglich(): boolean {
return Capacitor.isNativePlatform()
}
/**
* Meldet dem Plugin, dass dieses Bündel hochgekommen ist.
*
* Ohne diesen Aufruf rollt das Plugin nach kurzer Zeit auf das vorherige
* Bündel zurück — was genau richtig ist, wenn ein Update die App zerlegt hat.
* Deshalb steht der Aufruf bewusst dort, wo React nachweislich gerendert hat
* (App.tsx), und nicht schon in main.tsx: ein Absturz beim ersten Rendern
* soll als Fehlschlag gelten und den Rückfall auslösen.
*/
export async function startklarMelden(): Promise<void> {
if (!otaMoeglich()) return
try {
await CapacitorUpdater.notifyAppReady()
} catch (fehler) {
// Nur protokollieren: Scheitert die Meldung, greift der Rückfall — das
// ist unangenehm, aber sicher. Ein Absturz an dieser Stelle wäre schlimmer.
console.warn("OTA: notifyAppReady fehlgeschlagen", fehler)
}
}
/** Ist ein anderes Bündel verfügbar als das gerade laufende?
Dieselbe Vorsicht wie in versionVergleichen() (appVersion.ts): kennt die
App ihre eigene Version nicht (eigene === null — praktisch nur im
Dev-Server, ein natives Release baut nie ohne __APP_VERSION__), gilt das
als "nicht vergleichbar", nicht als "abweichend". Alles andere böte einen
Update-Knopf an, dessen Ziel man mit der laufenden Fassung gar nicht
abgleichen konnte. */
export function buendelPasst(buendel: Buendelangabe | null, eigene: string | null): boolean {
if (!buendel?.version || !buendel.url || !buendel.sha256 || !eigene) return false
// Das Bündel muss zu der Fassung gehören, die diese Installation
// ausliefert. Läge dort ein älteres, wäre ein "Update" ein Rückschritt.
return buendel.version !== eigene
}
export class OtaFehler extends Error {}
/**
* Lädt das Bündel und schaltet darauf um.
*
* Kehrt im Erfolgsfall **nie** zurück: `set()` zerstört den JavaScript-Kontext
* und lädt die App neu. Alles, was danach stünde, liefe nicht mehr — deshalb
* steht hier auch keine Erfolgsmeldung. Der Erfolg ist, dass die App neu
* startet.
*
* @param basisUrl Adresse der Home-Assistant-Instanz, wie beim Einrichten
* hinterlegt. Die Bündel-URL aus der Entität ist ein Pfad.
*/
export async function buendelAnwenden(
buendel: Buendelangabe,
basisUrl: string,
): Promise<never> {
if (!otaMoeglich()) throw new OtaFehler("Updates gibt es nur in der App auf dem Gerät.")
const url = new URL(buendel.url, basisUrl).href
let geladen
try {
geladen = await CapacitorUpdater.download({
url,
version: buendel.version,
// Das Plugin rechnet auf dem Gerät SHA-256 über die heruntergeladene
// Zip und verwirft sie bei Abweichung (calcChecksum in
// CapgoUpdater.swift). Ein abgebrochener Download oder eine halb
// gespiegelte Datei fällt damit auf, bevor die App sie startet.
checksum: buendel.sha256,
})
} catch (fehler) {
throw new OtaFehler(
`Das Update konnte nicht geladen werden (${fehler instanceof Error ? fehler.message : String(fehler)}).`,
)
}
try {
await CapacitorUpdater.set({ id: geladen.id })
} catch (fehler) {
throw new OtaFehler(
`Das Update wurde geladen, ließ sich aber nicht starten (${fehler instanceof Error ? fehler.message : String(fehler)}).`,
)
}
// Unerreichbar: set() lädt die App neu. Steht hier, damit der Rückgabetyp
// ehrlich bleibt.
throw new OtaFehler("Die App hätte an dieser Stelle neu starten müssen.")
}
+62 -1
View File
@@ -8,8 +8,10 @@ import { useRef, useState } from "react"
import { ActionButton, Feld, Seg, Switch, Tile } from "@audi-dash/ui"
import { DIENST_DOMAIN, zugangVerwerfen } from "../api"
import { DIENST_DOMAIN, zugangLesen, zugangVerwerfen } from "../api"
import { eigeneVersion } from "../daten/appVersion"
import { useDaten } from "../daten/DatenKontext"
import { OtaFehler, buendelAnwenden, buendelPasst, otaMoeglich } from "../daten/ota"
import type { Einstellungen as EinstellungenWerte } from "../daten/profilAdapter"
import { datumZeit, de, isoTag } from "../format"
import { useTheme } from "../theme"
@@ -35,6 +37,7 @@ export function Einstellungen({
fahrten,
tankvorgaenge,
rohprofil,
otaBuendel,
api,
profilSpeichern,
jetztAktualisieren,
@@ -44,8 +47,31 @@ export function Einstellungen({
const [laeuft, setzeLaeuft] = useState(false)
const [gespeichert, setzeGespeichert] = useState(false)
const [importOffen, setzeImportOffen] = useState(false)
const [otaLaeuft, setzeOtaLaeuft] = useState(false)
const [otaFehler, setzeOtaFehler] = useState<string | null>(null)
const dateiwahl = useRef<HTMLInputElement | null>(null)
const otaUpdateVerfuegbar = otaMoeglich() && buendelPasst(otaBuendel, eigeneVersion())
const otaAusloesen = async () => {
if (!otaBuendel) return
setzeOtaLaeuft(true)
setzeOtaFehler(null)
try {
const zugang = await zugangLesen()
if (!zugang) throw new OtaFehler("Kein Zugang eingerichtet.")
// Kehrt im Erfolgsfall nicht zurück - set() lädt die App neu, siehe
// daten/ota.ts. Der catch-Block unten fängt sowohl echte Fehlschläge
// als auch den defensiven letzten throw in buendelAnwenden() ab.
await buendelAnwenden(otaBuendel, zugang.basisUrl)
} catch (fehler) {
setzeOtaFehler(
fehler instanceof Error ? fehler.message : "Das Update konnte nicht installiert werden.",
)
setzeOtaLaeuft(false)
}
}
if (!einstellungen || !fahrzeug) return null
const werte = entwurf ?? einstellungen
@@ -349,6 +375,41 @@ export function Einstellungen({
</div>
</Tile>
{otaMoeglich() && (
<Tile>
<span className="ads-eyebrow">App-Update</span>
{otaUpdateVerfuegbar && otaBuendel ? (
<>
<Werteliste
kinder={
<Wertzeile
label="Verfügbare Version"
wert={otaBuendel.version}
zusatz={`${Math.round(otaBuendel.bytes / 1024)} KB`}
/>
}
/>
<p className="dm-fussnote">
Lädt die Oberfläche neu und startet die App neu. Fahrzeugprofil, Fahrten und
Tankvorgänge sind davon nicht betroffen die liegen in Home Assistant.
</p>
<div className="dm-knopfreihe">
<ActionButton onClick={() => void otaAusloesen()} disabled={otaLaeuft}>
{otaLaeuft ? "Lade Update …" : "Update installieren"}
</ActionButton>
</div>
{otaFehler && <p className="dm-fehler">{otaFehler}</p>}
</>
) : (
<p className="dm-fussnote">
{otaBuendel
? `Diese App ist aktuell (${eigeneVersion() ?? "unbekannte Version"}).`
: "Der Server hat gerade kein Update-Bündel hinterlegt."}
</p>
)}
</Tile>
)}
<Tile>
<span className="ads-eyebrow">Zugang</span>
<Werteliste