From c0acc836f0c6269bee9658135a177f3a71661b22 Mon Sep 17 00:00:00 2001 From: Tobi G Date: Fri, 28 Aug 2026 11:47:08 +0200 Subject: [PATCH] Fahrgestellnummer (FIN) automatisch aus den zugeordneten Sensoren ableiten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neues identitaet.py: durchsucht das Geräte-Register der aktuell im Setup zugeordneten Sensoren (cupra_eu_data_act, FMM003, ...) nach einem VIN-förmigen Geräte-Identifier/Seriennummer, statt ein eigenes Setup-Feld zu verlangen. Mehrere übereinstimmende Quellen werden akzeptiert, abweichende nur geloggt statt geraten; eine bereits von Hand eingetragene FIN wird nie überschrieben. Läuft nach jedem Start und nach jeder Setup-Änderung (koordinator.py/dienste.py). Co-Authored-By: Claude Sonnet 5 --- AGENTS.md | 50 +++++++ custom_components/audi_dashboard/dienste.py | 10 +- .../audi_dashboard/identitaet.py | 123 ++++++++++++++++++ .../audi_dashboard/koordinator.py | 7 +- .../audi_dashboard/manifest.json | 2 +- 5 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 custom_components/audi_dashboard/identitaet.py diff --git a/AGENTS.md b/AGENTS.md index c4b043e..e4f4e8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4394,6 +4394,56 @@ Verified live: `getBoundingClientRect()` on both real rendered `.v` blocks now r Verified: `node --check` clean at both steps, manifest bumped `2026.8.28.12` → `.13` → `.14`, `audi_ha_test` restarted and confirmed clean via log each time. +## AG. Fahrgestellnummer (FIN/VIN) automatisch aus den zugeordneten Sensoren abgeleitet (2026.8.28.15) + +Owner: "It shall be available at the EU Data Act as well as FMM003. Read it from all sensors (check which +sensors are used in setup) and compare the VIN if it is matching. If VIN is empty, than use just the one +available." The FIN was, until now, a purely manual profile field (`fahrzeugprofil.json`'s `fahrzeug.fin`, +edited via "Fahrzeug einrichten") - no sensor role for it existed at all. + +Investigated the real data in `audi_ha_test` before designing anything, rather than assuming a dedicated VIN +*entity* exists on either integration: neither `cupra_eu_data_act` nor the FMM003 device expose a `sensor.*` +with "vin"/"fahrgestell"/"chassis" in its name or a matching state attribute (checked via a live +`hass.callWS` scan of every entity on both devices). The VIN **is** present, though - on the +`cupra_eu_data_act` device itself, in the HA **device registry**: `identifiers: [["cupra_eu_data_act", +"WUAZZZF48PA902804"]]` and `serial_number: "WUAZZZF48PA902804"`, exactly matching this test profile's own +manually-entered FIN. The FMM003 device in this specific test container does not carry a VIN-shaped +identifier (its own identifier is just its configured integration name) - real hardware may differ depending +on firmware/channel configuration (Teltonika Codec8 Extended has a dedicated VIN AVL parameter), which is +exactly why the owner asked for "check both, don't assume one". + +Built on that finding rather than adding a new Setup field: a new `identitaet.py` derives the FIN from the +**device registry of whichever sensors are already mapped** in Setup, no separate FIN sensor role to pick. +`_zugeordnete_entity_ids()` walks every field of the live `Sensorzuordnung` dataclass generically (via +`dataclasses.fields()`, list-typed fields like `TUER_SENSOREN` included) to get every entity ID currently in +use; for each, its owning device is resolved (entity registry → device registry) and checked for a +VIN-shaped identifier/serial number (`_SIEHT_AUS_WIE_FIN`: 17 chars, A-Z minus I/O/Q plus digits - the real +VIN standard's own exclusion of those three letters, chosen specifically so an unrelated device identifier +like "FMM003 - Testintegratoin" can never false-match). Distinct candidates found across all mapped devices +are collected into a set: zero → no-op (nothing found yet); exactly one → adopted automatically, but **only +if the profile's own FIN is still empty** (a manually-entered value is never overwritten - matches this +project's "human input always wins over automation" precedent, e.g. `_fahrt_screenen()`'s `edited_fields` +protection); more than one distinct value → logged as a warning naming both, deliberately not guessed at +(same "no suggestion beats a wrong one" stance as `entitaetIdKurz()`'s own fallback). Wired into two places +so it re-attempts whenever it has a real chance of succeeding: once after every HA start (`koordinator.py`'s +`_nach_start()`, alongside `fahrterkennung.nach_neustart_fortsetzen()` - deliberately not in `starten()` +itself, for the same device/entity-registry-not-ready-yet reason `trigger_neu_binden()`'s own docstring +already explains) and again after every Setup-menu save (`dienste.py`'s `entitaeten_schreiben` - a mapping +change may be exactly what makes derivation newly possible). Both call sites re-publish the profile +afterward so a freshly-derived FIN reaches the frontend immediately, not after the next 60s safety-net tick. + +**Verified live, not just read from source** (this file's own section S lesson): backed up the test +container's real `fahrzeugprofil.json`, blanked `fahrzeug.fin`, restarted - log showed +`Fahrgestellnummer automatisch aus den zugeordneten Sensoren übernommen: WUAZZZF48PA902804`, the on-disk +file and the published `sensor.audi_dashboard_profil` entity both confirmed via direct query, no stale +frontend needed. Restored the original file byte-for-byte afterward and restarted once more to confirm a +clean, silent (no re-derivation - the guard against overwriting an already-set FIN held) boot. `py_compile` +clean on all three touched files (`identitaet.py` new, `koordinator.py`, `dienste.py`). No `services.yaml` +change needed (no new service, just two existing handlers doing more internally) and no frontend change +needed either - the "Fahrgestellnummer (FIN)" field in "Fahrzeug einrichten" already reads/writes this exact +profile field, so it now simply arrives pre-filled instead of blank, and stays fully editable/correctable by +hand same as before. + Verified live in `audi_ha_test` at every step (`.6` then `.7`), each time reading the actual rendered Setup popup fresh after a real restart (not a stale tab - confirmed via `performance.getEntriesByType('resource')` showing the new `?v=` served with a nonzero transfer size each time). Final state (`.7`), all 20 single-value diff --git a/custom_components/audi_dashboard/dienste.py b/custom_components/audi_dashboard/dienste.py index 851e529..bc36b58 100644 --- a/custom_components/audi_dashboard/dienste.py +++ b/custom_components/audi_dashboard/dienste.py @@ -34,6 +34,7 @@ from . import ( csv_import, fahrterkennung, historienimport, + identitaet, reifen, screening, sicherung, @@ -229,11 +230,18 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None: Das Neubinden ist der Grund, warum der frühere Hinweis "wirkt erst nach einem Neustart" und der Neustart-Knopf daneben entfallen konnten: - auch Zündung, Kilometerstand und Tankfüllstand wirken jetzt sofort.""" + auch Zündung, Kilometerstand und Tankfüllstand wirken jetzt sofort. + + Danach auch ein erneuter Versuch, die Fahrgestellnummer aus den + (jetzt ggf. neuen) zugeordneten Sensoren abzuleiten (identitaet.py) - + eine leere FIN kann durch diese Zuordnung gerade erst ableitbar + geworden sein.""" await k.zuordnung.speichern(_objekt(aufruf.data["zuordnung_json"])) await k.trigger_neu_binden() await k.zuordnung_veroeffentlichen() await k.fahrzeugstatus_veroeffentlichen() + await identitaet.fin_ableiten_und_uebernehmen(k.hass, k.zuordnung, k.ablage) + await k.profil_veroeffentlichen() async def historie_importieren(aufruf: ServiceCall) -> None: """Startet den Import und kehrt sofort zurück. diff --git a/custom_components/audi_dashboard/identitaet.py b/custom_components/audi_dashboard/identitaet.py new file mode 100644 index 0000000..01a0516 --- /dev/null +++ b/custom_components/audi_dashboard/identitaet.py @@ -0,0 +1,123 @@ +"""Fahrgestellnummer (FIN/VIN) automatisch aus den zugeordneten Sensoren ableiten. + +Die FIN ist ein reines Profilfeld (fahrzeugprofil.json, "Fahrzeug einrichten"), +ohne eigene Sensor-Zuordnung im Setup-Menü - obwohl mindestens eine der ohnehin +schon zugeordneten Integrationen sie meist bereits mitbringt: die +cupra_eu_data_act-Integration trägt sie als Geräte-Identifier/Seriennummer +(nicht als eigene Entität), der Teltonika FMM003 kann sie je nach Firmware-/ +Kanal-Konfiguration ebenfalls liefern. Ein eigenes Setup-Feld dafür würde vom +Nutzer verlangen, denselben Wert doppelt zu suchen und zuzuordnen, obwohl die +Information am Gerät der ohnehin schon zugeordneten Sensoren hängt. + +Stattdessen wird bei jedem Start und nach jeder Setup-Änderung das +Geräte-Register der AKTUELL zugeordneten Sensoren durchsucht (nicht aller +Entitäten der Instanz - nur was Sensorzuordnung tatsächlich referenziert): +für jede zugeordnete Entity-ID wird ihr Gerät nachgeschlagen und daraus eine +FIN-Kandidatin extrahiert (Geräte-Identifier oder Seriennummer, die wie eine +echte FIN aussieht - 17 Zeichen, ohne die im VIN-Standard verbotenen +Verwechslungsbuchstaben I/O/Q). Liefern mehrere Geräte je eine Kandidatin, +müssen sie übereinstimmen; weichen sie ab, wird nur gewarnt statt geraten - +dieselbe "kein Vorschlag ist besser als ein falscher" Haltung wie an anderen +Stellen dieser App (siehe z. B. entitaetIdKurz() im Panel). Ist im Profil +bereits eine FIN eingetragen, wird sie nie überschrieben - von Hand +eingetragene Werte haben Vorrang vor einer Ableitung. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import fields +from typing import TYPE_CHECKING + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import entity_registry as er + +if TYPE_CHECKING: + from .ablage import Ablage + from .einstellungen import Sensorzuordnung + from .zuordnung import Zuordnung + +_LOGGER = logging.getLogger(__name__) + +# 17 Zeichen, Großbuchstaben ohne I/O/Q (im VIN-Standard wegen Verwechslungs- +# gefahr mit 1/0 verboten) und Ziffern - erkennt eine FIN, ohne andere +# Geräte-Identifier (z. B. "FMM003 - Testintegratoin") fälschlich zu matchen. +_SIEHT_AUS_WIE_FIN = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$") + + +def _zugeordnete_entity_ids(werte: Sensorzuordnung) -> list[str]: + """Alle aktuell zugeordneten Entity-IDs, Listenfelder (Türen/Fenster ...) + eingeschlossen - dieselbe Quelle, aus der auch das Panel seine + Vergleichsbasis baut (setupAndereIdsOhneDomain() in audi-dashboard-app.js), + hier nur serverseitig.""" + ids: list[str] = [] + for feld_info in fields(werte): + wert = getattr(werte, feld_info.name) + if isinstance(wert, list): + ids.extend(eintrag for eintrag in wert if eintrag) + elif wert: + ids.append(wert) + return ids + + +def _fin_kandidat_fuer_geraet(geraet: dr.DeviceEntry) -> str | None: + werte = [geraet.serial_number] + [kennung for _domain, kennung in geraet.identifiers] + for wert in werte: + if wert and _SIEHT_AUS_WIE_FIN.match(wert.upper()): + return wert.upper() + return None + + +def fin_kandidaten_ermitteln(hass: HomeAssistant, werte: Sensorzuordnung) -> dict[str, str]: + """{Geräte-ID: FIN-Kandidatin} für jedes Gerät hinter einer aktuell + zugeordneten Entität, dessen Geräte-Identifier/Seriennummer wie eine + echte FIN aussieht.""" + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + gefunden: dict[str, str] = {} + for entity_id in _zugeordnete_entity_ids(werte): + eintrag = ent_reg.async_get(entity_id) + if not eintrag or not eintrag.device_id: + continue + geraet = dev_reg.async_get(eintrag.device_id) + if not geraet: + continue + kandidat = _fin_kandidat_fuer_geraet(geraet) + if kandidat: + gefunden[geraet.id] = kandidat + return gefunden + + +async def fin_ableiten_und_uebernehmen( + hass: HomeAssistant, zuordnung: Zuordnung, ablage: Ablage +) -> None: + """Trägt die FIN automatisch ins Profil ein, wenn sie dort noch fehlt und + sich aus den zugeordneten Sensoren eindeutig ableiten lässt.""" + profil = await ablage.profil_lesen() + if profil is None: + return + fahrzeug = profil.setdefault("fahrzeug", {}) + if fahrzeug.get("fin"): + return + + kandidaten = fin_kandidaten_ermitteln(hass, zuordnung.werte) + eindeutig = set(kandidaten.values()) + if not eindeutig: + return + if len(eindeutig) > 1: + _LOGGER.warning( + "Fahrgestellnummer nicht eindeutig aus den zugeordneten Sensoren " + "ableitbar (gefunden: %s) - bitte in \"Fahrzeug einrichten\" von " + "Hand eintragen.", + ", ".join(sorted(eindeutig)), + ) + return + + fin = eindeutig.pop() + fahrzeug["fin"] = fin + await ablage.profil_schreiben(profil) + _LOGGER.info( + "Fahrgestellnummer automatisch aus den zugeordneten Sensoren übernommen: %s", fin + ) diff --git a/custom_components/audi_dashboard/koordinator.py b/custom_components/audi_dashboard/koordinator.py index adf3ba9..00c5c72 100644 --- a/custom_components/audi_dashboard/koordinator.py +++ b/custom_components/audi_dashboard/koordinator.py @@ -47,7 +47,7 @@ from homeassistant.helpers.event import ( from homeassistant.helpers.start import async_at_started from homeassistant.helpers.storage import Store -from . import batterie, fahrterkennung, reifen, screening, sicherung, tankerkennung +from . import batterie, fahrterkennung, identitaet, reifen, screening, sicherung, tankerkennung from .ablage import Ablage from .const import ( BUENDEL_INFO, @@ -149,6 +149,11 @@ class Koordinator: async def _nach_start(self, _hass: HomeAssistant) -> None: await fahrterkennung.nach_neustart_fortsetzen(self) + # Erst hier (nicht in starten()): das Geräte-/Entity-Register der + # zugeordneten Sensoren ist vor async_at_started oft noch nicht + # vollständig - siehe die Begründung von trigger_neu_binden() oben. + await identitaet.fin_ableiten_und_uebernehmen(self.hass, self.zuordnung, self.ablage) + await self.profil_veroeffentlichen() async def beenden(self) -> None: for abmelden in (*self._takte, *self._beobachter): diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index bb76876..e00c9c7 100644 --- a/custom_components/audi_dashboard/manifest.json +++ b/custom_components/audi_dashboard/manifest.json @@ -1,7 +1,7 @@ { "domain": "audi_dashboard", "name": "Audi Dashboard", - "version": "2026.8.28.14", + "version": "2026.8.28.15", "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"],