Fahrgestellnummer (FIN) automatisch aus den zugeordneten Sensoren ableiten

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 11:47:08 +02:00
parent 8f88ccf581
commit c0acc836f0
5 changed files with 189 additions and 3 deletions
+50
View File
@@ -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` 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. 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 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')` 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 showing the new `?v=` served with a nonzero transfer size each time). Final state (`.7`), all 20 single-value
+9 -1
View File
@@ -34,6 +34,7 @@ from . import (
csv_import, csv_import,
fahrterkennung, fahrterkennung,
historienimport, historienimport,
identitaet,
reifen, reifen,
screening, screening,
sicherung, sicherung,
@@ -229,11 +230,18 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None:
Das Neubinden ist der Grund, warum der frühere Hinweis "wirkt erst Das Neubinden ist der Grund, warum der frühere Hinweis "wirkt erst
nach einem Neustart" und der Neustart-Knopf daneben entfallen konnten: 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.zuordnung.speichern(_objekt(aufruf.data["zuordnung_json"]))
await k.trigger_neu_binden() await k.trigger_neu_binden()
await k.zuordnung_veroeffentlichen() await k.zuordnung_veroeffentlichen()
await k.fahrzeugstatus_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: async def historie_importieren(aufruf: ServiceCall) -> None:
"""Startet den Import und kehrt sofort zurück. """Startet den Import und kehrt sofort zurück.
@@ -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
)
@@ -47,7 +47,7 @@ from homeassistant.helpers.event import (
from homeassistant.helpers.start import async_at_started from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.storage import Store 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 .ablage import Ablage
from .const import ( from .const import (
BUENDEL_INFO, BUENDEL_INFO,
@@ -149,6 +149,11 @@ class Koordinator:
async def _nach_start(self, _hass: HomeAssistant) -> None: async def _nach_start(self, _hass: HomeAssistant) -> None:
await fahrterkennung.nach_neustart_fortsetzen(self) 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: async def beenden(self) -> None:
for abmelden in (*self._takte, *self._beobachter): for abmelden in (*self._takte, *self._beobachter):
@@ -1,7 +1,7 @@
{ {
"domain": "audi_dashboard", "domain": "audi_dashboard",
"name": "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", "documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues", "issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
"codeowners": ["@paul"], "codeowners": ["@paul"],