Files
audi-app/custom_components/audi_dashboard/geraet.py
T
tobias 863284e540 Hauptuntersuchung: Ableitung aus Erstzulassung und Wartungsplan, drittes Feld entfernt
Gemeldet: Wartungsplan leer, Erstzulassung 01.03.2025, erwartet 01.03.2028 -
gezeigt wurden drei verschiedene Antworten (Uebersicht 01.08.2026, Mein Audi
"August 2026", Service "kein Eintrag im Wartungsplan").

Ursache war das Profilfeld hauptuntersuchung_faellig ("08/2026"), das die
Ableitung ueberstimmte, in keiner Oberflaeche ein Eingabefeld hatte und im
Backend kein Schema. Drei Bildschirme verarbeiteten denselben Wert
verschieden. Das Feld ist samt Lese- und Schreibweg entfernt; es gibt jetzt
zwei Quellen: Wartungsplan + 24 Monate, sonst Erstzulassung + 36 Monate.

Dabei mitbehoben:

* Die Service-Kachel verschluckte abgeleitete Termine, sobald kein
  Wartungsplan-Eintrag dahinterstand.
* monatePlus() in der App verlor einen Tag ueber die Zeitumstellung (drei von
  fuenf gemessenen Faellen) - betraf Oelwechsel- und Inspektionsprognose
  genauso. Das Panel rechnete dort seit jeher richtig.
* Die App verlangte fuer jeden Wartungsplan-Eintrag eine Kilometerangabe. Eine
  Hauptuntersuchung ist rein datumsbasiert; ohne km waere der erste HU-Eintrag
  ignoriert worden. Ausserdem nahm das Panel den ersten Treffer der Liste, die
  App den juengsten - beide nehmen jetzt den juengsten.
* Monatsgenauigkeit ("03/2025") bleibt auf beiden Seiten erhalten.

Neu: paritaet_hu.test.ts vergleicht die App gegen die aus der Panel-Quelle
herausgeschnittenen Originalfunktionen, 15 Faelle. Gesamt 292 App-Tests, 73
Python-Tests mit 231 Untertests, 0 Tracebacks. Live an der Testinstanz auf
allen drei gemeldeten Bildschirmen geprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 01:56:11 +02:00

141 lines
5.5 KiB
Python

