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:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user