Fahrzustand aus der Zuendung statt aus dem Fahrtstatus, App-Version vergleichbar

BUG "Fahrzeug faehrt" aenderte sich nie. standortZustand() leitete den
Fahrzustand aus TRIPS[0].status === "offen" ab - zwei Fehler
uebereinander, die sich gegenseitig verstaerkt haben.

Erstens heisst "offen" nicht "unterwegs", sondern "Daten noch
unvollstaendig": _fahrt_beenden() legt die Fahrt mit diesem Status an,
wenn sie ENDET, und das Kilometerstand-Screening fuellt sie spaeter. Eine
Fahrt, die nie eine Strecke bekam - etwa weil damals kein KM_SENSOR
zugeordnet war -, bleibt fuer immer "offen". Zweitens ist TRIPS[0] die
AELTESTE Fahrt, nicht die neueste: fahrten_veroeffentlichen() reicht
profil.fahrten_lesen() unveraendert in Dateireihenfolge weiter, und die
ist aufsteigend. Zusammen hat die aelteste jemals unvollstaendig
gebliebene Fahrt das Fahrzeug dauerhaft als fahrend angezeigt; an der
Testinstanz war das eine seit 18 Tagen beendete Fahrt.

Nicht den Index geflickt, sondern die Quelle korrigiert: das Backend
veroeffentlicht jetzt "zuendung" im Fahrzeugstatus, gelesen aus dem
ohnehin zugeordneten ZUENDUNG_SENSOR - demselben Signal, das auch
fahrterkennung.py als massgeblich nimmt. Anzeige und Erfassung koennen
dadurch gar nicht mehr auseinanderlaufen. Ohne zugeordneten Sensor
(null) steht "Fahrzustand unbekannt" statt einer Behauptung.

companion-app hatte denselben Fehler spiegelverkehrt: liveZustandLesen()
las "zuendung" (gab es nie) und "lat"/"lon" (das Backend liefert
standort_lat/standort_lon), und der Typ Fahrzeug kannte keines dieser
Felder. Die Live-Ansicht meldete deshalb dauerhaft "Das Fahrzeug steht"
und zeigte nie eine Position. Felder in Fahrzeugstatus/Fahrzeug und im
Adapter ergaenzt, damit der Typ diese Fehlerklasse kuenftig faengt -
wovor sein eigener Kopfkommentar seit einem frueheren Vorfall warnt.