"""Der letzte Datensatz des Dongles, so wie er in Home Assistant angekommen ist.
WOFUER
------
Fuer die Fehlersuche am Fahrzeug, nicht fuer den taeglichen Gebrauch: Steht das
Fahrzeug wirklich, oder ist nur die Meldung alt? Liefert das Geraet gerade
ueberhaupt CAN-Werte? Die Antwort steht in Home Assistants Zustaenden, aber
verteilt auf siebzig Entitaeten - hier zusammengefasst an einer Stelle, die
beide Oberflaechen ohne eigene Registerabfrage lesen koennen.
WAS "EIN DATENSATZ" IST
-----------------------
Die flespi-Integration setzt die Entitaeten EINES Geraetedatensatzes
nacheinander, aber innerhalb weniger Millisekunden (am 01.09.2026 gemessen:
Zuendung 07:35:23.633, zugehoerige Meldezeit 07:35:23.635 - siehe Abschnitt AY
der AGENTS.md). Der letzte Datensatz ist deshalb: alle Entitaeten des Geraets,
deren letzte Aktualisierung hoechstens FENSTER_S nach der juengsten liegt.
Das ist bewusst eine Naeherung ueber die ANKUNFTSzeit. Die Zeit des Geraets
steht daneben (aus MELDEZEIT_SENSOR), und genau der Abstand zwischen beiden ist
die Angabe, die bei gepufferten Datensaetzen interessiert - er war die Wurzel
des 3-km-Fahrt-Fehlers (Abschnitt CB).
WELCHES GERAET
--------------
Nicht geraten: genommen wird das Geraet der Entitaet, die MELDEZEIT_SENSOR
zugeordnet ist, sonst das des Fahrtsignals. Ist keines von beiden zugeordnet,
gibt es nichts zu zeigen - dann bleibt der Rueckgabewert None, statt
irgendein Geraet der Instanz zu waehlen.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from . import verlauf
if TYPE_CHECKING:
from .einstellungen import Sensorzuordnung
_LOGGER = logging.getLogger(__name__)
# Wie weit ein Wert hinter dem juengsten liegen darf, um noch zum selben
# Datensatz gezaehlt zu werden. Gemessen wurden Millisekunden; zwei Sekunden
# sind grosszuegig genug fuer eine langsame Instanz und immer noch weit von der
# kuerzesten Sendeperiode des Geraets entfernt (10 s beim Fahren).
FENSTER_S = 2.0
# Ein Datensatz des FMM003 traegt je nach Konfiguration einige Dutzend Werte.
# Die Obergrenze ist kein fachliches Limit, sondern verhindert, dass eine
# fehlerhafte Zuordnung die veroeffentlichten Attribute aufblaeht.
MAX_WERTE = 80
def _meldezeit(hass: HomeAssistant, werte: Sensorzuordnung) -> str | None:
"""Die Zeit, die das Geraet dem letzten Datensatz gegeben hat."""
if not werte.MELDEZEIT_SENSOR:
return None
zustand = hass.states.get(werte.MELDEZEIT_SENSOR)
if zustand is None:
return None
try:
gemeldet = datetime.fromtimestamp(float(zustand.state), tz=timezone.utc)
except (TypeError, ValueError, OSError, OverflowError):
return None
# Dieselbe Pruefung wie in der Fahrterkennung: eine Sensor-ID statt eines
# Zeitstempels ergibt sonst einen Zeitpunkt kurz nach 1970 (Abschnitt BO).
if not verlauf.geraetezeit_plausibel(gemeldet, datetime.now(timezone.utc)):
return None
return gemeldet.isoformat()
def _geraet_id(hass: HomeAssistant, werte: Sensorzuordnung) -> str | None:
register = er.async_get(hass)
for eid in (werte.MELDEZEIT_SENSOR, werte.ZUENDUNG_SENSOR):
if not eid:
continue
eintrag = register.async_get(eid)
if eintrag and eintrag.device_id:
return eintrag.device_id
return None
def letzter_datensatz(hass: HomeAssistant, werte: Sensorzuordnung) -> dict[str, Any] | None:
"""Die Werte der juengsten Meldung des Dongles - None, wenn keine da ist."""
geraet_id = _geraet_id(hass, werte)
if not geraet_id:
return None
register = er.async_get(hass)
eintraege = er.async_entries_for_device(register, geraet_id, include_disabled_entities=False)
zustaende = []
for eintrag in eintraege:
zustand = hass.states.get(eintrag.entity_id)
# unavailable/unknown tragen keinen Wert - sie stehen fuer eine
# Entitaet, die das Geraet in diesem Datensatz nicht gemeldet hat.
if zustand is None or zustand.state in ("unavailable", "unknown"):
continue
zustaende.append(zustand)
if not zustaende:
return None
juengste = max(z.last_updated for z in zustaende)
grenze = juengste - timedelta(seconds=FENSTER_S)
im_datensatz = sorted(
(z for z in zustaende if z.last_updated >= grenze),
key=lambda z: z.attributes.get("friendly_name") or z.entity_id,
)
geraete_register = dr.async_get(hass)
geraet = geraete_register.async_get(geraet_id)
name = (geraet.name if geraet else None) or geraet_id
def kurz(voll: str) -> str:
"""Der Geraetename steht schon in der Kopfzeile - hier nur das Signal."""
rest = voll[len(name):].strip() if voll.startswith(name) else voll
return rest or voll
return {
"geraet": name,
"angekommen": juengste.isoformat(),
# Die Zeit, die das GERAET dem Datensatz gegeben hat. Der Abstand zur
# Ankunft ist der Puffer-Rueckstand.
"meldezeit": _meldezeit(hass, werte),
"werte": [
{
"name": kurz(z.attributes.get("friendly_name") or z.entity_id),
"wert": z.state,
"einheit": z.attributes.get("unit_of_measurement"),
}
for z in im_datensatz[:MAX_WERTE]
],
"gesamt": len(im_datensatz),
}