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 <[email protected]>
This commit is contained in:
2026-08-12 15:55:59 +02:00
co-authored by Claude Sonnet 5
parent 7e979ecfab
commit 9794193803
13 changed files with 848 additions and 197 deletions
+256 -11
View File
@@ -37,6 +37,7 @@ let CAR = null;
let TRIPS = [];
let FILLS = [];
let BATTERIEVERLAUF = [];
let ENTITAETEN = null; // {katalog, werte} aus pyscript.audi_dashboard_entitaeten (Setup-Menü)
let DATEN_GELADEN = false;
// Zeitpunkt (ms) des letzten echten Fahrzeugstatus-Updates vom Backend -
// null, solange keiner bekannt ist (siehe standAlterText()).
@@ -88,7 +89,6 @@ function profilZuConfig(p) {
nachtBis: p.einstellungen.nacht_bis,
pauseMin: p.einstellungen.fahrten_pausenzeit_min,
markenname: p.einstellungen.kraftstoffanbieter,
wlanName: p.fahrzeug.wlan_name,
backupIntervall: p.einstellungen.backup_intervall || "aus",
letztesBackup: p.einstellungen.letztes_backup || null,
ausfuehrung: p.fahrzeug.ausfuehrung || "",
@@ -156,7 +156,6 @@ function configCarZuProfil(vorherigesProfil) {
p.fahrzeug.zusatz = CONFIG.fahrzeugzusatz;
p.fahrzeug.untertitel = CONFIG.fahrzeuguntertitel;
p.fahrzeug.kennzeichen = CONFIG.kennzeichen;
p.fahrzeug.wlan_name = CONFIG.wlanName;
p.fahrzeug.hauptuntersuchung_faellig = CAR.hu;
p.fahrzeug.fin = CAR.fin;
p.fahrzeug.erstzulassung = CAR.erstzulassung;
@@ -1754,10 +1753,8 @@ function vEinst() {
<input id="einKennzeichen" value="${esc(CONFIG.kennzeichen || "")}" data-einrichten="kennzeichen"></div>
<div class="feld"><label for="einErstzulassung">Erstzulassung</label>
<input id="einErstzulassung" value="${esc(CAR.erstzulassung || "")}" data-einrichten="erstzulassung" placeholder="MM/JJJJ"></div>
<div class="feld"><label for="einAusfuehrung">Ausführung</label>
<div class="feld" style="border-bottom:0"><label for="einAusfuehrung">Ausführung</label>
<input id="einAusfuehrung" value="${esc(CONFIG.ausfuehrung || "")}" data-einrichten="ausfuehrung"></div>
<div class="feld" style="border-bottom:0"><label for="einWlan">Fahrzeug-WLAN (SSID)</label>
<input id="einWlan" value="${esc(CONFIG.wlanName || "")}" data-einrichten="wlan" placeholder="z. B. Audi_MMI_XXXX_5GHz"></div>
<span class="label" style="margin-top:20px">Ölwechsel-Intervall</span>
<div class="seg" style="margin-top:8px">
<button data-oel="hersteller" class="${CAR.oel.modus === "hersteller" ? "on" : ""}">Herstellervorgabe</button>
@@ -1773,8 +1770,9 @@ function vEinst() {
<select id="oelKm" data-oelkm>${[5000, 10000, 15000, 20000].map((k) => `<option value="${k}" ${k === CAR.oel.km ? "selected" : ""}>${de(k)} km</option>`).join("")}</select></div>
<div class="feld" style="border-bottom:0"><label for="oelM">Nach Zeit</label>
<select id="oelM" data-oelm>${[12, 24].map((m) => `<option value="${m}" ${m === CAR.oel.monate ? "selected" : ""}>${m === 12 ? "1 Jahr" : "2 Jahre"}</option>`).join("")}</select></div>`}
<button class="aktion" style="margin-top:20px" data-setup-oeffnen>Setup — Sensoren zuordnen</button>
` : ""}
<button class="aktion ${einrichtenOffen ? "primaer" : ""}" data-einrichten-auf style="margin-top:16px">${einrichtenOffen ? "Fertig" : "Einrichten"}</button>
<button class="aktion ${einrichtenOffen ? "primaer" : ""}" style="margin-top:16px" data-einrichten-auf>${einrichtenOffen ? "Fertig" : "Einrichten"}</button>
</div>
<div class="tile"><span class="label">Darstellung</span>
<div class="seg">
@@ -1906,6 +1904,94 @@ let nmOffen = false;
let einrichtenOffen = false;
let bildMenuOffen = null;
let smartdealPopupOffen = false;
/* --------------------------------------------------------- Setup-Popup
Sensor-zu-Entität-Zuordnung (Einstellungen -> Fahrzeug einrichten ->
Setup). setupZuordnung ist eine Arbeitskopie, die erst bei "Speichern"
an den Service geht - Abbrechen verwirft sie einfach ohne Nebeneffekt. */
let setupOffen = false;
let setupZuordnung = null; // {KEY: "entity.id"} bzw. {KEY: [4 IDs]} für Listenfelder
let setupNurPassend = true; // globaler Schalter: Kandidaten auf passende Domain/Einheit einschränken
let setupSucheOffen = null; // Feld-Id ("KEY" oder "KEY:idx") des offenen Dropdowns, sonst null
let setupSuchtext = "";
let setupSpeichertLaeuft = false;
let ALLE_ENTITAETEN = [];
function setupKatalog() { return (ENTITAETEN && ENTITAETEN.katalog) || []; }
function alleEntitaeten() {
if (!HASS || !HASS.states) return [];
return Object.keys(HASS.states).map((id) => {
const attrs = (HASS.states[id] && HASS.states[id].attributes) || {};
return { id, name: attrs.friendly_name || id, domain: id.split(".")[0], unit: attrs.unit_of_measurement || "", deviceClass: attrs.device_class || "" };
});
}
function entitaetName(id) {
if (!id) return "";
const treffer = ALLE_ENTITAETEN.find((e) => e.id === id);
return treffer ? treffer.name : id;
}
/* Höher = besser passend: Stichwort- und Freitext-Treffer zählen am meisten,
Domain/Einheit/Device-Class sind Zusatzpunkte fürs Sortieren, wenn der
Schalter "Nur passende Sensoren" aus ist (dann filtern sie nicht hart,
siehe entitaetKandidaten()). */
function entitaetScore(feld, e, suchtext) {
let score = 0;
const hay = (e.id + " " + e.name).toLowerCase();
for (const wort of feld.stichworte || []) if (hay.includes(wort)) score += 5;
if (feld.domains && feld.domains.includes(e.domain)) score += 3;
if (feld.device_classes && feld.device_classes.length && feld.device_classes.includes(e.deviceClass)) score += 3;
if (feld.units && feld.units.length && feld.units.includes(e.unit)) score += 2;
if (suchtext && hay.includes(suchtext.trim().toLowerCase())) score += 10;
return score;
}
function entitaetKandidaten(feldKey, suchtext, aktuellerWert) {
const feld = setupKatalog().find((f) => f.key === feldKey);
if (!feld) return [];
const text = (suchtext || "").trim().toLowerCase();
const liste = ALLE_ENTITAETEN.filter((e) => {
if (e.id === aktuellerWert) return true;
if (setupNurPassend) {
if (feld.domains && feld.domains.length && !feld.domains.includes(e.domain)) return false;
if (feld.units && feld.units.length && !feld.units.includes(e.unit)) return false;
}
if (text && !(e.id.toLowerCase().includes(text) || e.name.toLowerCase().includes(text))) return false;
return true;
});
liste.sort((a, b) => entitaetScore(feld, b, suchtext) - entitaetScore(feld, a, suchtext));
return liste.slice(0, 60);
}
function setupOeffnen() {
ALLE_ENTITAETEN = alleEntitaeten();
setupNurPassend = true;
const werte = (ENTITAETEN && ENTITAETEN.werte) || {};
const zuordnung = {};
for (const feld of setupKatalog()) {
if (feld.liste) {
zuordnung[feld.key] = feld.positionen.map((_, i) => {
const vorhanden = (werte[feld.key] || [])[i];
if (vorhanden) return vorhanden;
const vorschlag = entitaetKandidaten(feld.key, "", "")[0];
return vorschlag && entitaetScore(feld, vorschlag, "") >= 3 ? vorschlag.id : "";
});
} else {
const vorhanden = werte[feld.key];
if (vorhanden) { zuordnung[feld.key] = vorhanden; continue; }
const vorschlag = entitaetKandidaten(feld.key, "", "")[0];
zuordnung[feld.key] = vorschlag && entitaetScore(feld, vorschlag, "") >= 3 ? vorschlag.id : "";
}
}
setupZuordnung = zuordnung;
setupSucheOffen = null; setupSuchtext = ""; setupSpeichertLaeuft = false;
setupOffen = true; render();
}
function setupSchliessen() {
setupOffen = false; setupSucheOffen = null; setupZuordnung = null; render();
}
function vReifen() {
const r = CAR.reifen;
const feld = (k, schluessel, bez, typ) => {
@@ -1994,10 +2080,11 @@ function vTripsFormular() {
function vTrips() {
const jahre = [...new Set(TRIPS.map((t) => new Date(t.ts_start).getFullYear()))].sort((a, b) => b - a);
/* D5: Leerzustand nennt die Ursache - die Fahrtenerkennung greift erst, wenn
das Telefon einmal im Fahrzeug-WLAN war. */
die Zündungs-Entität (Setup -> Für die Fahrterkennung nötig) erkennt,
dass der Motor läuft. */
if (!jahre.length) return `<div class="tile"><div class="leer">
<strong>Noch keine Fahrten</strong>
<p>Fahrten werden erkannt, sobald sich das Telefon mit dem WLAN des Fahrzeugs verbindet (${esc(CONFIG.wlanName || "noch nicht eingerichtet")}). Bis dahin lässt sich jede Fahrt von Hand erfassen.</p>
<p>Fahrten werden erkannt, sobald der Motor gestartet wird. Bis dahin lässt sich jede Fahrt von Hand erfassen.</p>
</div></div>
${vTripsFormular()}`;
let h = `<div class="tile">`;
@@ -2546,6 +2633,89 @@ function sheetMarkup() {
</div>`;
}
/* ------------------------------------------------------------ Setup-Popup */
const LUPE_SVG = `<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><circle cx="8.5" cy="8.5" r="6"/><path d="m17 17-4.3-4.3"/></svg>`;
function entitaetZeilenMarkup(feldId, key, suchtext, aktuellerWert) {
const kandidaten = entitaetKandidaten(key, suchtext, aktuellerWert);
if (!kandidaten.length) return `<div class="entitaet-leer">Keine passende Entität gefunden</div>`;
return kandidaten.map((e) => `
<button type="button" class="entitaet-eintrag ${e.id === aktuellerWert ? "aktiv" : ""}" data-setup-wahl="${esc(feldId)}" data-setup-entity="${esc(e.id)}">
<span class="entitaet-eintrag-name">${esc(e.name)}</span>
<span class="entitaet-eintrag-id">${esc(e.id)}${e.unit ? " · " + esc(e.unit) : ""}</span>
</button>`).join("");
}
function entitaetCombo(key, idx) {
const feldId = idx == null ? key : `${key}:${idx}`;
const wert = idx == null ? setupZuordnung[key] : (setupZuordnung[key] || [])[idx];
const offen = setupSucheOffen === feldId;
const anzeige = offen ? setupSuchtext : entitaetName(wert);
return `<div class="entitaet-combo">
<span class="entitaet-combo-icon">${LUPE_SVG}</span>
<input type="text" class="entitaet-combo-input" autocomplete="off" spellcheck="false"
placeholder="Entität suchen …" value="${esc(anzeige)}" data-setup-suche="${esc(feldId)}">
${offen ? `<div class="entitaet-liste" data-setup-liste="${esc(feldId)}">${entitaetZeilenMarkup(feldId, key, setupSuchtext, wert)}</div>` : ""}
</div>`;
}
function setupFeldzeile(feld) {
const kopf = `<div class="setup-feld-kopf">
<span class="setup-feld-label">${esc(feld.label)}</span>
<span class="setup-feld-hinweis">${esc(feld.hinweis)}</span>
${feld.trigger_gebunden ? `<span class="setup-feld-neustart">Wirkt nach dem Speichern erst nach einem Neustart von Home Assistant.</span>` : ""}
</div>`;
if (feld.liste) {
return `<div class="setup-feldzeile">${kopf}
<div class="setup-unterfelder">
${feld.positionen.map((pos, i) => `
<div class="setup-unterfeld">
<span class="setup-unterfeld-label">${esc(pos)}</span>
${entitaetCombo(feld.key, i)}
</div>`).join("")}
</div>
</div>`;
}
return `<div class="setup-feldzeile">${kopf}${entitaetCombo(feld.key, null)}</div>`;
}
const SETUP_GRUPPEN = [
["fahrterkennung", "Für die Fahrterkennung nötig"],
["uebersicht", "Übersicht & Status"],
["sicherheit", "Sicherheit"],
["standort", "Standort"],
];
function vSetupPopup() {
if (!setupOffen) return "";
const katalog = setupKatalog();
return `<div class="setup-catcher" data-setup-abbrechen></div>
<div class="setup-popup" role="dialog" aria-modal="true" aria-label="Setup">
<div class="setup-kopf">
<strong>Setup</strong>
<span>Sensoren dieser Home-Assistant-Instanz den App-Funktionen zuordnen.</span>
<div class="setup-kopf-schalter">
<label class="switch"><input type="checkbox" data-setup-nurpassend ${setupNurPassend ? "checked" : ""}><span class="track"></span></label>
<span>Nur passende Sensoren anzeigen</span>
</div>
</div>
<div class="setup-koerper">
${SETUP_GRUPPEN.map(([key, titel]) => {
const felder = katalog.filter((f) => f.gruppe === key);
if (!felder.length) return "";
return `<div class="setup-gruppe">
<div class="setup-gruppe-titel">${esc(titel)}</div>
${felder.map((feld) => setupFeldzeile(feld)).join("")}
</div>`;
}).join("")}
</div>
<div class="setup-fuss">
<button type="button" class="aktion" data-setup-abbrechen>Abbrechen</button>
<button type="button" class="aktion primaer" data-setup-speichern>${setupSpeichertLaeuft ? "Speichert …" : "Speichern"}</button>
</div>
</div>`;
}
let letzteAnsicht = null;
let routeStack = [];
let navRichtung = null;
@@ -2595,7 +2765,23 @@ function render() {
v.classList.add(navRichtung === "raus" ? "push-raus" : "push-rein");
}
navRichtung = null;
ROOT.getElementById("overlay").innerHTML = sheetMarkup();
// .setup-koerper ist ein eigener Scroll-Container innerhalb des Overlays -
// die innerHTML-Ersetzung gleich darunter erzeugt ihn neu (scrollTop
// faellt sonst bei jedem render(), z. B. beim Umschalten des Filters,
// auf 0 zurueck).
const setupKoerperScroll = setupOffen ? (ROOT.querySelector(".setup-koerper") || {}).scrollTop : 0;
ROOT.getElementById("overlay").innerHTML = sheetMarkup() + vSetupPopup();
if (setupOffen) {
const koerper = ROOT.querySelector(".setup-koerper");
if (koerper) koerper.scrollTop = setupKoerperScroll || 0;
}
// Ein offenes Setup-Suchfeld verliert bei jedem render() sein DOM-Element
// (v.innerHTML-Ersatz) - hier neu fokussieren, Cursor ans Ende, sonst
// müsste man nach jedem Tippen erneut anklicken.
if (setupOffen && setupSucheOffen) {
const feld = ROOT.querySelector(`[data-setup-suche="${setupSucheOffen}"]`);
if (feld) { feld.focus(); const len = feld.value.length; feld.setSelectionRange(len, len); }
}
/* D4: die Ringe sind nur auf den Tab-Ebenen sichtbar - auf Unterseiten steht
dort der Zurück-Pfeil. */
ROOT.getElementById("marke").style.display = istTab(route.name) ? "inline-flex" : "none";
@@ -2651,7 +2837,7 @@ function randwischenVerdrahten() {
const phone = ROOT.querySelector(".phone");
phone.addEventListener("pointerdown", (e) => {
randX0 = null;
if (!e.isPrimary || sheet) return;
if (!e.isPrimary || sheet || setupOffen) return;
const links = phone.getBoundingClientRect().left;
if (e.clientX - links <= 26 && !istTab(route.name)) { randX0 = e.clientX; randY0 = e.clientY; }
});
@@ -2876,6 +3062,46 @@ function ereignisseVerdrahten() {
return;
}
if (sheet) return;
if (e.target.closest("[data-setup-oeffnen]")) { setupOeffnen(); return; }
if (setupOffen) {
if (e.target.closest("[data-setup-abbrechen]")) { setupSchliessen(); return; }
const wahl = e.target.closest("[data-setup-wahl]");
if (wahl) {
const feldId = wahl.dataset.setupWahl, entityId = wahl.dataset.setupEntity;
const teile = feldId.split(":");
if (teile.length === 2) setupZuordnung[teile[0]][Number(teile[1])] = entityId;
else setupZuordnung[feldId] = entityId;
setupSucheOffen = null; setupSuchtext = ""; render();
return;
}
if (e.target.closest("[data-setup-speichern]")) {
if (setupSpeichertLaeuft) return;
setupSpeichertLaeuft = true; render();
(async () => {
try {
await HASS.callService("pyscript", "audi_dashboard_entitaeten_schreiben", { zuordnung_json: JSON.stringify(setupZuordnung) });
} catch (err) {
setupSpeichertLaeuft = false; hinweis("Speichern fehlgeschlagen", err.message); return;
}
setupOffen = false; setupSucheOffen = null; setupZuordnung = null; setupSpeichertLaeuft = false; render();
})();
return;
}
// Klick aufs (noch geschlossene) Suchfeld öffnet dessen Dropdown; ein
// Klick, während es schon offen ist (z. B. Cursor neu setzen), soll
// keinen render() auslösen - würde die laufende Eingabe unterbrechen.
const sucheInput = e.target.closest("[data-setup-suche]");
if (sucheInput) {
const feldId = sucheInput.dataset.setupSuche;
if (setupSucheOffen !== feldId) { setupSucheOffen = feldId; setupSuchtext = ""; render(); }
return;
}
// Klick irgendwo sonst im Popup (aber nicht auf den Catcher, der schon
// oben behandelt wurde) schließt ein offenes Dropdown, ohne das ganze
// Popup zu verlassen.
if (setupSucheOffen && !e.target.closest(".entitaet-combo")) { setupSucheOffen = null; setupSuchtext = ""; render(); return; }
return;
}
const bvReset = e.target.closest("[data-bvreset]");
if (bvReset) { bvDomain = [...bvVollDomain]; bvAusgewaehlt = null; bvZeichnen(); return; }
// Nachlaufender Klick derselben Geste (Maus feuert ihn trotz Bewegung
@@ -3155,6 +3381,20 @@ function ereignisseVerdrahten() {
});
ROOT.addEventListener("input", (e) => {
const sucheFeld = e.target.dataset.setupSuche;
if (sucheFeld) {
// Nur die Kandidatenliste patchen, nicht render() - ein voller
// Re-Render würde bei jedem Tastendruck das Eingabefeld neu erzeugen
// und den Fokus verlieren (gleiches Problem/gleiche Lösung wie beim
// bestehenden data-tankpreisfeld-Handler direkt hier drunter).
setupSuchtext = e.target.value;
const teile = sucheFeld.split(":");
const key = teile[0];
const wert = teile.length === 2 ? (setupZuordnung[key] || [])[Number(teile[1])] : setupZuordnung[key];
const liste = ROOT.querySelector(`[data-setup-liste="${sucheFeld}"]`);
if (liste) liste.innerHTML = entitaetZeilenMarkup(sucheFeld, key, setupSuchtext, wert);
return;
}
const praefix = e.target.dataset.tankpreisfeld;
if (!praefix) return;
const liter = parseFloat((ROOT.getElementById(`${praefix}Liter`).value || "0").replace(",", "."));
@@ -3223,7 +3463,6 @@ function ereignisseVerdrahten() {
if (ein) {
if (ein === "kennzeichen") CONFIG.kennzeichen = e.target.value;
else if (ein === "ausfuehrung") CONFIG.ausfuehrung = e.target.value;
else if (ein === "wlan") CONFIG.wlanName = e.target.value;
else if (ein === "modell") CONFIG.fahrzeugtitel = e.target.value;
else CAR[ein] = e.target.value;
profilSpeichern(); return;
@@ -3282,6 +3521,7 @@ function ereignisseVerdrahten() {
if (e.target.dataset.oelkm !== undefined) { CAR.oel.km = parseInt(e.target.value, 10); profilSpeichern(); return; }
if (e.target.dataset.oelm !== undefined) { CAR.oel.monate = parseInt(e.target.value, 10); profilSpeichern(); return; }
if (e.target.dataset.boden !== undefined) { bodenAn = e.target.checked; render(); return; }
if (e.target.dataset.setupNurpassend !== undefined) { setupNurPassend = e.target.checked; render(); return; }
if (e.target.dataset.startbild !== undefined) { CONFIG.startbild = e.target.value; profilSpeichern(); render(); return; }
if (e.target.dataset.svsel !== undefined) { svAuswahl = parseInt(e.target.value, 10); render(); return; }
});
@@ -3388,6 +3628,11 @@ async function datenLaden(erzwingen) {
const statusState = HASS.states["pyscript.audi_dashboard_fahrzeugstatus"] || NACHGELADEN.status;
const battState = HASS.states["pyscript.audi_dashboard_batterieverlauf"] || NACHGELADEN.batt;
const belegState = HASS.states["pyscript.audi_dashboard_beleg_ergebnis"];
// Unabhängig vom geaendert-Block unten gelesen (der trackt nur profil/
// fahrten/tank/status/batt) - ändert sich erst nach einem Setup-Speichern,
// nicht Teil des normalen Update-Rhythmus.
const entitaetenState = HASS.states["pyscript.audi_dashboard_entitaeten"];
if (entitaetenState && entitaetenState.attributes) ENTITAETEN = entitaetenState.attributes.daten;
if (belegState && belegState !== letzterBelegState) {
letzterBelegState = belegState;
@@ -1 +1 @@
{"version": 1786524729}
{"version": 1786536000}
+148 -24
View File
@@ -229,13 +229,20 @@ main#view::-webkit-scrollbar { width: 0; height: 0; }
transition: color .15s;
}
.tab.on { color: var(--fg); }
/* Die Pille ist ein eigenes Feld um das Symbol - so bleibt ihre Groesse
unabhaengig vom Symbol und der Wechsel laeuft sauber ueber background. */
.tabpille {
display: flex; align-items: center; justify-content: center;
width: 60px; height: 34px; border-radius: var(--r-pill);
transition: background .18s;
}
.tab.on .tabpille { background: var(--tile-2); }
.tab svg {
fill: none; stroke: currentColor; stroke-width: 1.6;
stroke-linecap: round; stroke-linejoin: round;
border-radius: var(--r-pill);
transition: box-shadow .18s, background .18s;
transition: transform .18s;
}
.tab.on svg { background: var(--tile-2); box-shadow: 0 0 0 11px var(--tile-2); }
.tab svg.voll { fill: currentColor; stroke: none; }
.tab:active svg { transform: scale(.9); }
.tab span { font-size: 11px; letter-spacing: .01em; }
.tabbar.ohne .tab span { display: none; }
@@ -664,11 +671,12 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
}
.fahrzeug-pin-wrap { background: none; border: none; }
/* poi-car aus dem CI-Satz - rotes Symbol, weiss abgesetzt statt in einem
Kasten, damit die Spitze exakt auf der Position steht. */
.fahrzeug-pin {
width: 34px; height: 34px; border-radius: 10px;
background: var(--red); border: 2px solid #fff;
box-shadow: 0 3px 10px rgba(0,0,0,.35);
color: var(--red);
display: flex; align-items: center; justify-content: center;
filter: drop-shadow(0 0 1px #fff) drop-shadow(0 0 2px #fff) drop-shadow(0 2px 5px rgba(0,0,0,.45));
}
.user-pin-wrap { background: none; border: none; }
.user-pin {
@@ -688,45 +696,67 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
.kartensteuerung {
position: absolute; z-index: 400; top: 16px; right: 16px;
display: flex; flex-direction: column; gap: 10px;
display: flex; flex-direction: column; gap: 12px;
}
.kartensteuerung button {
width: 42px; height: 42px; border-radius: 50%;
width: 48px; height: 48px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
background: var(--tile); color: var(--fg);
border: none; box-shadow: 0 3px 10px rgba(0,0,0,.25);
background: rgba(16, 20, 26, .84); color: #fff;
border: none; box-shadow: 0 4px 14px rgba(0,0,0,.32);
-webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
cursor: pointer; padding: 0;
}
.kartensteuerung button:active { background: var(--tile-2); transform: scale(.94); }
/* Tag-Theme: helle Kapsel mit dunklem Symbol - die CI-Symbole selbst tragen
fill="currentColor" und folgen der Textfarbe, es gibt keine zweite Datei. */
:host([data-theme="tag"]) .kartensteuerung button {
background: rgba(255,255,255,.92); color: #101418;
box-shadow: 0 4px 14px rgba(0,0,0,.18);
}
.kartensteuerung button:active { transform: scale(.94); }
:host([data-theme="tag"]) .kartensteuerung button:active { background: #fff; }
/* Griffzone/Zug-Amplitude (STANDORT_PEEK_PX im Frontend) - beide Werte
müssen zusammenpassen, siehe Kommentar dort. */
/* Sichtbarer Anteil im geschlossenen Zustand (STANDORT_PEEK_PX im Frontend) -
beide Werte müssen zusammenpassen, siehe Kommentar dort. Der Rest des
Blatts wird nach unten geschoben und mit einem Zug nach oben geöffnet. */
.standortmenu {
position: absolute; z-index: 410; left: 0; right: 0; bottom: 0;
background: var(--tile-2);
--standort-peek: 96px;
/* Deckend, nicht durchscheinend - unter dem Blatt liegt die Karte. */
background: var(--tile-deckend, var(--canvas));
border-radius: 20px 20px 0 0;
box-shadow: 0 -6px 24px rgba(0,0,0,.35);
padding: 0 20px max(18px, env(safe-area-inset-bottom));
transform: translateY(108px);
transform: translateY(calc(100% - var(--standort-peek)));
transition: transform .28s cubic-bezier(.22,.61,.36,1);
}
.standortmenu.offen { transform: translateY(0); }
.standortmenu-griffzone { padding: 10px 0 14px; touch-action: none; cursor: grab; position: relative; }
.standortmenu-griff { width: 36px; height: 4px; border-radius: 2px; background: var(--line-strong); margin: 0 auto; }
.standortmenu-schliessen {
position: absolute; top: 6px; right: 0; width: 30px; height: 30px; border-radius: 50%;
position: absolute; top: 14px; right: 0; width: 32px; height: 32px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
background: none; border: none; color: var(--fg3); cursor: pointer; padding: 0;
background: var(--tile); border: none; color: var(--fg2); cursor: pointer; padding: 0;
}
.standortmenu-kopf { margin-top: 6px; }
.standortmenu-distanz { font-size: 12px; color: var(--fg3); }
.standortmenu-name { font-size: 17px; color: var(--fg); margin-top: 2px; }
.standortmenu-adresse { font-size: 13.5px; color: var(--fg2); margin-top: 3px; }
.standortmenu-status { display: flex; align-items: center; gap: 9px; margin-top: 16px; font-size: 14px; color: var(--fg); }
.standortmenu-kopf { margin-top: 8px; padding-right: 44px; }
.standortmenu-name { font-size: 17px; color: var(--fg); }
.standortmenu-distanz { font-size: 12.5px; color: var(--fg3); margin-top: 4px; }
/* Abstand als leere Zeile zwischen Kopf und Details */
.standortmenu-adresse { font-size: 13.5px; color: var(--fg2); margin-top: 20px; }
.standortmenu-status { display: flex; align-items: center; gap: 9px; margin-top: 8px; font-size: 13.5px; color: var(--fg2); }
.standortmenu-status svg { color: var(--fg3); flex: 0 0 auto; }
.dot.neutral { background: var(--fg3); }
.standortmenu-werte { display: flex; gap: 30px; margin-top: 16px; }
.standortmenu-werte { display: flex; align-items: center; gap: 10px; margin-top: 8px; font-size: 13.5px; color: var(--fg2); }
.standortmenu-werte svg { color: var(--fg3); flex: 0 0 auto; }
.standortmenu-aktionen { display: flex; gap: 10px; margin-top: 18px; padding-bottom: 4px; }
.standortmenu-aktionen .aktion { flex: 1; margin-top: 0; }
.standort-pille {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
height: 40px; padding: 0 20px; border-radius: var(--r-pill);
border: none; background: var(--tile); color: var(--fg);
font-family: inherit; font-size: 14px; text-decoration: none; cursor: pointer;
}
.standort-pille.primaer { background: var(--line-strong); }
.standort-pille:active { transform: scale(.97); }
.standort-pille[disabled] { opacity: .4; pointer-events: none; }
/* ------------------------------------------------------- Bilderverwaltung */
.bildgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-4); margin-top: var(--sp-4); }
@@ -811,6 +841,100 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
}
.sheet-abbrechen:active { background: var(--shade); }
/* ------------------------------------------------------- Setup-Popup
Sensor-zu-Entität-Zuordnung (Einstellungen -> Fahrzeug einrichten ->
Setup). Gleiche Catcher/Karten-Bauweise wie .bildmenu/.sdpopup oben,
aber großformatig und scrollbar statt Bottom-Sheet-Größe; nutzt die
bereits vorhandenen sheet-fade/sheet-rein-Keyframes. z-index bewusst
unter .sheet (1200er), damit ein Fehlerhinweis (hinweis()) bei
fehlgeschlagenem Speichern über dem noch offenen Setup-Popup liegt. */
.setup-catcher {
position: absolute; inset: 0; z-index: 1000;
background: rgba(0,0,0,.45);
animation: sheet-fade .18s ease-out;
}
.setup-popup {
position: absolute; z-index: 1001;
left: 10px; right: 10px;
top: max(10px, env(safe-area-inset-top));
bottom: max(10px, env(safe-area-inset-bottom));
/* --tile-2 ist inzwischen transluzent (rgba(255,255,255,.1) im Nacht-
Theme) - fuer ein frei ueber dem Inhalt schwebendes Popup braucht es
einen blickdichten Hintergrund, gleiches Muster wie .standortmenu
oben. */
background: var(--tile-deckend, var(--canvas));
border-radius: 20px;
display: flex; flex-direction: column;
overflow: hidden;
animation: sheet-rein .24s cubic-bezier(.22,.61,.36,1);
}
.setup-kopf {
flex: 0 0 auto;
padding: 18px 20px 14px;
border-bottom: 1px solid var(--line);
display: flex; flex-direction: column; gap: 4px;
}
.setup-kopf strong { font-size: 17px; font-weight: 400; color: var(--fg); }
.setup-kopf span { font-size: 13px; color: var(--fg2); line-height: 1.45; }
.setup-kopf-schalter { display: flex; align-items: center; gap: 10px; margin-top: 10px; }
.setup-kopf-schalter span { font-size: 13.5px; color: var(--fg); }
.setup-koerper { flex: 1 1 0; min-height: 0; overflow-y: auto; padding: 4px 20px 20px; }
.setup-gruppe { margin-top: 18px; }
.setup-gruppe:first-child { margin-top: 6px; }
.setup-gruppe-titel { font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--fg3); margin-bottom: 6px; }
.setup-feldzeile { padding: 12px 0; border-bottom: 1px solid var(--line); }
.setup-feldzeile:last-child { border-bottom: none; }
.setup-feld-kopf { display: flex; flex-direction: column; gap: 2px; margin-bottom: 8px; }
.setup-feld-label { font-size: 14.5px; color: var(--fg); }
.setup-feld-hinweis { font-size: 12.5px; color: var(--fg2); line-height: 1.4; }
.setup-feld-neustart { font-size: 12px; color: var(--warn); line-height: 1.4; }
.setup-unterfelder { display: flex; flex-direction: column; gap: 12px; }
.setup-unterfeld { display: flex; flex-direction: column; gap: 4px; }
.setup-unterfeld-label { font-size: 12px; color: var(--fg3); }
.entitaet-combo { position: relative; }
.entitaet-combo-icon {
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
color: var(--fg3); display: flex; pointer-events: none;
}
.entitaet-combo-input {
width: 100%; box-sizing: border-box;
background: var(--tile-deckend, var(--canvas)); border: 1px solid var(--line); color: var(--fg);
font-family: inherit; font-size: 16px; /* siehe .feld input weiter oben: >=16px gegen Safari-Autozoom */
padding: 11px 13px 11px 36px;
border-radius: 12px;
text-align: left;
-webkit-appearance: none; appearance: none;
}
.entitaet-combo-input:focus { outline: none; border-color: var(--red); }
.entitaet-liste {
position: absolute; z-index: 5; left: 0; right: 0; top: calc(100% + 4px);
max-height: 240px; overflow-y: auto;
background: var(--tile-deckend, var(--canvas)); border: 1px solid var(--line-strong);
border-radius: 12px;
box-shadow: 0 12px 28px rgba(0,0,0,.35);
}
.entitaet-eintrag {
display: flex; flex-direction: column; gap: 1px;
width: 100%; text-align: left;
background: none; border: none; border-bottom: 1px solid var(--line);
color: var(--fg); font-family: inherit;
padding: 9px 12px; cursor: pointer;
}
.entitaet-eintrag:last-child { border-bottom: none; }
.entitaet-eintrag:active, .entitaet-eintrag.aktiv { background: var(--shade); }
.entitaet-eintrag-name { font-size: 14px; color: var(--fg); }
.entitaet-eintrag-id { font-size: 11.5px; color: var(--fg3); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.entitaet-leer { padding: 14px 12px; font-size: 13px; color: var(--fg2); }
.setup-fuss {
flex: 0 0 auto;
display: flex; gap: 10px;
padding: 14px 20px max(14px, env(safe-area-inset-bottom));
border-top: 1px solid var(--line);
}
.setup-fuss .aktion { flex: 1; margin-top: 0; }
/* ------------------------------------------------- B3: Push/Pop
Seitenwechsel wie auf iOS: neue Ebene kommt von rechts, zurück geht sie
dorthin. */