diff --git a/AGENTS.md b/AGENTS.md index 0fe65bb..4438c25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//` (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/` 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 diff --git a/README.md b/README.md index dadaa21..2dba268 100644 --- a/README.md +++ b/README.md @@ -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): diff --git a/VERSIONIERUNG.md b/VERSIONIERUNG.md index 5871909..2c751fb 100644 --- a/VERSIONIERUNG.md +++ b/VERSIONIERUNG.md @@ -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. diff --git a/companion-app/capacitor.config.ts b/companion-app/capacitor.config.ts index 7604cff..d3afc40 100644 --- a/companion-app/capacitor.config.ts +++ b/companion-app/capacitor.config.ts @@ -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, + }, }, } diff --git a/companion-app/package.json b/companion-app/package.json index 765b1dd..524f2bf 100644 --- a/companion-app/package.json +++ b/companion-app/package.json @@ -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" diff --git a/companion-app/scripts/ota-paket.ps1 b/companion-app/scripts/ota-paket.ps1 new file mode 100644 index 0000000..4cedcfa --- /dev/null +++ b/companion-app/scripts/ota-paket.ps1 @@ -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 "" diff --git a/companion-app/src/App.tsx b/companion-app/src/App.tsx index 43325e0..dfa7ce9 100644 --- a/companion-app/src/App.tsx +++ b/companion-app/src/App.tsx @@ -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 ( { + async appVersionAngabeLesen(): Promise { 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; } diff --git a/companion-app/src/api/types.ts b/companion-app/src/api/types.ts index a39954d..5be5ee6 100644 --- a/companion-app/src/api/types.ts +++ b/companion-app/src/api/types.ts @@ -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. diff --git a/companion-app/src/daten/DatenKontext.tsx b/companion-app/src/daten/DatenKontext.tsx index 100b2ee..9f1aff3 100644 --- a/companion-app/src/daten/DatenKontext.tsx +++ b/companion-app/src/daten/DatenKontext.tsx @@ -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("getrennt") const [warteschlange, setzeWarteschlange] = useState([]) const [serverVersion, setzeServerVersion] = useState(null) + const [otaBuendel, setzeOtaBuendel] = useState(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, diff --git a/companion-app/src/daten/ota.test.ts b/companion-app/src/daten/ota.test.ts new file mode 100644 index 0000000..d65d46e --- /dev/null +++ b/companion-app/src/daten/ota.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest" + +import { buendelPasst } from "./ota" +import type { Buendelangabe } from "../api" + +const buendel = (teil: Partial = {}): 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) + }) +}) diff --git a/companion-app/src/daten/ota.ts b/companion-app/src/daten/ota.ts new file mode 100644 index 0000000..05da5c8 --- /dev/null +++ b/companion-app/src/daten/ota.ts @@ -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 { + 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 { + 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.") +} diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx index c6566c0..49bed5e 100644 --- a/companion-app/src/screens/Einstellungen.tsx +++ b/companion-app/src/screens/Einstellungen.tsx @@ -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(null) const dateiwahl = useRef(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({ + {otaMoeglich() && ( + + App-Update + {otaUpdateVerfuegbar && otaBuendel ? ( + <> + + } + /> +

+ 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. +

+
+ void otaAusloesen()} disabled={otaLaeuft}> + {otaLaeuft ? "Lade Update …" : "Update installieren"} + +
+ {otaFehler &&

{otaFehler}

} + + ) : ( +

+ {otaBuendel + ? `Diese App ist aktuell (${eigeneVersion() ?? "unbekannte Version"}).` + : "Der Server hat gerade kein Update-Bündel hinterlegt."} +

+ )} +
+ )} + Zugang 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() diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index c156908..d29ee1e 100644 --- a/custom_components/audi_dashboard/manifest.json +++ b/custom_components/audi_dashboard/manifest.json @@ -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, diff --git a/homeassistant/INSTALL.md b/homeassistant/INSTALL.md index cb4c28f..8e81d5c 100644 --- a/homeassistant/INSTALL.md +++ b/homeassistant/INSTALL.md @@ -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 diff --git a/homeassistant/installationspaket/ANLEITUNG.md b/homeassistant/installationspaket/ANLEITUNG.md index 9223029..fc2aa87 100644 --- a/homeassistant/installationspaket/ANLEITUNG.md +++ b/homeassistant/installationspaket/ANLEITUNG.md @@ -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. diff --git a/homeassistant/installationspaket/install.ps1 b/homeassistant/installationspaket/install.ps1 index 700844a..6c0d150 100644 --- a/homeassistant/installationspaket/install.ps1 +++ b/homeassistant/installationspaket/install.ps1 @@ -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 ---------------------------------------------------- diff --git a/package-lock.json b/package-lock.json index 9b99e7c..90bd351 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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",