diff --git a/AGENTS.md b/AGENTS.md index 239b0a8..4318a07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` diff --git a/homeassistant/data/fahrzeugprofil.example.json b/homeassistant/data/fahrzeugprofil.example.json index feb1488..901e04d 100644 --- a/homeassistant/data/fahrzeugprofil.example.json +++ b/homeassistant/data/fahrzeugprofil.example.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" }, diff --git a/homeassistant/pyscript/fahrtabschluss.py b/homeassistant/pyscript/fahrtabschluss.py index c215168..07eb09a 100644 --- a/homeassistant/pyscript/fahrtabschluss.py +++ b/homeassistant/pyscript/fahrtabschluss.py @@ -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 diff --git a/homeassistant/pyscript/fahrterkennung.py b/homeassistant/pyscript/fahrterkennung.py index 78f7928..2d7ce9d 100644 --- a/homeassistant/pyscript/fahrterkennung.py +++ b/homeassistant/pyscript/fahrterkennung.py @@ -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()) diff --git a/homeassistant/pyscript/frontend_api.py b/homeassistant/pyscript/frontend_api.py index f63a057..0fdb794 100644 --- a/homeassistant/pyscript/frontend_api.py +++ b/homeassistant/pyscript/frontend_api.py @@ -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") diff --git a/homeassistant/pyscript/modules/einstellungen.py b/homeassistant/pyscript/modules/einstellungen.py index 9037bb0..9378306 100644 --- a/homeassistant/pyscript/modules/einstellungen.py +++ b/homeassistant/pyscript/modules/einstellungen.py @@ -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 - diff --git a/homeassistant/pyscript/modules/entitaeten.py b/homeassistant/pyscript/modules/entitaeten.py new file mode 100644 index 0000000..f52848a --- /dev/null +++ b/homeassistant/pyscript/modules/entitaeten.py @@ -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} diff --git a/homeassistant/pyscript/modules/frontend_veroeffentlichung.py b/homeassistant/pyscript/modules/frontend_veroeffentlichung.py index acfc1c7..a5be5b5 100644 --- a/homeassistant/pyscript/modules/frontend_veroeffentlichung.py +++ b/homeassistant/pyscript/modules/frontend_veroeffentlichung.py @@ -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() diff --git a/homeassistant/pyscript/reifenzaehler.py b/homeassistant/pyscript/reifenzaehler.py index 95c62f6..b415c3c 100644 --- a/homeassistant/pyscript/reifenzaehler.py +++ b/homeassistant/pyscript/reifenzaehler.py @@ -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 diff --git a/homeassistant/pyscript/tankerkennung.py b/homeassistant/pyscript/tankerkennung.py index 23c9889..56ecd71 100644 --- a/homeassistant/pyscript/tankerkennung.py +++ b/homeassistant/pyscript/tankerkennung.py @@ -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): diff --git a/homeassistant/www/audi-dashboard-app.js b/homeassistant/www/audi-dashboard-app.js index 3be45a8..b2a1221 100644 --- a/homeassistant/www/audi-dashboard-app.js +++ b/homeassistant/www/audi-dashboard-app.js @@ -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() {
-
+
-
-
Ölwechsel-Intervall
@@ -1773,8 +1770,9 @@ function vEinst() {
`} + ` : ""} - +
Darstellung
@@ -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 `
Noch keine Fahrten -

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.

+

Fahrten werden erkannt, sobald der Motor gestartet wird. Bis dahin lässt sich jede Fahrt von Hand erfassen.

${vTripsFormular()}`; let h = `
`; @@ -2546,6 +2633,89 @@ function sheetMarkup() {
`; } +/* ------------------------------------------------------------ Setup-Popup */ +const LUPE_SVG = ``; + +function entitaetZeilenMarkup(feldId, key, suchtext, aktuellerWert) { + const kandidaten = entitaetKandidaten(key, suchtext, aktuellerWert); + if (!kandidaten.length) return `
Keine passende Entität gefunden
`; + return kandidaten.map((e) => ` + `).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 `
+ ${LUPE_SVG} + + ${offen ? `
${entitaetZeilenMarkup(feldId, key, setupSuchtext, wert)}
` : ""} +
`; +} + +function setupFeldzeile(feld) { + const kopf = `
+ ${esc(feld.label)} + ${esc(feld.hinweis)} + ${feld.trigger_gebunden ? `Wirkt nach dem Speichern erst nach einem Neustart von Home Assistant.` : ""} +
`; + if (feld.liste) { + return `
${kopf} +
+ ${feld.positionen.map((pos, i) => ` +
+ ${esc(pos)} + ${entitaetCombo(feld.key, i)} +
`).join("")} +
+
`; + } + return `
${kopf}${entitaetCombo(feld.key, null)}
`; +} + +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 `
+ `; +} + 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; diff --git a/homeassistant/www/audi-dashboard-version.json b/homeassistant/www/audi-dashboard-version.json index 77b4125..62b77ca 100644 --- a/homeassistant/www/audi-dashboard-version.json +++ b/homeassistant/www/audi-dashboard-version.json @@ -1 +1 @@ -{"version": 1786524729} +{"version": 1786536000} diff --git a/homeassistant/www/audi-dashboard.css b/homeassistant/www/audi-dashboard.css index 8ec5d46..947c77a 100644 --- a/homeassistant/www/audi-dashboard.css +++ b/homeassistant/www/audi-dashboard.css @@ -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. */