Setup-Menü für Sensor-Zuordnung + Umstellung von WLAN/VAG-Integration auf FMM003

Setup-Menü (Einstellungen -> Fahrzeug einrichten -> Setup): ordnet alle von
der App genutzten Sensor-Rollen echten HA-Entitäten zu, statt sie in
einstellungen.py von Hand einzutragen - durchsuchbares Dropdown je Feld,
Vorauswahl aus vorhandenen Entitäten, Schalter für "nur passende Sensoren".
Neues Modul entitaeten.py (Katalog + JSON-Override, zur Laufzeit über
setattr() auf einstellungen angewendet, kein Neustart nötig außer für die
drei trigger-gebundenen Felder).

Zusätzlich: Fahrzeug-WLAN-Erkennung und alle Entity-IDs der nicht mehr
genutzten TommiG1/HA_VAG-EU-Data-Act-Integration entfernt. Fahrterkennung
läuft jetzt über den Zündungs-/ACC-Status des neu angebundenen Teltonika
FMM003 (ersetzt die WLAN-Verbindungserkennung); Standort und 12V-
Batteriespannung kommen ebenfalls vom FMM003. Kilometerstand, Tankfüllstand,
Reichweite, Türen/Fenster/Schlösser sowie Ölwechsel-/Inspektionsdaten haben
dadurch vorerst keine Quelle mehr und zeigen "unbekannt" - die Funktionen
selbst bleiben erhalten und lassen sich über das neue Setup-Menü jederzeit
neu zuordnen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 15:55:59 +02:00
parent 7e979ecfab
commit 9794193803
13 changed files with 848 additions and 197 deletions
+78 -9
View File
@@ -1,6 +1,6 @@
# AGENTS.md — Project state, review findings, open items, and working rules # AGENTS.md — Project state, review findings, open items, and working rules
**Last updated: 2026-08-11** (full-repo review). This file is the entry point for every new agent **Last updated: 2026-08-12**. This file is the entry point for every new agent
session: what this repo is, what is finished, what is missing, and how to work here. Detail lives in session: what this repo is, what is finished, what is missing, and how to work here. Detail lives in
the linked documents — this file points, it does not duplicate. the linked documents — this file points, it does not duplicate.
@@ -119,16 +119,24 @@ capacitor/iframe/browser (`umgebung.ts`), types + entity table (`types.ts`), fac
### HA panel — known gaps (most also in SPECIFICATION.md §7) ### HA panel — known gaps (most also in SPECIFICATION.md §7)
- **GPS is dead schema:** `start_lat/lon`, addresses, `route`, `avg_speed_kmh` never populated. - **GPS is dead schema for trip records specifically:** `start_lat/lon`, addresses, `route`,
The trip-detail map draws a **fabricated** line via `fakeTrack()` `avg_speed_kmh` on individual trips never populated. The trip-detail map draws a **fabricated**
(`audi-dashboard-app.js:414`) — not a real track. line via `fakeTrack()` (`audi-dashboard-app.js:414`) — not a real track. (Unrelated: the
Übersicht's live Standort-Kachel is no longer in this state — `STANDORT_TRACKER` now points at
a real `device_tracker`, see section B/C. This item is only about per-trip route data, which
nothing currently populates.)
- **No GPS fallback in trip completion:** trips without an odometer match stay `status="offen"` - **No GPS fallback in trip completion:** trips without an odometer match stay `status="offen"`
forever (`modules/fahrtabschluss_logik.py:16-20`). forever (`modules/fahrtabschluss_logik.py:16-20`).
- **RAM-only state:** running trip (`_fahrt_start_ts`) and fuel low-water-mark - **RAM-only state:** running trip (`_fahrt_start_ts`) and fuel low-water-mark
(`_tiefststand_pct`) do not survive an HA restart. Deliberately deferred hardening. (`_tiefststand_pct`) do not survive an HA restart. Deliberately deferred hardening.
- **Inactive features:** `BATTERIE_SENSOR = ""` (entire battery-history feature is a no-op) and - **Inactive features:** `UPDATE_REPO_URL = ""` (self-update inactive) in
`UPDATE_REPO_URL = ""` (self-update inactive), both in `pyscript/modules/einstellungen.py`. `pyscript/modules/einstellungen.py`. `www/bilder/` has no vehicle photos (all slots show
`www/bilder/` has no vehicle photos (all slots show placeholders); `steuer.faellig` unset. placeholders); `steuer.faellig` unset. (`BATTERIE_SENSOR` was the same kind of gap until
2026-08-12 — now wired to the FMM003's `external_power_voltage`, see section B/C below. Several
*other* fields are newly inactive since the same date for a different reason — the
`TommiG1/HA_VAG-EU-Data-Act` integration they depended on was dropped: `KM_SENSOR`,
`TANK_SENSOR`, `RANGE_SENSOR`, `REFRESH_BUTTON`, all door/window/lock sensors, oil-change/
inspection sensors. All are configurable again via the new Setup menu, see section C.)
- **Robustness:** `profil_lesen()` in `modules/profil.py` does not handle a missing/corrupt - **Robustness:** `profil_lesen()` in `modules/profil.py` does not handle a missing/corrupt
`fahrzeugprofil.json` — all callers throw. `fahrzeugprofil.json` — all callers throw.
- **Tests:** only the receipt parser is tested (`data/tests/test_shell_beleg_parser.py`, 10 real - **Tests:** only the receipt parser is tested (`data/tests/test_shell_beleg_parser.py`, 10 real
@@ -144,6 +152,12 @@ capacitor/iframe/browser (`umgebung.ts`), types + entity table (`types.ts`), fac
- `INSTALL.md` step 4 names variables that no longer exist (`DOORS_SENSOR`, `WINDOWS_SENSOR`, - `INSTALL.md` step 4 names variables that no longer exist (`DOORS_SENSOR`, `WINDOWS_SENSOR`,
`LOCK_ENTITY`, `BATTERY_VOLTAGE_SENSOR`) — actual names: `TUER_SENSOREN`/`FENSTER_SENSOREN`/ `LOCK_ENTITY`, `BATTERY_VOLTAGE_SENSOR`) — actual names: `TUER_SENSOREN`/`FENSTER_SENSOREN`/
`TUERSCHLOSS_SENSOREN`/`BATTERIE_SENSOR`. `TUERSCHLOSS_SENSOREN`/`BATTERIE_SENSOR`.
- **(worse than drift, since 2026-08-12)** `INSTALL.md` step 4 and its troubleshooting table
still walk through configuring `WLAN_SENSOR` and the `TommiG1/HA_VAG-EU-Data-Act` entities
(`KM_SENSOR`, `TANK_SENSOR`, etc.) — both are gone (see section B). A fresh install following
this doc today would configure settings that no longer exist and skip `ZUENDUNG_SENSOR`/
`STANDORT_TRACKER`, which now actually matter. Needs a real rewrite, not a find-replace — not
done yet, flagging so it isn't mistaken for accurate.
- `homeassistant/README.md:110` mentions the removed 97% full-tank rule (removal documented in - `homeassistant/README.md:110` mentions the removed 97% full-tank rule (removal documented in
`belegverarbeitung.py:18-20`); the README file list omits 5 pyscript files. `belegverarbeitung.py:18-20`); the README file list omits 5 pyscript files.
- Obsolete comment `belegverarbeitung.py:41` ("TODO: Datei ablegen" — file has long existed). - Obsolete comment `belegverarbeitung.py:41` ("TODO: Datei ablegen" — file has long existed).
@@ -234,8 +248,33 @@ capacitor/iframe/browser (`umgebung.ts`), types + entity table (`types.ts`), fac
self-hosted broker is picked back up later. self-hosted broker is picked back up later.
- [ ] Capture the first real Codec JSON message (`mosquitto_sub`/MQTT Explorer) and build the - [ ] Capture the first real Codec JSON message (`mosquitto_sub`/MQTT Explorer) and build the
field mapping from it — **do not guess beforehand** (explicit decision) field mapping from it — **do not guess beforehand** (explicit decision)
- [ ] Move trip detection to FMM003 ignition (reuse the `fahrterkennung.py` pattern, keep pause - [x] Move trip detection to FMM003 ignition — done 2026-08-12. `fahrterkennung.py` rewritten:
tolerance); then remove `sensor.iphone_wifi_connection` from `einstellungen.py` trigger is now `einstellungen.ZUENDUNG_SENSOR`
(`binary_sensor.testzone_fmm003_engine_ignition_or_acc_status`, on = trip running) instead
of the iPhone WLAN sensor; same `task.unique()` pause-tolerance pattern kept unchanged.
`sensor.iphone_wifi_connection` and the `fahrzeug.wlan_name` profile field are fully removed
(backend and frontend). At the same time, all entity IDs sourced from the now-abandoned
`TommiG1/HA_VAG-EU-Data-Act` HACS integration were blanked in `einstellungen.py`
(`KM_SENSOR`, `TANK_SENSOR`, `RANGE_SENSOR`, `REFRESH_BUTTON`, door/window/lock sensors,
oil-change/inspection sensors) — the consuming features (Übersicht tiles, Reifenzähler,
automatic Tankerkennung, Fahrtabschluss-Screening) are untouched and degrade gracefully to
"unbekannt"/inactive per the existing `zustand_oder_none()` convention, rather than being
deleted; `fahrtabschluss.py`/`tankerkennung.py`/`reifenzaehler.py` now guard their
`@state_trigger(f"{einstellungen.KM_SENSOR}")`-style decorators with
`if einstellungen.KM_SENSOR:` (untested previously whether pyscript tolerates an empty
trigger string — not worth relying on). `STANDORT_TRACKER` now points at the real
`device_tracker.testzone_fmm003` and `BATTERIE_SENSOR` at
`sensor.testzone_fmm003_external_power_voltage` (the vehicle's 12V bus voltage as measured
by the tracker — NOT `sensor.testzone_fmm003_battery_voltage`, which is the tracker's own
internal backup cell and unrelated to the car). Real entity list for this device: the user's
`Entiaeten.csv` export (OneDrive, not in this repo). Verified in `audi_ha_test`: pyscript
reload (`pyscript.reload`) produced no errors/warnings for any of the changed files; the
Übersicht correctly shows "Status unbekannt" and em-dash placeholders for the now-blank VAG
fields instead of crashing (that container itself has no `device_tracker.testzone_fmm003`
registered, so Standort still shows "Kein GPS-Signal" there — expected, the real HA instance
has it). **Follow-up still needed:** `INSTALL.md` step 4 and its troubleshooting table still
document the old WLAN/TommiG1 entity list — now actively wrong, not just stale (see
Documentation drift below).
### C) Maintain the existing HA panel (low priority — being replaced) ### C) Maintain the existing HA panel (low priority — being replaced)
@@ -276,6 +315,36 @@ capacitor/iframe/browser (`umgebung.ts`), types + entity table (`types.ts`), fac
a few seconds in the `audi_ha_test` container (no crash, stays on "Adresse wird ermittelt …" a few seconds in the `audi_ha_test` container (no crash, stays on "Adresse wird ermittelt …"
indefinitely) — likely just that container's outbound network to Nominatim specifically; indefinitely) — likely just that container's outbound network to Nominatim specifically;
unconfirmed whether this reproduces on the real HA instance. unconfirmed whether this reproduces on the real HA instance.
- [x] Setup menu (Einstellungen → Fahrzeug einrichten → last item when expanded) — done
2026-08-12. Lets the user map every sensor role the app uses (~18 roles, 3 of them 4-item
door/window/lock lists) to real HA entities from a searchable dropdown, instead of hand-
editing `einstellungen.py`. New backend module `pyscript/modules/entitaeten.py`: a
`FELDER` catalog (label, hint, expected domain/device_class/unit, keywords, whether it's
one of the trigger-bound fields) plus JSON override I/O (`data/entitaeten.json`, same
atomic-write pattern as `profil.py`) and `overrides_anwenden()`, which does
`setattr(einstellungen, key, value)` on the already-imported `einstellungen` module — every
existing consumer does `import einstellungen` + live attribute access (verified: none use
`from einstellungen import X`), so this needs no changes to any consumer file. New service
`audi_dashboard_entitaeten_schreiben` (`frontend_api.py`) and publication
`entitaeten_veroeffentlichen()` (`frontend_veroeffentlichung.py`) follow the project's
existing read-via-state/write-via-service pattern. Frontend: new popup (`vSetupPopup()`,
modeled on the existing `.sheet` pattern) with a from-scratch searchable-combobox component
(none existed in the repo before) — client-side only, built directly from `HASS.states`, no
new backend read service; a global "Nur passende Sensoren anzeigen" switch hard-filters
candidates by domain/unit, a keyword+domain+unit score ranks and auto-suggests for empty
fields. Deliberately does NOT auto-apply changes for the three trigger-bound fields
(`ZUENDUNG_SENSOR`/`KM_SENSOR`/`TANK_SENSOR``@state_trigger` bakes the entity ID in at
module-load time) — the UI shows an inline "wirkt erst nach Neustart" hint instead of
attempting a risky self-`pyscript.reload()` from inside a running service call. Two real
bugs found and fixed during live verification, both now also pushed to the Claude Design
project: (1) a full popup re-render on every keystroke fought the search input for focus —
fixed by patching just the candidate-list DOM node on `input`, matching the same fix the
existing `data-tankpreisfeld` handler already uses for the identical reason; (2) the popup
background used `--tile-2`, which Design had since changed to a translucent
`rgba(255,255,255,.1)` in the night theme (for the frosted-glass tile look elsewhere) — made
the whole popup partially see-through against the app content behind it. Fixed by switching
to `--tile-deckend` (Design's own opaque-surface token, already used by `.standortmenu` for
the same reason), with `var(--canvas)` as a defensive fallback.
- [ ] Fix documentation drift (statistics claim, INSTALL variable names, README gaps, obsolete - [ ] Fix documentation drift (statistics claim, INSTALL variable names, README gaps, obsolete
TODO comment) — text-only changes TODO comment) — text-only changes
- [ ] Harden `profil_lesen()` against missing/corrupt `fahrzeugprofil.json` - [ ] Harden `profil_lesen()` against missing/corrupt `fahrzeugprofil.json`
@@ -10,7 +10,6 @@
"details": "2.9 TFSI quattro competition · 331 kW · 450 PS", "details": "2.9 TFSI quattro competition · 331 kW · 450 PS",
"erstzulassung": "", "erstzulassung": "",
"hauptuntersuchung_faellig": "", "hauptuntersuchung_faellig": "",
"wlan_name": "",
"tankvolumen_liter": 58, "tankvolumen_liter": 58,
"ausfuehrung": "competition" "ausfuehrung": "competition"
}, },
+9 -5
View File
@@ -13,11 +13,15 @@ import einstellungen
import fahrtabschluss_logik import fahrtabschluss_logik
@state_trigger(f"{einstellungen.KM_SENSOR}") # Ohne konfigurierten Kilometerstand-Sensor keinen Trigger registrieren -
def kilometerstand_geaendert(value=None, old_value=None): # eine leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes
if value is None or old_value is None: # Verhalten, siehe gleiches Muster in fahrterkennung.py.
return if einstellungen.KM_SENSOR:
fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR) @state_trigger(f"{einstellungen.KM_SENSOR}")
def kilometerstand_geaendert(value=None, old_value=None):
if value is None or old_value is None:
return
fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR)
@service @service
+47 -44
View File
@@ -1,17 +1,25 @@
"""Fahrterkennung über die WLAN-Verbindung zum Fahrzeug (§7.1). """Fahrterkennung über den Zündungs-/ACC-Status des FMM003 (§7.1).
Start: der WLAN-Sensor der Companion App meldet den in den Einstellungen Start: die Zündungs-Entität (einstellungen.ZUENDUNG_SENSOR) wechselt auf
hinterlegten Fahrzeug-WLAN-Namen (Fahrzeugprofil, Feld fahrzeug.wlan_name). "on". Ende: sie wechselt auf "off" und kommt binnen der Pausenzeit nicht
Ende: die Verbindung bricht ab und kommt binnen der Pausenzeit nicht zurück. zurück.
Die Pausenregel wird über task.unique() umgesetzt: jede neue Änderung des 2026-08-12: ersetzt die frühere WLAN-basierte Erkennung (Vergleich des vom
WLAN-Sensors killt eine noch laufende, wartende Bestätigung des vorherigen iPhone gemeldeten verbundenen WLANs gegen den im Fahrzeugprofil hinterlegten
Ereignisses. Kommt die Verbindung während der Wartezeit zurück, wird dadurch Namen) - die Zündung ist ein direkteres, von jeder Companion-App-Eigenheit
die wartende Ende-Bestätigung automatisch abgebrochen, ohne dass die Fahrt unabhängiges Signal.
beendet wird - das entspricht der Pausenregel aus §7.1.
Die Pausenregel wird über task.unique() umgesetzt: jede neue Änderung der
Zündungs-Entität killt eine noch laufende, wartende Bestätigung des
vorherigen Ereignisses. Kommt die Zündung während der Wartezeit zurück, wird
dadurch die wartende Ende-Bestätigung automatisch abgebrochen, ohne dass die
Fahrt beendet wird - das entspricht der Pausenregel aus §7.1.
Die Entity-IDs stehen zentral in pyscript/modules/einstellungen.py - dort vor Die Entity-IDs stehen zentral in pyscript/modules/einstellungen.py - dort vor
der Installation einmalig anpassen (siehe INSTALL.md). der Installation einmalig anpassen (siehe INSTALL.md), oder über das
Setup-Menü in der Oberfläche zuordnen (siehe entitaeten.py; Änderungen dort
wirken für DIESEN Trigger allerdings erst nach einem Neustart, da
@state_trigger die Entity-ID einmalig beim Modul-Laden einliest).
EINSCHRÄNKUNG: der Zustand einer laufenden Fahrt (_fahrt_start_ts) lebt nur EINSCHRÄNKUNG: der Zustand einer laufenden Fahrt (_fahrt_start_ts) lebt nur
im Arbeitsspeicher dieses Skripts. Ein HA-Neustart mitten in einer Fahrt oder im Arbeitsspeicher dieses Skripts. Ein HA-Neustart mitten in einer Fahrt oder
@@ -32,11 +40,6 @@ import profil
_fahrt_start_ts = None _fahrt_start_ts = None
def _profil_wlan_name():
p = profil.profil_lesen()
return p.get("fahrzeug", {}).get("wlan_name")
def _pausenzeit_sekunden(): def _pausenzeit_sekunden():
p = profil.profil_lesen() p = profil.profil_lesen()
return p.get("einstellungen", {}).get("fahrten_pausenzeit_min", 15) * 60 return p.get("einstellungen", {}).get("fahrten_pausenzeit_min", 15) * 60
@@ -47,39 +50,39 @@ def beim_start():
profil.ordner_sicherstellen() profil.ordner_sicherstellen()
@state_trigger(f"{einstellungen.WLAN_SENSOR}") # Ohne konfigurierte Zündungs-Entität keinen Trigger registrieren - eine
def wlan_verbindung_geaendert(value=None, old_value=None): # leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes Verhalten
# Killt eine noch laufende Ende-Bestätigung aus einer vorherigen # (siehe zustand_oder_none()-Kommentar in frontend_veroeffentlichung.py zur
# Änderung - das ist der Mechanismus hinter der Pausenregel. # Grundhaltung "kein geratener Wert"), hier stattdessen ganz weggelassen.
task.unique("audi_dashboard_fahrt_ende_pruefung") if einstellungen.ZUENDUNG_SENSOR:
@state_trigger(f"{einstellungen.ZUENDUNG_SENSOR}")
def zuendung_geaendert(value=None, old_value=None):
# Killt eine noch laufende Ende-Bestätigung aus einer vorherigen
# Änderung - das ist der Mechanismus hinter der Pausenregel.
task.unique("audi_dashboard_fahrt_ende_pruefung")
ziel = _profil_wlan_name() global _fahrt_start_ts
if not ziel: an_jetzt = (value == "on")
log.warning("audi_dashboard: kein fahrzeug.wlan_name im Fahrzeugprofil hinterlegt") an_vorher = (old_value == "on")
return
global _fahrt_start_ts if an_jetzt and not an_vorher:
verbunden_jetzt = (value == ziel) if _fahrt_start_ts is None:
verbunden_vorher = (old_value == ziel) _fahrt_start_ts = datetime.datetime.now(datetime.timezone.utc)
log.info(f"audi_dashboard: Fahrt gestartet um {_fahrt_start_ts.isoformat()}")
return
if verbunden_jetzt and not verbunden_vorher: if an_vorher and not an_jetzt and _fahrt_start_ts is not None:
if _fahrt_start_ts is None: start_ts = _fahrt_start_ts
_fahrt_start_ts = datetime.datetime.now(datetime.timezone.utc) abbruch_ts = datetime.datetime.now(datetime.timezone.utc)
log.info(f"audi_dashboard: Fahrt gestartet um {_fahrt_start_ts.isoformat()}") wartezeit_s = _pausenzeit_sekunden()
return
if verbunden_vorher and not verbunden_jetzt and _fahrt_start_ts is not None: # task.unique() oben markiert diese Ausführung als die aktuell
start_ts = _fahrt_start_ts # gültige. Kommt die Zündung vor Ablauf der Wartezeit zurück,
abbruch_ts = datetime.datetime.now(datetime.timezone.utc) # ruft der nächste Trigger erneut task.unique() auf und killt
wartezeit_s = _pausenzeit_sekunden() # genau diesen task.sleep() - der Code danach läuft dann nie.
task.sleep(wartezeit_s)
# task.unique() oben markiert diese Ausführung als die aktuell _fahrt_beenden(start_ts, abbruch_ts)
# gültige. Kommt die Verbindung vor Ablauf der Wartezeit zurück,
# ruft der nächste Trigger erneut task.unique() auf und killt genau
# diesen task.sleep() - der Code danach läuft dann nie.
task.sleep(wartezeit_s)
_fahrt_beenden(start_ts, abbruch_ts)
def _fahrt_beenden(start_ts, ende_ts): def _fahrt_beenden(start_ts, ende_ts):
@@ -120,7 +123,7 @@ def _fahrt_beenden(start_ts, ende_ts):
@service @service
def audi_dashboard_fahrt_manuell_anlegen(ts_start=None, ts_end=None, art="privat"): def audi_dashboard_fahrt_manuell_anlegen(ts_start=None, ts_end=None, art="privat"):
"""Manuelle Fahrterfassung aus der Oberfläche (§5.3), unabhängig von der """Manuelle Fahrterfassung aus der Oberfläche (§5.3), unabhängig von der
automatischen WLAN-Erkennung. Aufruf als pyscript.audi_dashboard_fahrt_manuell_anlegen.""" automatischen Zündungs-Erkennung. Aufruf als pyscript.audi_dashboard_fahrt_manuell_anlegen."""
start_ts = datetime.datetime.fromisoformat(ts_start) start_ts = datetime.datetime.fromisoformat(ts_start)
ende_ts = datetime.datetime.fromisoformat(ts_end) ende_ts = datetime.datetime.fromisoformat(ts_end)
dauer_s = int((ende_ts - start_ts).total_seconds()) dauer_s = int((ende_ts - start_ts).total_seconds())
+24
View File
@@ -19,6 +19,7 @@ statt für jedes einzelne Feld einen eigenen Service zu brauchen.
import json import json
import einstellungen import einstellungen
import entitaeten
import frontend_veroeffentlichung import frontend_veroeffentlichung
import profil import profil
@@ -26,6 +27,10 @@ import profil
@time_trigger("startup") @time_trigger("startup")
def beim_start(): def beim_start():
profil.ordner_sicherstellen() profil.ordner_sicherstellen()
# Vor der ersten Veröffentlichung anwenden, damit sie die im Setup-Menü
# gespeicherten Zuordnungen (data/entitaeten.json) schon zeigt, siehe
# entitaeten.py-Kopfkommentar.
entitaeten.overrides_anwenden()
frontend_veroeffentlichung.alles_veroeffentlichen() frontend_veroeffentlichung.alles_veroeffentlichen()
@@ -73,3 +78,22 @@ def audi_dashboard_profil_schreiben(profil_json=None):
profil.profil_schreiben(p) profil.profil_schreiben(p)
frontend_veroeffentlichung.profil_veroeffentlichen() frontend_veroeffentlichung.profil_veroeffentlichen()
log.info("audi_dashboard: Profil aktualisiert") log.info("audi_dashboard: Profil aktualisiert")
@service
def audi_dashboard_entitaeten_schreiben(zuordnung_json=None):
"""Speichert die im Setup-Menü (Einstellungen -> Fahrzeug einrichten ->
Setup) gewählte Sensor-zu-Entität-Zuordnung, wendet sie sofort an und
veröffentlicht Katalog+Status neu, damit Übersicht/Mein Audi ohne
Neustart die neuen Werte zeigen (Ausnahme: die drei trigger-gebundenen
Felder, siehe entitaeten.py). Aufruf als
pyscript.audi_dashboard_entitaeten_schreiben."""
if not zuordnung_json:
log.warning("audi_dashboard: entitaeten_schreiben ohne zuordnung_json aufgerufen")
return
mapping = json.loads(zuordnung_json) if isinstance(zuordnung_json, str) else zuordnung_json
entitaeten.overrides_schreiben(mapping)
entitaeten.overrides_anwenden()
frontend_veroeffentlichung.entitaeten_veroeffentlichen()
frontend_veroeffentlichung.fahrzeugstatus_veroeffentlichen()
log.info("audi_dashboard: Entitäten-Zuordnung aktualisiert")
+62 -83
View File
@@ -7,106 +7,85 @@ des Fahrzeugprofils (data/fahrzeugprofil.json) und über die Oberfläche
Fahrzeugstatus-Abfrage brauchen sie als festen Wert, bevor überhaupt ein Fahrzeugstatus-Abfrage brauchen sie als festen Wert, bevor überhaupt ein
Profil gelesen werden kann. Profil gelesen werden kann.
In Home Assistant unter Entwicklerwerkzeuge -> Zustände nachschlagen. In Home Assistant unter Entwicklerwerkzeuge -> Zustände nachschlagen. Werte
lassen sich außerdem über das Setup-Menü in der Oberfläche zuordnen (siehe
entitaeten.py) - Änderungen von dort werden zur Laufzeit auf diese Variablen
angewendet (überschreiben also die hier hinterlegten Standardwerte), ohne
diese Datei anzufassen.
Die drei ersten (WLAN, Kilometerstand, Tankfüllstand) werden vom Backend 2026-08-12: Die HACS-Integration TommiG1/HA_VAG-EU-Data-Act (bisherige
zwingend gebraucht (§7.1, §7.2, §7.4, §7.5). Alles danach speist nur die Quelle für Kilometerstand, Tankfüllstand, Türen/Fenster/Schlösser,
Übersicht im Frontend (§5.1, §5.2, §9) — bleibt ein Wert leer ("") oder passt Ölwechsel-/Inspektionsdaten) wird nicht mehr verwendet - die zugehörigen
eine Entity-ID nicht zur tatsächlichen Integration, liefert Entity-IDs sind deshalb unten bewusst leer. Neue Datenquelle ist der
zustand_oder_none() in frontend_veroeffentlichung.py für das jeweilige Feld Teltonika FMM003 (GPS-Tracker mit CAN-Anbindung, siehe AGENTS.md Abschnitt
None statt abzustürzen; die Oberfläche zeigt dann „unbekannt" statt eines B); er liefert Standort, Zündungsstatus und Batteriespannung, aber keinen
falschen Werts. Tankfüllstand, keine Tür-/Fenster-/Schlossdaten und keine Ölwechsel-/
Inspektionstermine - die entsprechenden Kacheln zeigen deshalb bis auf
Alle Entity-IDs unten sind an der echten Integration TommiG1/HA_VAG-EU-Data-Act Weiteres "unbekannt" statt eines falschen Werts (siehe zustand_oder_none()
(Version 0.6.35, per HACS installiert) real geprüft, nicht nur vermutet: das in frontend_veroeffentlichung.py). Bleibt ein Wert leer ("") oder passt eine
Gerät heißt dort "Audi RS 4 Avant" (mit Leerzeichen vor der "4") - deshalb Entity-ID nicht zur tatsächlichen Integration, liefert zustand_oder_none()
"rs_4" mit Unterstrich im Slug, nicht "rs4". Eine ältere Integrationsversion für das jeweilige Feld None statt abzustürzen.
hatte hier noch deutsche Feldnamen (z. B. "..._tur_vorne_links"); die aktuelle
Version benennt englisch (z. B. "..._front_left_door").
""" """
# Companion-App-Sensor für das aktuell verbundene WLAN (iOS-App: # Fahrterkennung (§7.1): Start/Ende einer Fahrt wird über den Zündungs-/ACC-
# Einstellungen -> Companion App -> Sensoren -> "Verbundenes WLAN") # Status des FMM003 erkannt (on = Fahrt läuft), nicht mehr über die WLAN-
WLAN_SENSOR = "sensor.iphone_wifi_connection" # Verbindung des iPhones zum Fahrzeug (siehe fahrterkennung.py).
ZUENDUNG_SENSOR = "binary_sensor.testzone_fmm003_engine_ignition_or_acc_status"
# Kilometerstand aus der HACS-Integration TommiG1/HA_VAG-EU-Data-Act # Kilometerstand - bisher aus der TommiG1-Integration, aktuell keine Quelle
KM_SENSOR = "sensor.audi_rs_4_avant_mileage" # vorhanden. Der FMM003 liefert unter sensor.testzone_fmm003_total_calculated_mileage
# einen selbst berechneten Wert, der aber auf einer anderen Zählbasis beruht
# als der echte Fahrzeug-Kilometerstand (GPS-Streckenberechnung statt
# Tacho) - bewusst NICHT automatisch übernommen, um Reifenzähler,
# Ölwechsel-Prognose und Fahrtabschluss-Screening nicht mit einem
# inkonsistenten Basiswert zu verfälschen. Bei Bedarf über das Setup-Menü
# gezielt zuordnen.
KM_SENSOR = ""
# Tankfüllstand aus derselben Integration (Prozent) # Tankfüllstand (Prozent) - keine Quelle mehr vorhanden (der FMM003 ist kein
TANK_SENSOR = "sensor.audi_rs_4_avant_fuel_level" # Tankgeber).
TANK_SENSOR = ""
# Reichweite (§5.1 Übersicht) # Reichweite (§5.1 Übersicht) - keine Quelle mehr vorhanden.
RANGE_SENSOR = "sensor.audi_rs_4_avant_range_primary" RANGE_SENSOR = ""
# 12V-Batteriespannung (Mein Audi -> Zustand). Die aktuell genutzte # 12V-Batteriespannung (Mein Audi -> Zustand). Vom FMM003 geliefert -
# HACS-Integration (TommiG1/HA_VAG-EU-Data-Act 0.6.35) liefert dafür KEINEN # external_power_voltage ist die vom Gerät gemessene Bordnetzspannung des
# Sensor (real geprüft: kein "battery"/"voltage"-Entity unter diesem Gerät) - # Fahrzeugs, NICHT battery_voltage (das ist die interne Pufferbatterie des
# deshalb hier bewusst leer gelassen. Liefert später eine andere Quelle (z. B. # Trackers selbst und hat mit der Fahrzeugbatterie nichts zu tun).
# ein OBD-Adapter) eine Spannung, einfach die Entity-ID eintragen; bis dahin BATTERIE_SENSOR = "sensor.testzone_fmm003_external_power_voltage"
# zeigt die Oberfläche "unbekannt" statt eines falschen Werts (siehe
# zustand_oder_none() in frontend_veroeffentlichung.py).
BATTERIE_SENSOR = ""
# Der von der Integration selbst bereitgestellte Knopf für eine sofortige # Knopf für eine sofortige Neuabfrage beim Fahrzeug - kam aus der
# Neuabfrage beim Fahrzeug (Entity-Domäne "button", real geprüft) - genau der # TommiG1-Integration, keine Entsprechung beim FMM003 vorhanden.
# Weg, den "Ziehen zum Aktualisieren" im Frontend auslöst (siehe REFRESH_BUTTON = ""
# audi_dashboard_jetzt_aktualisieren() in frontend_api.py), statt eines
# generischen homeassistant.update_entity auf einzelne Sensoren.
REFRESH_BUTTON = "button.audi_rs_4_avant_refresh_now"
# Türen (§4.1) - die Integration liefert vier Einzelsensoren statt eines # Türen (§4.1) - keine Quelle mehr vorhanden.
# Summensensors. binary_sensor, device_class "door": "off" = zu (real TUER_SENSOREN = []
# geprüft).
TUER_SENSOREN = [
"binary_sensor.audi_rs_4_avant_front_left_door",
"binary_sensor.audi_rs_4_avant_front_right_door",
"binary_sensor.audi_rs_4_avant_rear_left_door",
"binary_sensor.audi_rs_4_avant_rear_right_door",
]
# Fenster (§4.1) - device_class "window": "off" = zu (real geprüft) # Fenster (§4.1) - keine Quelle mehr vorhanden.
FENSTER_SENSOREN = [ FENSTER_SENSOREN = []
"binary_sensor.audi_rs_4_avant_front_left_window",
"binary_sensor.audi_rs_4_avant_front_right_window",
"binary_sensor.audi_rs_4_avant_rear_left_window",
"binary_sensor.audi_rs_4_avant_rear_right_window",
]
# Verriegelung - kein lock.*-Entity vorhanden, sondern vier # Verriegelung - keine Quelle mehr vorhanden.
# Türschloss-Einzelsensoren. binary_sensor mit device_class "lock" (real TUERSCHLOSS_SENSOREN = []
# geprüft): "on" = entriegelt, "off" = verriegelt (Home-Assistant-Konvention
# für diese device_class).
TUERSCHLOSS_SENSOREN = [
"binary_sensor.audi_rs_4_avant_front_left_door_lock",
"binary_sensor.audi_rs_4_avant_front_right_door_lock",
"binary_sensor.audi_rs_4_avant_rear_left_door_lock",
"binary_sensor.audi_rs_4_avant_rear_right_door_lock",
]
# Heckklappe und Motorhaube - von der Integration separat von den vier Türen # Heckklappe und Motorhaube - keine Quelle mehr vorhanden.
# geführt, für "Sicher abgestellt" (§9) aber ebenso relevant wie diese HECKKLAPPE_SENSOR = ""
HECKKLAPPE_SENSOR = "binary_sensor.audi_rs_4_avant_tailgate" HECKKLAPPENSCHLOSS_SENSOR = ""
HECKKLAPPENSCHLOSS_SENSOR = "binary_sensor.audi_rs_4_avant_tailgate_lock" HAUBE_SENSOR = ""
HAUBE_SENSOR = "binary_sensor.audi_rs_4_avant_hood" HAUBENSCHLOSS_SENSOR = ""
HAUBENSCHLOSS_SENSOR = "binary_sensor.audi_rs_4_avant_hood_lock"
# Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die App-eigene, # Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die App-eigene,
# aus dem Servicebuch berechnete Prognose - siehe fahrzeugMeldung() im # aus dem Servicebuch berechnete Prognose) - keine Quelle mehr vorhanden.
# Frontend). Je ein Datum (ISO mit Zeitzone) und eine Streckenangabe in km; NAECHSTER_OELWECHSEL_SENSOR = ""
# die Streckensensoren liefern negative Werte (Restkilometer als Countdown), OELWECHSEL_STRECKE_SENSOR = ""
# im Frontend wird davon nur der Betrag angezeigt. NAECHSTE_INSPEKTION_SENSOR = ""
NAECHSTER_OELWECHSEL_SENSOR = "sensor.audi_rs_4_avant_oil_change_due" INSPEKTION_STRECKE_SENSOR = ""
OELWECHSEL_STRECKE_SENSOR = "sensor.audi_rs_4_avant_oil_change_distance"
NAECHSTE_INSPEKTION_SENSOR = "sensor.audi_rs_4_avant_inspection_due"
INSPEKTION_STRECKE_SENSOR = "sensor.audi_rs_4_avant_inspection_distance"
# Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel). Erwartet # Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel). Erwartet
# eine device_tracker.*-Entity mit "latitude"/"longitude"-Attributen (HA- # eine device_tracker.*-Entity mit "latitude"/"longitude"-Attributen (HA-
# Konvention, siehe _standort() in frontend_veroeffentlichung.py) - bleibt # Konvention, siehe _standort() in frontend_veroeffentlichung.py) - jetzt
# leer, solange keine Quelle angebunden ist (siehe AGENTS.md Abschnitt B, # vom FMM003 geliefert.
# FMM003/flespi offen); die Oberfläche zeigt dann "kein GPS-Signal" statt STANDORT_TRACKER = "device_tracker.testzone_fmm003"
# eines falschen Werts, genau wie bei BATTERIE_SENSOR oben.
STANDORT_TRACKER = ""
# Update-Funktion (Einstellungen -> "Update suchen", siehe # Update-Funktion (Einstellungen -> "Update suchen", siehe
# updateverwaltung.py): Git-Repository, in das dieses Projekt gepflegt wird - # updateverwaltung.py): Git-Repository, in das dieses Projekt gepflegt wird -
@@ -0,0 +1,188 @@
"""Sensor-zu-Entität-Zuordnung fürs Setup-Menü (Einstellungen -> Fahrzeug
einrichten -> Setup).
Ersetzt NICHT das manuelle Eintragen in einstellungen.py (dessen Kommentare
sind die eigentliche Dokumentation der Integration und bleiben unangetastet),
sondern legt eine zusätzliche, zur Laufzeit angewendete Override-Ebene
darüber: data/entitaeten.json enthält nur die vom Nutzer über das Setup-Menü
geänderten Zuordnungen, keyed nach den einstellungen.py-Variablennamen.
Warum das funktioniert, ohne jede Verbraucher-Datei anzufassen: Alle
bestehenden Leser (frontend_veroeffentlichung.py, fahrterkennung.py, ...)
machen `import einstellungen` und lesen dann `einstellungen.KM_SENSOR` -
ein lebendiger Attributzugriff auf das Modul-Objekt, kein `from einstellungen
import KM_SENSOR` (das würde den Wert beim Import einfrieren). overrides_
anwenden() unten nutzt genau das: setattr(einstellungen, key, wert) ändert
das Attribut auf dem bereits importierten Modul-Objekt, jeder spätere
Zugriff `einstellungen.KM_SENSOR` liefert sofort den neuen Wert.
Eine Ausnahme (siehe TRIGGER_GEBUNDEN unten): mehrere @state_trigger(f"...")-
Dekoratoren backen ZUENDUNG_SENSOR/KM_SENSOR/TANK_SENSOR einmalig beim
Modul-Laden in die Trigger-Registrierung ein. Eine Änderung dieser drei
Felder wirkt für die Fahrterkennung selbst erst nach einem Neustart von
Home Assistant (bzw. pyscript.reload) - das Setup-Menü weist deshalb bei
diesen drei Feldern ausdrücklich darauf hin, statt automatisch und riskant
einen Selbst-Reload aus einer laufenden pyscript-Funktion heraus zu
versuchen.
"""
import io
import json
import os
import einstellungen
BASIS = "/config/audi_dashboard"
OVERRIDES_PFAD = f"{BASIS}/entitaeten.json"
# Vier gleich aufgebaute Positionen, wie schon in frontend_veroeffentlichung.py
# (dort POSITIONEN genannt) - hier dupliziert statt importiert, damit
# entitaeten.py nicht von frontend_veroeffentlichung.py abhängt (wäre die
# falsche Richtung: die Veröffentlichung importiert diese Datei, nicht
# umgekehrt).
POSITIONEN = ["vorne links", "vorne rechts", "hinten links", "hinten rechts"]
# Katalog aller von der App genutzten Entity-Rollen, fürs Setup-Menü im
# Frontend. Reihenfolge = Anzeige-Reihenfolge innerhalb jeder Gruppe.
# domains/device_classes/units sind Erwartungen fürs Frontend-seitige
# Filtern+Vorschlagen (Schalter "Nur passende Sensoren anzeigen") - eine
# fehlende oder leere Liste bedeutet "keine Einschränkung" bzw. "diese Rolle
# hat üblicherweise keine Einheit" (binary_sensor/button/device_tracker).
FELDER = [
{"key": "ZUENDUNG_SENSOR", "label": "Zündung/ACC-Status", "gruppe": "fahrterkennung",
"hinweis": "on = Fahrt läuft, erkennt Fahrtbeginn/-ende (ersetzt die frühere WLAN-Erkennung).",
"domains": ["binary_sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": True,
"stichworte": ["zündung", "ignition", "acc", "motor", "engine"]},
{"key": "KM_SENSOR", "label": "Kilometerstand", "gruppe": "fahrterkennung",
"hinweis": "Gesamt-Kilometerstand des Fahrzeugs - für Fahrtabschluss-Screening, Reifenzähler und Ölwechsel-Prognose.",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
"stichworte": ["kilometer", "km", "mileage", "odometer", "tacho", "laufleistung"]},
{"key": "TANK_SENSOR", "label": "Tankfüllstand", "gruppe": "fahrterkennung",
"hinweis": "Füllstand in Prozent - für die automatische Tankerkennung.",
"domains": ["sensor"], "device_classes": [], "units": ["%"], "liste": False, "pflicht": False,
"stichworte": ["tank", "fuel", "kraftstoff", "füllstand", "level"]},
{"key": "RANGE_SENSOR", "label": "Reichweite", "gruppe": "uebersicht",
"hinweis": "Für die Übersicht - bleibt leer, zeigt die Oberfläche \"unbekannt\".",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
"stichworte": ["reichweite", "range"]},
{"key": "BATTERIE_SENSOR", "label": "12V-Batteriespannung", "gruppe": "uebersicht",
"hinweis": "Nicht jede Integration liefert diesen Wert - bleibt er leer, zeigt \"Mein Audi\" \"unbekannt\".",
"domains": ["sensor"], "device_classes": ["voltage"], "units": ["V"], "liste": False, "pflicht": False,
"stichworte": ["batterie", "battery", "spannung", "volt", "12v"]},
{"key": "REFRESH_BUTTON", "label": "Sofort-Aktualisierung (Knopf)", "gruppe": "uebersicht",
"hinweis": "Der von der Integration bereitgestellte Knopf für \"Ziehen zum Aktualisieren\".",
"domains": ["button"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
"stichworte": ["refresh", "aktualisieren", "update", "abrufen", "poll"]},
{"key": "TUER_SENSOREN", "label": "Türen", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu. Für \"Sicher abgestellt\" wichtig.",
"domains": ["binary_sensor"], "device_classes": ["door"], "units": [], "liste": True, "positionen": POSITIONEN,
"pflicht": False, "stichworte": ["tür", "door"]},
{"key": "FENSTER_SENSOREN", "label": "Fenster", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["window"], "units": [], "liste": True, "positionen": POSITIONEN,
"pflicht": False, "stichworte": ["fenster", "window"]},
{"key": "TUERSCHLOSS_SENSOREN", "label": "Türschlösser", "gruppe": "sicherheit",
"hinweis": "\"on\" = entriegelt, \"aus\"/off = verriegelt (device_class \"lock\"-Konvention).",
"domains": ["binary_sensor"], "device_classes": ["lock"], "units": [], "liste": True, "positionen": POSITIONEN,
"pflicht": False, "stichworte": ["schloss", "lock", "verriegel", "entriegel"]},
{"key": "HECKKLAPPE_SENSOR", "label": "Heckklappe", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
"stichworte": ["heckklappe", "tailgate", "kofferraum", "trunk"]},
{"key": "HECKKLAPPENSCHLOSS_SENSOR", "label": "Heckklappenschloss", "gruppe": "sicherheit",
"hinweis": "\"on\" = entriegelt, \"aus\"/off = verriegelt.",
"domains": ["binary_sensor"], "device_classes": ["lock"], "units": [], "liste": False, "pflicht": False,
"stichworte": ["heckklappe", "tailgate", "schloss", "lock"]},
{"key": "HAUBE_SENSOR", "label": "Motorhaube", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
"stichworte": ["haube", "hood", "bonnet", "motorhaube"]},
{"key": "HAUBENSCHLOSS_SENSOR", "label": "Haubenschloss", "gruppe": "sicherheit",
"hinweis": "\"on\" = entriegelt, \"aus\"/off = verriegelt.",
"domains": ["binary_sensor"], "device_classes": ["lock"], "units": [], "liste": False, "pflicht": False,
"stichworte": ["haube", "hood", "schloss", "lock"]},
{"key": "NAECHSTER_OELWECHSEL_SENSOR", "label": "Nächster Ölwechsel (Datum)", "gruppe": "uebersicht",
"hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit, ergänzt die App-eigene Servicebuch-Prognose.",
"domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False,
"stichworte": ["ölwechsel", "oil", "service", "change"]},
{"key": "OELWECHSEL_STRECKE_SENSOR", "label": "Nächster Ölwechsel (Restkilometer)", "gruppe": "uebersicht",
"hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
"stichworte": ["ölwechsel", "oil", "distance", "strecke"]},
{"key": "NAECHSTE_INSPEKTION_SENSOR", "label": "Nächste Inspektion (Datum)", "gruppe": "uebersicht",
"hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit.",
"domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False,
"stichworte": ["inspektion", "inspection", "service"]},
{"key": "INSPEKTION_STRECKE_SENSOR", "label": "Nächste Inspektion (Restkilometer)", "gruppe": "uebersicht",
"hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
"stichworte": ["inspektion", "inspection", "distance", "strecke"]},
{"key": "STANDORT_TRACKER", "label": "GPS-Standort", "gruppe": "standort",
"hinweis": "Braucht latitude/longitude-Attribute (HA-Konvention für device_tracker).",
"domains": ["device_tracker"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
"stichworte": ["standort", "gps", "tracker", "position", "ortung"]},
]
# Mehrere @state_trigger(f"...")-Dekoratoren (fahrterkennung.py,
# fahrtabschluss.py, tankerkennung.py, reifenzaehler.py) backen diese drei
# Felder beim Modul-Laden ein (siehe Kopfkommentar) - im Setup-Menü als
# "wirkt erst nach Neustart"-Hinweis markiert.
_TRIGGER_GEBUNDEN = {"ZUENDUNG_SENSOR", "KM_SENSOR", "TANK_SENSOR"}
for _feld in FELDER:
_feld["trigger_gebunden"] = _feld["key"] in _TRIGGER_GEBUNDEN
_SCHLUESSEL = {feld["key"] for feld in FELDER}
# --------------------------------------------------------------- JSON-I/O ---
# Gleiches Muster wie profil.profil_lesen()/profil_schreiben(): io.open nur
# über task.executor (pyscript-Einschränkung, siehe Kopfkommentar dort),
# atomares Schreiben über eine .tmp-Datei + os.replace.
def overrides_lesen():
if not os.path.exists(OVERRIDES_PFAD):
return {}
f = task.executor(io.open, OVERRIDES_PFAD, "r")
inhalt = f.read()
f.close()
return json.loads(inhalt) if inhalt.strip() else {}
def overrides_schreiben(mapping):
"""Schreibt nur bekannte Schlüssel (siehe FELDER); unbekannte werden
stillschweigend verworfen, damit ein späterer Katalog-Umbau keine
Datenleichen in entitaeten.json hinterlässt."""
bereinigt = {k: v for k, v in mapping.items() if k in _SCHLUESSEL}
os.makedirs(BASIS, exist_ok=True)
tmp = OVERRIDES_PFAD + ".tmp"
text = json.dumps(bereinigt, ensure_ascii=False, indent=2)
f = task.executor(io.open, tmp, "w")
f.write(text)
f.close()
os.replace(tmp, OVERRIDES_PFAD)
# ------------------------------------------------------- Anwenden/Lesen ---
def overrides_anwenden():
"""setattr(einstellungen, key, wert) für jeden in entitaeten.json
hinterlegten, nicht-leeren Wert - siehe Kopfkommentar, warum das
ausreicht, damit alle bestehenden Verbraucher den neuen Wert sofort
sehen (Ausnahme: die drei TRIGGER_GEBUNDEN-Felder, dort erst nach
Neustart)."""
overrides = overrides_lesen()
for key, wert in overrides.items():
if key not in _SCHLUESSEL:
continue
if wert in (None, "", []):
continue
setattr(einstellungen, key, wert)
def aktueller_stand():
"""Katalog + aktuell wirksamer Wert je Feld (aus dem einstellungen-Modul
selbst gelesen, nicht direkt aus der JSON-Datei) - zeigt damit sowohl
bereits angewendete Overrides als auch die eingebauten Standardwerte
einheitlich. Grundlage für pyscript.audi_dashboard_entitaeten."""
werte = {feld["key"]: getattr(einstellungen, feld["key"], None) for feld in FELDER}
return {"katalog": FELDER, "werte": werte}
@@ -20,6 +20,7 @@ tankvorgaenge_veroeffentlichen() auf einen Zeitraum eingeschränkt werden
""" """
import einstellungen import einstellungen
import entitaeten
import profil import profil
@@ -39,6 +40,12 @@ def batterieverlauf_veroeffentlichen():
state.set("pyscript.audi_dashboard_batterieverlauf", "aktuell", new_attributes={"daten": profil.batterieverlauf_lesen()}) state.set("pyscript.audi_dashboard_batterieverlauf", "aktuell", new_attributes={"daten": profil.batterieverlauf_lesen()})
def entitaeten_veroeffentlichen():
"""Katalog + aktuell wirksame Zuordnung fürs Setup-Menü (Einstellungen ->
Fahrzeug einrichten -> Setup) - siehe entitaeten.py."""
state.set("pyscript.audi_dashboard_entitaeten", "aktuell", new_attributes={"daten": entitaeten.aktueller_stand()})
def beleg_ergebnis_veroeffentlichen(daten): def beleg_ergebnis_veroeffentlichen(daten):
"""Ergebnis eines Beleg-Uploads (geparste Werte fürs Ausfüllen eines """Ergebnis eines Beleg-Uploads (geparste Werte fürs Ausfüllen eines
Entwurfs, oder ein Fehler wie 'Falsches Belegdatum') fürs Frontend, nach Entwurfs, oder ein Fehler wie 'Falsches Belegdatum') fürs Frontend, nach
@@ -190,3 +197,4 @@ def alles_veroeffentlichen():
tankvorgaenge_veroeffentlichen() tankvorgaenge_veroeffentlichen()
fahrzeugstatus_veroeffentlichen() fahrzeugstatus_veroeffentlichen()
batterieverlauf_veroeffentlichen() batterieverlauf_veroeffentlichen()
entitaeten_veroeffentlichen()
+7 -3
View File
@@ -85,9 +85,13 @@ def beim_start():
_fortschreiben_und_veroeffentlichen(_aktueller_km()) _fortschreiben_und_veroeffentlichen(_aktueller_km())
@state_trigger(f"{einstellungen.KM_SENSOR}") # Ohne konfigurierten Kilometerstand-Sensor keinen Trigger registrieren -
def kilometerstand_geaendert(value=None): # eine leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes
_fortschreiben_und_veroeffentlichen(_aktueller_km()) # Verhalten, siehe gleiches Muster in fahrterkennung.py.
if einstellungen.KM_SENSOR:
@state_trigger(f"{einstellungen.KM_SENSOR}")
def kilometerstand_geaendert(value=None):
_fortschreiben_und_veroeffentlichen(_aktueller_km())
@service @service
+20 -16
View File
@@ -67,26 +67,30 @@ def beim_start():
profil.ordner_sicherstellen() profil.ordner_sicherstellen()
@state_trigger(f"{einstellungen.TANK_SENSOR}") # Ohne konfigurierten Tankfüllstand-Sensor keinen Trigger registrieren -
def tankfuellstand_geaendert(value=None, old_value=None): # eine leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes
global _tiefststand_pct # Verhalten, siehe gleiches Muster in fahrterkennung.py.
if einstellungen.TANK_SENSOR:
@state_trigger(f"{einstellungen.TANK_SENSOR}")
def tankfuellstand_geaendert(value=None, old_value=None):
global _tiefststand_pct
aktuell = _als_zahl(value) aktuell = _als_zahl(value)
if aktuell is None: if aktuell is None:
return return
if _tiefststand_pct is None: if _tiefststand_pct is None:
_tiefststand_pct = aktuell _tiefststand_pct = aktuell
return return
if aktuell <= _tiefststand_pct: if aktuell <= _tiefststand_pct:
_tiefststand_pct = aktuell _tiefststand_pct = aktuell
return return
anstieg = aktuell - _tiefststand_pct anstieg = aktuell - _tiefststand_pct
if anstieg >= _schwelle_prozent(): if anstieg >= _schwelle_prozent():
_tankvorgang_automatisch_anlegen(anstieg) _tankvorgang_automatisch_anlegen(anstieg)
_tiefststand_pct = aktuell _tiefststand_pct = aktuell
def _tankvorgang_automatisch_anlegen(anstieg_pct): def _tankvorgang_automatisch_anlegen(anstieg_pct):
+256 -11
View File
@@ -37,6 +37,7 @@ let CAR = null;
let TRIPS = []; let TRIPS = [];
let FILLS = []; let FILLS = [];
let BATTERIEVERLAUF = []; let BATTERIEVERLAUF = [];
let ENTITAETEN = null; // {katalog, werte} aus pyscript.audi_dashboard_entitaeten (Setup-Menü)
let DATEN_GELADEN = false; let DATEN_GELADEN = false;
// Zeitpunkt (ms) des letzten echten Fahrzeugstatus-Updates vom Backend - // Zeitpunkt (ms) des letzten echten Fahrzeugstatus-Updates vom Backend -
// null, solange keiner bekannt ist (siehe standAlterText()). // null, solange keiner bekannt ist (siehe standAlterText()).
@@ -88,7 +89,6 @@ function profilZuConfig(p) {
nachtBis: p.einstellungen.nacht_bis, nachtBis: p.einstellungen.nacht_bis,
pauseMin: p.einstellungen.fahrten_pausenzeit_min, pauseMin: p.einstellungen.fahrten_pausenzeit_min,
markenname: p.einstellungen.kraftstoffanbieter, markenname: p.einstellungen.kraftstoffanbieter,
wlanName: p.fahrzeug.wlan_name,
backupIntervall: p.einstellungen.backup_intervall || "aus", backupIntervall: p.einstellungen.backup_intervall || "aus",
letztesBackup: p.einstellungen.letztes_backup || null, letztesBackup: p.einstellungen.letztes_backup || null,
ausfuehrung: p.fahrzeug.ausfuehrung || "", ausfuehrung: p.fahrzeug.ausfuehrung || "",
@@ -156,7 +156,6 @@ function configCarZuProfil(vorherigesProfil) {
p.fahrzeug.zusatz = CONFIG.fahrzeugzusatz; p.fahrzeug.zusatz = CONFIG.fahrzeugzusatz;
p.fahrzeug.untertitel = CONFIG.fahrzeuguntertitel; p.fahrzeug.untertitel = CONFIG.fahrzeuguntertitel;
p.fahrzeug.kennzeichen = CONFIG.kennzeichen; p.fahrzeug.kennzeichen = CONFIG.kennzeichen;
p.fahrzeug.wlan_name = CONFIG.wlanName;
p.fahrzeug.hauptuntersuchung_faellig = CAR.hu; p.fahrzeug.hauptuntersuchung_faellig = CAR.hu;
p.fahrzeug.fin = CAR.fin; p.fahrzeug.fin = CAR.fin;
p.fahrzeug.erstzulassung = CAR.erstzulassung; p.fahrzeug.erstzulassung = CAR.erstzulassung;
@@ -1754,10 +1753,8 @@ function vEinst() {
<input id="einKennzeichen" value="${esc(CONFIG.kennzeichen || "")}" data-einrichten="kennzeichen"></div> <input id="einKennzeichen" value="${esc(CONFIG.kennzeichen || "")}" data-einrichten="kennzeichen"></div>
<div class="feld"><label for="einErstzulassung">Erstzulassung</label> <div class="feld"><label for="einErstzulassung">Erstzulassung</label>
<input id="einErstzulassung" value="${esc(CAR.erstzulassung || "")}" data-einrichten="erstzulassung" placeholder="MM/JJJJ"></div> <input id="einErstzulassung" value="${esc(CAR.erstzulassung || "")}" data-einrichten="erstzulassung" placeholder="MM/JJJJ"></div>
<div class="feld"><label for="einAusfuehrung">Ausführung</label> <div class="feld" style="border-bottom:0"><label for="einAusfuehrung">Ausführung</label>
<input id="einAusfuehrung" value="${esc(CONFIG.ausfuehrung || "")}" data-einrichten="ausfuehrung"></div> <input id="einAusfuehrung" value="${esc(CONFIG.ausfuehrung || "")}" data-einrichten="ausfuehrung"></div>
<div class="feld" style="border-bottom:0"><label for="einWlan">Fahrzeug-WLAN (SSID)</label>
<input id="einWlan" value="${esc(CONFIG.wlanName || "")}" data-einrichten="wlan" placeholder="z. B. Audi_MMI_XXXX_5GHz"></div>
<span class="label" style="margin-top:20px">Ölwechsel-Intervall</span> <span class="label" style="margin-top:20px">Ölwechsel-Intervall</span>
<div class="seg" style="margin-top:8px"> <div class="seg" style="margin-top:8px">
<button data-oel="hersteller" class="${CAR.oel.modus === "hersteller" ? "on" : ""}">Herstellervorgabe</button> <button data-oel="hersteller" class="${CAR.oel.modus === "hersteller" ? "on" : ""}">Herstellervorgabe</button>
@@ -1773,8 +1770,9 @@ function vEinst() {
<select id="oelKm" data-oelkm>${[5000, 10000, 15000, 20000].map((k) => `<option value="${k}" ${k === CAR.oel.km ? "selected" : ""}>${de(k)} km</option>`).join("")}</select></div> <select id="oelKm" data-oelkm>${[5000, 10000, 15000, 20000].map((k) => `<option value="${k}" ${k === CAR.oel.km ? "selected" : ""}>${de(k)} km</option>`).join("")}</select></div>
<div class="feld" style="border-bottom:0"><label for="oelM">Nach Zeit</label> <div class="feld" style="border-bottom:0"><label for="oelM">Nach Zeit</label>
<select id="oelM" data-oelm>${[12, 24].map((m) => `<option value="${m}" ${m === CAR.oel.monate ? "selected" : ""}>${m === 12 ? "1 Jahr" : "2 Jahre"}</option>`).join("")}</select></div>`} <select id="oelM" data-oelm>${[12, 24].map((m) => `<option value="${m}" ${m === CAR.oel.monate ? "selected" : ""}>${m === 12 ? "1 Jahr" : "2 Jahre"}</option>`).join("")}</select></div>`}
<button class="aktion" style="margin-top:20px" data-setup-oeffnen>Setup — Sensoren zuordnen</button>
` : ""} ` : ""}
<button class="aktion ${einrichtenOffen ? "primaer" : ""}" data-einrichten-auf style="margin-top:16px">${einrichtenOffen ? "Fertig" : "Einrichten"}</button> <button class="aktion ${einrichtenOffen ? "primaer" : ""}" style="margin-top:16px" data-einrichten-auf>${einrichtenOffen ? "Fertig" : "Einrichten"}</button>
</div> </div>
<div class="tile"><span class="label">Darstellung</span> <div class="tile"><span class="label">Darstellung</span>
<div class="seg"> <div class="seg">
@@ -1906,6 +1904,94 @@ let nmOffen = false;
let einrichtenOffen = false; let einrichtenOffen = false;
let bildMenuOffen = null; let bildMenuOffen = null;
let smartdealPopupOffen = false; let smartdealPopupOffen = false;
/* --------------------------------------------------------- Setup-Popup
Sensor-zu-Entität-Zuordnung (Einstellungen -> Fahrzeug einrichten ->
Setup). setupZuordnung ist eine Arbeitskopie, die erst bei "Speichern"
an den Service geht - Abbrechen verwirft sie einfach ohne Nebeneffekt. */
let setupOffen = false;
let setupZuordnung = null; // {KEY: "entity.id"} bzw. {KEY: [4 IDs]} für Listenfelder
let setupNurPassend = true; // globaler Schalter: Kandidaten auf passende Domain/Einheit einschränken
let setupSucheOffen = null; // Feld-Id ("KEY" oder "KEY:idx") des offenen Dropdowns, sonst null
let setupSuchtext = "";
let setupSpeichertLaeuft = false;
let ALLE_ENTITAETEN = [];
function setupKatalog() { return (ENTITAETEN && ENTITAETEN.katalog) || []; }
function alleEntitaeten() {
if (!HASS || !HASS.states) return [];
return Object.keys(HASS.states).map((id) => {
const attrs = (HASS.states[id] && HASS.states[id].attributes) || {};
return { id, name: attrs.friendly_name || id, domain: id.split(".")[0], unit: attrs.unit_of_measurement || "", deviceClass: attrs.device_class || "" };
});
}
function entitaetName(id) {
if (!id) return "";
const treffer = ALLE_ENTITAETEN.find((e) => e.id === id);
return treffer ? treffer.name : id;
}
/* Höher = besser passend: Stichwort- und Freitext-Treffer zählen am meisten,
Domain/Einheit/Device-Class sind Zusatzpunkte fürs Sortieren, wenn der
Schalter "Nur passende Sensoren" aus ist (dann filtern sie nicht hart,
siehe entitaetKandidaten()). */
function entitaetScore(feld, e, suchtext) {
let score = 0;
const hay = (e.id + " " + e.name).toLowerCase();
for (const wort of feld.stichworte || []) if (hay.includes(wort)) score += 5;
if (feld.domains && feld.domains.includes(e.domain)) score += 3;
if (feld.device_classes && feld.device_classes.length && feld.device_classes.includes(e.deviceClass)) score += 3;
if (feld.units && feld.units.length && feld.units.includes(e.unit)) score += 2;
if (suchtext && hay.includes(suchtext.trim().toLowerCase())) score += 10;
return score;
}
function entitaetKandidaten(feldKey, suchtext, aktuellerWert) {
const feld = setupKatalog().find((f) => f.key === feldKey);
if (!feld) return [];
const text = (suchtext || "").trim().toLowerCase();
const liste = ALLE_ENTITAETEN.filter((e) => {
if (e.id === aktuellerWert) return true;
if (setupNurPassend) {
if (feld.domains && feld.domains.length && !feld.domains.includes(e.domain)) return false;
if (feld.units && feld.units.length && !feld.units.includes(e.unit)) return false;
}
if (text && !(e.id.toLowerCase().includes(text) || e.name.toLowerCase().includes(text))) return false;
return true;
});
liste.sort((a, b) => entitaetScore(feld, b, suchtext) - entitaetScore(feld, a, suchtext));
return liste.slice(0, 60);
}
function setupOeffnen() {
ALLE_ENTITAETEN = alleEntitaeten();
setupNurPassend = true;
const werte = (ENTITAETEN && ENTITAETEN.werte) || {};
const zuordnung = {};
for (const feld of setupKatalog()) {
if (feld.liste) {
zuordnung[feld.key] = feld.positionen.map((_, i) => {
const vorhanden = (werte[feld.key] || [])[i];
if (vorhanden) return vorhanden;
const vorschlag = entitaetKandidaten(feld.key, "", "")[0];
return vorschlag && entitaetScore(feld, vorschlag, "") >= 3 ? vorschlag.id : "";
});
} else {
const vorhanden = werte[feld.key];
if (vorhanden) { zuordnung[feld.key] = vorhanden; continue; }
const vorschlag = entitaetKandidaten(feld.key, "", "")[0];
zuordnung[feld.key] = vorschlag && entitaetScore(feld, vorschlag, "") >= 3 ? vorschlag.id : "";
}
}
setupZuordnung = zuordnung;
setupSucheOffen = null; setupSuchtext = ""; setupSpeichertLaeuft = false;
setupOffen = true; render();
}
function setupSchliessen() {
setupOffen = false; setupSucheOffen = null; setupZuordnung = null; render();
}
function vReifen() { function vReifen() {
const r = CAR.reifen; const r = CAR.reifen;
const feld = (k, schluessel, bez, typ) => { const feld = (k, schluessel, bez, typ) => {
@@ -1994,10 +2080,11 @@ function vTripsFormular() {
function vTrips() { function vTrips() {
const jahre = [...new Set(TRIPS.map((t) => new Date(t.ts_start).getFullYear()))].sort((a, b) => b - a); const jahre = [...new Set(TRIPS.map((t) => new Date(t.ts_start).getFullYear()))].sort((a, b) => b - a);
/* D5: Leerzustand nennt die Ursache - die Fahrtenerkennung greift erst, wenn /* D5: Leerzustand nennt die Ursache - die Fahrtenerkennung greift erst, wenn
das Telefon einmal im Fahrzeug-WLAN war. */ die Zündungs-Entität (Setup -> Für die Fahrterkennung nötig) erkennt,
dass der Motor läuft. */
if (!jahre.length) return `<div class="tile"><div class="leer"> if (!jahre.length) return `<div class="tile"><div class="leer">
<strong>Noch keine Fahrten</strong> <strong>Noch keine Fahrten</strong>
<p>Fahrten werden erkannt, sobald sich das Telefon mit dem WLAN des Fahrzeugs verbindet (${esc(CONFIG.wlanName || "noch nicht eingerichtet")}). Bis dahin lässt sich jede Fahrt von Hand erfassen.</p> <p>Fahrten werden erkannt, sobald der Motor gestartet wird. Bis dahin lässt sich jede Fahrt von Hand erfassen.</p>
</div></div> </div></div>
${vTripsFormular()}`; ${vTripsFormular()}`;
let h = `<div class="tile">`; let h = `<div class="tile">`;
@@ -2546,6 +2633,89 @@ function sheetMarkup() {
</div>`; </div>`;
} }
/* ------------------------------------------------------------ Setup-Popup */
const LUPE_SVG = `<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><circle cx="8.5" cy="8.5" r="6"/><path d="m17 17-4.3-4.3"/></svg>`;
function entitaetZeilenMarkup(feldId, key, suchtext, aktuellerWert) {
const kandidaten = entitaetKandidaten(key, suchtext, aktuellerWert);
if (!kandidaten.length) return `<div class="entitaet-leer">Keine passende Entität gefunden</div>`;
return kandidaten.map((e) => `
<button type="button" class="entitaet-eintrag ${e.id === aktuellerWert ? "aktiv" : ""}" data-setup-wahl="${esc(feldId)}" data-setup-entity="${esc(e.id)}">
<span class="entitaet-eintrag-name">${esc(e.name)}</span>
<span class="entitaet-eintrag-id">${esc(e.id)}${e.unit ? " · " + esc(e.unit) : ""}</span>
</button>`).join("");
}
function entitaetCombo(key, idx) {
const feldId = idx == null ? key : `${key}:${idx}`;
const wert = idx == null ? setupZuordnung[key] : (setupZuordnung[key] || [])[idx];
const offen = setupSucheOffen === feldId;
const anzeige = offen ? setupSuchtext : entitaetName(wert);
return `<div class="entitaet-combo">
<span class="entitaet-combo-icon">${LUPE_SVG}</span>
<input type="text" class="entitaet-combo-input" autocomplete="off" spellcheck="false"
placeholder="Entität suchen …" value="${esc(anzeige)}" data-setup-suche="${esc(feldId)}">
${offen ? `<div class="entitaet-liste" data-setup-liste="${esc(feldId)}">${entitaetZeilenMarkup(feldId, key, setupSuchtext, wert)}</div>` : ""}
</div>`;
}
function setupFeldzeile(feld) {
const kopf = `<div class="setup-feld-kopf">
<span class="setup-feld-label">${esc(feld.label)}</span>
<span class="setup-feld-hinweis">${esc(feld.hinweis)}</span>
${feld.trigger_gebunden ? `<span class="setup-feld-neustart">Wirkt nach dem Speichern erst nach einem Neustart von Home Assistant.</span>` : ""}
</div>`;
if (feld.liste) {
return `<div class="setup-feldzeile">${kopf}
<div class="setup-unterfelder">
${feld.positionen.map((pos, i) => `
<div class="setup-unterfeld">
<span class="setup-unterfeld-label">${esc(pos)}</span>
${entitaetCombo(feld.key, i)}
</div>`).join("")}
</div>
</div>`;
}
return `<div class="setup-feldzeile">${kopf}${entitaetCombo(feld.key, null)}</div>`;
}
const SETUP_GRUPPEN = [
["fahrterkennung", "Für die Fahrterkennung nötig"],
["uebersicht", "Übersicht & Status"],
["sicherheit", "Sicherheit"],
["standort", "Standort"],
];
function vSetupPopup() {
if (!setupOffen) return "";
const katalog = setupKatalog();
return `<div class="setup-catcher" data-setup-abbrechen></div>
<div class="setup-popup" role="dialog" aria-modal="true" aria-label="Setup">
<div class="setup-kopf">
<strong>Setup</strong>
<span>Sensoren dieser Home-Assistant-Instanz den App-Funktionen zuordnen.</span>
<div class="setup-kopf-schalter">
<label class="switch"><input type="checkbox" data-setup-nurpassend ${setupNurPassend ? "checked" : ""}><span class="track"></span></label>
<span>Nur passende Sensoren anzeigen</span>
</div>
</div>
<div class="setup-koerper">
${SETUP_GRUPPEN.map(([key, titel]) => {
const felder = katalog.filter((f) => f.gruppe === key);
if (!felder.length) return "";
return `<div class="setup-gruppe">
<div class="setup-gruppe-titel">${esc(titel)}</div>
${felder.map((feld) => setupFeldzeile(feld)).join("")}
</div>`;
}).join("")}
</div>
<div class="setup-fuss">
<button type="button" class="aktion" data-setup-abbrechen>Abbrechen</button>
<button type="button" class="aktion primaer" data-setup-speichern>${setupSpeichertLaeuft ? "Speichert …" : "Speichern"}</button>
</div>
</div>`;
}
let letzteAnsicht = null; let letzteAnsicht = null;
let routeStack = []; let routeStack = [];
let navRichtung = null; let navRichtung = null;
@@ -2595,7 +2765,23 @@ function render() {
v.classList.add(navRichtung === "raus" ? "push-raus" : "push-rein"); v.classList.add(navRichtung === "raus" ? "push-raus" : "push-rein");
} }
navRichtung = null; navRichtung = null;
ROOT.getElementById("overlay").innerHTML = sheetMarkup(); // .setup-koerper ist ein eigener Scroll-Container innerhalb des Overlays -
// die innerHTML-Ersetzung gleich darunter erzeugt ihn neu (scrollTop
// faellt sonst bei jedem render(), z. B. beim Umschalten des Filters,
// auf 0 zurueck).
const setupKoerperScroll = setupOffen ? (ROOT.querySelector(".setup-koerper") || {}).scrollTop : 0;
ROOT.getElementById("overlay").innerHTML = sheetMarkup() + vSetupPopup();
if (setupOffen) {
const koerper = ROOT.querySelector(".setup-koerper");
if (koerper) koerper.scrollTop = setupKoerperScroll || 0;
}
// Ein offenes Setup-Suchfeld verliert bei jedem render() sein DOM-Element
// (v.innerHTML-Ersatz) - hier neu fokussieren, Cursor ans Ende, sonst
// müsste man nach jedem Tippen erneut anklicken.
if (setupOffen && setupSucheOffen) {
const feld = ROOT.querySelector(`[data-setup-suche="${setupSucheOffen}"]`);
if (feld) { feld.focus(); const len = feld.value.length; feld.setSelectionRange(len, len); }
}
/* D4: die Ringe sind nur auf den Tab-Ebenen sichtbar - auf Unterseiten steht /* D4: die Ringe sind nur auf den Tab-Ebenen sichtbar - auf Unterseiten steht
dort der Zurück-Pfeil. */ dort der Zurück-Pfeil. */
ROOT.getElementById("marke").style.display = istTab(route.name) ? "inline-flex" : "none"; ROOT.getElementById("marke").style.display = istTab(route.name) ? "inline-flex" : "none";
@@ -2651,7 +2837,7 @@ function randwischenVerdrahten() {
const phone = ROOT.querySelector(".phone"); const phone = ROOT.querySelector(".phone");
phone.addEventListener("pointerdown", (e) => { phone.addEventListener("pointerdown", (e) => {
randX0 = null; randX0 = null;
if (!e.isPrimary || sheet) return; if (!e.isPrimary || sheet || setupOffen) return;
const links = phone.getBoundingClientRect().left; const links = phone.getBoundingClientRect().left;
if (e.clientX - links <= 26 && !istTab(route.name)) { randX0 = e.clientX; randY0 = e.clientY; } if (e.clientX - links <= 26 && !istTab(route.name)) { randX0 = e.clientX; randY0 = e.clientY; }
}); });
@@ -2876,6 +3062,46 @@ function ereignisseVerdrahten() {
return; return;
} }
if (sheet) return; if (sheet) return;
if (e.target.closest("[data-setup-oeffnen]")) { setupOeffnen(); return; }
if (setupOffen) {
if (e.target.closest("[data-setup-abbrechen]")) { setupSchliessen(); return; }
const wahl = e.target.closest("[data-setup-wahl]");
if (wahl) {
const feldId = wahl.dataset.setupWahl, entityId = wahl.dataset.setupEntity;
const teile = feldId.split(":");
if (teile.length === 2) setupZuordnung[teile[0]][Number(teile[1])] = entityId;
else setupZuordnung[feldId] = entityId;
setupSucheOffen = null; setupSuchtext = ""; render();
return;
}
if (e.target.closest("[data-setup-speichern]")) {
if (setupSpeichertLaeuft) return;
setupSpeichertLaeuft = true; render();
(async () => {
try {
await HASS.callService("pyscript", "audi_dashboard_entitaeten_schreiben", { zuordnung_json: JSON.stringify(setupZuordnung) });
} catch (err) {
setupSpeichertLaeuft = false; hinweis("Speichern fehlgeschlagen", err.message); return;
}
setupOffen = false; setupSucheOffen = null; setupZuordnung = null; setupSpeichertLaeuft = false; render();
})();
return;
}
// Klick aufs (noch geschlossene) Suchfeld öffnet dessen Dropdown; ein
// Klick, während es schon offen ist (z. B. Cursor neu setzen), soll
// keinen render() auslösen - würde die laufende Eingabe unterbrechen.
const sucheInput = e.target.closest("[data-setup-suche]");
if (sucheInput) {
const feldId = sucheInput.dataset.setupSuche;
if (setupSucheOffen !== feldId) { setupSucheOffen = feldId; setupSuchtext = ""; render(); }
return;
}
// Klick irgendwo sonst im Popup (aber nicht auf den Catcher, der schon
// oben behandelt wurde) schließt ein offenes Dropdown, ohne das ganze
// Popup zu verlassen.
if (setupSucheOffen && !e.target.closest(".entitaet-combo")) { setupSucheOffen = null; setupSuchtext = ""; render(); return; }
return;
}
const bvReset = e.target.closest("[data-bvreset]"); const bvReset = e.target.closest("[data-bvreset]");
if (bvReset) { bvDomain = [...bvVollDomain]; bvAusgewaehlt = null; bvZeichnen(); return; } if (bvReset) { bvDomain = [...bvVollDomain]; bvAusgewaehlt = null; bvZeichnen(); return; }
// Nachlaufender Klick derselben Geste (Maus feuert ihn trotz Bewegung // Nachlaufender Klick derselben Geste (Maus feuert ihn trotz Bewegung
@@ -3155,6 +3381,20 @@ function ereignisseVerdrahten() {
}); });
ROOT.addEventListener("input", (e) => { ROOT.addEventListener("input", (e) => {
const sucheFeld = e.target.dataset.setupSuche;
if (sucheFeld) {
// Nur die Kandidatenliste patchen, nicht render() - ein voller
// Re-Render würde bei jedem Tastendruck das Eingabefeld neu erzeugen
// und den Fokus verlieren (gleiches Problem/gleiche Lösung wie beim
// bestehenden data-tankpreisfeld-Handler direkt hier drunter).
setupSuchtext = e.target.value;
const teile = sucheFeld.split(":");
const key = teile[0];
const wert = teile.length === 2 ? (setupZuordnung[key] || [])[Number(teile[1])] : setupZuordnung[key];
const liste = ROOT.querySelector(`[data-setup-liste="${sucheFeld}"]`);
if (liste) liste.innerHTML = entitaetZeilenMarkup(sucheFeld, key, setupSuchtext, wert);
return;
}
const praefix = e.target.dataset.tankpreisfeld; const praefix = e.target.dataset.tankpreisfeld;
if (!praefix) return; if (!praefix) return;
const liter = parseFloat((ROOT.getElementById(`${praefix}Liter`).value || "0").replace(",", ".")); const liter = parseFloat((ROOT.getElementById(`${praefix}Liter`).value || "0").replace(",", "."));
@@ -3223,7 +3463,6 @@ function ereignisseVerdrahten() {
if (ein) { if (ein) {
if (ein === "kennzeichen") CONFIG.kennzeichen = e.target.value; if (ein === "kennzeichen") CONFIG.kennzeichen = e.target.value;
else if (ein === "ausfuehrung") CONFIG.ausfuehrung = e.target.value; else if (ein === "ausfuehrung") CONFIG.ausfuehrung = e.target.value;
else if (ein === "wlan") CONFIG.wlanName = e.target.value;
else if (ein === "modell") CONFIG.fahrzeugtitel = e.target.value; else if (ein === "modell") CONFIG.fahrzeugtitel = e.target.value;
else CAR[ein] = e.target.value; else CAR[ein] = e.target.value;
profilSpeichern(); return; profilSpeichern(); return;
@@ -3282,6 +3521,7 @@ function ereignisseVerdrahten() {
if (e.target.dataset.oelkm !== undefined) { CAR.oel.km = parseInt(e.target.value, 10); profilSpeichern(); return; } if (e.target.dataset.oelkm !== undefined) { CAR.oel.km = parseInt(e.target.value, 10); profilSpeichern(); return; }
if (e.target.dataset.oelm !== undefined) { CAR.oel.monate = parseInt(e.target.value, 10); profilSpeichern(); return; } if (e.target.dataset.oelm !== undefined) { CAR.oel.monate = parseInt(e.target.value, 10); profilSpeichern(); return; }
if (e.target.dataset.boden !== undefined) { bodenAn = e.target.checked; render(); return; } if (e.target.dataset.boden !== undefined) { bodenAn = e.target.checked; render(); return; }
if (e.target.dataset.setupNurpassend !== undefined) { setupNurPassend = e.target.checked; render(); return; }
if (e.target.dataset.startbild !== undefined) { CONFIG.startbild = e.target.value; profilSpeichern(); render(); return; } if (e.target.dataset.startbild !== undefined) { CONFIG.startbild = e.target.value; profilSpeichern(); render(); return; }
if (e.target.dataset.svsel !== undefined) { svAuswahl = parseInt(e.target.value, 10); render(); return; } if (e.target.dataset.svsel !== undefined) { svAuswahl = parseInt(e.target.value, 10); render(); return; }
}); });
@@ -3388,6 +3628,11 @@ async function datenLaden(erzwingen) {
const statusState = HASS.states["pyscript.audi_dashboard_fahrzeugstatus"] || NACHGELADEN.status; const statusState = HASS.states["pyscript.audi_dashboard_fahrzeugstatus"] || NACHGELADEN.status;
const battState = HASS.states["pyscript.audi_dashboard_batterieverlauf"] || NACHGELADEN.batt; const battState = HASS.states["pyscript.audi_dashboard_batterieverlauf"] || NACHGELADEN.batt;
const belegState = HASS.states["pyscript.audi_dashboard_beleg_ergebnis"]; const belegState = HASS.states["pyscript.audi_dashboard_beleg_ergebnis"];
// Unabhängig vom geaendert-Block unten gelesen (der trackt nur profil/
// fahrten/tank/status/batt) - ändert sich erst nach einem Setup-Speichern,
// nicht Teil des normalen Update-Rhythmus.
const entitaetenState = HASS.states["pyscript.audi_dashboard_entitaeten"];
if (entitaetenState && entitaetenState.attributes) ENTITAETEN = entitaetenState.attributes.daten;
if (belegState && belegState !== letzterBelegState) { if (belegState && belegState !== letzterBelegState) {
letzterBelegState = belegState; letzterBelegState = belegState;
@@ -1 +1 @@
{"version": 1786524729} {"version": 1786536000}
+148 -24
View File
@@ -229,13 +229,20 @@ main#view::-webkit-scrollbar { width: 0; height: 0; }
transition: color .15s; transition: color .15s;
} }
.tab.on { color: var(--fg); } .tab.on { color: var(--fg); }
/* Die Pille ist ein eigenes Feld um das Symbol - so bleibt ihre Groesse
unabhaengig vom Symbol und der Wechsel laeuft sauber ueber background. */
.tabpille {
display: flex; align-items: center; justify-content: center;
width: 60px; height: 34px; border-radius: var(--r-pill);
transition: background .18s;
}
.tab.on .tabpille { background: var(--tile-2); }
.tab svg { .tab svg {
fill: none; stroke: currentColor; stroke-width: 1.6; fill: none; stroke: currentColor; stroke-width: 1.6;
stroke-linecap: round; stroke-linejoin: round; stroke-linecap: round; stroke-linejoin: round;
border-radius: var(--r-pill); transition: transform .18s;
transition: box-shadow .18s, background .18s;
} }
.tab.on svg { background: var(--tile-2); box-shadow: 0 0 0 11px var(--tile-2); } .tab svg.voll { fill: currentColor; stroke: none; }
.tab:active svg { transform: scale(.9); } .tab:active svg { transform: scale(.9); }
.tab span { font-size: 11px; letter-spacing: .01em; } .tab span { font-size: 11px; letter-spacing: .01em; }
.tabbar.ohne .tab span { display: none; } .tabbar.ohne .tab span { display: none; }
@@ -664,11 +671,12 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
} }
.fahrzeug-pin-wrap { background: none; border: none; } .fahrzeug-pin-wrap { background: none; border: none; }
/* poi-car aus dem CI-Satz - rotes Symbol, weiss abgesetzt statt in einem
Kasten, damit die Spitze exakt auf der Position steht. */
.fahrzeug-pin { .fahrzeug-pin {
width: 34px; height: 34px; border-radius: 10px; color: var(--red);
background: var(--red); border: 2px solid #fff;
box-shadow: 0 3px 10px rgba(0,0,0,.35);
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
filter: drop-shadow(0 0 1px #fff) drop-shadow(0 0 2px #fff) drop-shadow(0 2px 5px rgba(0,0,0,.45));
} }
.user-pin-wrap { background: none; border: none; } .user-pin-wrap { background: none; border: none; }
.user-pin { .user-pin {
@@ -688,45 +696,67 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
.kartensteuerung { .kartensteuerung {
position: absolute; z-index: 400; top: 16px; right: 16px; position: absolute; z-index: 400; top: 16px; right: 16px;
display: flex; flex-direction: column; gap: 10px; display: flex; flex-direction: column; gap: 12px;
} }
.kartensteuerung button { .kartensteuerung button {
width: 42px; height: 42px; border-radius: 50%; width: 48px; height: 48px; border-radius: 50%;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
background: var(--tile); color: var(--fg); background: rgba(16, 20, 26, .84); color: #fff;
border: none; box-shadow: 0 3px 10px rgba(0,0,0,.25); border: none; box-shadow: 0 4px 14px rgba(0,0,0,.32);
-webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
cursor: pointer; padding: 0; cursor: pointer; padding: 0;
} }
.kartensteuerung button:active { background: var(--tile-2); transform: scale(.94); } /* Tag-Theme: helle Kapsel mit dunklem Symbol - die CI-Symbole selbst tragen
fill="currentColor" und folgen der Textfarbe, es gibt keine zweite Datei. */
:host([data-theme="tag"]) .kartensteuerung button {
background: rgba(255,255,255,.92); color: #101418;
box-shadow: 0 4px 14px rgba(0,0,0,.18);
}
.kartensteuerung button:active { transform: scale(.94); }
:host([data-theme="tag"]) .kartensteuerung button:active { background: #fff; }
/* Griffzone/Zug-Amplitude (STANDORT_PEEK_PX im Frontend) - beide Werte /* Sichtbarer Anteil im geschlossenen Zustand (STANDORT_PEEK_PX im Frontend) -
müssen zusammenpassen, siehe Kommentar dort. */ beide Werte müssen zusammenpassen, siehe Kommentar dort. Der Rest des
Blatts wird nach unten geschoben und mit einem Zug nach oben geöffnet. */
.standortmenu { .standortmenu {
position: absolute; z-index: 410; left: 0; right: 0; bottom: 0; position: absolute; z-index: 410; left: 0; right: 0; bottom: 0;
background: var(--tile-2); --standort-peek: 96px;
/* Deckend, nicht durchscheinend - unter dem Blatt liegt die Karte. */
background: var(--tile-deckend, var(--canvas));
border-radius: 20px 20px 0 0; border-radius: 20px 20px 0 0;
box-shadow: 0 -6px 24px rgba(0,0,0,.35); box-shadow: 0 -6px 24px rgba(0,0,0,.35);
padding: 0 20px max(18px, env(safe-area-inset-bottom)); padding: 0 20px max(18px, env(safe-area-inset-bottom));
transform: translateY(108px); transform: translateY(calc(100% - var(--standort-peek)));
transition: transform .28s cubic-bezier(.22,.61,.36,1); transition: transform .28s cubic-bezier(.22,.61,.36,1);
} }
.standortmenu.offen { transform: translateY(0); } .standortmenu.offen { transform: translateY(0); }
.standortmenu-griffzone { padding: 10px 0 14px; touch-action: none; cursor: grab; position: relative; } .standortmenu-griffzone { padding: 10px 0 14px; touch-action: none; cursor: grab; position: relative; }
.standortmenu-griff { width: 36px; height: 4px; border-radius: 2px; background: var(--line-strong); margin: 0 auto; } .standortmenu-griff { width: 36px; height: 4px; border-radius: 2px; background: var(--line-strong); margin: 0 auto; }
.standortmenu-schliessen { .standortmenu-schliessen {
position: absolute; top: 6px; right: 0; width: 30px; height: 30px; border-radius: 50%; position: absolute; top: 14px; right: 0; width: 32px; height: 32px; border-radius: 50%;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
background: none; border: none; color: var(--fg3); cursor: pointer; padding: 0; background: var(--tile); border: none; color: var(--fg2); cursor: pointer; padding: 0;
} }
.standortmenu-kopf { margin-top: 6px; } .standortmenu-kopf { margin-top: 8px; padding-right: 44px; }
.standortmenu-distanz { font-size: 12px; color: var(--fg3); } .standortmenu-name { font-size: 17px; color: var(--fg); }
.standortmenu-name { font-size: 17px; color: var(--fg); margin-top: 2px; } .standortmenu-distanz { font-size: 12.5px; color: var(--fg3); margin-top: 4px; }
.standortmenu-adresse { font-size: 13.5px; color: var(--fg2); margin-top: 3px; } /* Abstand als leere Zeile zwischen Kopf und Details */
.standortmenu-status { display: flex; align-items: center; gap: 9px; margin-top: 16px; font-size: 14px; color: var(--fg); } .standortmenu-adresse { font-size: 13.5px; color: var(--fg2); margin-top: 20px; }
.standortmenu-status { display: flex; align-items: center; gap: 9px; margin-top: 8px; font-size: 13.5px; color: var(--fg2); }
.standortmenu-status svg { color: var(--fg3); flex: 0 0 auto; }
.dot.neutral { background: var(--fg3); } .dot.neutral { background: var(--fg3); }
.standortmenu-werte { display: flex; gap: 30px; margin-top: 16px; } .standortmenu-werte { display: flex; align-items: center; gap: 10px; margin-top: 8px; font-size: 13.5px; color: var(--fg2); }
.standortmenu-werte svg { color: var(--fg3); flex: 0 0 auto; }
.standortmenu-aktionen { display: flex; gap: 10px; margin-top: 18px; padding-bottom: 4px; } .standortmenu-aktionen { display: flex; gap: 10px; margin-top: 18px; padding-bottom: 4px; }
.standortmenu-aktionen .aktion { flex: 1; margin-top: 0; } .standort-pille {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
height: 40px; padding: 0 20px; border-radius: var(--r-pill);
border: none; background: var(--tile); color: var(--fg);
font-family: inherit; font-size: 14px; text-decoration: none; cursor: pointer;
}
.standort-pille.primaer { background: var(--line-strong); }
.standort-pille:active { transform: scale(.97); }
.standort-pille[disabled] { opacity: .4; pointer-events: none; }
/* ------------------------------------------------------- Bilderverwaltung */ /* ------------------------------------------------------- Bilderverwaltung */
.bildgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-4); margin-top: var(--sp-4); } .bildgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-4); margin-top: var(--sp-4); }
@@ -811,6 +841,100 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
} }
.sheet-abbrechen:active { background: var(--shade); } .sheet-abbrechen:active { background: var(--shade); }
/* ------------------------------------------------------- Setup-Popup
Sensor-zu-Entität-Zuordnung (Einstellungen -> Fahrzeug einrichten ->
Setup). Gleiche Catcher/Karten-Bauweise wie .bildmenu/.sdpopup oben,
aber großformatig und scrollbar statt Bottom-Sheet-Größe; nutzt die
bereits vorhandenen sheet-fade/sheet-rein-Keyframes. z-index bewusst
unter .sheet (1200er), damit ein Fehlerhinweis (hinweis()) bei
fehlgeschlagenem Speichern über dem noch offenen Setup-Popup liegt. */
.setup-catcher {
position: absolute; inset: 0; z-index: 1000;
background: rgba(0,0,0,.45);
animation: sheet-fade .18s ease-out;
}
.setup-popup {
position: absolute; z-index: 1001;
left: 10px; right: 10px;
top: max(10px, env(safe-area-inset-top));
bottom: max(10px, env(safe-area-inset-bottom));
/* --tile-2 ist inzwischen transluzent (rgba(255,255,255,.1) im Nacht-
Theme) - fuer ein frei ueber dem Inhalt schwebendes Popup braucht es
einen blickdichten Hintergrund, gleiches Muster wie .standortmenu
oben. */
background: var(--tile-deckend, var(--canvas));
border-radius: 20px;
display: flex; flex-direction: column;
overflow: hidden;
animation: sheet-rein .24s cubic-bezier(.22,.61,.36,1);
}
.setup-kopf {
flex: 0 0 auto;
padding: 18px 20px 14px;
border-bottom: 1px solid var(--line);
display: flex; flex-direction: column; gap: 4px;
}
.setup-kopf strong { font-size: 17px; font-weight: 400; color: var(--fg); }
.setup-kopf span { font-size: 13px; color: var(--fg2); line-height: 1.45; }
.setup-kopf-schalter { display: flex; align-items: center; gap: 10px; margin-top: 10px; }
.setup-kopf-schalter span { font-size: 13.5px; color: var(--fg); }
.setup-koerper { flex: 1 1 0; min-height: 0; overflow-y: auto; padding: 4px 20px 20px; }
.setup-gruppe { margin-top: 18px; }
.setup-gruppe:first-child { margin-top: 6px; }
.setup-gruppe-titel { font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--fg3); margin-bottom: 6px; }
.setup-feldzeile { padding: 12px 0; border-bottom: 1px solid var(--line); }
.setup-feldzeile:last-child { border-bottom: none; }
.setup-feld-kopf { display: flex; flex-direction: column; gap: 2px; margin-bottom: 8px; }
.setup-feld-label { font-size: 14.5px; color: var(--fg); }
.setup-feld-hinweis { font-size: 12.5px; color: var(--fg2); line-height: 1.4; }
.setup-feld-neustart { font-size: 12px; color: var(--warn); line-height: 1.4; }
.setup-unterfelder { display: flex; flex-direction: column; gap: 12px; }
.setup-unterfeld { display: flex; flex-direction: column; gap: 4px; }
.setup-unterfeld-label { font-size: 12px; color: var(--fg3); }
.entitaet-combo { position: relative; }
.entitaet-combo-icon {
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
color: var(--fg3); display: flex; pointer-events: none;
}
.entitaet-combo-input {
width: 100%; box-sizing: border-box;
background: var(--tile-deckend, var(--canvas)); border: 1px solid var(--line); color: var(--fg);
font-family: inherit; font-size: 16px; /* siehe .feld input weiter oben: >=16px gegen Safari-Autozoom */
padding: 11px 13px 11px 36px;
border-radius: 12px;
text-align: left;
-webkit-appearance: none; appearance: none;
}
.entitaet-combo-input:focus { outline: none; border-color: var(--red); }
.entitaet-liste {
position: absolute; z-index: 5; left: 0; right: 0; top: calc(100% + 4px);
max-height: 240px; overflow-y: auto;
background: var(--tile-deckend, var(--canvas)); border: 1px solid var(--line-strong);
border-radius: 12px;
box-shadow: 0 12px 28px rgba(0,0,0,.35);
}
.entitaet-eintrag {
display: flex; flex-direction: column; gap: 1px;
width: 100%; text-align: left;
background: none; border: none; border-bottom: 1px solid var(--line);
color: var(--fg); font-family: inherit;
padding: 9px 12px; cursor: pointer;
}
.entitaet-eintrag:last-child { border-bottom: none; }
.entitaet-eintrag:active, .entitaet-eintrag.aktiv { background: var(--shade); }
.entitaet-eintrag-name { font-size: 14px; color: var(--fg); }
.entitaet-eintrag-id { font-size: 11.5px; color: var(--fg3); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.entitaet-leer { padding: 14px 12px; font-size: 13px; color: var(--fg2); }
.setup-fuss {
flex: 0 0 auto;
display: flex; gap: 10px;
padding: 14px 20px max(14px, env(safe-area-inset-bottom));
border-top: 1px solid var(--line);
}
.setup-fuss .aktion { flex: 1; margin-top: 0; }
/* ------------------------------------------------- B3: Push/Pop /* ------------------------------------------------- B3: Push/Pop
Seitenwechsel wie auf iOS: neue Ebene kommt von rechts, zurück geht sie Seitenwechsel wie auf iOS: neue Ebene kommt von rechts, zurück geht sie
dorthin. */ dorthin. */