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 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 09:20:14 +02:00
parent d8b12da36d
commit 1354ca6b06
22 changed files with 695 additions and 99 deletions
+64 -22
View File
@@ -188,7 +188,7 @@ assets. Never mix these the other way around.
| Area | What | Status |
|---|---|---|
| `custom_components/audi_dashboard/` | The HA integration: backend + panel, installable via HACS | ✅ **finished, in use** — replaced the pyscript backend on 2026-08-23 (section H) |
| `custom_components/audi_dashboard/` | The HA integration: backend, panel, OTA app bundle. HACS-shaped but HACS-installable only in principle — the repo is private and HACS categorically refuses private repos, so `install.ps1` is the actual, only install path (section H) | ✅ **finished, in use** — replaced the pyscript backend on 2026-08-23 (section H) |
| `homeassistant/` | Install package, recorder snippet, guides — **no code any more** | ✅ |
| `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) |
@@ -216,10 +216,11 @@ the repo.
**`companion-app/` — what exists:** the full app. Data layer (`src/api/`: REST, WebSocket with
reconnect backoff, persistent offline write queue, credential storage), domain logic
(`src/daten/`: profile adapter, statistics, service forecast, data context), all 21 screens
(`src/screens/`), Audi assets (`src/assets/audi/`), PWA manifest and icons. Verified by 90 unit
and render tests plus 9 checks against a live Home Assistant. `npm run dev` in the repo root
starts it; `testumgebung/aufsetzen.sh` provides the server side.
(`src/daten/`: profile adapter, statistics, service forecast, data context, OTA update flow — see
section H), all 21 screens (`src/screens/`), Audi assets (`src/assets/audi/`), PWA manifest and
icons. Verified by 117 unit and render tests (`npm test`) plus the two smoke suites against a live
Home Assistant (`npm run smoke`, `npm run smoke:auth`). `npm run dev` in the repo root starts it;
`testumgebung/aufsetzen.sh` provides the server side.
**DataMetric360 architecture (short — details in `COMPANION_APP_ARCHITECTURE.md`):**
- Future data source: **Teltonika FMM003** on the CAN bus, fully replacing the iPhone WLAN sensor
@@ -1814,10 +1815,20 @@ HACS 2.0.5 rather than from memory:
`custom_templates/` and `appdaemon/apps/<name>/` (verified in `repositories/*.py``localpath`).
The app needed `/config/pyscript/` **and** `/config/audi_dashboard/` **and** `configuration.yaml`
entries. This is what the conversion solved.
- **HACS is GitHub-only.** `github.com`/`api.github.com` are hardcoded throughout; the repo lives
on `gitea.nothaft.cloud`. This is **not** solved and cannot be solved from this side — it needs
the repo mirrored to GitHub. Until then `homeassistant/installationspaket/install.ps1` installs
the identical folder without HACS, so the conversion is useful either way.
- **HACS is GitHub-only.** `github.com`/`api.github.com` are hardcoded throughout; the repo lived
on `gitea.nothaft.cloud`. 2026-08-24: the owner mirrored it to `github.com/T130B/DM360`, and the
manifest URLs/codeowners now point there — but this does **not** make HACS usable. **HACS
categorically refuses private repositories**, confirmed against its own docs
(hacs.xyz/docs/faq/private_repositories: "Private GitHub repositories can not be used with HACS
at all... HACS can only get publicly available information") — no token, no signed-in-account
exception. An earlier note in this file claimed the opposite ("HACS must be signed in with the
account that owns it"); that was wrong and is corrected here. The GitHub mirror is **private on
purpose** (it carries the Audi typeface and the model badges, licensed for this one private
install only), so making it public to satisfy HACS is not on the table.
`homeassistant/installationspaket/install.ps1` is therefore not a fallback — it is **the only
installation path**, and the docs (`README.md`, `homeassistant/INSTALL.md`,
`installationspaket/ANLEITUNG.md`, `VERSIONIERUNG.md`, the script's own header) were rewritten
2026-08-24 to say so instead of presenting a HACS path that cannot work.
**The shape.** `custom_components/audi_dashboard/` at repo root (HACS looks for
`custom_components/<first dir>` there and nowhere else), `hacs.json` beside it. Config flow, one
@@ -1904,8 +1915,14 @@ left to add), `ANLEITUNG.pdf` (stale and not regenerable here; the `.md` beside
**Do not resurrect the pyscript tree.** If something is missing, port it — running both backends
writes two sets of trips into the same files.
**Still open, unchanged by this:** the repo must reach GitHub before HACS can install it. Everything
else about the HACS path is built and tested.
**HACS is not the delivery path — confirmed dead-end, not a gap.** 2026-08-24, checked against
HACS's own docs: private repos are refused categorically, no exception. The layout is still HACS
shape (`custom_components/audi_dashboard/` + `hacs.json` at repo root, manifest with `domain`,
`name`, `version`, `documentation`, `issue_tracker`, `codeowners`) in case the repo is ever
restructured to separate the licensed Audi assets from the installable code — but there is no
current plan to do that, and `install.ps1` is not standing in for HACS temporarily; it is the
permanent, only path. Don't spend effort re-verifying HACS compatibility for this repo again
unless the private/public situation changes.
### The deployed-parity problem, and what was built for it (2026-08-23)
@@ -1913,8 +1930,8 @@ The parity rule (binding, above) guarantees *source* parity: both codebases chan
session. It guarantees nothing about what is *running*. After the conversion the two sides are
wired differently in a new way:
- the panel ships **inside** the integration — one HACS update moves backend and panel together,
and it cannot go stale at all
- the panel ships **inside** the integration — one `install.ps1` run (the only delivery path — HACS
is a dead end here, see section H) moves backend and panel together, and it cannot go stale at all
- the companion app is a Capacitor shell with `webDir: "dist"` and **bundled** assets, sideloaded
through Xcode. It stays on whatever was bundled at signing time, silently, indefinitely
@@ -1928,15 +1945,40 @@ silently on a format change. Either side missing yields `"unbekannt"` and **no**
older backend or an offline start cannot produce a false alarm. And the vite build **fails hard** if
the manifest version is absent rather than emitting an app that cannot detect its own staleness.
**OTA delivery: verified viable, not yet built.** `@capgo/capacitor-updater` 8.51.14 checked against
the real package: MPL-2.0, peer `@capacitor/core: ^8.0.0` against our `^8.5.0`, and self-hosting is
first-class (`updateUrl`, or manual mode entirely). Manual mode is the good fit —
`download({version, url})` + `set()` against a plain zip under `/local/`, needing **no** custom
endpoint at all, with automatic rollback to the last good bundle via `notifyAppReady()`. It pairs
exactly with the version entity above: that entity is already the signal that a newer bundle exists.
Owner approved going this route, and a paid Apple developer account is available through Paul (Paul
Nothaft, the Gitea repo owner) — which is what makes it worthwhile, since the free account's 7-day
signature expiry would otherwise force Xcode weekly anyway.
**OTA delivery: built and verified end to end, 2026-08-24.** `@capgo/capacitor-updater` 8.51.14
installed (MPL-2.0, peer `@capacitor/core: ^8.0.0` against our `^8.5.0`). Manual mode, not
`autoUpdate` (that stays `false` on purpose — an update must never swap the UI out from under
someone mid-entry): `companion-app/src/daten/ota.ts` wraps `download({url, version, checksum})` +
`set({id})`, wired to a button in the Einstellungen screen (`otaUpdateVerfuegbar` /
`otaAusloesen`). `notifyAppReady()` runs from a `useEffect` in `AngemeldeteApp` (`App.tsx`) —
deliberately there and not in `main.tsx`, so a crash before React actually renders never gets
confirmed and the plugin's `appReadyTimeout` rollback (`capacitor.config.ts`, 20s, more generous
than the 10s default because first load also fetches profile/trips/refuels) takes over.
The bundle itself lives **inside the integration**, not under `/local/` as first sketched:
`custom_components/audi_dashboard/frontend/app/{bundle.zip,bundle.json}`, built by
`companion-app/scripts/ota-paket.ps1`, served at `/audi_dashboard_static/app/bundle.zip`. Reason
for the relocation: `/local/` would have needed its own delivery step that `install.ps1` doesn't
touch and could be forgotten; inside the integration folder it travels with every install/update
automatically, same as the panel. `sensor.audi_dashboard_app_version`'s `daten` gained a `buendel`
field (`koordinator._buendel_lesen()`) carrying `{version, sha256, bytes, gebaut, url}``null`
when no bundle is published, a normal state, not an error.
One real bug the build caught: `[IO.Compression.ZipFile]::CreateFromDirectory` on Windows
PowerShell 5.1 (.NET Framework, not Core) writes **backslashes** as path separators for nested
entries — a spec-violating zip that iOS would not have unpacked correctly. Caught by inspecting the
actual zip contents after the first build, not assumed; fixed by writing entries by hand via
`ZipFile::Open` + `CreateEntryFromFile` with `\` replaced by `/`. The plugin verifies SHA-256 over
the *downloaded zip itself* before unpacking (`CapgoUpdater.swift` `calcChecksum`, confirmed by
reading the plugin source, not the README) — `ota-paket.ps1` hashes the same file it writes, so the
two can't drift.
Verified against the running test container: entry reload picks up code changes but **not** a
manifest version bump (HA caches the `Integration` object — needs a full restart, same lesson as
the pyscript-removal step in section G); after a real restart, `sensor.audi_dashboard_app_version`
published the new manifest version and a complete `buendel` block; the zip served over HTTP hashed
identically to `bundle.json`'s `sha256`; `unzip -l`/`-t` confirmed 13 entries, `index.html` at the
root, no backslashes, no corruption.
Capacitor's `server.url` pointed at HA was considered and **rejected**: it would make the app as
current as the panel, but the shell then cannot boot without reaching HA, gutting the deliberately
+11 -17
View File
@@ -15,25 +15,19 @@ Belegdaten aus hochgeladenen Tank-PDFs, ein Spannungsverlauf über Jahre.
---
## Installation über HACS
## Installation
1. HACS **Integrationen** → Menü oben rechts → **Benutzerdefinierte
Repositories**
2. Dieses Repository eintragen, Kategorie **Integration**
3. **Audi Dashboard** herunterladen
4. Home Assistant neu starten
5. **Einstellungen → Geräte & Dienste → Integration hinzufügen → Audi
Dashboard**
HACS scheidet aus, und zwar endgültig: laut eigener Dokumentation
(hacs.xyz/docs/faq/private_repositories) kann HACS **grundsätzlich nicht**
mit privaten GitHub-Repositories arbeiten — „HACS can only get publicly
available information", ohne Ausnahme für Tokens oder verbundene Konten. Und
öffentlich machen ist keine Option: das Repository enthält die
Audi-Hausschrift und die Typenschilder, die nur für diese eine private
Installation lizenziert sind.
Danach steht **Mein Audi** in der Seitenleiste.
**Voraussetzung, die HACS selbst mitbringt:** HACS spricht ausschließlich mit
GitHub — `github.com` und `api.github.com` stecken fest im Code, Gitea, GitLab
und selbst gehostete Instanzen kennt es nicht. Liegt dieses Repository nicht
auf GitHub, findet HACS es auch als benutzerdefiniertes Repository nicht. Für
diesen Fall gibt es den Weg darunter, der denselben Ordner installiert.
## Installation ohne HACS
Der Installer unten ist deshalb nicht die zweite Wahl, sondern der einzige
Weg. Er ist genauso wenig Handarbeit wie ein HACS-Update — derselbe Aufruf
installiert und aktualisiert.
Von einem Windows-Rechner aus, der das `config`-Verzeichnis der HA-Instanz
erreicht (Samba-Add-on oder gemounteter Pfad):
+52 -13
View File
@@ -30,10 +30,16 @@ In `custom_components/audi_dashboard/manifest.json`, Feld `version`. Format
`2026.8.23.2` (Datum + laufende Nummer).
Genau dort und nirgends sonst. Home Assistant verlangt das Feld für jede
benutzerdefinierte Integration, HACS zeigt es an und entscheidet danach, ob ein
Update bereitliegt — eine zweite Quelle für dieselbe Angabe wäre eine zu viel.
Sie hätten irgendwann auseinandergelegen, und dann wäre der Vergleich still
falsch geworden statt laut.
benutzerdefinierte Integration — eine zweite Quelle für dieselbe Angabe wäre
eine zu viel. Sie hätten irgendwann auseinandergelegen, und dann wäre der
Vergleich still falsch geworden statt laut.
> HACS würde dieses Feld ebenfalls lesen und danach entscheiden, ob ein
> Update bereitliegt — spielt hier aber keine Rolle: HACS kann laut eigener
> Dokumentation grundsätzlich nicht mit privaten GitHub-Repositories
> arbeiten, und das Repository ist privat (lizenzierte Audi-Assets). Das
> Manifest-Feld bleibt trotzdem die einzig richtige Stelle - Home Assistant
> selbst braucht es unabhängig von HACS.
> Bis 2026-08-23 gab es dafür eine eigene Datei `VERSION` in der Repo-Wurzel,
> daneben eine Unix-Sekundenzahl in `www/audi-dashboard-version.json` als
@@ -82,8 +88,8 @@ ohne Netz darf keinen Fehlalarm auslösen.
1. `version` in `manifest.json` erhöhen — bei mehreren Änderungen am selben Tag
die laufende Nummer: `2026.8.23.2``2026.8.23.3`, am nächsten Tag
`2026.8.24.1`.
2. Integration ausliefern: über HACS (wenn das Repository auf GitHub liegt)
oder mit `homeassistant\installationspaket\Installieren.cmd`.
2. Integration ausliefern: `homeassistant\installationspaket\Installieren.cmd`
(HACS scheidet aus — siehe Kasten oben, das Repository ist privat).
3. iOS-App neu bauen (`npm run build`), damit sie dieselbe Zahl einkompiliert
bekommt.
@@ -92,11 +98,44 @@ dass sie älter ist als der Server.
**Bei einer reinen Neuinstallation** ohne Codeänderung: nichts tun.
## Ausblick: OTA-Updates
## OTA-Updates (seit 2026-08-24)
Sobald `@capgo/capacitor-updater` eingebaut ist (geprüft: MPL-2.0, passt zu
Capacitor 8, Selbst-Hosting ohne fremde Cloud möglich), holt sich die iOS-App
den neuen Stand selbst — dann entfällt Schritt 3 für alles, was nur
JavaScript/CSS betrifft. Xcode wird dann nur noch für echte native Änderungen
gebraucht. Der Vergleich aus dieser Datei bleibt dabei unverändert nützlich: er
ist genau das Signal, an dem die App erkennt, dass ein neues Bündel bereitliegt.
`@capgo/capacitor-updater` ist eingebaut. Für reine Oberflächen-Änderungen
(JavaScript, CSS, Bilder) entfällt Schritt 3 oben — die App lädt sich den
neuen Stand selbst, auf einen Tastendruck des Nutzers in Einstellungen →
App-Update. Xcode wird nur noch für echte native Änderungen gebraucht
(neue Plugins, Berechtigungen, `capacitor.config.ts`).
**Woher das Bündel kommt.** `companion-app/scripts/ota-paket.ps1` packt
`dist/` (ohne Sourcemaps) in
`custom_components/audi_dashboard/frontend/app/bundle.zip`, daneben
`bundle.json` mit Version, SHA-256 und Größe. Weil das im Integrationsordner
liegt, reist es bei jeder Auslieferung automatisch mit — kein zweiter Weg, den
man vergessen könnte. Ausgeliefert wird es unter
`/audi_dashboard_static/app/bundle.zip`, gemeldet über dasselbe Feld, das
schon den Versionsvergleich trägt: `sensor.audi_dashboard_app_version`,
Attribut `daten.buendel`.
**Was die App damit macht** (`companion-app/src/daten/ota.ts`): erscheint ein
Bündel, dessen Version von der eigenen abweicht, zeigt Einstellungen →
App-Update einen Knopf. Ein Tastendruck lädt die Zip, prüft sie clientseitig
gegen die mitgelieferte SHA-256 (das Plugin selbst, nicht diese App) und
tauscht die Oberfläche aus — die App startet dabei neu.
**Die Rückfallebene.** `notifyAppReady()` läuft in `App.tsx`, sobald React
tatsächlich gerendert hat. Bleibt diese Meldung aus, weil das neue Bündel
die App zerlegt hat, rollt das Plugin nach der eingestellten Frist
(`appReadyTimeout` in `capacitor.config.ts`) von selbst auf das vorherige
Bündel zurück — ein kaputtes Update kann das Gerät deshalb nicht dauerhaft
unbrauchbar machen.
**Warum kein Selbstlauf.** `autoUpdate: false` — die App lädt nur auf
ausdrücklichen Tastendruck, nie im Hintergrund. Ein Update, das während einer
Fahrteintragung ungefragt die Oberfläche austauscht, wäre die falsche Sorte
Hilfsbereitschaft.
**HACS spielt hier keine Rolle und wird es auch nicht.** Das Update-Bündel
reist mit `install.ps1` (dem einzigen Installationsweg, siehe oben) genauso
mit wie mit einem hypothetischen HACS-Download — der Mechanismus hängt nicht
daran, wie die Integration selbst auf die Instanz kommt, nur daran, dass das
Bündel im Integrationsordner liegt.
+28
View File
@@ -38,6 +38,34 @@ const konfiguration: CapacitorConfig = {
// CORS-Prüfung — die App braucht dadurch keinerlei CORS-Einstellung am
// Server. Die WebSocket-Verbindung läuft unberührt weiter.
CapacitorHttp: { enabled: true },
// Oberflächen-Updates ohne Xcode (siehe src/daten/ota.ts und
// ../VERSIONIERUNG.md). Das Bündel liegt in der Integration und wird
// über Home Assistant ausgeliefert — keine fremde Cloud beteiligt.
CapacitorUpdater: {
// Kein Selbstlauf: die App lädt nur, wenn der Benutzer den Knopf in den
// Einstellungen drückt. Ein Update, das ungefragt die Oberfläche
// austauscht, während jemand gerade eine Fahrt einträgt, wäre die
// schlechtere Sorte Hilfsbereitschaft.
autoUpdate: false,
// Kommt eine frisch signierte App über Xcode aufs Gerät, gilt wieder
// deren eingebautes Bündel — sonst liefe ein älteres OTA-Bündel weiter
// und überdeckte genau die native Änderung, für die Xcode nötig war.
resetWhenUpdate: true,
// Meldet sich die App nach einem Wechsel nicht binnen dieser Zeit als
// startklar (notifyAppReady in src/main.tsx), kehrt das Plugin von
// selbst zum vorherigen Bündel zurück. Das ist die Rückfallebene: ein
// kaputtes Bündel kann das Gerät nicht dauerhaft lahmlegen. Bewusst
// großzügiger als die 10 Sekunden Vorgabe — die Erstladung holt Profil,
// Fahrten und Tankvorgänge, und ein langsames Netz darf nicht wie ein
// defektes Bündel aussehen.
appReadyTimeout: 20000,
// Ein Bündel, das beim Start durchgefallen ist, wird nicht aufbewahrt.
autoDeleteFailed: true,
},
},
}
+2
View File
@@ -11,6 +11,7 @@
"typecheck": "tsc --noEmit",
"smoke": "node --experimental-strip-types scripts/smoke.ts",
"smoke:auth": "node --experimental-strip-types scripts/smoke-auth.ts",
"ota": "powershell -ExecutionPolicy Bypass -File scripts/ota-paket.ps1 -Bauen",
"test": "vitest run"
},
"dependencies": {
@@ -19,6 +20,7 @@
"@capacitor/android": "^8.5.0",
"@capacitor/core": "^8.5.0",
"@capacitor/ios": "^8.5.0",
"@capgo/capacitor-updater": "^8.51.14",
"leaflet": "^1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1"
+153
View File
@@ -0,0 +1,153 @@
<#
.SYNOPSIS
Packt die gebaute Companion-App als OTA-Bündel in die Integration.
.BESCHREIBUNG
Erzeugt aus companion-app/dist zwei Dateien:
custom_components/audi_dashboard/frontend/app/bundle.zip
custom_components/audi_dashboard/frontend/app/bundle.json
Die iOS-App lädt die Zip zur Laufzeit nach und tauscht ihre Oberfläche aus,
ohne dass Xcode gebraucht wird (siehe companion-app/src/daten/ota.ts).
WARUM DAS BÜNDEL IN DER INTEGRATION LIEGT
-----------------------------------------
Weil es dann von selbst dorthin kommt, wo die App es sucht. install.ps1
(der einzige Installationsweg - HACS scheidet aus, siehe VERSIONIERUNG.md)
kopiert den Ordner custom_components/audi_dashboard/ vollständig - das
Bündel reist bei jedem Lauf mit, ohne zweiten Auslieferungsweg. Ein
Ablageort unter /config/www/ hätte einen eigenen Weg dorthin gebraucht, den
man beim Ausliefern hätte vergessen können.
Der Nebeneffekt ist der eigentliche Gewinn: Bündel und Integration können
gar nicht auseinanderlaufen, weil sie dieselbe Datei sind.
DREI DINGE, DIE HIER BEWUSST SO SIND
------------------------------------
1. **Sourcemaps fliegen raus.** Sie machen rund zwei Drittel von dist/ aus
(1,4 von 2,0 MB) und werden auf dem Gerät nie gebraucht. Sie mitzuladen
hieße, bei jedem Update das Dreifache über die Leitung zu schicken.
2. **Die Einträge werden von Hand geschrieben, nicht von einer Bequem-API.**
Die Zip-Spezifikation verlangt "/" als Trennzeichen. Windows PowerShell
5.1 läuft auf dem .NET Framework, und dort schreiben SOWOHL
Compress-Archive ALS AUCH [IO.Compression.ZipFile]::CreateFromDirectory
Backslashes in die Einträge von Unterordnern (in .NET Core behoben, hier
aber nicht verfügbar). Nachgemessen: die erste Fassung dieses Skripts
erzeugte "assets\index-*.js" statt "assets/index-*.js" - iOS hätte daraus
Dateien mit Backslash im Namen gemacht statt einen Ordner, und die App
wäre mit fehlenden Assets gestartet.
Deshalb ZipFile::Open plus CreateEntryFromFile mit selbst gebautem
Eintragsnamen: dann steht genau das im Archiv, was hier steht. Der Inhalt
liegt ohne umschließenden Ordner in der Wurzel, index.html also direkt
dort - genau das erwartet das Plugin; fehlt es, wirft set() "no index.html
file inside the bundle folder".
3. **SHA-256 über die Zip, klein geschrieben.** Das Plugin rechnet auf dem
Gerät denselben Hash über die heruntergeladene Datei (calcChecksum in
CapgoUpdater.swift) und verwirft das Bündel bei Abweichung. Ein
abgeschnittener Download oder eine halb gespiegelte Datei fällt damit
auf, bevor die App sie startet.
.PARAMETER Bauen
Vorher "npm run build" ausführen. Ohne das wird der Inhalt von dist/
genommen, wie er gerade daliegt.
.BEISPIEL
.\ota-paket.ps1 -Bauen
npm run ota
#>
param(
[switch]$Bauen
)
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
$hier = $PSScriptRoot
$app = Split-Path $hier -Parent
$projekt = Split-Path $app -Parent
$dist = Join-Path $app "dist"
$manifest = Join-Path $projekt "custom_components\audi_dashboard\manifest.json"
$ziel = Join-Path $projekt "custom_components\audi_dashboard\frontend\app"
function Gut($t) { Write-Host " [ok] $t" -ForegroundColor Green }
function Info($t) { Write-Host " [info] $t" -ForegroundColor DarkGray }
Write-Host ""
Write-Host " OTA-Bündel bauen" -ForegroundColor White
Write-Host " ================" -ForegroundColor White
if ($Bauen) {
Info "npm run build ..."
Push-Location $app
try { npm run build; if ($LASTEXITCODE -ne 0) { throw "npm run build fehlgeschlagen" } }
finally { Pop-Location }
}
if (-not (Test-Path (Join-Path $dist "index.html"))) {
throw "$dist\index.html fehlt - erst 'npm run build' laufen lassen (oder -Bauen benutzen)."
}
$version = (Get-Content $manifest -Raw | ConvertFrom-Json).version
if (-not $version) { throw "Kein 'version'-Feld in $manifest" }
Gut "Version: $version"
# In einen Zwischenordner spiegeln, damit die Sourcemaps nicht mitwandern -
# dist/ selbst bleibt unangetastet (die Maps sind beim Entwickeln nützlich).
$tmp = Join-Path ([IO.Path]::GetTempPath()) ("dm360-ota-" + [Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
try {
Copy-Item "$dist\*" $tmp -Recurse -Force
$maps = Get-ChildItem $tmp -Recurse -File -Filter "*.map"
$maps | ForEach-Object { Remove-Item $_.FullName -Force }
Info "$($maps.Count) Sourcemap(s) ausgelassen"
if (-not (Test-Path (Join-Path $tmp "index.html"))) {
throw "index.html liegt nicht in der Wurzel des Bündels - das Plugin würde es ablehnen."
}
New-Item -ItemType Directory -Force -Path $ziel | Out-Null
$zip = Join-Path $ziel "bundle.zip"
if (Test-Path $zip) { Remove-Item $zip -Force }
# Eintragsnamen selbst bauen (siehe Punkt 2 im Kopfkommentar): relativer
# Pfad ab dem Zwischenordner, Backslashes durch "/" ersetzt.
$archiv = [IO.Compression.ZipFile]::Open($zip, [System.IO.Compression.ZipArchiveMode]::Create)
try {
foreach ($datei in Get-ChildItem $tmp -Recurse -File | Sort-Object FullName) {
$name = $datei.FullName.Substring($tmp.Length + 1).Replace("\", "/")
[void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
$archiv, $datei.FullName, $name, [IO.Compression.CompressionLevel]::Optimal)
}
}
finally { $archiv.Dispose() }
$hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
$groesse = (Get-Item $zip).Length
Gut ("bundle.zip {0:N0} Bytes" -f $groesse)
Gut "sha256 $hash"
# Kleingeschriebener Hex-Hash, weil das Plugin auf dem Geraet ebenfalls
# kleingeschrieben vergleicht.
$daten = [ordered]@{
version = $version
sha256 = $hash
bytes = $groesse
gebaut = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
}
$json = ($daten | ConvertTo-Json -Compress)
[IO.File]::WriteAllText((Join-Path $ziel "bundle.json"), $json, (New-Object Text.UTF8Encoding($false)))
Gut "bundle.json geschrieben"
}
finally {
Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue
}
Write-Host ""
Write-Host " Fertig. Das Bündel wird mit der Integration ausgeliefert -" -ForegroundColor Green
Write-Host " homeassistant\installationspaket\Installieren.cmd nimmt es automatisch mit."
Write-Host ""
+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
@@ -37,6 +37,14 @@ PANEL_KOMPONENTE = "audi-dashboard-panel"
STATIK_URL = "/audi_dashboard_static"
STATIK_ORDNER = "frontend"
# Das OTA-Bündel der iOS-App: dieselbe Oberfläche, als Zip zum Nachladen.
# Liegt bewusst mit im Integrationsordner - dann liefert HACS es bei jedem
# Update mit aus, und Bündel und Integration können gar nicht auseinander-
# laufen (siehe companion-app/scripts/ota-paket.ps1).
BUENDEL_ORDNER = "frontend/app"
BUENDEL_URL = f"{STATIK_URL}/app/bundle.zip"
BUENDEL_INFO = "bundle.json"
# Vom Nutzer hochgeladene Fahrzeugfotos. Die bleiben in /config/www/bilder,
# also unter /local/bilder/: sie sind NUTZERDATEN, kein Auslieferbestandteil.
# Lägen sie im Integrationsordner, würde das nächste HACS-Update sie
@@ -0,0 +1 @@
{"version":"2026.8.24.1","sha256":"967ab36fc866187cf5fdc03d95a3ec21cb6e42003d8c012f8f67b99c67373324","bytes":230770,"gebaut":"2026-08-24T07:16:44Z"}
@@ -30,6 +30,7 @@ from __future__ import annotations
import asyncio
import datetime
import json
import logging
import os
from collections.abc import Callable, Coroutine
@@ -49,6 +50,9 @@ from homeassistant.helpers.storage import Store
from . import batterie, fahrterkennung, reifen, screening, sicherung, tankerkennung
from .ablage import Ablage
from .const import (
BUENDEL_INFO,
BUENDEL_ORDNER,
BUENDEL_URL,
E_APP_VERSION,
E_BATTERIEVERLAUF,
E_BELEG_ERGEBNIS,
@@ -303,8 +307,35 @@ class Koordinator:
async def import_status_veroeffentlichen(self, zustand: str, daten: dict) -> None:
self.setzen(E_IMPORT_STATUS, zustand, daten)
def _buendel_lesen(self) -> dict | None:
"""Angaben zum mitgelieferten OTA-Bündel, oder None.
None ist ein völlig normaler Zustand: wer nur das Panel benutzt,
braucht kein App-Bündel, und eine Installation ohne bundle.json ist
deshalb nicht kaputt, sondern schlicht ohne diese Möglichkeit."""
pfad = os.path.join(
os.path.dirname(__file__), BUENDEL_ORDNER.replace("/", os.sep), BUENDEL_INFO
)
if not os.path.exists(pfad):
return None
try:
with open(pfad, encoding="utf-8") as datei:
info = json.load(datei)
except (ValueError, OSError) as fehler:
_LOGGER.warning("%s ist nicht lesbar (%s)", pfad, fehler)
return None
if not isinstance(info, dict) or not info.get("version") or not info.get("sha256"):
_LOGGER.warning("%s hat nicht die erwartete Form - Bündel wird nicht gemeldet", pfad)
return None
return {**info, "url": BUENDEL_URL}
async def alles_veroeffentlichen(self) -> None:
self.setzen(E_APP_VERSION, self.version, {"app": self.version})
buendel = await self.hass.async_add_executor_job(self._buendel_lesen)
# Die App liest beides aus derselben Entität: welchen Stand diese
# Installation ausliefert, und ob ein nachladbares Bündel danebenliegt.
# Zusammen an einer Stelle, damit die App nicht zwei Quellen abgleichen
# muss, die auseinanderlaufen können.
self.setzen(E_APP_VERSION, self.version, {"app": self.version, "buendel": buendel})
await self.profil_veroeffentlichen()
await self.fahrten_veroeffentlichen()
await self.tankvorgaenge_veroeffentlichen()
@@ -1,10 +1,10 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.8.23.2",
"documentation": "https://github.com/paulnothaft/audi-app/blob/main/README.md",
"issue_tracker": "https://github.com/paulnothaft/audi-app/issues",
"codeowners": ["@paulnothaft"],
"version": "2026.8.24.1",
"documentation": "https://github.com/T130B/DM360/blob/main/README.md",
"issue_tracker": "https://github.com/T130B/DM360/issues",
"codeowners": ["@T130B"],
"config_flow": true,
"integration_type": "service",
"single_config_entry": true,
+15 -20
View File
@@ -1,8 +1,17 @@
# Installation — Schritt für Schritt
Zwei Wege führen zum selben Ergebnis: HACS lädt den Ordner
`custom_components/audi_dashboard/` aus dem Repository, das Skript kopiert
denselben Ordner von der Festplatte. Was danach passiert, ist identisch.
HACS scheidet für dieses Repository aus — nicht als Vorbehalt, sondern nach
eigener Dokumentation von HACS: „Private GitHub repositories can not be used
with HACS at all" (hacs.xyz/docs/faq/private_repositories), ohne Ausnahme für
Tokens oder verbundene Konten. Das Repository ist privat, weil es die
Audi-Hausschrift und die Typenschilder enthält, die nur für diese eine
private Installation lizenziert sind — öffentlich machen ist deshalb keine
Option.
Es gibt also nur einen Weg: das Skript unten kopiert
`custom_components/audi_dashboard/` von der Festplatte in die Instanz. Das
ist keine Verlegenheitslösung — es prüft dieselben Dinge, die HACS auch
prüfen würde, und derselbe Aufruf installiert und aktualisiert.
Wer von der früheren pyscript-Fassung kommt: [Umstieg](#umstieg-von-der-pyscript-fassung)
weiter unten. Die Fahrzeugdaten bleiben dabei, wo sie sind.
@@ -13,7 +22,7 @@ weiter unten. Die Fahrzeugdaten bleiben dabei, wo sie sind.
- Home Assistant 2025.1 oder neuer
- Zugriff auf das `config`-Verzeichnis (Samba-Add-on, SSH oder ein
gemounteter Pfad) — nur für den Weg ohne HACS
gemounteter Pfad)
- Mindestens eine Datenquelle, die den Zündungs-/ACC-Status des Fahrzeugs
als `binary_sensor` meldet. Ohne sie läuft die App, erkennt aber keine
Fahrten.
@@ -25,21 +34,7 @@ langlebiges Zugriffstoken, ein `pip install` im Container und Einträge in der
---
## Weg A — über HACS
1. HACS → **Integrationen** → Menü oben rechts → **Benutzerdefinierte
Repositories**
2. Repository-URL eintragen, Kategorie **Integration**, hinzufügen
3. **Audi Dashboard** suchen und herunterladen
4. Home Assistant neu starten
**Hürde, die man kennen muss:** HACS spricht ausschließlich mit GitHub —
`github.com` und `api.github.com` stehen fest im Code, es gibt keinen
Schalter für Gitea, GitLab oder eine selbst gehostete Instanz. Liegt das
Repository woanders, findet HACS es auch als benutzerdefiniertes Repository
nicht, und es bleibt Weg B.
## Weg B — ohne HACS, per Skript
## Installation per Skript
Vom Windows-Rechner aus, der das `config`-Verzeichnis erreicht:
@@ -160,7 +155,7 @@ Dateiformate sind unverändert. Die Integration liest den bestehenden Bestand
einfach weiter — es gibt keine Migration und damit auch keinen Weg, dabei
etwas zu verlieren.
1. Integration installieren (Weg A oder B oben)
1. Integration installieren (siehe oben)
2. Aus der `configuration.yaml` entfernen: den `pyscript:`-Block und den
`panel_custom:`-Eintrag `audi-dashboard-panel`. Bleiben sie stehen, gibt
es den Sidebar-Eintrag zweimal, und beide Backends schreiben in dieselben
@@ -147,5 +147,6 @@ Ordner wird ersetzt, die Fahrzeugdaten unter `audi_dashboard\` und die eigenen
Fotos unter `www\bilder\` bleiben unangetastet. Danach HA neu starten (oder
kürzer: **Einstellungen → Geräte & Dienste → Audi Dashboard → Neu laden**).
Liegt das Projekt auf GitHub, übernimmt HACS das: es meldet neue Fassungen
von selbst, installiert sie und kann zurückrollen.
HACS ist hier bewusst keine Alternative: es kann laut eigener Dokumentation
grundsätzlich nicht mit privaten GitHub-Repositories arbeiten, und das
Repository ist privat, weil es lizenzierte Audi-Assets enthält.
+14 -8
View File
@@ -14,14 +14,20 @@
Fahrzeugprofil aus der Vorlage an und bringt pypdf über ihre manifest.json
mit, das Home Assistant beim ersten Laden nachinstalliert.
WOFÜR ES DIESES SKRIPT ÜBERHAUPT NOCH GIBT
------------------------------------------
Der eigentliche Weg ist HACS. HACS spricht aber ausschließlich mit GitHub
(github.com/api.github.com stecken fest im Code) - solange dieses Projekt
nur auf der eigenen Gitea-Instanz liegt, findet HACS es nicht. Dieses Skript
ist der Weg dorthin, der ohne GitHub auskommt. Es installiert exakt
denselben Ordner, den auch HACS installieren würde; ein späterer Wechsel auf
HACS überschreibt ihn einfach.
WOFÜR ES DIESES SKRIPT ÜBERHAUPT GIBT
--------------------------------------
HACS scheidet für dieses Repository aus, und zwar endgültig: laut eigener
Dokumentation (hacs.xyz/docs/faq/private_repositories) kann HACS
"grundsätzlich nicht" mit privaten GitHub-Repositories arbeiten - ohne
Ausnahme für Tokens oder verbundene Konten. Das Projekt wird zwar von der
eigenen Gitea-Instanz nach github.com/T130B/DM360 gespiegelt, aber das
Repository bleibt privat: es enthält die Audi-Hausschrift und die
Typenschilder, die nur für diese eine private Installation lizenziert
sind, und öffentlich machen ist deshalb keine Option.
Dieses Skript ist damit nicht die Rückfallebene, sondern der einzige Weg.
Es installiert exakt den Ordner, den auch HACS installieren würde, wenn es
könnte - dieselbe Prüfung, ohne die HACS-Voraussetzung.
SICHERHEIT GEGENÜBER DER BESTEHENDEN HA-INSTALLATION
----------------------------------------------------
+10
View File
@@ -21,6 +21,7 @@
"@capacitor/android": "^8.5.0",
"@capacitor/core": "^8.5.0",
"@capacitor/ios": "^8.5.0",
"@capgo/capacitor-updater": "^8.51.14",
"leaflet": "^1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1"
@@ -533,6 +534,15 @@
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capgo/capacitor-updater": {
"version": "8.51.14",
"resolved": "https://registry.npmjs.org/@capgo/capacitor-updater/-/capacitor-updater-8.51.14.tgz",
"integrity": "sha512-2x4xEiS9nv3KSviSYKGS94yrZFoi12GYtn5vwv90BsEv7zc2dDh+Imk/F5eWAsSu20sHx5TBq3Hu/8BtWUA9JQ==",
"license": "MPL-2.0",
"peerDependencies": {
"@capacitor/core": "^8.0.0"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",