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
**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
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)
- **GPS is dead schema:** `start_lat/lon`, addresses, `route`, `avg_speed_kmh` never populated.
The trip-detail map draws a **fabricated** line via `fakeTrack()`
(`audi-dashboard-app.js:414`) — not a real track.
- **GPS is dead schema for trip records specifically:** `start_lat/lon`, addresses, `route`,
`avg_speed_kmh` on individual trips never populated. The trip-detail map draws a **fabricated**
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"`
forever (`modules/fahrtabschluss_logik.py:16-20`).
- **RAM-only state:** running trip (`_fahrt_start_ts`) and fuel low-water-mark
(`_tiefststand_pct`) do not survive an HA restart. Deliberately deferred hardening.
- **Inactive features:** `BATTERIE_SENSOR = ""` (entire battery-history feature is a no-op) and
`UPDATE_REPO_URL = ""` (self-update inactive), both in `pyscript/modules/einstellungen.py`.
`www/bilder/` has no vehicle photos (all slots show placeholders); `steuer.faellig` unset.
- **Inactive features:** `UPDATE_REPO_URL = ""` (self-update inactive) in
`pyscript/modules/einstellungen.py`. `www/bilder/` has no vehicle photos (all slots show
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
`fahrzeugprofil.json` — all callers throw.
- **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`,
`LOCK_ENTITY`, `BATTERY_VOLTAGE_SENSOR`) — actual names: `TUER_SENSOREN`/`FENSTER_SENSOREN`/
`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
`belegverarbeitung.py:18-20`); the README file list omits 5 pyscript files.
- 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.
- [ ] Capture the first real Codec JSON message (`mosquitto_sub`/MQTT Explorer) and build the
field mapping from it — **do not guess beforehand** (explicit decision)
- [ ] Move trip detection to FMM003 ignition (reuse the `fahrterkennung.py` pattern, keep pause
tolerance); then remove `sensor.iphone_wifi_connection` from `einstellungen.py`
- [x] Move trip detection to FMM003 ignition — done 2026-08-12. `fahrterkennung.py` rewritten:
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)
@@ -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 …"
indefinitely) — likely just that container's outbound network to Nominatim specifically;
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
TODO comment) — text-only changes
- [ ] Harden `profil_lesen()` against missing/corrupt `fahrzeugprofil.json`
@@ -10,7 +10,6 @@
"details": "2.9 TFSI quattro competition · 331 kW · 450 PS",
"erstzulassung": "",
"hauptuntersuchung_faellig": "",
"wlan_name": "",
"tankvolumen_liter": 58,
"ausfuehrung": "competition"
},
+9 -5
View File
@@ -13,11 +13,15 @@ import einstellungen
import fahrtabschluss_logik
@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)
# Ohne konfigurierten Kilometerstand-Sensor keinen Trigger registrieren -
# eine leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes
# Verhalten, siehe gleiches Muster in fahrterkennung.py.
if 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
+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
hinterlegten Fahrzeug-WLAN-Namen (Fahrzeugprofil, Feld fahrzeug.wlan_name).
Ende: die Verbindung bricht ab und kommt binnen der Pausenzeit nicht zurück.
Start: die Zündungs-Entität (einstellungen.ZUENDUNG_SENSOR) wechselt auf
"on". Ende: sie wechselt auf "off" und kommt binnen der Pausenzeit nicht
zurück.
Die Pausenregel wird über task.unique() umgesetzt: jede neue Änderung des
WLAN-Sensors killt eine noch laufende, wartende Bestätigung des vorherigen
Ereignisses. Kommt die Verbindung 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.
2026-08-12: ersetzt die frühere WLAN-basierte Erkennung (Vergleich des vom
iPhone gemeldeten verbundenen WLANs gegen den im Fahrzeugprofil hinterlegten
Namen) - die Zündung ist ein direkteres, von jeder Companion-App-Eigenheit
unabhängiges Signal.
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
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
im Arbeitsspeicher dieses Skripts. Ein HA-Neustart mitten in einer Fahrt oder
@@ -32,11 +40,6 @@ import profil
_fahrt_start_ts = None
def _profil_wlan_name():
p = profil.profil_lesen()
return p.get("fahrzeug", {}).get("wlan_name")
def _pausenzeit_sekunden():
p = profil.profil_lesen()
return p.get("einstellungen", {}).get("fahrten_pausenzeit_min", 15) * 60
@@ -47,39 +50,39 @@ def beim_start():
profil.ordner_sicherstellen()
@state_trigger(f"{einstellungen.WLAN_SENSOR}")
def wlan_verbindung_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")
# Ohne konfigurierte Zündungs-Entität keinen Trigger registrieren - eine
# leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes Verhalten
# (siehe zustand_oder_none()-Kommentar in frontend_veroeffentlichung.py zur
# Grundhaltung "kein geratener Wert"), hier stattdessen ganz weggelassen.
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()
if not ziel:
log.warning("audi_dashboard: kein fahrzeug.wlan_name im Fahrzeugprofil hinterlegt")
return
global _fahrt_start_ts
an_jetzt = (value == "on")
an_vorher = (old_value == "on")
global _fahrt_start_ts
verbunden_jetzt = (value == ziel)
verbunden_vorher = (old_value == ziel)
if an_jetzt and not an_vorher:
if _fahrt_start_ts is None:
_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 _fahrt_start_ts is None:
_fahrt_start_ts = datetime.datetime.now(datetime.timezone.utc)
log.info(f"audi_dashboard: Fahrt gestartet um {_fahrt_start_ts.isoformat()}")
return
if an_vorher and not an_jetzt and _fahrt_start_ts is not None:
start_ts = _fahrt_start_ts
abbruch_ts = datetime.datetime.now(datetime.timezone.utc)
wartezeit_s = _pausenzeit_sekunden()
if verbunden_vorher and not verbunden_jetzt and _fahrt_start_ts is not None:
start_ts = _fahrt_start_ts
abbruch_ts = datetime.datetime.now(datetime.timezone.utc)
wartezeit_s = _pausenzeit_sekunden()
# task.unique() oben markiert diese Ausführung als die aktuell
# gültige. Kommt die Zündung 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)
# task.unique() oben markiert diese Ausführung als die aktuell
# 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)
_fahrt_beenden(start_ts, abbruch_ts)
def _fahrt_beenden(start_ts, ende_ts):
@@ -120,7 +123,7 @@ def _fahrt_beenden(start_ts, ende_ts):
@service
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
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)
ende_ts = datetime.datetime.fromisoformat(ts_end)
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 einstellungen
import entitaeten
import frontend_veroeffentlichung
import profil
@@ -26,6 +27,10 @@ import profil
@time_trigger("startup")
def beim_start():
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()
@@ -73,3 +78,22 @@ def audi_dashboard_profil_schreiben(profil_json=None):
profil.profil_schreiben(p)
frontend_veroeffentlichung.profil_veroeffentlichen()
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
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
zwingend gebraucht (§7.1, §7.2, §7.4, §7.5). Alles danach speist nur die
Übersicht im Frontend (§5.1, §5.2, §9) — bleibt ein Wert leer ("") oder passt
eine Entity-ID nicht zur tatsächlichen Integration, liefert
zustand_oder_none() in frontend_veroeffentlichung.py für das jeweilige Feld
None statt abzustürzen; die Oberfläche zeigt dann „unbekannt" statt eines
falschen Werts.
Alle Entity-IDs unten sind an der echten Integration TommiG1/HA_VAG-EU-Data-Act
(Version 0.6.35, per HACS installiert) real geprüft, nicht nur vermutet: das
Gerät heißt dort "Audi RS 4 Avant" (mit Leerzeichen vor der "4") - deshalb
"rs_4" mit Unterstrich im Slug, nicht "rs4". Eine ältere Integrationsversion
hatte hier noch deutsche Feldnamen (z. B. "..._tur_vorne_links"); die aktuelle
Version benennt englisch (z. B. "..._front_left_door").
2026-08-12: Die HACS-Integration TommiG1/HA_VAG-EU-Data-Act (bisherige
Quelle für Kilometerstand, Tankfüllstand, Türen/Fenster/Schlösser,
Ölwechsel-/Inspektionsdaten) wird nicht mehr verwendet - die zugehörigen
Entity-IDs sind deshalb unten bewusst leer. Neue Datenquelle ist der
Teltonika FMM003 (GPS-Tracker mit CAN-Anbindung, siehe AGENTS.md Abschnitt
B); er liefert Standort, Zündungsstatus und Batteriespannung, aber keinen
Tankfüllstand, keine Tür-/Fenster-/Schlossdaten und keine Ölwechsel-/
Inspektionstermine - die entsprechenden Kacheln zeigen deshalb bis auf
Weiteres "unbekannt" statt eines falschen Werts (siehe zustand_oder_none()
in frontend_veroeffentlichung.py). Bleibt ein Wert leer ("") oder passt eine
Entity-ID nicht zur tatsächlichen Integration, liefert zustand_oder_none()
für das jeweilige Feld None statt abzustürzen.
"""
# Companion-App-Sensor für das aktuell verbundene WLAN (iOS-App:
# Einstellungen -> Companion App -> Sensoren -> "Verbundenes WLAN")
WLAN_SENSOR = "sensor.iphone_wifi_connection"
# Fahrterkennung (§7.1): Start/Ende einer Fahrt wird über den Zündungs-/ACC-
# Status des FMM003 erkannt (on = Fahrt läuft), nicht mehr über die WLAN-
# 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
KM_SENSOR = "sensor.audi_rs_4_avant_mileage"
# Kilometerstand - bisher aus der TommiG1-Integration, aktuell keine Quelle
# 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)
TANK_SENSOR = "sensor.audi_rs_4_avant_fuel_level"
# Tankfüllstand (Prozent) - keine Quelle mehr vorhanden (der FMM003 ist kein
# Tankgeber).
TANK_SENSOR = ""
# Reichweite (§5.1 Übersicht)
RANGE_SENSOR = "sensor.audi_rs_4_avant_range_primary"
# Reichweite (§5.1 Übersicht) - keine Quelle mehr vorhanden.
RANGE_SENSOR = ""
# 12V-Batteriespannung (Mein Audi -> Zustand). Die aktuell genutzte
# HACS-Integration (TommiG1/HA_VAG-EU-Data-Act 0.6.35) liefert dafür KEINEN
# Sensor (real geprüft: kein "battery"/"voltage"-Entity unter diesem Gerät) -
# deshalb hier bewusst leer gelassen. Liefert später eine andere Quelle (z. B.
# ein OBD-Adapter) eine Spannung, einfach die Entity-ID eintragen; bis dahin
# zeigt die Oberfläche "unbekannt" statt eines falschen Werts (siehe
# zustand_oder_none() in frontend_veroeffentlichung.py).
BATTERIE_SENSOR = ""
# 12V-Batteriespannung (Mein Audi -> Zustand). Vom FMM003 geliefert -
# external_power_voltage ist die vom Gerät gemessene Bordnetzspannung des
# Fahrzeugs, NICHT battery_voltage (das ist die interne Pufferbatterie des
# Trackers selbst und hat mit der Fahrzeugbatterie nichts zu tun).
BATTERIE_SENSOR = "sensor.testzone_fmm003_external_power_voltage"
# Der von der Integration selbst bereitgestellte Knopf für eine sofortige
# Neuabfrage beim Fahrzeug (Entity-Domäne "button", real geprüft) - genau der
# Weg, den "Ziehen zum Aktualisieren" im Frontend auslöst (siehe
# 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"
# Knopf für eine sofortige Neuabfrage beim Fahrzeug - kam aus der
# TommiG1-Integration, keine Entsprechung beim FMM003 vorhanden.
REFRESH_BUTTON = ""
# Türen (§4.1) - die Integration liefert vier Einzelsensoren statt eines
# Summensensors. binary_sensor, device_class "door": "off" = zu (real
# 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",
]
# Türen (§4.1) - keine Quelle mehr vorhanden.
TUER_SENSOREN = []
# Fenster (§4.1) - device_class "window": "off" = zu (real geprüft)
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",
]
# Fenster (§4.1) - keine Quelle mehr vorhanden.
FENSTER_SENSOREN = []
# Verriegelung - kein lock.*-Entity vorhanden, sondern vier
# Türschloss-Einzelsensoren. binary_sensor mit device_class "lock" (real
# 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",
]
# Verriegelung - keine Quelle mehr vorhanden.
TUERSCHLOSS_SENSOREN = []
# Heckklappe und Motorhaube - von der Integration separat von den vier Türen
# geführt, für "Sicher abgestellt" (§9) aber ebenso relevant wie diese
HECKKLAPPE_SENSOR = "binary_sensor.audi_rs_4_avant_tailgate"
HECKKLAPPENSCHLOSS_SENSOR = "binary_sensor.audi_rs_4_avant_tailgate_lock"
HAUBE_SENSOR = "binary_sensor.audi_rs_4_avant_hood"
HAUBENSCHLOSS_SENSOR = "binary_sensor.audi_rs_4_avant_hood_lock"
# Heckklappe und Motorhaube - keine Quelle mehr vorhanden.
HECKKLAPPE_SENSOR = ""
HECKKLAPPENSCHLOSS_SENSOR = ""
HAUBE_SENSOR = ""
HAUBENSCHLOSS_SENSOR = ""
# Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die App-eigene,
# aus dem Servicebuch berechnete Prognose - siehe fahrzeugMeldung() im
# Frontend). Je ein Datum (ISO mit Zeitzone) und eine Streckenangabe in km;
# die Streckensensoren liefern negative Werte (Restkilometer als Countdown),
# im Frontend wird davon nur der Betrag angezeigt.
NAECHSTER_OELWECHSEL_SENSOR = "sensor.audi_rs_4_avant_oil_change_due"
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"
# aus dem Servicebuch berechnete Prognose) - keine Quelle mehr vorhanden.
NAECHSTER_OELWECHSEL_SENSOR = ""
OELWECHSEL_STRECKE_SENSOR = ""
NAECHSTE_INSPEKTION_SENSOR = ""
INSPEKTION_STRECKE_SENSOR = ""
# Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel). Erwartet
# eine device_tracker.*-Entity mit "latitude"/"longitude"-Attributen (HA-
# Konvention, siehe _standort() in frontend_veroeffentlichung.py) - bleibt
# leer, solange keine Quelle angebunden ist (siehe AGENTS.md Abschnitt B,
# FMM003/flespi offen); die Oberfläche zeigt dann "kein GPS-Signal" statt
# eines falschen Werts, genau wie bei BATTERIE_SENSOR oben.
STANDORT_TRACKER = ""
# Konvention, siehe _standort() in frontend_veroeffentlichung.py) - jetzt
# vom FMM003 geliefert.
STANDORT_TRACKER = "device_tracker.testzone_fmm003"
# Update-Funktion (Einstellungen -> "Update suchen", siehe
# 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 entitaeten
import profil
@@ -39,6 +40,12 @@ def batterieverlauf_veroeffentlichen():
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):
"""Ergebnis eines Beleg-Uploads (geparste Werte fürs Ausfüllen eines
Entwurfs, oder ein Fehler wie 'Falsches Belegdatum') fürs Frontend, nach
@@ -190,3 +197,4 @@ def alles_veroeffentlichen():
tankvorgaenge_veroeffentlichen()
fahrzeugstatus_veroeffentlichen()
batterieverlauf_veroeffentlichen()
entitaeten_veroeffentlichen()
+7 -3
View File
@@ -85,9 +85,13 @@ def beim_start():
_fortschreiben_und_veroeffentlichen(_aktueller_km())
@state_trigger(f"{einstellungen.KM_SENSOR}")
def kilometerstand_geaendert(value=None):
_fortschreiben_und_veroeffentlichen(_aktueller_km())
# Ohne konfigurierten Kilometerstand-Sensor keinen Trigger registrieren -
# eine leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes
# 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
+20 -16
View File
@@ -67,26 +67,30 @@ def beim_start():
profil.ordner_sicherstellen()
@state_trigger(f"{einstellungen.TANK_SENSOR}")
def tankfuellstand_geaendert(value=None, old_value=None):
global _tiefststand_pct
# Ohne konfigurierten Tankfüllstand-Sensor keinen Trigger registrieren -
# eine leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes
# 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)
if aktuell is None:
return
aktuell = _als_zahl(value)
if aktuell is None:
return
if _tiefststand_pct is None:
_tiefststand_pct = aktuell
return
if _tiefststand_pct is None:
_tiefststand_pct = aktuell
return
if aktuell <= _tiefststand_pct:
_tiefststand_pct = aktuell
return
if aktuell <= _tiefststand_pct:
_tiefststand_pct = aktuell
return
anstieg = aktuell - _tiefststand_pct
if anstieg >= _schwelle_prozent():
_tankvorgang_automatisch_anlegen(anstieg)
_tiefststand_pct = aktuell
anstieg = aktuell - _tiefststand_pct
if anstieg >= _schwelle_prozent():
_tankvorgang_automatisch_anlegen(anstieg)
_tiefststand_pct = aktuell
def _tankvorgang_automatisch_anlegen(anstieg_pct):
+256 -11
View File
@@ -37,6 +37,7 @@ let CAR = null;
let TRIPS = [];
let FILLS = [];
let BATTERIEVERLAUF = [];
let ENTITAETEN = null; // {katalog, werte} aus pyscript.audi_dashboard_entitaeten (Setup-Menü)
let DATEN_GELADEN = false;
// Zeitpunkt (ms) des letzten echten Fahrzeugstatus-Updates vom Backend -
// null, solange keiner bekannt ist (siehe standAlterText()).
@@ -88,7 +89,6 @@ function profilZuConfig(p) {
nachtBis: p.einstellungen.nacht_bis,
pauseMin: p.einstellungen.fahrten_pausenzeit_min,
markenname: p.einstellungen.kraftstoffanbieter,
wlanName: p.fahrzeug.wlan_name,
backupIntervall: p.einstellungen.backup_intervall || "aus",
letztesBackup: p.einstellungen.letztes_backup || null,
ausfuehrung: p.fahrzeug.ausfuehrung || "",
@@ -156,7 +156,6 @@ function configCarZuProfil(vorherigesProfil) {
p.fahrzeug.zusatz = CONFIG.fahrzeugzusatz;
p.fahrzeug.untertitel = CONFIG.fahrzeuguntertitel;
p.fahrzeug.kennzeichen = CONFIG.kennzeichen;
p.fahrzeug.wlan_name = CONFIG.wlanName;
p.fahrzeug.hauptuntersuchung_faellig = CAR.hu;
p.fahrzeug.fin = CAR.fin;
p.fahrzeug.erstzulassung = CAR.erstzulassung;
@@ -1754,10 +1753,8 @@ function vEinst() {
<input id="einKennzeichen" value="${esc(CONFIG.kennzeichen || "")}" data-einrichten="kennzeichen"></div>
<div class="feld"><label for="einErstzulassung">Erstzulassung</label>
<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>
<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>
<div class="seg" style="margin-top:8px">
<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>
<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>`}
<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 class="tile"><span class="label">Darstellung</span>
<div class="seg">
@@ -1906,6 +1904,94 @@ let nmOffen = false;
let einrichtenOffen = false;
let bildMenuOffen = null;
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() {
const r = CAR.reifen;
const feld = (k, schluessel, bez, typ) => {
@@ -1994,10 +2080,11 @@ function vTripsFormular() {
function vTrips() {
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
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">
<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>
${vTripsFormular()}`;
let h = `<div class="tile">`;
@@ -2546,6 +2633,89 @@ function sheetMarkup() {
</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 routeStack = [];
let navRichtung = null;
@@ -2595,7 +2765,23 @@ function render() {
v.classList.add(navRichtung === "raus" ? "push-raus" : "push-rein");
}
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
dort der Zurück-Pfeil. */
ROOT.getElementById("marke").style.display = istTab(route.name) ? "inline-flex" : "none";
@@ -2651,7 +2837,7 @@ function randwischenVerdrahten() {
const phone = ROOT.querySelector(".phone");
phone.addEventListener("pointerdown", (e) => {
randX0 = null;
if (!e.isPrimary || sheet) return;
if (!e.isPrimary || sheet || setupOffen) return;
const links = phone.getBoundingClientRect().left;
if (e.clientX - links <= 26 && !istTab(route.name)) { randX0 = e.clientX; randY0 = e.clientY; }
});
@@ -2876,6 +3062,46 @@ function ereignisseVerdrahten() {
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]");
if (bvReset) { bvDomain = [...bvVollDomain]; bvAusgewaehlt = null; bvZeichnen(); return; }
// Nachlaufender Klick derselben Geste (Maus feuert ihn trotz Bewegung
@@ -3155,6 +3381,20 @@ function ereignisseVerdrahten() {
});
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;
if (!praefix) return;
const liter = parseFloat((ROOT.getElementById(`${praefix}Liter`).value || "0").replace(",", "."));
@@ -3223,7 +3463,6 @@ function ereignisseVerdrahten() {
if (ein) {
if (ein === "kennzeichen") CONFIG.kennzeichen = 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 CAR[ein] = e.target.value;
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.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.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.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 battState = HASS.states["pyscript.audi_dashboard_batterieverlauf"] || NACHGELADEN.batt;
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) {
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;
}
.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 {
fill: none; stroke: currentColor; stroke-width: 1.6;
stroke-linecap: round; stroke-linejoin: round;
border-radius: var(--r-pill);
transition: box-shadow .18s, background .18s;
transition: transform .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 span { font-size: 11px; letter-spacing: .01em; }
.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; }
/* poi-car aus dem CI-Satz - rotes Symbol, weiss abgesetzt statt in einem
Kasten, damit die Spitze exakt auf der Position steht. */
.fahrzeug-pin {
width: 34px; height: 34px; border-radius: 10px;
background: var(--red); border: 2px solid #fff;
box-shadow: 0 3px 10px rgba(0,0,0,.35);
color: var(--red);
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 {
@@ -688,45 +696,67 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
.kartensteuerung {
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 {
width: 42px; height: 42px; border-radius: 50%;
width: 48px; height: 48px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
background: var(--tile); color: var(--fg);
border: none; box-shadow: 0 3px 10px rgba(0,0,0,.25);
background: rgba(16, 20, 26, .84); color: #fff;
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;
}
.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
müssen zusammenpassen, siehe Kommentar dort. */
/* Sichtbarer Anteil im geschlossenen Zustand (STANDORT_PEEK_PX im Frontend) -
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 {
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;
box-shadow: 0 -6px 24px rgba(0,0,0,.35);
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);
}
.standortmenu.offen { transform: translateY(0); }
.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-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;
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-distanz { font-size: 12px; color: var(--fg3); }
.standortmenu-name { font-size: 17px; color: var(--fg); margin-top: 2px; }
.standortmenu-adresse { font-size: 13.5px; color: var(--fg2); margin-top: 3px; }
.standortmenu-status { display: flex; align-items: center; gap: 9px; margin-top: 16px; font-size: 14px; color: var(--fg); }
.standortmenu-kopf { margin-top: 8px; padding-right: 44px; }
.standortmenu-name { font-size: 17px; color: var(--fg); }
.standortmenu-distanz { font-size: 12.5px; color: var(--fg3); margin-top: 4px; }
/* Abstand als leere Zeile zwischen Kopf und Details */
.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); }
.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 .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 */
.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); }
/* ------------------------------------------------------- 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
Seitenwechsel wie auf iOS: neue Ebene kommt von rechts, zurück geht sie
dorthin. */