BUG "Standortzugriff verweigert" war irrefuehrend. Die Zeile steht
direkt unter dem Fahrzeugnamen, wo sonst der Abstand zum Auto steht,
handelt aber vom Standort DIESES Geraets - sie las sich, als sei der
Standort des Autos nicht abrufbar. Jetzt "GPS offline" wie gewuenscht.
Die verweigerte Freigabe behaelt eine eigene Meldung ("GPS-Freigabe
fehlt"): sie ist der einzige Fall mit anderer Abhilfe, und "GPS offline"
wuerde dort zur Signalsuche statt zum Freigabeschalter schicken.

VERSIONIERUNG: die Zahl, die alle fuer "die Version" hielten, ist keine.
Der Integer in audi-dashboard-version.json ist ein Cache-Brecher, den
install.ps1/update.ps1 bei jedem Deploy mit UtcNow neu setzen -
unabhaengig davon, ob sich Code geaendert hat. Zwei Builds derselben
Quelle bekommen verschiedene Zahlen. Er kann die Frage "ist das derselbe
Stand?" grundsaetzlich nicht beantworten.

Deshalb beide Aufgaben getrennt: neue Datei VERSION im Projektstamm
(2026.08.23.1) als Identitaet, von Hand erhoeht; der Integer bleibt
unveraendert der Cache-Brecher. VERSION fliesst in beide Seiten - als
zweites Feld "app" in audi-dashboard-version.json (alle drei
Deploy-Skripte uebernehmen es jetzt; sie haben die Datei bisher komplett
ueberschrieben und haetten es still zerstoert) und ueber vite define als
__APP_VERSION__ in den Companion-Build. Das Backend veroeffentlicht
pyscript.audi_dashboard_app_version, die App vergleicht und meldet eine
Abweichung in der Hinweisleiste - deren erklaerter Grundsatz "nie eine
stille Veraltung" genau dieser Fall ist, nur dass hier nicht die Anzeige
veraltet, sondern die App selbst.

Bewusst nur Gleichheitsvergleich, nie groesser/kleiner: die Version ist
eine Kennung, keine Zahl; Sortieren waere scheingenau und wuerde bei
einem Formatwechsel still falsch antworten. Fehlt eine der beiden
Seiten, wird nicht verglichen und nichts gemeldet - ein aelteres Backend
oder ein Start ohne Netz darf keinen Fehlalarm ausloesen. Der
vite-Build bricht dagegen hart ab, wenn VERSION fehlt, statt eine App zu
erzeugen, die ihre eigene Veraltung nicht erkennen kann. Das Panel
braucht nichts davon: es laedt bei jedem Seitenaufruf neu.

Geprueft: Backend meldet zuendung: False und app_version 2026.08.23.1 im
Testcontainer, Panel zeigt statt "Fahrzeug faehrt" jetzt "Geparkt seit
13 Tg. 13 Std." und statt der alten Meldung "GPS-Freigabe fehlt";
VERSION landet nachweislich im Build (im Bundle gegriffen) und der Build
bricht ohne die Datei ab (gegengeprueft); tsc sauber, Tests 112/112,
vite build sauber, HA-Start ohne Fehler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 21:59:14 +02:00
parent 41f2a644d3
commit 99cef7c393
24 changed files with 614 additions and 47 deletions
+5 -1
View File
@@ -71,7 +71,11 @@ function AngemeldeteApp({ beiAbmeldung }: { beiAbmeldung: () => void }) {
geheZu={geheZu}
tabBeschriftung={tabBeschriftung}
hinweis={
<Hinweisleiste verbindung={daten.verbindung} warteschlange={daten.warteschlange} />
<Hinweisleiste
verbindung={daten.verbindung}
warteschlange={daten.warteschlange}
versionsstand={daten.versionsstand}
/>
}
>
<SeiteFuer
+15
View File
@@ -172,6 +172,21 @@ export class DataMetricApi {
return this.rest.dienstAufrufen("pyscript", "audi_dashboard_jetzt_aktualisieren");
}
/** Welchen Oberflächen-Stand das Backend ausliefert (Datei VERSION, siehe
VERSIONIERUNG.md). `null`, wenn die Entität fehlt — etwa weil das Backend
älter ist als diese Funktion; dann wird nichts verglichen und nichts
gemeldet, statt einen Fehlalarm auszulösen. */
async appVersionLesen(): Promise<string | null> {
try {
const zustand = await this.rest.zustandLesen<{ daten?: { app?: string | null } }>(
ENTITAETEN.appVersion,
);
return zustand.attributes?.daten?.app ?? null;
} catch {
return null;
}
}
/* ------------------------------------------- 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
+8
View File
@@ -106,6 +106,13 @@ export interface Fahrzeugstatus {
batteriespannung?: number | null;
gesichert?: boolean | null;
sicherheitscheck?: SicherheitsPunkt[];
/** Zündung an = fährt gerade. `null`/fehlend heißt „kein Zündungssensor
zugeordnet", also unbekannt — ausdrücklich nicht „steht". */
zuendung?: boolean | null;
standort_lat?: number | null;
standort_lon?: number | null;
standort_genauigkeit_m?: number | null;
standort_zeit?: string | null;
oelwechsel_faellig_ts?: string | null;
oelwechsel_faellig_km?: number | null;
inspektion_faellig_ts?: string | null;
@@ -163,6 +170,7 @@ export const ENTITAETEN = {
belegErgebnis: "pyscript.audi_dashboard_beleg_ergebnis",
updateStatus: "pyscript.audi_dashboard_update_status",
importStatus: "pyscript.audi_dashboard_import_status",
appVersion: "pyscript.audi_dashboard_app_version",
} as const;
export type EntitaetsSchluessel = keyof typeof ENTITAETEN;
+12
View File
@@ -29,6 +29,8 @@ import {
type Verbindungszustand,
type WartenderAuftrag,
} from "../api"
import type { Versionsstand } from "./appVersion"
import { eigeneVersion, versionVergleichen } from "./appVersion"
import type { Einstellungen, Fahrzeug } from "./profilAdapter"
import { profilZuEinstellungen, profilZuFahrzeug, zusammenfuehren } from "./profilAdapter"
@@ -40,6 +42,9 @@ export interface DatenWert {
warteschlange: readonly WartenderAuftrag[]
/** Zeitpunkt der letzten Statusmeldung — treibt die Veraltet-Anzeige. */
statusStand: string | null
/** Ob diese App noch dem Stand entspricht, den das Backend ausliefert.
Siehe appVersion.ts und VERSIONIERUNG.md. */
versionsstand: Versionsstand
einstellungen: Einstellungen | null
fahrzeug: Fahrzeug | null
@@ -81,6 +86,7 @@ export function DatenAnbieter({
const [ladefehler, setzeLadefehler] = useState<string | null>(null)
const [verbindung, setzeVerbindung] = useState<Verbindungszustand>("getrennt")
const [warteschlange, setzeWarteschlange] = useState<readonly WartenderAuftrag[]>([])
const [serverVersion, setzeServerVersion] = useState<string | null>(null)
// Damit profilSpeichern immer gegen den neuesten Rohstand arbeitet, auch
// wenn zwischendurch ein Push hereinkam.
@@ -102,6 +108,11 @@ export function DatenAnbieter({
setzeStatusStand(s.last_updated)
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())
} catch (fehler) {
// Kein Netz ist kein Ladefehler, solange schon Daten da sind — dann
// zeigt die App den zwischengespeicherten Stand mit Offline-Hinweis.
@@ -198,6 +209,7 @@ export function DatenAnbieter({
verbindung,
warteschlange,
statusStand,
versionsstand: versionVergleichen(eigeneVersion(), serverVersion).stand,
einstellungen,
fahrzeug,
fahrten,
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest"
import { versionVergleichen } from "./appVersion"
/* Der Vergleich entscheidet, ob die App dem Nutzer sagt „ich bin älter als der
Server". Beide Fehlrichtungen sind teuer: ein Fehlalarm schickt ihn grundlos
an Xcode, ein verschluckter Hinweis lässt genau die stille Veraltung zu, die
die ganze Mechanik verhindern soll. */
describe("versionVergleichen", () => {
it("meldet Gleichstand bei identischer Version", () => {
const v = versionVergleichen("2026.08.23.1", "2026.08.23.1")
expect(v.stand).toBe("gleich")
})
it("meldet eine Abweichung, sobald die Zeichenketten sich unterscheiden", () => {
expect(versionVergleichen("2026.08.23.1", "2026.08.24.1").stand).toBe("abweichend")
expect(versionVergleichen("2026.08.23.2", "2026.08.23.1").stand).toBe("abweichend")
})
it("vergleicht nicht, wenn das Backend nichts meldet", () => {
// Altes Backend ohne die Versions-Entität, oder offline. Ein Hinweis waere
// hier geraten, nicht gewusst.
expect(versionVergleichen("2026.08.23.1", null).stand).toBe("unbekannt")
})
it("vergleicht nicht, wenn die App ihre eigene Version nicht kennt", () => {
expect(versionVergleichen(null, "2026.08.23.1").stand).toBe("unbekannt")
})
it("behandelt eine leere Zeichenkette wie fehlend, nicht wie einen Wert", () => {
expect(versionVergleichen("", "2026.08.23.1").stand).toBe("unbekannt")
expect(versionVergleichen("2026.08.23.1", "").stand).toBe("unbekannt")
})
it("gibt beide Staende zur Anzeige zurueck", () => {
const v = versionVergleichen("2026.08.23.1", "2026.08.24.1")
expect(v.app).toBe("2026.08.23.1")
expect(v.server).toBe("2026.08.24.1")
})
})
+45
View File
@@ -0,0 +1,45 @@
/**
* Vergleich zwischen dem Stand, den diese App mitbringt, und dem, den das
* Backend ausliefert. Hintergrund: ../../VERSIONIERUNG.md
*
* Warum es das braucht: Das Panel lädt seine Dateien bei jedem Seitenaufruf
* frisch und kann gar nicht veralten. Die iOS-App trägt ihre Dateien fest
* gebündelt und bleibt auf dem Stand vom Signieren — unbegrenzt und bisher
* unbemerkt. Diese Datei macht daraus ein sichtbares Signal.
*
* Bewusst kein „größer/kleiner"-Vergleich: die Version ist eine Kennung
* (`2026.08.23.1`), keine Zahl. Ein Sortierversuch wäre nur scheingenau und
* würde bei einem Formatwechsel still falsche Antworten geben. Verglichen wird
* auf Gleichheit — alles andere heißt „stimmt nicht überein", und was davon
* älter ist, entscheidet der Mensch.
*/
export type Versionsstand =
/** App und Backend melden denselben Stand. */
| "gleich"
/** Sie weichen ab — die App ist mit hoher Wahrscheinlichkeit älter. */
| "abweichend"
/** Kein Vergleich möglich (Backend meldet nichts, offline, altes Backend). */
| "unbekannt"
export interface Versionsvergleich {
stand: Versionsstand
app: string | null
server: string | null
}
/** Die in diese App einkompilierte Version (vite, siehe vite.config.ts). */
export function eigeneVersion(): string | null {
// Der Zugriff ist abgesichert, weil die Konstante in Testläufen und im
// Dev-Server nicht zwingend gesetzt ist. Fehlt sie, wird nicht verglichen,
// statt eine Abweichung zu behaupten.
return typeof __APP_VERSION__ === "string" && __APP_VERSION__ ? __APP_VERSION__ : null
}
export function versionVergleichen(
app: string | null,
server: string | null,
): Versionsvergleich {
if (!app || !server) return { stand: "unbekannt", app, server }
return { stand: app === server ? "gleich" : "abweichend", app, server }
}
+10
View File
@@ -48,6 +48,12 @@ export interface Fahrzeug {
batteriespannung: number | null
gesichert: boolean | null
sicherheitscheck: SicherheitsPunkt[]
/** Zündung an = das Fahrzeug fährt gerade. `null` heißt „kein
Zündungssensor zugeordnet", also unbekannt — nicht „steht". */
zuendung: boolean | null
standortLat: number | null
standortLon: number | null
standortZeit: string | null
oelwechselFaelligTs: string | null
oelwechselFaelligKm: number | null
inspektionFaelligTs: string | null
@@ -144,6 +150,10 @@ export function profilZuFahrzeug(profil: Profil, status: Fahrzeugstatus): Fahrze
batteriespannung: status.batteriespannung ?? null,
gesichert: status.gesichert ?? null,
sicherheitscheck: status.sicherheitscheck ?? [],
zuendung: status.zuendung ?? null,
standortLat: status.standort_lat ?? null,
standortLon: status.standort_lon ?? null,
standortZeit: status.standort_zeit ?? null,
oelwechselFaelligTs: status.oelwechsel_faellig_ts ?? null,
oelwechselFaelligKm: status.oelwechsel_faellig_km ?? null,
inspektionFaelligTs: status.inspektion_faellig_ts ?? null,
+4
View File
@@ -16,6 +16,10 @@ function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
batteriespannung: null,
gesichert: null,
sicherheitscheck: [],
zuendung: null,
standortLat: null,
standortLon: null,
standortZeit: null,
oelwechselFaelligTs: null,
oelwechselFaelligKm: null,
inspektionFaelligTs: null,
+13 -2
View File
@@ -7,22 +7,31 @@
*/
import type { Verbindungszustand, WartenderAuftrag } from "../api"
import type { Versionsstand } from "../daten/appVersion"
import { SymbolWarnung } from "../symbole"
export function Hinweisleiste({
verbindung,
warteschlange,
versionsstand = "unbekannt",
}: {
verbindung: Verbindungszustand
warteschlange: readonly WartenderAuftrag[]
/** Ob diese App noch dem Stand entspricht, den das Backend ausliefert.
Siehe daten/appVersion.ts und VERSIONIERUNG.md. */
versionsstand?: Versionsstand
}) {
const offline = verbindung !== "verbunden"
const wartend = warteschlange.length
// Derselbe Grundsatz wie beim Offline-Hinweis oben: keine stille Veraltung.
// Nur hier veraltet nicht die Anzeige, sondern die App selbst — sie trägt
// ihre Dateien fest gebündelt und merkt eine neuere Fassung sonst nie.
const veraltet = versionsstand === "abweichend"
// "verbindet" gleich beim Start nicht als Offline melden — das würde bei
// jedem App-Start kurz aufblitzen.
if (!offline && wartend === 0) return null
if (verbindung === "verbindet" && wartend === 0) return null
if (!offline && wartend === 0 && !veraltet) return null
if (verbindung === "verbindet" && wartend === 0 && !veraltet) return null
const gescheitert = warteschlange.filter((a) => a.fehlversuche > 0).length
@@ -42,6 +51,8 @@ export function Hinweisleiste({
: `${wartend} Änderungen warten auf Übertragung.`
: null}
{gescheitert > 0 ? ` ${gescheitert} davon mit Fehlversuchen.` : null}
{veraltet && (offline || wartend > 0) ? " " : null}
{veraltet ? "Diese App ist älter als der Server — bitte aktualisieren." : null}
</span>
</div>
)
+11 -5
View File
@@ -24,13 +24,19 @@ export interface LiveZustand {
dauerS?: number | null
}
/** Liest die Live-Werte aus dem Fahrzeugstatus. Solange der FMM003 fehlt,
liefert das Backend diese Felder nicht — dann ist alles null. */
/** Liest die Live-Werte aus dem aufbereiteten Fahrzeug-Objekt.
Bis 2026-08-23 las diese Funktion `lat`/`lon`/`zuendung` roh aus dem
Status-Blob — Feldnamen, die es dort nie gab: das Backend veröffentlicht
`standort_lat`/`standort_lon`, und `zuendung` gab es überhaupt noch nicht.
Beides kam nie an, die Live-Ansicht meldete deshalb dauerhaft „Das
Fahrzeug steht" und zeigte nie eine Position. Jetzt über die benannten
Felder aus profilAdapter, die der Typ `Fahrzeugstatus` absichert. */
export function liveZustandLesen(status: Record<string, unknown>): LiveZustand {
const lat = typeof status["lat"] === "number" ? status["lat"] : null
const lon = typeof status["lon"] === "number" ? status["lon"] : null
const lat = typeof status["standortLat"] === "number" ? status["standortLat"] : null
const lon = typeof status["standortLon"] === "number" ? status["standortLon"] : null
return {
faehrt: status["zuendung"] === true || status["zuendung"] === 1,
faehrt: status["zuendung"] === true,
geschwindigkeit: typeof status["geschwindigkeit"] === "number" ? status["geschwindigkeit"] : null,
position: lat != null && lon != null ? { lat, lon } : null,
streckeKm: typeof status["fahrt_strecke_km"] === "number" ? status["fahrt_strecke_km"] : null,
+9
View File
@@ -0,0 +1,9 @@
/**
* Von vite beim Bauen eingesetzte Konstanten (siehe vite.config.ts).
*
* `__APP_VERSION__` ist der Inhalt der Datei VERSION im Projektstamm — dieselbe
* Zahl, die auch das Panel trägt. Damit kann die App vergleichen, ob sie noch
* dem Stand entspricht, den das Backend ausliefert. Hintergrund und Ablauf:
* ../../VERSIONIERUNG.md
*/
declare const __APP_VERSION__: string