Ölwechsel/Inspektion-Anzeige repariert, Selbst-Update der Integration gebaut

Zwei getrennte Themen in einem Commit, beide in derselben Sitzung entstanden:

1. Ölwechsel/Inspektion wurden komplett ausgeblendet ("kein Eintrag im
   Servicebuch"), sobald kein Servicebucheintrag vorlag - selbst wenn der
   zugeordnete Sensor eine gültige Fälligkeit meldete. In beiden Frontends
   prüfte die Anzeige nur den Servicebuch-Zweig, bevor sie die
   Fahrzeugmeldung überhaupt las. Jetzt steht die Fahrzeugmeldung für sich;
   fehlt zusätzlich ein Servicebucheintrag, übernimmt eine neue,
   fahrtenlog-basierte Prognose (kmProTagAusFahrten()/meldungsPrognose())
   die Hochrechnung statt der Servicebuch-Rate - deckelt auf die vom
   Fahrzeug selbst gemeldete Zeitgrenze, falls zu wenig gefahren wird.

2. install.ps1 als Update-Weg wird von Windows Smart App Control blockiert,
   ohne Umgehungsmöglichkeit. Die Integration lädt sich jetzt auf
   Tastendruck selbst von Gitea (aktualisierung.py), verifiziert das
   Manifest vor jedem Tausch und tauscht per os.rename mit automatischem
   Rollback bei Fehlern - install.ps1 bleibt nur noch für die
   Erstinstallation nötig. Zugangstoken über einen neuen OptionsFlow in
   entry.options, nie in configuration.yaml.

Nebenbei: mehrere seit der HACS-Ausschluss-Entscheidung liegen gebliebene
falsche HACS-Referenzen in Code-Kommentaren und einem UI-Text korrigiert.

Details, Sicherheitsbegründung und Verifikationsstand in AGENTS.md,
Abschnitte I und J.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 12:01:01 +02:00
parent 5ff33c92d9
commit fb251154c7
27 changed files with 1268 additions and 82 deletions
+165
View File
@@ -1926,6 +1926,30 @@ Gitea-vs-mirrored-GitHub changes it: HACS refuses private repos regardless of wh
mirrored, and the private/public constraint (licensed Audi assets in the repo) isn't going away.
`install.ps1` is the permanent, only install path, not a stand-in.
**Stale HACS claims found and fixed in code comments/UI text (2026-08-24).** The docs-level cleanup
above happened the same day as this section was written, but a full-repo grep afterward (prompted by
the owner spotting live UI text still claiming "Updates laufen über HACS") turned up leftovers the
first pass missed, because it focused on the standalone docs (`README.md`, `INSTALL.md`, etc.) and
not source comments or UI strings. Fixed: the panel's Einstellungen → Version tile literally told the
user HACS would notice and install updates itself — replaced with the actual mechanism
(`install.ps1`, panel ships inside the integration and can't go stale on its own). Also fixed,
all now pointing at `install.ps1`/"jeder Auslieferung" instead of "HACS"/"HACS-Update": comments in
`audi-dashboard-app.js` (the version-tracking block and the retired-updater history note),
`companion-app/vite.config.ts` (manifest-version header comment), `const.py` (`STATIK_URL`,
`BUENDEL_ORDNER`, `BILDER_ORDNER` comments), `bilder.py` (module docstring), `__init__.py`
(`cache_headers=False` rationale), and a diagram branch in `VERSIONIERUNG.md` that stated "HACS
vergleicht sie gegen das Repository und meldet Updates" as flat fact, contradicting the boxed note
one screen above it. Left alone: `install.ps1`'s own header (already correctly explains the
exclusion), `ota-paket.ps1`'s comment (already correct), and one debugging note in
`audi-dashboard-app.js` (~line 4720) that recalls what was actually running in a specific test
container during a past incident — a historical fact about that container, not a claim about how
this project works, so not in scope for this cleanup. Also out of scope, a different topic entirely:
mentions of the *retired vehicle-data-source* HACS integration `TommiG1/HA_VAG-EU-Data-Act` in older
planning docs (`SPECIFICATION.md`, `COMPANION_APP_ARCHITECTURE.md`, `bauauftrag.md/html`,
`testumgebung/`) — that HACS integration is unrelated to whether HACS can install *this* app; it was
FMM003's predecessor as a data source, already documented as retired elsewhere in those same files.
Manifest bumped to `2026.8.24.3` and `npm run ota` rerun (the panel text change is user-visible).
### The deployed-parity problem, and what was built for it (2026-08-23)
The parity rule (binding, above) guarantees *source* parity: both codebases change in the same
@@ -2131,6 +2155,147 @@ was 1.00:1, white on white. Both pills now follow the app's own button language
as a button fill. No companion-app port: the location sheet's share/route pills do not exist there,
which the parity rule exempts as panel-only.
### I) Ölwechsel/Inspektion could vanish entirely with a mapped sensor and no Servicebuch (found and fixed 2026-08-24)
Owner reported: "no inspektion and ölwechsel shown even if there is the right sensor and
herstellervorgabe." Both were true — sensor mapped correctly, "Herstellervorgabe" selected — and the
row still just said "kein Eintrag im Servicebuch" (no Servicebuch entry).
**Root cause, identical in both codebases.** The Ölwechsel/Inspektion display combines two
independent sources on purpose (see the header comment in `companion-app/src/screens/Service.tsx`):
the vehicle's own reported due date/km (`fahrzeugMeldungRoh()` / `fahrzeug.oelwechselFaelligTs` etc.,
sourced from the mapped HA sensor) is meant to stand *alongside* the app's own Servicebuch-based
forecast, not depend on it. But the actual gating checked only the Servicebuch side:
- Panel (`frontend/audi-dashboard-app.js`, `vService()`): `const iv = intervalle(t); if (!iv) return
...„kein Eintrag im Servicebuch"...` — and `intervalle()` returns `null` whenever there's no
Servicebuch basis, full stop. The vehicle-reported `fm`/`hatMeldung` was computed *after* this early
return, so a mapped sensor with valid data never even got read for a fahrzeug with an empty
Servicebuch — exactly the state of a freshly set-up car.
- companion-app (`src/screens/Service.tsx`): same shape, `{oelHatEintrag ? ... : "kein Eintrag im
Servicebuch"}` — gated purely on a Servicebuch entry existing, ignoring `oelHatMeldung`/
`inspHatMeldung` computed one line above it. `src/daten/service.ts`'s `naechsterService()` (feeds
the Home-screen "Nächster Service" tile) had the same gap one level up: it only ever built
candidates from `oelwechselPrognose()`/`inspektionPrognose()` (both Servicebuch-only), never
falling back to `fahrzeug.oelwechselFaelligTs`/`inspektionFaelligTs`.
**Fixed by reordering, not by adding a new data path** — the sensor data was already being read
correctly by the backend and delivered to both frontends; only the display-gating ignored it.
Panel: `fm`/`hatMeldung` now computed *before* the early-return check, which now reads `if (!iv &&
!hatMeldung)`; `anzeige`/`herstellervorgabe` guarded for `iv` possibly being `null` (both remain safe
because the code paths that dereference `iv` only run when `hatMeldung` is false, and the new guard
guarantees `iv` is non-null whenever that's the case). companion-app: `oelHatEintrag ||
oelHatMeldung` / `inspHatEintrag || inspHatMeldung` as the render condition; `naechsterService()`
gained an `else if (fahrzeug.oelwechselFaelligTs)` / `...inspektionFaelligTs` fallback branch per
service type, added only when the Servicebuch-based prognose is `null` — a real Servicebuch entry
still wins when both exist (own forecast is finer-grained, factoring actual driven km/day). The
Hauptuntersuchung path is unaffected either way: `fahrzeugMeldungRoh("Hauptuntersuchung")` always
returns `{ts: null, km: null}`, so `hatMeldung` is always `false` for it — same behavior as before.
Verified: `tsc --noEmit` clean, all 120 companion-app tests pass (including 3 new regression tests in
`service.test.ts` covering the meldung-only fallback for both service types and confirming a real
Servicebuch prognose still takes priority when both are present), panel JS re-checked with `node
--check`. `manifest.json` bumped to `2026.8.24.2` and `npm run ota` rerun (own rule from section H,
enforced by `install.ps1` since the same day) since this touched both the panel and the companion-app.
### J) Self-update: the integration pulls its own updates from Gitea (built 2026-08-24)
**Why.** `install.ps1` (section H) stopped being a reliable update path: Windows Smart App Control
blocks its execution, and unlike SmartScreen there is no "run anyway" override — once fully
enabled, Smart App Control can only be turned off by reinstalling Windows. The owner's actual daily
pain wasn't the block itself but the whole cycle it forced: open Gitea, download a zip, unpack it,
run the (now-blocked) installer, re-enter the Samba password every time (see section H's Samba-auth
fix earlier the same day). Owner chose, from three options laid out (free workarounds first / code-sign
the installer / have Home Assistant pull its own updates), the third: **the integration now updates
itself.** `install.ps1` remains necessary for the *first* install only — the integration has to
already be running before it can update itself — which is the rare case; the daily-update tedium is
what this closes.
**Safety design — stricter than install.ps1, because it runs inside what it replaces.**
`install.ps1` is an external process against a foreign HA instance; this feature's code lives
*inside* `custom_components/audi_dashboard/` and replaces that very folder while its own service
call is still executing. `aktualisierung.py`'s module docstring carries the full reasoning; the
shape:
1. Download and verification happen entirely in a sibling staging folder
(`custom_components/audi_dashboard_update_staging/`) — the live folder is never touched until the
download is confirmed complete and plausible.
2. The swap is `os.rename`, not delete-then-copy: the live folder is renamed to
`audi_dashboard_backup` (kept, not deleted — reversible) before the staging folder is renamed into
its place. If the second rename fails, the first is undone automatically rather than leaving a
half-replaced folder.
3. The downloaded `manifest.json` is checked for `domain == "audi_dashboard"` and a `version` field
before either rename runs — matches install.ps1's own "won't touch a folder that isn't
provably ours" rule (section G/H).
4. **No automatic reload or restart.** A reload triggered from inside the same service call that's
still running would risk being interrupted by its own reload. The UI shows a "restart Home
Assistant" message instead — the same pattern install.ps1's Restliste already uses.
5. Nothing outside `custom_components/` is touched — same invariant as install.ps1.
6. The check step (a Gitea network call) runs only on explicit button press, never on the
coordinator's normal publish tick — same "no unsolicited background action" stance as the OTA
`autoUpdate: false` decision.
Renaming the very folder the running code was imported from is safe on Linux (this project runs on
Home Assistant OS / Docker): Python holds no open file handles on `.py` sources after import
completes, and `os.rename` only operates on path strings, not live handles.
**Token storage.** No prior mechanism existed for secrets in this integration — `config_flow.py`'s
`entry.data` was always `{}`, no `OptionsFlow` existed. Added `AudiDashboardOptionsFlow`
(Settings → Devices & Services → Audi Dashboard → Configure), one masked field
(`CONF_GITEA_TOKEN` in `const.py`), stored in `entry.options` — HA's own encrypted config-entry
store, never `configuration.yaml`. A read-only-scoped Gitea token is enough; the form text says so.
**What was built:**
- `custom_components/audi_dashboard/aktualisierung.py` — `version_pruefen()` (one lightweight Gitea
Contents-API call for just `manifest.json`, no repo download) and `update_installieren()`
(downloads the full repo archive via Gitea's archive endpoint — Gitea has no single-folder
download — delegates to the blocking `entpacken_pruefen_tauschen()` via
`hass.async_add_executor_job`). The blocking function takes all paths as parameters rather than
reading module constants directly, specifically so it's testable against a temp directory without
touching the real integration folder.
- Two new services (`update_pruefen`, `update_installieren`), registered in `dienste.py` following
the existing `SCHEMATA`/`behandler`/`_als_dienst()` pattern; both zero-field, both catch
`aktualisierung.AktualisierungsFehler` and turn it into a user-readable message instead of an
unhandled exception.
- `koordinator.py`: `alles_veroeffentlichen()`'s `E_APP_VERSION` publish was factored out into its
own `app_version_veroeffentlichen()` method and extended with `integration_update`. This holds the
*last* check/install result (`self.letzte_update_pruefung`) — deliberately **not** recomputed on
every publish tick, only ever set by the two new service handlers, so normal operation triggers zero
Gitea calls.
- Both frontends got a new "Integration-Update" tile (companion-app: `Einstellungen.tsx` +
`IntegrationUpdateAngabe` type in `api/types.ts` + `integrationUpdate` in `DatenKontext.tsx`; panel:
new tile next to the existing Version tile in `audi-dashboard-app.js`, backed by a new
`INTEGRATION_UPDATE` global read from `daten.integration_update`, wired to the existing
`data-*`/`serviceRufen()`/`ereignisseVerdrahten()` click-dispatch pattern) — not an extension of the
existing OTA "App-Update" tile, which is a different concept (the Capacitor iOS shell going stale)
that doesn't apply to a browser-rendered panel.
**Verified, honestly scoped by what this session's sandbox could actually run:**
- The safety-critical pure logic (`entpacken_pruefen_tauschen()`) has 7 unit tests
(`tests/aktualisierung/test_aktualisierung.py`, `unittest`, same style as
`tests/belegparser/test_shell_beleg_parser.py`) covering: successful swap with the old version
preserved in the backup folder, `__pycache__` cleanup, wrong domain rejected, missing `version`
field rejected, missing `manifest.json` rejected, corrupt zip rejected, archive missing the
expected path rejected — and every rejection case asserts the live folder is byte-for-byte
untouched. Run inside the project's existing throwaway `ghcr.io/home-assistant/home-assistant:stable`
test container (`audi_ha_test`) via `docker exec`, since no Python interpreter exists in the
session sandbox itself.
- The complete integration (config flow, both services, koordinator changes, both frontends) was
deployed into that same container and restarted from a clean state, twice, ending on
`Audi Dashboard 2026.8.24.4 eingerichtet` with zero errors or tracebacks — confirms the
`OptionsFlow`/service registration/coordinator wiring is structurally correct against a real,
current (2026.8.0) Home Assistant.
- companion-app: `tsc --noEmit` clean, all 127 tests pass.
- **Not verified**: an actual successful download+swap against the real private Gitea repo, since
that needs the owner's own token — entering it is the owner's action via the HA UI, not something
to hand to an assistant in chat. The failure paths (missing token, bad token, unreachable Gitea) are
covered by the unit tests and code review, not by a live call against Gitea. First real end-to-end
use is the owner's to do once a token is configured — worth a first careful try on the test
container before the real instance, same caution as every other install.ps1-era change in this
project.
**Docs updated to stop calling install.ps1 "the only way":** `README.md`, `VERSIONIERUNG.md` — both
now say install.ps1 is the only way *to install*; the self-update is now the preferred way *to
update* an already-running instance.
---
## Working conventions (observed — keep them)
+12 -2
View File
@@ -26,8 +26,18 @@ Audi-Hausschrift und die Typenschilder, die nur für diese eine private
Installation lizenziert sind.
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.
Weg für die **Erstinstallation** — die Integration muss laufen, bevor sie
sich selbst aktualisieren kann.
Für **Updates danach** gibt es seit 2026-08-24 einen zweiten, bevorzugten
Weg: die Integration lädt ihre neueste Fassung direkt von Gitea und ersetzt
sich selbst (Einstellungen → Geräte & Dienste → Audi Dashboard →
Konfigurieren, dort ein Gitea-Zugriffstoken eintragen; danach in der App
unter Einstellungen → Integration-Update). Grund: Windows Smart App Control
blockiert die Ausführung von `install.ps1` zuverlässig und lässt sich —
anders als der SmartScreen davor — nicht per „trotzdem ausführen" umgehen.
Der Installer unten bleibt der Weg für die Erstinstallation und für den
seltenen Fall, dass das Selbst-Update selbst nicht mehr erreichbar ist.
Von einem Windows-Rechner aus, der das `config`-Verzeichnis der HA-Instanz
erreicht (Samba-Add-on oder gemounteter Pfad):
+12 -6
View File
@@ -55,7 +55,6 @@ Vergleich still falsch geworden statt laut.
manifest.json { "version": "2026.8.23.2" }
├─► Home Assistant führt sie als Version der Integration
│ └─► HACS vergleicht sie gegen das Repository und meldet Updates
├─► die Integration veröffentlicht sie als
│ sensor.audi_dashboard_app_version
@@ -97,7 +96,12 @@ ohne Netz darf keinen Fehlalarm auslösen.
Bündel) — ein Widerspruch, aus dem es ohne diesen Schritt keinen Ausweg
gibt. `install.ps1` prüft das seit 2026-08-24 selbst und warnt, falls
vergessen — aber besser, es passiert gar nicht erst.
3. Integration ausliefern: `homeassistant\installationspaket\Installieren.cmd`
3. Integration ausliefern: nach `git push` entweder in der App selbst unter
Einstellungen → Integration-Update auf „Auf Update prüfen" → „Update
installieren" (seit 2026-08-24, ersetzt install.ps1 als laufenden
Update-Weg — Windows Smart App Control blockiert dessen Ausführung
zuverlässig), danach Home Assistant neu starten; oder weiterhin
`homeassistant\installationspaket\Installieren.cmd` von Hand
(HACS scheidet aus — siehe Kasten oben, das Repository ist privat).
4. iOS-App für Xcode neu bauen (`npm run build`), damit eine über App Store
Connect / Sideload signierte Fassung dieselbe Zahl einkompiliert bekommt.
@@ -148,7 +152,9 @@ 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.
reist mit jedem Weg, der die Integration ausliefert, automatisch mit —
`install.ps1` für die Erstinstallation ebenso wie das Selbst-Update seit
2026-08-24 (aktualisierung.py, ersetzt install.ps1 als laufenden Update-Weg,
siehe Abschnitt J in AGENTS.md) — der Mechanismus hängt nicht daran, wie die
Integration selbst auf die Instanz kommt, nur daran, dass das Bündel im
Integrationsordner liegt.
+20
View File
@@ -197,6 +197,26 @@ export class DataMetricApi {
}
}
/* ------------------------------------------------- Selbst-Update der
Integration (aktualisierung.py) - Ersatz für install.ps1, das Windows
Smart App Control blockiert. Bewusst NICHT über die Warteschlange, wie
historieImportieren() oben: beides sind ausdrückliche, sofortige
Aktionen, kein Zustand, den man im Funkloch absetzt und später
nachgeholt haben will. Ergebnis kommt über
appVersionAngabeLesen()/daten.integration_update zurück, nicht über den
Rückgabewert dieser Aufrufe. */
/** Fragt nur nach, ob eine neuere Fassung vorliegt - lädt nichts herunter. */
updatePruefen(): Promise<unknown> {
return this.rest.dienstAufrufen(DIENST_DOMAIN, "update_pruefen");
}
/** Lädt die neueste Fassung und ersetzt den Integrationsordner. Home
Assistant muss danach neu gestartet werden. */
updateInstallieren(): Promise<unknown> {
return this.rest.dienstAufrufen(DIENST_DOMAIN, "update_installieren");
}
/* ------------------------------------------- Import aus dem HA-Verlauf
Bewusst NICHT über die Warteschlange, anders als die übrigen
schreibenden Vorgänge: der Import ist keine Eingabe, die man im Funkloch
+16
View File
@@ -172,10 +172,26 @@ export interface Buendelangabe {
url: string;
}
/** Ergebnis der letzten "Auf Update prüfen"/"Update installieren"-Aktion
(custom_components/audi_dashboard/aktualisierung.py, dienste.py). `null`,
solange noch nie geprüft wurde - ein normaler Zustand, kein Fehler.
Ersetzt install.ps1 als Update-Weg, weil Windows Smart App Control es
zuverlässig blockiert. */
export interface IntegrationUpdateAngabe {
verfuegbar: boolean;
version: string | null;
geprueft_am: string;
/** true unmittelbar nach einer erfolgreichen Installation - dann ist ein
Neustart von Home Assistant nötig, damit die neue Fassung geladen wird. */
installiert: boolean;
fehler: string | null;
}
/** Nutzlast von sensor.audi_dashboard_app_version. */
export interface AppVersionAngabe {
app: string | null;
buendel: Buendelangabe | null;
integration_update: IntegrationUpdateAngabe | null;
}
/* -------------------------------------------------- Entitäts-Verzeichnis */
+9
View File
@@ -25,6 +25,7 @@ import {
type Buendelangabe,
type Fahrt,
type Fahrzeugstatus,
type IntegrationUpdateAngabe,
type Profil,
type Tankvorgang,
type Verbindungszustand,
@@ -50,6 +51,9 @@ export interface DatenWert {
oder null — kein Fehler, sondern der Zustand ohne Bündel oder ohne
Verbindung. */
otaBuendel: Buendelangabe | null
/** Ergebnis der letzten Selbst-Update-Prüfung/-Installation der
Integration (aktualisierung.py), oder null vor der ersten Prüfung. */
integrationUpdate: IntegrationUpdateAngabe | null
einstellungen: Einstellungen | null
fahrzeug: Fahrzeug | null
@@ -93,6 +97,9 @@ export function DatenAnbieter({
const [warteschlange, setzeWarteschlange] = useState<readonly WartenderAuftrag[]>([])
const [serverVersion, setzeServerVersion] = useState<string | null>(null)
const [otaBuendel, setzeOtaBuendel] = useState<Buendelangabe | null>(null)
const [integrationUpdate, setzeIntegrationUpdate] = useState<IntegrationUpdateAngabe | null>(
null,
)
// Damit profilSpeichern immer gegen den neuesten Rohstand arbeitet, auch
// wenn zwischendurch ein Push hereinkam.
@@ -121,6 +128,7 @@ export function DatenAnbieter({
const versionsangabe = await api.appVersionAngabeLesen()
setzeServerVersion(versionsangabe?.app ?? null)
setzeOtaBuendel(versionsangabe?.buendel ?? null)
setzeIntegrationUpdate(versionsangabe?.integration_update ?? null)
} catch (fehler) {
// Kein Netz ist kein Ladefehler, solange schon Daten da sind — dann
// zeigt die App den zwischengespeicherten Stand mit Offline-Hinweis.
@@ -219,6 +227,7 @@ export function DatenAnbieter({
statusStand,
versionsstand: versionVergleichen(eigeneVersion(), serverVersion).stand,
otaBuendel,
integrationUpdate,
einstellungen,
fahrzeug,
fahrten,
+110 -4
View File
@@ -1,6 +1,13 @@
import { describe, expect, it } from "vitest"
import { inspektionPrognose, letzterOelwechsel, naechsterService, oelwechselPrognose } from "./service"
import {
inspektionPrognose,
kmProTagAusFahrten,
letzterOelwechsel,
meldungsPrognose,
naechsterService,
oelwechselPrognose,
} from "./service"
import type { Fahrzeug } from "./profilAdapter"
function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
@@ -128,7 +135,7 @@ describe("naechsterService", () => {
const insp = inspektionPrognose(fahrzeug(46000), BUCH, jetzt)
const erwarteteArt = oel!.datum <= insp!.datum ? "Ölwechsel" : "Inspektion"
const s = naechsterService(fahrzeug(46000), BUCH, jetzt)
const s = naechsterService(fahrzeug(46000), BUCH, [], jetzt)
expect(s).not.toBeNull()
expect(s!.art).toBe(erwarteteArt)
expect(s!.datum).toBe(erwarteteArt === "Ölwechsel" ? oel!.datum : insp!.datum)
@@ -136,7 +143,7 @@ describe("naechsterService", () => {
it("nimmt die Hauptuntersuchung auf, obwohl sie keine eigene Prognose hat", () => {
const f = { ...fahrzeug(46000), hu: "2026-09-01" }
const s = naechsterService(f, [], jetzt)
const s = naechsterService(f, [], [], jetzt)
expect(s).not.toBeNull()
expect(s!.art).toBe("Hauptuntersuchung")
expect(s!.restKm).toBeNull()
@@ -144,6 +151,105 @@ describe("naechsterService", () => {
})
it("liefert nichts ohne Servicebuch und ohne gepflegte Hauptuntersuchung", () => {
expect(naechsterService(fahrzeug(46000), [], jetzt)).toBeNull()
expect(naechsterService(fahrzeug(46000), [], [], jetzt)).toBeNull()
})
it("fällt ohne Servicebucheintrag auf die vom Fahrzeug gemeldete Ölwechsel-Fälligkeit zurück", () => {
// Ein frisch eingerichtetes Fahrzeug mit zugeordnetem Sensor, aber noch
// ganz ohne Servicebuch, muss die Meldung trotzdem zeigen - siehe
// AGENTS.md, Abschnitt H, "no inspektion and ölwechsel shown ...".
const f = { ...fahrzeug(46000), oelwechselFaelligTs: "2027-03-14T00:00:00+00:00", oelwechselFaelligKm: 11750 }
const s = naechsterService(f, [], [], jetzt)
expect(s).not.toBeNull()
expect(s!.art).toBe("Ölwechsel")
expect(s!.datum).toBe("2027-03-14")
expect(s!.restKm).toBe(11750)
})
it("fällt ohne Servicebucheintrag auf die vom Fahrzeug gemeldete Inspektions-Fälligkeit zurück", () => {
const f = { ...fahrzeug(46000), inspektionFaelligTs: "2027-01-01T00:00:00+00:00", inspektionFaelligKm: 5000 }
const s = naechsterService(f, [], [], jetzt)
expect(s).not.toBeNull()
expect(s!.art).toBe("Inspektion")
expect(s!.datum).toBe("2027-01-01")
expect(s!.restKm).toBe(5000)
})
it("bevorzugt die eigene Prognose gegenüber der Fahrzeugmeldung, wenn beide vorliegen", () => {
// Servicebucheintrag vorhanden -> oelwechselPrognose() greift; die
// Meldung (weit in der Zukunft) darf sie nicht verdrängen.
const f = { ...fahrzeug(46000), oelwechselFaelligTs: "2099-01-01T00:00:00+00:00", oelwechselFaelligKm: 99999 }
const oel = oelwechselPrognose(f, BUCH, jetzt)
expect(oel).not.toBeNull()
const s = naechsterService(f, BUCH, [], jetzt)
expect(s!.datum).not.toBe("2099-01-01")
})
})
describe("kmProTagAusFahrten", () => {
const jetzt = new Date("2026-08-11T12:00:00")
it("rechnet aus abgeschlossenen Fahrten seit der ältesten die km/Tag-Rate", () => {
// Exakt 30 Tage vor jetzt (gleiche Uhrzeit), 900 km insgesamt -> 30 km/Tag.
const fahrten = [
{ ts_start: "2026-07-12T12:00:00", distance_km: 400, status: "abgeschlossen" },
{ ts_start: "2026-08-01T08:00:00", distance_km: 500, status: "abgeschlossen" },
]
expect(kmProTagAusFahrten(fahrten, jetzt)).toBeCloseTo(30, 5)
})
it("ignoriert offene Fahrten und Fahrten ohne bekannte Distanz", () => {
const fahrten = [
{ ts_start: "2026-07-12T08:00:00", distance_km: 400, status: "abgeschlossen" },
{ ts_start: "2026-08-10T08:00:00", distance_km: null, status: "abgeschlossen" },
{ ts_start: "2026-08-11T08:00:00", distance_km: 9999, status: "offen" },
]
// Nur die erste Fahrt zählt -> dieselbe Rate wie ohne die beiden anderen.
expect(kmProTagAusFahrten(fahrten, jetzt)).toBe(
kmProTagAusFahrten([fahrten[0]!], jetzt),
)
})
it("liefert nichts ohne Fahrten oder ohne belastbaren Zeitraum", () => {
expect(kmProTagAusFahrten([], jetzt)).toBeNull()
expect(kmProTagAusFahrten([{ ts_start: jetzt.toISOString(), distance_km: 5, status: "abgeschlossen" }], jetzt))
.toBeNull()
})
})
describe("meldungsPrognose", () => {
const jetzt = new Date("2026-08-11T12:00:00")
// 30 km/Tag, siehe kmProTagAusFahrten-Tests oben.
const fahrten = [
{ ts_start: "2026-07-12T12:00:00", distance_km: 400, status: "abgeschlossen" },
{ ts_start: "2026-08-01T08:00:00", distance_km: 500, status: "abgeschlossen" },
]
it("rechnet die Restkilometer mit der Fahrleistung aus dem Fahrtenlog auf ein Datum hoch", () => {
// 300 km Rest bei 30 km/Tag -> 10 Tage.
const p = meldungsPrognose("2099-01-01T00:00:00+00:00", 300, fahrten, jetzt)
expect(p).not.toBeNull()
expect(p!.datum).toBe("2026-08-21")
expect(p!.durchZeitlimit).toBe(false)
})
it("deckelt auf die vom Fahrzeug gemeldete Zeitgrenze, wenn zu wenig gefahren wird", () => {
// 30000 km Rest bei 30 km/Tag läge Jahre in der Zukunft - die gemeldete
// Zeitgrenze (fünf Tage später) greift zuerst.
const p = meldungsPrognose("2026-08-16T00:00:00+00:00", 30000, fahrten, jetzt)
expect(p).not.toBeNull()
expect(p!.datum).toBe("2026-08-16")
expect(p!.durchZeitlimit).toBe(true)
})
it("fällt ohne Fahrtenlog auf die reine Zeitgrenze der Meldung zurück", () => {
const p = meldungsPrognose("2027-01-01T00:00:00+00:00", 5000, [], jetzt)
expect(p).not.toBeNull()
expect(p!.datum).toBe("2027-01-01")
expect(p!.durchZeitlimit).toBe(true)
})
it("liefert nichts ohne jede brauchbare Angabe", () => {
expect(meldungsPrognose(null, null, fahrten, jetzt)).toBeNull()
})
})
+76 -6
View File
@@ -126,6 +126,59 @@ export function inspektionPrognose(
return servicePrognose(fahrzeug, letzterEintrag(buch, /inspektion/i), 30000, 24, jetzt)
}
interface Fahrtdaten {
ts_start: string
distance_km: number | null
status?: string
}
/** Fahrleistung pro Tag aus dem tatsächlichen Fahrtenlog, unabhängig vom
Servicebuch - Grundlage für meldungsPrognose() unten, die auch ganz ohne
Servicebucheintrag funktionieren soll. Nur abgeschlossene Fahrten mit
bekannter Distanz zählen; die Rate ist der Durchschnitt seit der ältesten
erfassten Fahrt, kein kurzes Zeitfenster - damit bleibt sie robust gegen
einzelne fahrtenarme Tage. null ohne belastbare Datenlage. */
export function kmProTagAusFahrten(fahrten: readonly Fahrtdaten[], jetzt = new Date()): number | null {
const gueltig = fahrten.filter((f) => f.status !== "offen" && f.distance_km != null)
if (!gueltig.length) return null
const km = gueltig.reduce((s, f) => s + (f.distance_km ?? 0), 0)
const aeltesteZeit = Math.min(...gueltig.map((f) => new Date(f.ts_start).getTime()))
const tage = (jetzt.getTime() - aeltesteZeit) / TAG_MS
return tage >= 1 ? km / tage : null
}
export interface Meldungsprognose {
/** ISO-Tagesdatum. */
datum: string
/** Die vom Fahrzeug gemeldete Zeitgrenze hat gegriffen, nicht die
hochgerechnete Kilometergrenze - der Nutzer fährt zu wenig, um die
km-Grenze rechtzeitig zu erreichen. */
durchZeitlimit: boolean
}
/** Prognose rein aus der Fahrzeugmeldung, für den Fall ganz ohne
Servicebucheintrag (siehe Aufrufer unten und in Service.tsx) - ein echter
Servicebucheintrag bleibt vorrangig, näher am tatsächlichen letzten
Service. Restkilometer aus der Meldung, hochgerechnet mit der echten
Fahrleistung aus dem Fahrtenlog statt einer Servicebuch-Rate; gedeckelt
auf das vom Fahrzeug selbst gemeldete Datum als Zeitgrenze. */
export function meldungsPrognose(
faelligTs: string | null,
faelligKm: number | null,
fahrten: readonly Fahrtdaten[],
jetzt = new Date(),
): Meldungsprognose | null {
const zeitDatum = faelligTs ? faelligTs.slice(0, 10) : null
if (faelligKm == null) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true } : null
const restKm = Math.abs(faelligKm)
if (restKm <= 0) return { datum: jetzt.toISOString().slice(0, 10), durchZeitlimit: false }
const rate = kmProTagAusFahrten(fahrten, jetzt)
if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true } : null
const kmDatum = new Date(jetzt.getTime() + (restKm / rate) * TAG_MS).toISOString().slice(0, 10)
const durchZeitlimit = !!zeitDatum && zeitDatum < kmDatum
return { datum: durchZeitlimit ? (zeitDatum as string) : kmDatum, durchZeitlimit }
}
/* ------------------------------------------------------ Nächster Service */
export type Serviceart = "Ölwechsel" | "Inspektion" | "Hauptuntersuchung"
@@ -143,23 +196,40 @@ export interface NaechsterService {
* Der zeitlich nächste anstehende Service über alle drei Arten hinweg —
* Gegenstück zu `naechsterTermin()` im Panel.
*
* Ölwechsel und Inspektion kommen aus der eigenen Prognose (Servicebuch plus
* tatsächliche Fahrleistung), die Hauptuntersuchung aus dem im Profil
* gepflegten Termin: sie hat kein Kilometerziel, das Fahrzeug meldet sie nicht,
* und hochrechnen lässt sie sich auch nicht.
* Ölwechsel und Inspektion kommen vorrangig aus der eigenen Prognose
* (Servicebuch plus tatsächliche Fahrleistung); ohne Servicebucheintrag dazu
* fällt jede der beiden einzeln auf meldungsPrognose() zurück - die vom
* Fahrzeug selbst gemeldete Fälligkeit, hochgerechnet mit der Fahrleistung
* aus dem Fahrtenlog. Ein frisch eingerichtetes Fahrzeug mit zugeordnetem
* Sensor, aber noch ganz ohne Servicebuch, zeigte hier vorher "kein Eintrag
* im Servicebuch", obwohl die Fälligkeit längst bekannt war. Die
* Hauptuntersuchung kommt weiterhin nur aus dem im Profil gepflegten
* Termin: sie hat kein Kilometerziel, das Fahrzeug meldet sie nicht, und
* hochrechnen lässt sie sich auch nicht.
*/
export function naechsterService(
fahrzeug: Fahrzeug,
buch: readonly Buchhaltung[],
fahrten: readonly Fahrtdaten[] = [],
jetzt = new Date(),
): NaechsterService | null {
const kandidaten: NaechsterService[] = []
const oel = oelwechselPrognose(fahrzeug, buch, jetzt)
if (oel) kandidaten.push({ art: "Ölwechsel", restKm: oel.restKm, datum: oel.datum })
if (oel) {
kandidaten.push({ art: "Ölwechsel", restKm: oel.restKm, datum: oel.datum })
} else {
const m = meldungsPrognose(fahrzeug.oelwechselFaelligTs, fahrzeug.oelwechselFaelligKm, fahrten, jetzt)
if (m) kandidaten.push({ art: "Ölwechsel", restKm: fahrzeug.oelwechselFaelligKm, datum: m.datum })
}
const insp = inspektionPrognose(fahrzeug, buch, jetzt)
if (insp) kandidaten.push({ art: "Inspektion", restKm: insp.restKm, datum: insp.datum })
if (insp) {
kandidaten.push({ art: "Inspektion", restKm: insp.restKm, datum: insp.datum })
} else {
const m = meldungsPrognose(fahrzeug.inspektionFaelligTs, fahrzeug.inspektionFaelligKm, fahrten, jetzt)
if (m) kandidaten.push({ art: "Inspektion", restKm: fahrzeug.inspektionFaelligKm, datum: m.datum })
}
// fahrzeug.hu wird von Hand gepflegt und kann leer oder unlesbar sein.
const hu = new Date(fahrzeug.hu)
@@ -38,9 +38,11 @@ export function Einstellungen({
tankvorgaenge,
rohprofil,
otaBuendel,
integrationUpdate,
api,
profilSpeichern,
jetztAktualisieren,
neuLaden,
} = useDaten()
const [theme, themeSetzen] = useTheme()
const [entwurf, setzeEntwurf] = useState<EinstellungenWerte | null>(null)
@@ -49,6 +51,8 @@ export function Einstellungen({
const [importOffen, setzeImportOffen] = useState(false)
const [otaLaeuft, setzeOtaLaeuft] = useState(false)
const [otaFehler, setzeOtaFehler] = useState<string | null>(null)
const [updatePruefenLaeuft, setzeUpdatePruefenLaeuft] = useState(false)
const [updateInstallierenLaeuft, setzeUpdateInstallierenLaeuft] = useState(false)
const dateiwahl = useRef<HTMLInputElement | null>(null)
const otaUpdateVerfuegbar = otaMoeglich() && buendelPasst(otaBuendel, eigeneVersion())
@@ -72,6 +76,33 @@ export function Einstellungen({
}
}
// Selbst-Update der Integration (aktualisierung.py) - Ersatz für
// install.ps1, das Windows Smart App Control blockiert. Beide Aktionen
// laufen sofort (keine Warteschlange, siehe api/index.ts) und laden
// danach über neuLaden() den neuen Stand von
// sensor.audi_dashboard_app_version - der Fehlertext bei fehlendem Token
// kommt von dort mit, eine eigene "ist ein Token eingetragen?"-Prüfung
// gibt es bewusst nicht.
const updatePruefenAusloesen = async () => {
setzeUpdatePruefenLaeuft(true)
try {
await api.updatePruefen()
await neuLaden()
} finally {
setzeUpdatePruefenLaeuft(false)
}
}
const updateInstallierenAusloesen = async () => {
setzeUpdateInstallierenLaeuft(true)
try {
await api.updateInstallieren()
await neuLaden()
} finally {
setzeUpdateInstallierenLaeuft(false)
}
}
if (!einstellungen || !fahrzeug) return null
const werte = entwurf ?? einstellungen
@@ -410,6 +441,56 @@ export function Einstellungen({
</Tile>
)}
<Tile>
<span className="ads-eyebrow">Integration-Update</span>
{integrationUpdate?.installiert ? (
<>
<p className="dm-fussnote">
Version {integrationUpdate.version} installiert. Home Assistant neu starten, damit
die neue Fassung geladen wird Einstellungen System Neu starten.
</p>
<div className="dm-knopfreihe">
<ActionButton onClick={() => void updatePruefenAusloesen()} disabled={updatePruefenLaeuft}>
{updatePruefenLaeuft ? "Prüfe …" : "Erneut prüfen"}
</ActionButton>
</div>
</>
) : integrationUpdate?.verfuegbar ? (
<>
<Werteliste
kinder={<Wertzeile label="Verfügbare Version" wert={integrationUpdate.version ?? "?"} />}
/>
<p className="dm-fussnote">
Lädt die neueste Fassung von Gitea und ersetzt den Integrationsordner. Home Assistant
muss danach neu gestartet werden. Fahrzeugprofil, Fahrten und Tankvorgänge sind davon
nicht betroffen.
</p>
<div className="dm-knopfreihe">
<ActionButton
onClick={() => void updateInstallierenAusloesen()}
disabled={updateInstallierenLaeuft}
>
{updateInstallierenLaeuft ? "Installiere …" : "Update installieren"}
</ActionButton>
</div>
</>
) : (
<>
<p className="dm-fussnote">
{integrationUpdate
? `Integration ist aktuell (${integrationUpdate.geprueft_am ? datumZeit(integrationUpdate.geprueft_am) : ""}).`
: "Noch nicht geprüft."}
</p>
<div className="dm-knopfreihe">
<ActionButton onClick={() => void updatePruefenAusloesen()} disabled={updatePruefenLaeuft}>
{updatePruefenLaeuft ? "Prüfe …" : "Auf Update prüfen"}
</ActionButton>
</div>
</>
)}
{integrationUpdate?.fehler && <p className="dm-fehler">{integrationUpdate.fehler}</p>}
</Tile>
<Tile>
<span className="ads-eyebrow">Zugang</span>
<Werteliste
+26 -5
View File
@@ -13,7 +13,13 @@ import { useState } from "react"
import { ActionButton, Feld, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { inspektionPrognose, letzterInspektion, letzterOelwechsel, oelwechselPrognose } from "../daten/service"
import {
inspektionPrognose,
letzterInspektion,
letzterOelwechsel,
meldungsPrognose,
oelwechselPrognose,
} from "../daten/service"
import { datum, de, isoTag } from "../format"
import type { SeitenName } from "../navigation"
import { SymbolInspektion, SymbolOelwechsel } from "../symbole"
@@ -37,7 +43,7 @@ interface Autohaus {
}
export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { fahrzeug } = useDaten()
const { fahrzeug, fahrten } = useDaten()
if (!fahrzeug) return null
const buch = (fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]
@@ -62,6 +68,12 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
: prognose
? `${de(prognose.restKm)} km · ${datum(prognose.datum)}`
: null
// Ganz ohne Servicebucheintrag (prognose null) übernimmt die
// Fahrzeugmeldung selbst die Rolle der Prognosequelle, hochgerechnet mit
// der echten Fahrleistung aus dem Fahrtenlog statt einer Servicebuch-Rate.
const oelMeldungProg = oelHatMeldung && !prognose
? meldungsPrognose(fahrzeug.oelwechselFaelligTs, fahrzeug.oelwechselFaelligKm, fahrten)
: null
// Bei Herstellervorgabe rechnet die eigene Prognose ohnehin schon mit dem
// Herstellerintervall - die Restkilometerzahl daneben wäre nur eine zweite,
// verwirrende km-Angabe neben dem Fahrzeug-Wert oben. Nur beim eigenen
@@ -70,6 +82,8 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
? fahrzeug.oel.modus === "hersteller"
? `voraussichtlich am ${datum(prognose.datum)}`
: `${de(prognose.restKm)} km, voraussichtlich am ${datum(prognose.datum)}`
: oelMeldungProg
? `voraussichtlich am ${datum(oelMeldungProg.datum)}`
: null
// Der Ölwechsel folgt in "Einrichten" wahlweise der Herstellervorgabe oder
// einem kürzeren eigenen Intervall (fahrzeug.oel.modus) - die Inspektion hat
@@ -92,7 +106,14 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
: inspektionsprognose
? datum(inspektionsprognose.datum)
: null
const inspFussnote = inspektionsprognose ? `voraussichtlich am ${datum(inspektionsprognose.datum)}` : null
const inspMeldungProg = inspHatMeldung && !inspektionsprognose
? meldungsPrognose(fahrzeug.inspektionFaelligTs, fahrzeug.inspektionFaelligKm, fahrten)
: null
const inspFussnote = inspektionsprognose
? `voraussichtlich am ${datum(inspektionsprognose.datum)}`
: inspMeldungProg
? `voraussichtlich am ${datum(inspMeldungProg.datum)}`
: null
const huDatum = fahrzeug.hu ? new Date(fahrzeug.hu) : null
const huGueltig = !!huDatum && !Number.isNaN(huDatum.getTime())
@@ -107,7 +128,7 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
<SymbolOelwechsel groesse={26} />
<span>Ölwechsel</span>
</div>
{oelHatEintrag ? (
{oelHatEintrag || oelHatMeldung ? (
<>
{oelHerstellervorgabe && <div className="dm-tgvorgabe">Herstellervorgabe</div>}
<div className="dm-tgwert">{oelWert}</div>
@@ -123,7 +144,7 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
<SymbolInspektion groesse={20} />
<span>Inspektion</span>
</div>
{inspHatEintrag ? (
{inspHatEintrag || inspHatMeldung ? (
<>
<div className="dm-tgvorgabe">Herstellervorgabe</div>
<div className="dm-tgwert">{inspWert}</div>
+1 -1
View File
@@ -53,7 +53,7 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
.trim()
const buch = (fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]
const service = naechsterService(fahrzeug, buch)
const service = naechsterService(fahrzeug, buch, fahrten)
const letzteFahrt = fahrten
.slice()
+9 -7
View File
@@ -5,18 +5,20 @@ import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
// Die App-Version kommt aus der manifest.json der Integration — derselben
// Zahl, die HACS anzeigt, die Home Assistant als Version der Integration führt
// und die das Backend über sensor.audi_dashboard_app_version meldet (siehe
// Zahl, die Home Assistant als Version der Integration führt und die das
// Backend über sensor.audi_dashboard_app_version meldet (siehe
// VERSIONIERUNG.md). Sie wird hier fest einkompiliert, damit die fertige App
// weiß, aus welchem Stand sie gebaut wurde, und das gegen den vom Backend
// gemeldeten Stand halten kann.
//
// Bis 2026-08-23 stand die Zahl in einer eigenen Datei VERSION im
// Projektstamm. Die ist mit dem Umbau zur HACS-Integration überflüssig
// geworden: die manifest.json MUSS eine Version tragen, HACS verlangt sie und
// vergleicht danach. Zwei Quellen für dieselbe Angabe wären eine Quelle zu
// viel — sie hätten irgendwann auseinandergelegen, und der Vergleich, den
// diese Zahl trägt, wäre still falsch geworden.
// Projektstamm. Die ist mit dem Umbau zur Integration überflüssig geworden:
// Home Assistant selbst verlangt ein version-Feld in jeder manifest.json,
// unabhängig von HACS (das für dieses private Repository ohnehin dauerhaft
// ausscheidet, siehe AGENTS.md Abschnitt H). Zwei Quellen für dieselbe
// Angabe wären eine Quelle zu viel — sie hätten irgendwann
// auseinandergelegen, und der Vergleich, den diese Zahl trägt, wäre still
// falsch geworden.
//
// Absichtlich hart: fehlt die Datei oder das Feld, soll der Build abbrechen
// statt still eine App ohne Versionsangabe zu erzeugen — die könnte ihre
+4 -4
View File
@@ -104,10 +104,10 @@ async def _statik_anmelden(hass: HomeAssistant) -> None:
scheitern. Das ist unkritisch - der Pfad zeigt auf einen Ordner, dessen
Inhalt sich beim Neu-Laden nicht ändert.
cache_headers=False ist Absicht: die Dateien werden von HACS ersetzt, und
ein Browser, der eine alte Fassung 31 Tage lang aus dem Cache bedient
(die HA-Vorgabe für statische Pfade), wäre genau die stille Veraltung, die
das Versions-Verfahren verhindern soll."""
cache_headers=False ist Absicht: die Dateien werden von install.ps1
ersetzt, und ein Browser, der eine alte Fassung 31 Tage lang aus dem
Cache bedient (die HA-Vorgabe für statische Pfade), wäre genau die
stille Veraltung, die das Versions-Verfahren verhindern soll."""
if hass.data.get(DOMAIN, {}).get("statik"):
return
ordner = os.path.join(os.path.dirname(__file__), STATIK_ORDNER)
@@ -0,0 +1,244 @@
"""Selbst-Update der Integration direkt von Gitea.
Ersetzt install.ps1 als Update-Weg - nicht als Erstinstallations-Weg, die
Integration muss bereits laufen, um sich selbst aktualisieren zu können.
Grund: Windows Smart App Control blockiert die Ausführung von install.ps1
zuverlässig und lässt sich, anders als der SmartScreen davor, nicht per
"trotzdem ausführen" umgehen.
SICHERHEIT: dieses Modul läuft, anders als install.ps1, INNERHALB des
Prozesses, den es aktualisiert - der eigene Ordner wird ersetzt, während der
Dienstaufruf noch läuft. Deshalb strenger als install.ps1s
"leeren-dann-kopieren":
1. Laden und Prüfen passiert komplett in einem Geschwisterordner
(audi_dashboard_update_staging). Der Live-Ordner bleibt unangetastet,
bis feststeht, dass der Download vollständig und plausibel ist.
2. Erst danach ein atomarer Tausch per os.rename, nicht Löschen+Kopieren:
der alte Ordner wird zu audi_dashboard_backup umbenannt statt gelöscht -
reversibel, falls das Update Probleme macht.
3. Schlägt der zweite Rename fehl, wird der erste zurückgenommen, statt
einen halb ersetzten Ordner zurückzulassen.
4. Kein automatischer Reload oder Neustart. Ein Reload aus dem eigenen,
noch laufenden Dienstaufruf heraus anzustoßen wäre riskant - der
Handler könnte mitten in der Ausführung durch den eigenen Reload
unterbrochen werden. Die Oberfläche zeigt stattdessen "Home Assistant
neu starten" an, genau wie install.ps1 es über seine Restliste tut.
5. Nichts außerhalb des eigenen Ordners: configuration.yaml, .storage/,
www/ und der separate Fahrzeugdaten-Ordner werden nirgends geöffnet.
Das Umbenennen des eigenen, gerade ausgeführten Ordners ist auf Linux (Home
Assistant OS/Docker, worauf dieses Projekt läuft) unkritisch: Python hält
nach dem Import keine offenen Dateizugriffe auf die .py-Quelltexte mehr,
und der Verzeichnisname selbst ist nur eine Zeichenkette in os.rename() -
kein Handle, das durch die Umbenennung ungültig würde.
"""
from __future__ import annotations
import base64
import datetime
import json
import logging
import os
import shutil
import zipfile
from io import BytesIO
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
_LOGGER = logging.getLogger(__name__)
_REPO_BESITZER = "paul"
_REPO_NAME = "audi-app"
_ZWEIG = "main"
_GITEA_BASIS = "https://gitea.nothaft.cloud/api/v1"
_MANIFEST_PFAD_IM_REPO = "custom_components/audi_dashboard/manifest.json"
_INTEGRATIONS_PFAD_IM_REPO = "custom_components/audi_dashboard/"
# Absolute Pfade, einmal zur Modul-Ladezeit bestimmt - __file__ liegt selbst
# im Live-Ordner, dessen Elternordner (custom_components/) ist der Ort für
# Staging- und Backup-Geschwisterordner.
INTEGRATIONSORDNER = os.path.dirname(os.path.abspath(__file__))
_STAGING_ORDNER = os.path.join(
os.path.dirname(INTEGRATIONSORDNER), "audi_dashboard_update_staging"
)
_BACKUP_ORDNER = os.path.join(os.path.dirname(INTEGRATIONSORDNER), "audi_dashboard_backup")
class AktualisierungsFehler(Exception):
"""Jeder Fehlerfall beim Prüfen oder Installieren - Netzwerk, falscher
Token, kaputtes Zip, falsche Domain, fehlgeschlagener Tausch. Eine
eigene Klasse statt roher Exceptions, damit der Dienstaufruf sie gezielt
abfangen und der Oberfläche eine verständliche Meldung zeigen kann,
statt eines unbehandelten Fehlers."""
def _header(token: str) -> dict[str, str]:
return {"Authorization": f"token {token}"}
async def version_pruefen(hass: HomeAssistant, token: str, eigene_version: str) -> dict[str, Any]:
"""Fragt nur die manifest.json von Gitea ab (ein kleiner Request, kein
Repo-Download) und vergleicht die Version gegen die installierte.
Läuft ausschließlich auf Tastendruck aus der Oberfläche - niemals aus
dem Koordinator-Takt heraus, sonst würde jeder normale Veröffentlichungs-
Zyklus einen Netzwerkaufruf zu Gitea auslösen."""
if not token:
raise AktualisierungsFehler(
"Kein Gitea-Token hinterlegt - unter Einstellungen -> Geräte & Dienste -> "
"Audi Dashboard -> Konfigurieren eintragen."
)
url = f"{_GITEA_BASIS}/repos/{_REPO_BESITZER}/{_REPO_NAME}/contents/{_MANIFEST_PFAD_IM_REPO}"
session = async_get_clientsession(hass)
try:
async with session.get(url, headers=_header(token), params={"ref": _ZWEIG}) as antwort:
if antwort.status == 401:
raise AktualisierungsFehler("Token ungültig oder abgelaufen.")
if antwort.status == 404:
raise AktualisierungsFehler("Repository oder Pfad bei Gitea nicht gefunden.")
if antwort.status != 200:
raise AktualisierungsFehler(f"Gitea antwortete mit Status {antwort.status}.")
rumpf = await antwort.json()
except AktualisierungsFehler:
raise
except Exception as fehler: # aiohttp-/Verbindungsfehler aller Art
raise AktualisierungsFehler(f"Gitea nicht erreichbar: {fehler}") from fehler
try:
roh = base64.b64decode(rumpf["content"])
remote_version = json.loads(roh)["version"]
except (KeyError, ValueError, TypeError) as fehler:
raise AktualisierungsFehler(f"Antwort von Gitea nicht lesbar: {fehler}") from fehler
return {
"verfuegbar": remote_version != eigene_version,
"version": remote_version,
"geprueft_am": datetime.datetime.now(datetime.timezone.utc).isoformat(),
}
async def update_installieren(hass: HomeAssistant, token: str) -> dict[str, Any]:
"""Lädt das komplette Repo-Archiv und delegiert Entpacken/Prüfen/Tauschen
an eine blockierende Funktion im Executor - Datei- und Zip-Operationen
gehören nicht in den Event-Loop."""
if not token:
raise AktualisierungsFehler(
"Kein Gitea-Token hinterlegt - unter Einstellungen -> Geräte & Dienste -> "
"Audi Dashboard -> Konfigurieren eintragen."
)
url = f"{_GITEA_BASIS}/repos/{_REPO_BESITZER}/{_REPO_NAME}/archive/{_ZWEIG}.zip"
session = async_get_clientsession(hass)
try:
async with session.get(url, headers=_header(token)) as antwort:
if antwort.status == 401:
raise AktualisierungsFehler("Token ungültig oder abgelaufen.")
if antwort.status != 200:
raise AktualisierungsFehler(f"Gitea antwortete mit Status {antwort.status}.")
zip_bytes = await antwort.read()
except AktualisierungsFehler:
raise
except Exception as fehler:
raise AktualisierungsFehler(f"Gitea nicht erreichbar: {fehler}") from fehler
neue_version = await hass.async_add_executor_job(
entpacken_pruefen_tauschen, zip_bytes, INTEGRATIONSORDNER, _STAGING_ORDNER, _BACKUP_ORDNER
)
return {"version": neue_version}
def entpacken_pruefen_tauschen(
zip_bytes: bytes, integrationsordner: str, staging_ordner: str, backup_ordner: str
) -> str:
"""Blockierend: entpackt nur custom_components/audi_dashboard/ aus dem
Archiv in staging_ordner, prüft das dortige manifest.json, und tauscht
per os.rename mit integrationsordner. Wirft AktualisierungsFehler bei
jedem Problem - und zwar VOR jedem Tausch, siehe Moduldocstring.
Bewusst ohne hass-Abhängigkeit und mit allen Pfaden als Parameter statt
fester Modul-Konstanten, damit sie sich isoliert mit einem selbstgebauten
Zip gegen einen Temp-Ordner testen lässt, ohne den echten
Integrationsordner anzufassen."""
if os.path.isdir(staging_ordner):
shutil.rmtree(staging_ordner)
try:
with zipfile.ZipFile(BytesIO(zip_bytes)) as archiv:
namen = archiv.namelist()
if not namen:
raise AktualisierungsFehler("Leeres Archiv erhalten.")
# Gitea packt alles unter einem Wurzelordner wie "audi-app-main/"
# - das wird hier gesucht statt geraten.
treffer = [
n for n in namen if _INTEGRATIONS_PFAD_IM_REPO in n and not n.endswith("/")
]
if not treffer:
raise AktualisierungsFehler(
f"{_INTEGRATIONS_PFAD_IM_REPO} nicht im heruntergeladenen Archiv gefunden."
)
wurzel_marker = treffer[0].split(_INTEGRATIONS_PFAD_IM_REPO, 1)[0] \
+ _INTEGRATIONS_PFAD_IM_REPO
os.makedirs(staging_ordner, exist_ok=True)
for name in treffer:
if wurzel_marker not in name:
continue
rel = name.split(wurzel_marker, 1)[1]
if not rel:
continue
ziel = os.path.join(staging_ordner, rel.replace("/", os.sep))
os.makedirs(os.path.dirname(ziel), exist_ok=True)
with archiv.open(name) as quelle, open(ziel, "wb") as senke:
shutil.copyfileobj(quelle, senke)
except zipfile.BadZipFile as fehler:
shutil.rmtree(staging_ordner, ignore_errors=True)
raise AktualisierungsFehler(f"Kein gültiges Zip-Archiv: {fehler}") from fehler
manifest_pfad = os.path.join(staging_ordner, "manifest.json")
if not os.path.exists(manifest_pfad):
shutil.rmtree(staging_ordner, ignore_errors=True)
raise AktualisierungsFehler("Entpackter Ordner enthält keine manifest.json.")
with open(manifest_pfad, encoding="utf-8") as datei:
manifest = json.load(datei)
if manifest.get("domain") != "audi_dashboard":
shutil.rmtree(staging_ordner, ignore_errors=True)
raise AktualisierungsFehler(
f"Heruntergeladenes Manifest gehört zu Domain '{manifest.get('domain')}', "
"nicht 'audi_dashboard' - Abbruch, nichts wurde angefasst."
)
neue_version = manifest.get("version")
if not neue_version:
shutil.rmtree(staging_ordner, ignore_errors=True)
raise AktualisierungsFehler("Heruntergeladenes Manifest hat kein version-Feld.")
# __pycache__ aus dem Staging-Ordner entfernen - dieselbe Sorgfalt wie
# install.ps1s Aufräumschritt nach dem Kopieren.
for wurzel, ordner, _dateien in os.walk(staging_ordner):
if "__pycache__" in ordner:
shutil.rmtree(os.path.join(wurzel, "__pycache__"), ignore_errors=True)
if os.path.isdir(backup_ordner):
shutil.rmtree(backup_ordner)
try:
os.rename(integrationsordner, backup_ordner)
except OSError as fehler:
shutil.rmtree(staging_ordner, ignore_errors=True)
raise AktualisierungsFehler(
f"Konnte den bestehenden Ordner nicht sichern: {fehler}"
) from fehler
try:
os.rename(staging_ordner, integrationsordner)
except OSError as fehler:
# Rollback: die alte Fassung zurückbenennen, statt HA ohne
# Integrationsordner dastehen zu lassen.
os.rename(backup_ordner, integrationsordner)
raise AktualisierungsFehler(
f"Tausch fehlgeschlagen, alte Fassung wiederhergestellt: {fehler}"
) from fehler
return neue_version
+4 -3
View File
@@ -14,9 +14,10 @@ bewusst nicht: jede Fotogröße muss hochladbar sein.
WARUM DIESE DATEIEN NICHT IM INTEGRATIONSORDNER LIEGEN: sie sind Nutzerdaten,
kein Auslieferbestandteil. Lägen sie unter custom_components/audi_dashboard/,
würde das nächste HACS-Update sie mitlöschen. /config/www/bilder/ gehört
dagegen dem Nutzer und wird von keinem Update angefasst; ausgeliefert wird es
weiterhin unter /local/bilder/.
würde der nächste install.ps1-Lauf sie mitlöschen (er leert den Zielordner
vor dem Kopieren, siehe install.ps1). /config/www/bilder/ gehört dagegen dem
Nutzer und wird von keinem Update angefasst; ausgeliefert wird es weiterhin
unter /local/bilder/.
"""
from __future__ import annotations
@@ -1,25 +1,34 @@
"""Einrichtung über die Oberfläche (Einstellungen -> Geräte & Dienste ->
Integration hinzufügen -> Audi Dashboard).
Bewusst ohne Eingabefelder. Es gäbe genau eine Sache zu fragen - welche
Entität welche Rolle im Fahrzeug spielt -, und die gehört nicht hierher: die
Zuordnung wird im Setup-Menü der App selbst vorgenommen, wo neben jedem Feld
steht, wofür es gebraucht wird und was passiert, wenn es leer bleibt. Sie
hier abzufragen hieße, dieselbe Auswahl an zwei Stellen zu pflegen - und die
schlechtere von beiden zuerst zu zeigen.
Bewusst ohne Eingabefelder im Ersteinrichtungs-Schritt. Es gäbe genau eine
Sache zu fragen - welche Entität welche Rolle im Fahrzeug spielt -, und die
gehört nicht hierher: die Zuordnung wird im Setup-Menü der App selbst
vorgenommen, wo neben jedem Feld steht, wofür es gebraucht wird und was
passiert, wenn es leer bleibt. Sie hier abzufragen hieße, dieselbe Auswahl an
zwei Stellen zu pflegen - und die schlechtere von beiden zuerst zu zeigen.
Eine einzige Instanz, erzwungen über single_config_entry in manifest.json:
die App gehört zu genau einem Fahrzeug, und alle Bestände liegen unter einem
festen Pfad.
Der OptionsFlow unten ist die einzige Ausnahme: ein Gitea-Zugriffstoken für
das Selbst-Update (siehe aktualisierung.py) - ein echtes Geheimnis, das
deshalb in HAs eigenem, verschlüsseltem Config-Entry-Speicher landet
(entry.options), nicht in configuration.yaml.
"""
from __future__ import annotations
from typing import Any
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
import voluptuous as vol
from .const import DOMAIN
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.core import callback
from homeassistant.helpers import selector
from .const import CONF_GITEA_TOKEN, DOMAIN
class AudiDashboardConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -35,3 +44,35 @@ class AudiDashboardConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is None:
return self.async_show_form(step_id="user")
return self.async_create_entry(title="Audi Dashboard", data={})
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
return AudiDashboardOptionsFlow()
class AudiDashboardOptionsFlow(OptionsFlow):
"""Einstellungen -> Geräte & Dienste -> Audi Dashboard -> Konfigurieren.
Ein Feld: der Gitea-Token fürs Selbst-Update. Leer lassen deaktiviert
die Update-Prüfung wieder (aktualisierung.py verlangt einen nicht-leeren
Token, sonst gibt es eine verständliche Fehlermeldung statt eines
unbehandelten Fehlers)."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
vol.Schema({
vol.Optional(CONF_GITEA_TOKEN, default=""): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
),
}),
self.config_entry.options,
),
)
+14 -6
View File
@@ -32,22 +32,22 @@ PANEL_KOMPONENTE = "audi-dashboard-panel"
# Statischer Pfad, unter dem die Oberflächen-Dateien aus frontend/
# ausgeliefert werden. Ersetzt /local/ (also /config/www/): die Dateien
# gehören jetzt zum Installationspaket der Integration und werden von HACS
# mitgeliefert, statt separat nach www/ kopiert zu werden.
# gehören jetzt zum Installationspaket der Integration und werden von
# install.ps1 mitgeliefert, statt separat nach www/ kopiert zu werden.
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).
# Liegt bewusst mit im Integrationsordner - dann liefert install.ps1 es bei
# jeder Auslieferung mit aus, und Bündel und Integration können gar nicht
# auseinanderlaufen (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
# Lägen sie im Integrationsordner, würde der nächste install.ps1-Lauf sie
# mitlöschen - genau das darf nicht passieren.
BILDER_ORDNER = "www/bilder"
@@ -89,3 +89,11 @@ D_REIFEN_KM_SETZEN = "reifen_km_setzen"
D_BACKUP_JETZT = "backup_jetzt"
D_BACKUP_WIEDERHERSTELLEN = "backup_wiederherstellen"
D_HISTORIE_IMPORTIEREN = "historie_importieren"
D_UPDATE_PRUEFEN = "update_pruefen"
D_UPDATE_INSTALLIEREN = "update_installieren"
# Schlüssel für den Gitea-Zugriffstoken in entry.options (siehe
# config_flow.py's AudiDashboardOptionsFlow und aktualisierung.py). Hier statt
# in config_flow.py definiert, damit aktualisierung.py/dienste.py ihn ohne
# Umweg über den Options-Flow importieren können.
CONF_GITEA_TOKEN = "gitea_token"
+74 -1
View File
@@ -15,7 +15,9 @@ nicht die Schema-Prüfung.
from __future__ import annotations
import datetime
import json
import logging
from collections.abc import Callable, Coroutine
from typing import Any
@@ -24,9 +26,19 @@ import voluptuous as vol
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from . import belege, bilder, fahrterkennung, historienimport, reifen, screening, sicherung
from . import (
aktualisierung,
belege,
bilder,
fahrterkennung,
historienimport,
reifen,
screening,
sicherung,
)
from .const import (
BILDER_ORDNER,
CONF_GITEA_TOKEN,
D_BACKUP_JETZT,
D_BACKUP_WIEDERHERSTELLEN,
D_BELEG_HOCHLADEN,
@@ -45,10 +57,14 @@ from .const import (
D_TANKVORGANG_AKTUALISIEREN,
D_TANKVORGANG_LOESCHEN,
D_TANKVORGANG_MANUELL,
D_UPDATE_INSTALLIEREN,
D_UPDATE_PRUEFEN,
DOMAIN,
)
from .koordinator import Koordinator
_LOGGER = logging.getLogger(__name__)
# Ein JSON-Feld darf als Zeichenkette oder schon ausgepackt ankommen: das
# Panel schickt JSON.stringify(...), ein Aufruf aus einer Automation heraus
# reicht dagegen naheliegenderweise die Struktur selbst durch.
@@ -126,6 +142,8 @@ SCHEMATA: dict[str, vol.Schema] = {
vol.Required("start"): cv.string,
vol.Required("ende"): cv.string,
}),
D_UPDATE_PRUEFEN: vol.Schema({}),
D_UPDATE_INSTALLIEREN: vol.Schema({}),
}
Fabrik = Callable[[ServiceCall], Coroutine[Any, Any, None]]
@@ -202,6 +220,59 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None:
"audi_dashboard_historienimport",
)
async def update_pruefen(_aufruf: ServiceCall) -> None:
"""Fragt bei Gitea nach, ob eine neuere Fassung vorliegt - ohne
etwas herunterzuladen. Läuft nur auf Tastendruck, siehe
aktualisierung.py's Moduldocstring."""
token = k.entry.options.get(CONF_GITEA_TOKEN, "")
jetzt = datetime.datetime.now(datetime.timezone.utc).isoformat()
try:
ergebnis = await aktualisierung.version_pruefen(hass, token, k.version)
k.letzte_update_pruefung = {
"verfuegbar": ergebnis["verfuegbar"],
"version": ergebnis["version"],
"geprueft_am": ergebnis["geprueft_am"],
"installiert": False,
"fehler": None,
}
except aktualisierung.AktualisierungsFehler as fehler:
_LOGGER.warning("Update-Prüfung fehlgeschlagen: %s", fehler)
k.letzte_update_pruefung = {
"verfuegbar": False,
"version": None,
"geprueft_am": jetzt,
"installiert": False,
"fehler": str(fehler),
}
await k.app_version_veroeffentlichen()
async def update_installieren(_aufruf: ServiceCall) -> None:
"""Lädt die neueste Fassung von Gitea und ersetzt den
Integrationsordner. Home Assistant muss danach von Hand neu
gestartet werden - kein automatischer Reload aus dem eigenen,
noch laufenden Aufruf heraus, siehe aktualisierung.py."""
token = k.entry.options.get(CONF_GITEA_TOKEN, "")
jetzt = datetime.datetime.now(datetime.timezone.utc).isoformat()
try:
ergebnis = await aktualisierung.update_installieren(hass, token)
k.letzte_update_pruefung = {
"verfuegbar": False,
"version": ergebnis["version"],
"geprueft_am": jetzt,
"installiert": True,
"fehler": None,
}
except aktualisierung.AktualisierungsFehler as fehler:
_LOGGER.warning("Update-Installation fehlgeschlagen: %s", fehler)
k.letzte_update_pruefung = {
"verfuegbar": (k.letzte_update_pruefung or {}).get("verfuegbar", False),
"version": (k.letzte_update_pruefung or {}).get("version"),
"geprueft_am": jetzt,
"installiert": False,
"fehler": str(fehler),
}
await k.app_version_veroeffentlichen()
bilder_ordner = hass.config.path(BILDER_ORDNER)
behandler: dict[str, Callable[[ServiceCall], Coroutine[Any, Any, None]]] = {
@@ -245,6 +316,8 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None:
D_BACKUP_WIEDERHERSTELLEN: _als_dienst(
lambda a: sicherung.wiederherstellen(k, dict(a.data))
),
D_UPDATE_PRUEFEN: update_pruefen,
D_UPDATE_INSTALLIEREN: update_installieren,
}
for name, funktion in behandler.items():
@@ -1 +1 @@
{"version":"2026.8.24.1","sha256":"967ab36fc866187cf5fdc03d95a3ec21cb6e42003d8c012f8f67b99c67373324","bytes":230770,"gebaut":"2026-08-24T07:16:44Z"}
{"version":"2026.8.24.4","sha256":"cbce1baa785a3577fc65168b9f8b2094ecd844aadc2ac36f99d6be8ccb4cb8c1","bytes":231567,"gebaut":"2026-08-24T09:39:59Z"}
@@ -1262,6 +1262,42 @@ function servicePrognose(stichwort, intervallKm, intervallMonate) {
return { km: zielKm, restKm, datum };
}
/* Fahrleistung pro Tag aus dem tatsächlichen Fahrtenlog (TRIPS), unabhängig
vom Servicebuch - Grundlage für meldungsPrognose() unten, die auch ganz
ohne Servicebucheintrag funktionieren soll. Nur abgeschlossene Fahrten mit
bekannter Distanz zählen; die Rate ist der Durchschnitt seit der ältesten
erfassten Fahrt, kein kurzes Zeitfenster - damit bleibt sie robust gegen
einzelne fahrtenarme Tage. null ohne belastbare Datenlage. */
function kmProTagAusFahrten() {
const gueltig = TRIPS.filter((t) => t.status !== "offen" && t.distance_km != null);
if (!gueltig.length) return null;
const km = gueltig.reduce((s, t) => s + t.distance_km, 0);
const aeltesteZeit = Math.min(...gueltig.map((t) => new Date(t.ts_start).getTime()));
const tage = (heute().getTime() - aeltesteZeit) / dTag;
return tage >= 1 ? km / tage : null;
}
/* Prognose rein aus der Fahrzeugmeldung, für den Fall ganz ohne
Servicebucheintrag (siehe Aufrufer in vService()) - ein echter
Servicebucheintrag bleibt vorrangig, näher am tatsächlichen letzten
Service. Restkilometer aus der Meldung, hochgerechnet mit der echten
Fahrleistung aus dem Fahrtenlog statt einer Servicebuch-Rate; gedeckelt
auf das vom Fahrzeug selbst gemeldete Datum als Zeitgrenze - fährt der
Nutzer zu wenig, um die km-Grenze rechtzeitig zu erreichen, ist die
gemeldete Zeitgrenze maßgeblich, nicht die hochgerechnete km-Grenze. */
function meldungsPrognose(art) {
const fm = fahrzeugMeldungRoh(art);
const zeitDatum = fm.ts && gueltigesDatum(new Date(fm.ts)) ? new Date(fm.ts) : null;
if (fm.km == null) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true } : null;
const restKm = Math.abs(fm.km);
if (restKm <= 0) return { datum: heute(), durchZeitlimit: false };
const rate = kmProTagAusFahrten();
if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true } : null;
const kmDatum = new Date(heute().getTime() + (restKm / rate) * dTag);
const durchZeitlimit = !!(zeitDatum && zeitDatum < kmDatum);
return { datum: durchZeitlimit ? zeitDatum : kmDatum, durchZeitlimit };
}
function oelwechselPrognose() {
const oel = CAR.oel;
return servicePrognose("öl",
@@ -1776,21 +1812,35 @@ function vService() {
const artIcon = ART_ICON[t.art];
const iconMarkup = artIcon ? ciSVG(artIcon, ART_ICON_GROESSE[t.art]) : "";
const kopf = `<div class="tg-name">${iconMarkup}<span>${t.art}</span></div>`;
// Fahrzeugmeldung zuerst prüfen: sie steht für sich, auch ganz ohne
// Servicebucheintrag - siehe fahrzeugMeldungRoh()'s Kommentar. Ein
// frisch eingerichtetes Fahrzeug mit zugeordnetem Sensor und
// "Herstellervorgabe", aber ohne einen einzigen Servicebucheintrag,
// zeigte vorher trotz korrekt gemeldeter Fälligkeit nur "kein Eintrag
// im Servicebuch" - iv war null und der frühe Return griff, bevor fm
// überhaupt gelesen wurde.
const fm = fahrzeugMeldungRoh(t.art);
const fmDatum = fm.ts ? dedat(new Date(fm.ts)) : null;
const hatMeldung = fm.km != null || fmDatum;
const iv = intervalle(t);
if (!iv) return `<div class="termingruppe">
if (!iv && !hatMeldung) return `<div class="termingruppe">
<div class="tg-kopf">${kopf}</div>
<div class="tg-wert leer">kein Eintrag im Servicebuch</div>
</div>`;
const anzeige = iv.hersteller || iv.eigen;
const fm = fahrzeugMeldungRoh(t.art);
const fmDatum = fm.ts ? dedat(new Date(fm.ts)) : null;
const anzeige = iv ? (iv.hersteller || iv.eigen) : null;
// Forecast-Zeile aus dem kundeneigenen Intervall in "Einrichten" mit
// eigenständiger Reset-nach-Service-Logik (servicePrognose()): beim
// Ölwechsel mit Restkilometern, bei der Inspektion auf Wunsch nur mit
// dem Datum. Hauptuntersuchung bleibt bewusst ohne.
// dem Datum. Hauptuntersuchung bleibt bewusst ohne. Beide liefern null
// ohne Servicebucheintrag (servicePrognose() braucht letzterEintrag) -
// dann bleibt nur die Fahrzeugmeldung als Wert übrig, siehe wert unten.
const eigenOel = t.art === "Ölwechsel" ? oelwechselPrognose() : null;
const eigenInsp = t.art === "Inspektion" ? inspektionPrognose() : null;
const hatMeldung = fm.km != null || fmDatum;
// Ganz ohne Servicebucheintrag (eigenOel/eigenInsp beide null) übernimmt
// die Fahrzeugmeldung selbst die Rolle der Prognosequelle, hochgerechnet
// mit der echten Fahrleistung aus dem Fahrtenlog statt einer
// Servicebuch-Rate - siehe meldungsPrognose().
const meldungProg = hatMeldung && !eigenOel && !eigenInsp ? meldungsPrognose(t.art) : null;
// Bei Herstellervorgabe rechnet die eigene Prognose ohnehin schon mit dem
// Herstellerintervall - die Restkilometerzahl daneben waere nur eine
// zweite, verwirrende km-Angabe neben dem Fahrzeug-Wert oben. Nur beim
@@ -1799,7 +1849,8 @@ function vService() {
? (CAR.oel.modus === "hersteller"
? `voraussichtlich am ${dedat(eigenOel.datum)}`
: `${de(eigenOel.restKm)} km, voraussichtlich am ${dedat(eigenOel.datum)}`)
: eigenInsp ? `voraussichtlich am ${dedat(eigenInsp.datum)}` : "";
: eigenInsp ? `voraussichtlich am ${dedat(eigenInsp.datum)}`
: meldungProg ? `voraussichtlich am ${dedat(meldungProg.datum)}` : "";
const wert = hatMeldung
? `${fm.km != null ? de(fm.km) + " km" : ""}${fm.km != null && fmDatum ? " / " : ""}${fmDatum || ""}`
: (eigenOel ? de(eigenOel.restKm) + " km · " + dedat(eigenOel.datum) : (anzeige.km !== null ? de(anzeige.km) + " km · " : "") + dedat(anzeige.datum));
@@ -1808,7 +1859,7 @@ function vService() {
// Intervall gar nicht. Deshalb zaehlt sie unabhaengig von iv.hersteller
// als Herstellervorgabe; nur der App-eigene Eigen-Intervall-Fallback
// (anzeige.eigen/eigenOel bei modus "eigen") ist es nicht.
const herstellervorgabe = hatMeldung || iv.hersteller;
const herstellervorgabe = hatMeldung || (iv && iv.hersteller);
return `<div class="termingruppe">
<div class="tg-kopf">
${kopf}
@@ -2331,11 +2382,37 @@ function vEinst() {
<div class="row"><dt>Fahrzeugdaten</dt><dd>FMM003<small>Zündung · Batterie</small></dd></div>
<div class="row" style="border-bottom:0"><dt>Position</dt><dd>FMM003<small>GPS-Tracker</small></dd></div>
</dl>
<span class="label" style="margin-top:14px">Updates laufen über HACS</span>
</div>
<div class="tile"><span class="label">Integration-Update</span>
${(() => {
const u = INTEGRATION_UPDATE;
// Ersetzt install.ps1 als laufenden Update-Weg (Windows Smart App
// Control blockiert dessen Ausführung zuverlässig) - install.ps1
// bleibt nur noch für die Erstinstallation nötig, bevor die
// Integration überhaupt läuft und sich selbst aktualisieren kann.
if (u && u.installiert) {
return `<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13.5px;line-height:1.6">
Version ${esc(u.version)} installiert. Home Assistant neu starten, damit die neue
Fassung geladen wird - Einstellungen -> System -> Neu starten.</span>
<button class="aktion" style="margin-top:14px" data-update="pruefen">Erneut prüfen</button>`;
}
if (u && u.verfuegbar) {
return `<dl class="rows"><div class="row" style="border-bottom:0"><dt>Verfügbare Version</dt><dd>${esc(u.version)}</dd></div></dl>
<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13.5px;line-height:1.6">
HACS meldet selbst, wenn eine neuere Fassung bereitliegt, und installiert sie.
Fahrzeugprofil, Fahrten, Tankvorgänge und eigene Fotos bleiben dabei unangetastet
die liegen außerhalb des Integrationsordners.</span>
Lädt die neueste Fassung von Gitea und ersetzt den Integrationsordner. Home Assistant
muss danach neu gestartet werden. Fahrzeugprofil, Fahrten, Tankvorgänge und eigene
Fotos bleiben dabei unangetastet - die liegen außerhalb des Integrationsordners.</span>
<button class="aktion" style="margin-top:14px" data-update="installieren">Update installieren</button>`;
}
return `<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13.5px;line-height:1.6">
${u ? `Integration ist aktuell${u.geprueft_am ? ` (geprüft ${dedat(new Date(u.geprueft_am))})` : ""}.` : "Noch nicht geprüft."}
Ein Gitea-Zugriffstoken muss zuvor unter Einstellungen -> Geräte & Dienste -> Audi
Dashboard -> Konfigurieren eingetragen sein.</span>
<button class="aktion" style="margin-top:14px" data-update="pruefen">Auf Update prüfen</button>`;
})()}
${INTEGRATION_UPDATE && INTEGRATION_UPDATE.fehler
? `<span class="label" style="margin-top:10px;color:var(--red)">${esc(INTEGRATION_UPDATE.fehler)}</span>`
: ""}
</div>`;
}
@@ -3022,18 +3099,25 @@ let entwurfIndex = null;
const SERVICE_ARTEN = ["Ölwechsel", "Inspektion", "Hauptuntersuchung", "Weitere"];
/* Welche Fassung der Integration gerade installiert ist (Einstellungen ->
Version). Kommt aus sensor.audi_dashboard_app_version, also aus der
manifest.json der Integration - der Zahl, die auch HACS anzeigt.
Version). Kommt aus sensor.audi_dashboard_app_version, also direkt aus der
manifest.json der Integration.
Hier stand bis 2026-08-23 eine eigene Update-Funktion: "Update suchen"
klonte ein Git-Repository in einen Zwischenordner und kopierte pyscript/
und www/ von dort über den laufenden Code. Das war nötig, solange die App
keine Installationsform hatte, die Home Assistant selbst kennt. Als
HACS-Integration gibt es die: HACS prüft, lädt, installiert und kann
zurückrollen, mit Versionsanzeige und Änderungsprotokoll. Eine zweite,
selbstgebaute Update-Mechanik daneben wäre mehr Angriffsfläche als
Nutzen - der eigene Updater ist deshalb ersatzlos entfallen. */
keine Installationsform hatte, die Home Assistant selbst kennt - jetzt
gibt es eine: install.ps1 kopiert genau einen Ordner, das Panel darin
inklusive, und ist damit der einzige Installations- wie Update-Weg (HACS
scheidet für dieses private Repository dauerhaft aus, siehe AGENTS.md
Abschnitt H). Eine zweite, selbstgebaute Update-Mechanik daneben wäre
mehr Angriffsfläche als Nutzen - der eigene Updater ist deshalb
ersatzlos entfallen. */
let APP_VERSION = null;
// Ergebnis der letzten "Auf Update prüfen"/"Update installieren"
// (custom_components/audi_dashboard/aktualisierung.py) - null, solange nie
// geprüft wurde. Ersetzt install.ps1 als Update-Weg, das Windows Smart App
// Control zuverlässig blockiert; siehe vVersion()/Einstellungen-Ansicht.
let INTEGRATION_UPDATE = null;
/* Auswahl für "Modell" unter Einrichten - setzt nur CONFIG.fahrzeugtitel
(Zeile "Modell" in der Identität, Kopfzeilen, Kalender-Einträge, und den
@@ -4392,6 +4476,9 @@ function ereignisseVerdrahten() {
a.download = `backup_${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(a); a.click(); a.remove();
return;
}
const upd = e.target.closest("[data-update]");
if (upd && upd.dataset.update === "pruefen") { serviceRufen("update_pruefen", {}); return; }
if (upd && upd.dataset.update === "installieren") { serviceRufen("update_installieren", {}); return; }
const cs = e.target.closest("[data-csv]");
if (cs) {
const w = cs.dataset.csv;
@@ -4755,6 +4842,11 @@ async function datenLaden(erzwingen) {
const entitaetenState = HASS.states[E.entitaeten] || NACHGELADEN.entitaeten;
const versionState = HASS.states[E.appVersion];
if (versionState && versionState.state) APP_VERSION = versionState.state;
if (versionState && versionState.attributes) {
INTEGRATION_UPDATE = versionState.attributes.daten
? versionState.attributes.daten.integration_update ?? null
: null;
}
if (entitaetenState && entitaetenState.attributes) ENTITAETEN = entitaetenState.attributes.daten;
if (belegState && belegState !== letzterBelegState) {
@@ -99,6 +99,13 @@ class Koordinator:
self._store = Store[dict](hass, STORE_VERSION, f"{entry.entry_id}_laufzeit")
self.fahrt_start_ts: datetime.datetime | None = None
self.tiefststand_pct: float | None = None
# Ergebnis des letzten "Auf Update prüfen"/"Update installieren" -
# nur auf Tastendruck gesetzt (siehe dienste.py), nie im normalen
# Veröffentlichungs-Takt neu berechnet, damit keine automatischen
# Gitea-Abfragen im Hintergrund laufen. app_version_veroeffentlichen()
# liest diesen Wert bei jedem Takt erneut mit, damit er nicht
# zwischen zwei Prüfungen verschwindet.
self.letzte_update_pruefung: dict | None = None
self._takte: list[CALLBACK_TYPE] = []
self._beobachter: list[CALLBACK_TYPE] = []
@@ -329,13 +336,25 @@ class Koordinator:
return None
return {**info, "url": BUENDEL_URL}
async def alles_veroeffentlichen(self) -> None:
async def app_version_veroeffentlichen(self) -> None:
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})
# Die App liest alle drei aus derselben Entität: welchen Stand diese
# Installation ausliefert, ob ein nachladbares OTA-Bündel danebenliegt,
# und ob eine neuere Fassung bei Gitea bereitliegt. Zusammen an einer
# Stelle, damit die App nicht mehrere Quellen abgleichen muss, die
# auseinanderlaufen können.
self.setzen(
E_APP_VERSION,
self.version,
{
"app": self.version,
"buendel": buendel,
"integration_update": self.letzte_update_pruefung,
},
)
async def alles_veroeffentlichen(self) -> None:
await self.app_version_veroeffentlichen()
await self.profil_veroeffentlichen()
await self.fahrten_veroeffentlichen()
await self.tankvorgaenge_veroeffentlichen()
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.8.24.1",
"version": "2026.8.24.4",
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
"codeowners": ["@paul"],
@@ -10,6 +10,8 @@
jetzt_aktualisieren:
screening_jetzt:
backup_jetzt:
update_pruefen:
update_installieren:
profil_schreiben:
fields:
@@ -10,6 +10,17 @@
"single_instance_allowed": "Audi Dashboard ist bereits eingerichtet."
}
},
"options": {
"step": {
"init": {
"title": "Selbst-Update",
"description": "Zugriffstoken für gitea.nothaft.cloud, damit die Integration ihre eigene neueste Fassung laden und sich selbst ersetzen kann - ersetzt install.ps1, das Windows Smart App Control zuverlässig blockiert. Ein Token mit reinem Lesezugriff auf das Repository genügt. Leer lassen deaktiviert die Update-Prüfung.",
"data": {
"gitea_token": "Gitea-Zugriffstoken"
}
}
}
},
"services": {
"jetzt_aktualisieren": {
"name": "Jetzt aktualisieren",
@@ -169,6 +180,14 @@
"start": { "name": "Von", "description": "Beginn des Zeitraums (ISO, Ortszeit erlaubt)." },
"ende": { "name": "Bis", "description": "Ende des Zeitraums (ISO, Ortszeit erlaubt)." }
}
},
"update_pruefen": {
"name": "Auf Update prüfen",
"description": "Fragt bei Gitea die neueste manifest.json ab und vergleicht die Version - lädt dabei noch nichts herunter."
},
"update_installieren": {
"name": "Update installieren",
"description": "Lädt die neueste Fassung von Gitea und ersetzt den Integrationsordner. Home Assistant muss danach neu gestartet werden."
}
}
}
@@ -10,6 +10,17 @@
"single_instance_allowed": "Audi Dashboard ist bereits eingerichtet."
}
},
"options": {
"step": {
"init": {
"title": "Selbst-Update",
"description": "Zugriffstoken für gitea.nothaft.cloud, damit die Integration ihre eigene neueste Fassung laden und sich selbst ersetzen kann - ersetzt install.ps1, das Windows Smart App Control zuverlässig blockiert. Ein Token mit reinem Lesezugriff auf das Repository genügt. Leer lassen deaktiviert die Update-Prüfung.",
"data": {
"gitea_token": "Gitea-Zugriffstoken"
}
}
}
},
"services": {
"jetzt_aktualisieren": {
"name": "Jetzt aktualisieren",
@@ -169,6 +180,14 @@
"start": { "name": "Von", "description": "Beginn des Zeitraums (ISO, Ortszeit erlaubt)." },
"ende": { "name": "Bis", "description": "Ende des Zeitraums (ISO, Ortszeit erlaubt)." }
}
},
"update_pruefen": {
"name": "Auf Update prüfen",
"description": "Fragt bei Gitea die neueste manifest.json ab und vergleicht die Version - lädt dabei noch nichts herunter."
},
"update_installieren": {
"name": "Update installieren",
"description": "Lädt die neueste Fassung von Gitea und ersetzt den Integrationsordner. Home Assistant muss danach neu gestartet werden."
}
}
}
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Regressionstest für aktualisierung.py's entpacken_pruefen_tauschen().
Testet die Sicherheitslogik des Selbst-Updates komplett isoliert: kein
Gitea-Zugriff, kein Home Assistant, nur ein selbstgebautes Zip gegen einen
Temp-Ordner. Deckt genau die Fälle ab, die vor jedem Tausch abgefangen
werden müssen (siehe Moduldocstring in aktualisierung.py):
- erfolgreicher Tausch inklusive Backup der alten Fassung
- falsche Domain im heruntergeladenen Manifest
- fehlendes version-Feld
- fehlende manifest.json überhaupt
- kaputtes Zip
- der gesuchte Pfad fehlt im Archiv
Aufruf: python3 tests/aktualisierung/test_aktualisierung.py
(braucht das homeassistant-Paket, weil aktualisierung.py es importiert -
in der Integration selbst immer vorhanden, hier z. B. im Test-Container)
"""
import io
import os
import shutil
import sys
import tempfile
import unittest
import zipfile
_HIER = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(
0, os.path.join(os.path.dirname(os.path.dirname(_HIER)), "custom_components", "audi_dashboard")
)
import aktualisierung as a # noqa: E402
def _zip_bauen(dateien: dict[str, bytes], wurzel: str = "audi-app-main") -> bytes:
"""Baut ein Zip mit den gegebenen Dateien unter
<wurzel>/custom_components/audi_dashboard/<pfad> - genau die Form, in
der Gitea ein Repo-Archiv liefert."""
puffer = io.BytesIO()
with zipfile.ZipFile(puffer, "w") as archiv:
for pfad, inhalt in dateien.items():
archiv.writestr(f"{wurzel}/custom_components/audi_dashboard/{pfad}", inhalt)
return puffer.getvalue()
_GUELTIGES_MANIFEST = b'{"domain": "audi_dashboard", "version": "2099.1.1.1"}'
class ErfolgreicherTausch(unittest.TestCase):
def setUp(self):
self.arbeitsordner = tempfile.mkdtemp()
self.integration = os.path.join(self.arbeitsordner, "audi_dashboard")
self.staging = os.path.join(self.arbeitsordner, "audi_dashboard_update_staging")
self.backup = os.path.join(self.arbeitsordner, "audi_dashboard_backup")
os.makedirs(self.integration)
with open(os.path.join(self.integration, "manifest.json"), "wb") as f:
f.write(b'{"domain": "audi_dashboard", "version": "2026.1.1.1"}')
with open(os.path.join(self.integration, "alte_datei.py"), "wb") as f:
f.write(b"# alte Fassung\n")
def tearDown(self):
shutil.rmtree(self.arbeitsordner, ignore_errors=True)
def test_tauscht_und_sichert_die_alte_fassung(self):
zip_bytes = _zip_bauen({
"manifest.json": _GUELTIGES_MANIFEST,
"neue_datei.py": b"# neue Fassung\n",
"frontend/app.js": b"// panel\n",
})
version = a.entpacken_pruefen_tauschen(
zip_bytes, self.integration, self.staging, self.backup
)
self.assertEqual(version, "2099.1.1.1")
# Live-Ordner trägt jetzt die neue Fassung.
with open(os.path.join(self.integration, "manifest.json"), "rb") as f:
self.assertIn(b'"2099.1.1.1"', f.read())
self.assertTrue(os.path.exists(os.path.join(self.integration, "neue_datei.py")))
self.assertTrue(os.path.exists(os.path.join(self.integration, "frontend", "app.js")))
self.assertFalse(os.path.exists(os.path.join(self.integration, "alte_datei.py")))
# Alte Fassung bleibt vollständig und unverändert im Backup-Ordner.
with open(os.path.join(self.backup, "manifest.json"), "rb") as f:
self.assertIn(b'"2026.1.1.1"', f.read())
self.assertTrue(os.path.exists(os.path.join(self.backup, "alte_datei.py")))
# Kein Staging-Rest übrig.
self.assertFalse(os.path.exists(self.staging))
def test_entfernt_pycache_aus_dem_staging_ordner(self):
zip_bytes = _zip_bauen({
"manifest.json": _GUELTIGES_MANIFEST,
"__pycache__/alte.cpython-313.pyc": b"\x00\x00",
})
a.entpacken_pruefen_tauschen(zip_bytes, self.integration, self.staging, self.backup)
self.assertFalse(os.path.exists(os.path.join(self.integration, "__pycache__")))
class AbgelehnteFaelle(unittest.TestCase):
def setUp(self):
self.arbeitsordner = tempfile.mkdtemp()
self.integration = os.path.join(self.arbeitsordner, "audi_dashboard")
self.staging = os.path.join(self.arbeitsordner, "audi_dashboard_update_staging")
self.backup = os.path.join(self.arbeitsordner, "audi_dashboard_backup")
os.makedirs(self.integration)
with open(os.path.join(self.integration, "manifest.json"), "wb") as f:
f.write(b'{"domain": "audi_dashboard", "version": "2026.1.1.1"}')
def tearDown(self):
shutil.rmtree(self.arbeitsordner, ignore_errors=True)
def _unangetastet(self):
"""Nach jedem abgelehnten Fall muss der Live-Ordner exakt so
dastehen wie vorher - keiner der Fehlerfälle darf vor dem Tausch
etwas am Live-Ordner verändern (siehe Moduldocstring)."""
with open(os.path.join(self.integration, "manifest.json"), "rb") as f:
self.assertIn(b'"2026.1.1.1"', f.read())
self.assertFalse(os.path.exists(self.backup))
def test_lehnt_falsche_domain_ab(self):
zip_bytes = _zip_bauen({
"manifest.json": b'{"domain": "hacs", "version": "2099.1.1.1"}',
})
with self.assertRaises(a.AktualisierungsFehler):
a.entpacken_pruefen_tauschen(zip_bytes, self.integration, self.staging, self.backup)
self._unangetastet()
def test_lehnt_fehlendes_version_feld_ab(self):
zip_bytes = _zip_bauen({
"manifest.json": b'{"domain": "audi_dashboard"}',
})
with self.assertRaises(a.AktualisierungsFehler):
a.entpacken_pruefen_tauschen(zip_bytes, self.integration, self.staging, self.backup)
self._unangetastet()
def test_lehnt_fehlende_manifest_ab(self):
zip_bytes = _zip_bauen({"irgendeine_datei.py": b"# leer\n"})
with self.assertRaises(a.AktualisierungsFehler):
a.entpacken_pruefen_tauschen(zip_bytes, self.integration, self.staging, self.backup)
self._unangetastet()
def test_lehnt_kaputtes_zip_ab(self):
with self.assertRaises(a.AktualisierungsFehler):
a.entpacken_pruefen_tauschen(
b"das ist kein Zip", self.integration, self.staging, self.backup
)
self._unangetastet()
def test_lehnt_archiv_ohne_gesuchten_pfad_ab(self):
puffer = io.BytesIO()
with zipfile.ZipFile(puffer, "w") as archiv:
archiv.writestr("irgendein-anderes-repo-main/README.md", b"# anderes Projekt\n")
with self.assertRaises(a.AktualisierungsFehler):
a.entpacken_pruefen_tauschen(
puffer.getvalue(), self.integration, self.staging, self.backup
)
self._unangetastet()
if __name__ == "__main__":
unittest.main()