Fix broken map rendering, split GPS cleanup, receipt parser and design backlog

Leaflet's stylesheet was appended to document.head, so it never reached the
panel's shadow root: .leaflet-tile{position:absolute} never applied, tiles laid
out as in-flow images (~1040px inside a 190px box) and the marker pane ended up
far below the visible area. That is the real cause of the long-standing
"fragmented Leaflet rendering" finding and of the invisible vehicle pin - every
tile request had actually succeeded. The stylesheet now goes into the shadow
root and is awaited before the map is built.

Also in this round:
- Map tiles are always light (Google-Maps-style), no dark variant at night.
- Vehicle marker uses the real CI poi-car icons (poi-car-l >=34px, poi-car-s
  below), with a white halo so the outline stays readable on tiles.
- Removed the obsolete combined STANDORT_TRACKER field; only the split
  lat/lon sensors remain.
- Receipt upload: widened the try block so base64/save failures surface, and
  the frontend call site now reports a rejected service call.
- Generic receipt parser: total detection is line-based (letter-spaced
  headings, no more matching the SUMME-EUR column header, tax lines excluded),
  address heuristic handles 4-digit postcodes and single-line address blocks,
  and "Preis/L" matches without a spelled-out "Liter".
- Design backlog: red hairline frame on list rows (delete button bled through
  at fractional row heights) and select fields now use the grey background box.

Shell 10-receipt regression suite still passes; all changes verified live in
the audi_ha_test container.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 12:46:48 +02:00
parent 25d1cebd12
commit 39da68cd87
11 changed files with 328 additions and 133 deletions
+112 -3
View File
@@ -6,8 +6,14 @@ oversized toggle switches, and fixed a desktop-layout audit (settings button / r
overflowing past the capped content column) — see `DESIGN_AUDIT_2026-08-13.md`; 2026-08-16: fixed overflowing past the capped content column) — see `DESIGN_AUDIT_2026-08-13.md`; 2026-08-16: fixed
five user-reported bugs — removed Türschloss/Haubenschloss checks, fixed a translucent confirm- five user-reported bugs — removed Türschloss/Haubenschloss checks, fixed a translucent confirm-
sheet, added a flespi-style split lat/lon location source, hardened the "Lädt …" bootstrap race, sheet, added a flespi-style split lat/lon location source, hardened the "Lädt …" bootstrap race,
and added a generic (station-independent) fuel-receipt parser fallback — see section C). This file and added a generic (station-independent) fuel-receipt parser fallback; later the same day: removed
is the entry point for every new agent the now-unused combined `STANDORT_TRACKER` field entirely (flespi never provides it), closed a real
silent-failure gap in the receipt-upload error path, and replaced the vehicle's map marker with a
two-tone pin+car icon — see section C; a third round the same day found and fixed the long-standing
"fragmented Leaflet rendering" defect at its root — the Leaflet stylesheet was loaded into
`document.head` and therefore never reached the panel's shadow root — plus light-only map tiles,
the CI `poi-car` marker, the red frame on list rows, the select-field grey box, and two more
generic-receipt-parser gaps). This file is the entry point for every new agent
session: what this repo is, what is finished, what is missing, and how to work here. Detail lives in session: what this repo is, what is finished, what is missing, and how to work here. Detail lives in
the linked documents — this file points, it does not duplicate. the linked documents — this file points, it does not duplicate.
@@ -150,7 +156,9 @@ the `design/` export's RS-6 error extends into the technical sample data (73 l t
FIN series 4G). Four browser-verified rendering defects: switches stretched by FIN series 4G). Four browser-verified rendering defects: switches stretched by
`.feld label{flex:1 1 auto}` (state display becomes ambiguous), double "offen" in the trip list, `.feld label{flex:1 1 auto}` (state display becomes ambiguous), double "offen" in the trip list,
empty oil-change tile when the service book is empty, and fragmented Leaflet rendering in all empty oil-change tile when the service book is empty, and fragmented Leaflet rendering in all
three maps (verify on a real device). Positives: the 300/400 font-weight rule and de-DE three maps (**fixed 2026-08-16** — root cause was Leaflet's stylesheet being loaded into
`document.head`, which never reaches the panel's shadow root; see the third-round entry in
"Open items"). Positives: the 300/400 font-weight rule and de-DE
formatting hold everywhere; `design-system/` is verifiably brand-free. formatting hold everywhere; `design-system/` is verifiably brand-free.
### HA panel — known gaps (most also in SPECIFICATION.md §7) ### HA panel — known gaps (most also in SPECIFICATION.md §7)
@@ -666,6 +674,107 @@ wraps the web app for iPhone; a PWA home-screen install is the accepted intermed
card by deliberately triggering it (this container's limited entity set causes a real card by deliberately triggering it (this container's limited entity set causes a real
duplicate auto-suggestion); pyscript reload clean, no new console errors beyond the duplicate auto-suggestion); pyscript reload clean, no new console errors beyond the
pre-existing placeholder-image 404s. Synced to `installationspaket/`. pre-existing placeholder-image 404s. Synced to `installationspaket/`.
- [x] Follow-up round the same day (2026-08-16), three more items:
(1) **Removed `STANDORT_TRACKER` entirely** — user confirmed the combined device_tracker
field is no longer needed (flespi only ever provides the split lat/lon sensors added earlier
that day). Deleted the field from `einstellungen.py` and `entitaeten.py`'s Setup catalog, and
simplified `_standort()` in `frontend_veroeffentlichung.py` down to the single split-sensor
path (no more two-branch fallback). `overrides_schreiben()`'s existing "only known keys
survive" behavior (see its docstring) meant no explicit migration was needed for stale
`STANDORT_TRACKER` entries already sitting in `data/entitaeten.json` — confirmed live: this
container's own leftover override file still had `STANDORT_TRACKER` from earlier testing
(plus stale `TUERSCHLOSS_SENSOREN`/`HAUBENSCHLOSS_SENSOR` from before finding (1) of the
previous entry), all silently dropped on the next Setup save, which is exactly why the GPS
tile was still showing "Kein GPS-Signal" after the user's own remap attempt earlier — that
attempt happened to save while the old combined field was still in the catalog, so nothing
ever actually persisted `STANDORT_LAT_SENSOR`/`STANDORT_LON_SENSOR`. Redid the Setup save
live (mapped both to `sensor.testcar_b9_fmm003_testintegratoin_lat/longitude_coordinate_value`)
and confirmed the Übersicht/fullscreen Standort map switched from the "no signal" placeholder
to a real rendered map at the correct coordinates (South Tyrol terrain, "Burgleralm" label
visible).
(2) **Fuel-receipt "silently does nothing", root cause found** — reproduced the exact
real upload flow (button click → real `<input type=file>` → synthetic `File`/`DataTransfer`
dispatch → `FileReader``hass.callService`) via browser instrumentation: the happy path
works completely (backend parses, publishes `pyscript.audi_dashboard_beleg_ergebnis`, frontend
fills the form) — `belegverarbeitung.py`'s existing `try/except` around `_parser_aufrufen()`
already publishes a visible `fehler` for parser failures specifically. The actual gap:
`base64.b64decode()` and `_pdf_speichern()` in `audi_dashboard_beleg_hochladen()` were
*outside* that try block — any failure there (corrupted upload, disk full, ...) raised an
unhandled exception with zero UI feedback, since `serviceRufen()`'s generic `.catch()` only
does `console.error()`. Widened the try block to cover all three steps. Also stopped using
`serviceRufen()` for this one call site specifically (`audi-dashboard-app.js`, the
`eingabe.onchange` handler) — it now calls `HASS.callService()` directly with its own
`.catch()` that sets `belegFehler` and re-renders, so a rejected service call (network,
timeout — anything that bypasses the backend's own state-publish) is visible too, not just
logged. Verified against the real 10-receipt Shell suite (still passing) both before and
after this change.
(3) **Vehicle map marker replaced with a two-tone pin+car icon** — added `CI.pinCar` (a
balloon/pin path + white badge circle + a simplified car-front glyph built from rects, not
circles, after an isolated-DOM-injection test round showed a circles-for-headlights version
read as a face/animal rather than a car) to `audi-dashboard-app.js`, replacing the single-color
`poiCar` teardrop previously used by `fahrzeugMarkerSVG()`. Updated both marker call sites'
`iconAnchor` to the new pin's actual tip position and simplified `.fahrzeug-pin` in
`audi-dashboard.css` (no more `color:var(--red)`/white-halo-filter hack, since the new icon
carries its own fixed colors). Verified the icon markup renders correctly via an isolated
DOM-injection test (bypassing Leaflet); could **not** get a final on-map screenshot in
`audi_ha_test` — the Standort map tiles themselves only ever partially load in this container
(reproduced on a fully fresh reload, unrelated to this change), consistent with a previously
documented network limitation of this specific sandbox (see the Standort-Kachel entry above:
reverse-geocoding via Nominatim had the same kind of container-specific network issue).
Structurally verified only; needs a look on a real device/network to confirm final visual
placement.
All three deployed to `audi_ha_test` (version `1786758000`) and synced to `installationspaket/`.
- [x] Third round the same day (2026-08-16) — **the "fragmented Leaflet rendering" defect is solved
at its root**, plus four smaller items:
(1) **Leaflet CSS never reached the shadow root.** `leafletLaden()` appended
`leaflet.min.css` to `document.head`, but the panel renders inside `this.shadowRoot` — and
document stylesheets do not cross a shadow boundary. Inside the panel, `.leaflet-tile
{position:absolute}` and friends therefore never applied: the tiles laid themselves out as
ordinary in-flow `<img>` elements (two per row, ~1040 px of stacked height inside a 190 px
box), so only a thin strip of map was ever visible, and the marker pane ended up ~1009 px
below the visible area — which is exactly why the new vehicle pin appeared "missing". This
also explains the earlier misdiagnosis as a container/network limitation: every tile request
succeeded (`complete:true`, `naturalWidth:512`), the geometry was the problem. Fixed by
injecting the stylesheet into `ROOT` and awaiting it before the map is built; the script tag
stays in `document.head` (it must, `window.L` is global). The CSS injection is deliberately
re-checked on every `leafletLaden()` call rather than guarded by the `window.L` check, because
a HA panel remount produces a fresh shadow root while `window.L` is already set. Verified live:
both the Übersicht preview map and the fullscreen Standort map now fill their containers and
show the pin at the vehicle position.
(2) **Maps are now always light.** `TILES[theme]` switched to CARTO `dark_all` in Nacht mode;
replaced by a single themeless `TILE_URL` (`light_all`), matching how Google Maps & co. keep
the standard road map light regardless of app theme.
(3) **Vehicle marker now uses the real CI `poi-car` icons** (`poi-car-l.svg` / `poi-car-s.svg`
from the delivered icon set) instead of the hand-built two-tone pin from the previous round:
`CI.poiCarL` (48-grid) at ≥34 px, the existing `CI.poiCar` (24-grid) below, anchors recomputed
per variant. Since these are single-color outline forms in `currentColor`, `.fahrzeug-pin`
regained a stacked white drop-shadow halo so the outline stays readable on map tiles.
(4) **Red partial frame on Fahrten/Tanken list rows** (open since the 2026-08-13 design audit,
finding 3) — reproduced and confirmed by recoloring the delete button live. Row heights are
fractional (66.28 px), so `.swipe-content`'s edges miss the device-pixel grid and the red
`.swipe-delete` behind it bled through as a hairline around every row. Fixed by only painting
the delete button while a swipe is actually happening: `.swipe-delete{visibility:hidden}` plus
a `wischt` class set on the first movement of the gesture (and the existing `swiped` class for
the open row). Verified the full gesture still works — button appears at the first pixel of
drag and the row settles open at 84 px.
(5) **Select fields now show the grey background box** (design audit finding 1) — replaced
`.feld select{color:var(--ios-tint)}` with `background:var(--ios-fill);padding:8px 10px`,
matching `.mitEinheit input` ("Pause bis [15] Minuten"), as the user asked. Verified on
Modell / Ölwechsel-Intervall / Bildposition; free text fields stay plain, so the box now
genuinely marks "there is a choice here".
(6) **Generic receipt parser: total and station name** — a real Austrian non-Shell receipt
returned the right litres but 6,00 € and no station. Two causes, both fixed: the free-running
total pattern matched the *column heading* `SUMME-EUR` and read the next line's article number
as the amount, and bon printers letter-space headings (`G E S A M T BETRAG EUR: 92,60`), which
the keyword never matched. Total detection is now line-based — keyword checked against the
whitespace-stripped line, amount must be a real money value (`_GELD`, decimals required), tax
lines (`MWST`/`UST`/`VAT`/`STEUER`/`NETTO`) excluded, largest remaining candidate wins. The
address heuristic no longer demands three fixed lines with a 5-digit postcode (AT uses 4
digits, and this receipt carries `SHELL TANKSTELLE, 6450 SÖLDEN` on one line): it finds the
first postcode line in the header and takes the name from the line above. `Preis/L` without a
spelled-out "Liter" now matches too. Result on that receipt: 42,13 l · 92,60 € · 2,198 €/l ·
no discount · `AUTO B. FRISCHMANN GMBH`. The 10-receipt Shell regression suite still passes.
All deployed to `audi_ha_test` (version `1786765000`) and synced to `installationspaket/`.
- [ ] Fix remaining documentation drift (statistics claim, README gaps, obsolete TODO comment) — - [ ] Fix remaining documentation drift (statistics claim, README gaps, obsolete TODO comment) —
text-only changes; INSTALL.md's WLAN/TommiG1 drift and stale variable names were fixed text-only changes; INSTALL.md's WLAN/TommiG1 drift and stale variable names were fixed
2026-08-12 (see section B); `DESIGN_REVIEW_2026-08-13.md` and `REVIEW_main_2026-08-13.md` 2026-08-12 (see section B); `DESIGN_REVIEW_2026-08-13.md` and `REVIEW_main_2026-08-13.md`
+5 -2
View File
@@ -177,8 +177,11 @@ Oberfläche „unbekannt"/em-dash statt eines Werts, kein Absturz:
(`RANGE_SENSOR`) — bis zu einer neuen Datenquelle unbelegt, siehe (`RANGE_SENSOR`) — bis zu einer neuen Datenquelle unbelegt, siehe
`AGENTS.md` (die frühere `TommiG1/HA_VAG-EU-Data-Act`-Integration liefert `AGENTS.md` (die frühere `TommiG1/HA_VAG-EU-Data-Act`-Integration liefert
diese nicht mehr) diese nicht mehr)
- Standort (`STANDORT_TRACKER`, ein `device_tracker`) und Batteriespannung - Standort (`STANDORT_LAT_SENSOR`/`STANDORT_LON_SENSOR`, zwei eigene
(`BATTERIE_SENSOR`) — vom FMM003, z. B. `device_tracker.<gerätename>` `sensor`-Entities für Breiten-/Längengrad — flespi liefert Koordinaten so,
nicht als `latitude`/`longitude`-Attribute eines `device_tracker`) und
Batteriespannung (`BATTERIE_SENSOR`) — vom FMM003, z. B.
`sensor.<gerätename>_latitude_coordinate_value`/`..._longitude_coordinate_value`
bzw. `sensor.<gerätename>_external_power_voltage` (**nicht** bzw. `sensor.<gerätename>_external_power_voltage` (**nicht**
`..._battery_voltage` — das ist die interne Pufferzelle des Trackers, `..._battery_voltage` — das ist die interne Pufferzelle des Trackers,
nicht die Fahrzeugbatterie) nicht die Fahrzeugbatterie)
+59 -12
View File
@@ -91,6 +91,14 @@ def _iso(tag, monat, jahr, stunde, minute, sekunde="0"):
_ZAHL = r"[\d.,]+" _ZAHL = r"[\d.,]+"
# Echte Geldangabe: Nachkommastellen sind Pflicht, damit im stationsunabhängigen
# Parser (_parsen_generisch) keine Artikel-/Belegnummer als Betrag durchgeht.
_GELD = r"\d{1,3}(?:[ .]\d{3})*,\d{2}"
_GESAMT_WOERTER = ("GESAMT", "ABSOLUT", "ENDBETRAG", "ZUZAHLEN", "TOTAL", "SUMME")
# Steuer- und Nettozeilen tragen dieselben Schlüsselwörter ("MWST GESAMT"),
# nennen aber nie den bezahlten Rechnungsbetrag.
_STEUER_WOERTER = ("MWST", "UST", "VAT", "STEUER", "NETTO")
def _parsen(text): def _parsen(text):
def suchen(muster, *, pflicht=True, flags=re.MULTILINE): def suchen(muster, *, pflicht=True, flags=re.MULTILINE):
@@ -225,20 +233,57 @@ def _parsen_generisch(text):
def suchen(muster, flags=re.IGNORECASE | re.MULTILINE): def suchen(muster, flags=re.IGNORECASE | re.MULTILINE):
return re.search(muster, text, flags) return re.search(muster, text, flags)
# ---- Adresse: Name-/Straßen-/PLZ-Ort-Zeile hintereinander - wie bei zeilen = [z.rstrip() for z in text.splitlines()]
# Shell (_parsen() oben), aber ohne Ankerzeile "Obj.-Nr." danach, also
# direkt an der PLZ-Ort-Form (5 Ziffern + Text) erkannt. Nicht Pflicht. # ---- Adresse -----------------------------------------------------------
adresse = suchen(r"([^\n]{2,60})\n([^\n]{2,60})\n(\d{5}\s+[^\n]{2,40})") # Die PLZ-Ort-Angabe steht nicht zuverlässig auf einer eigenen Zeile (ein
station_name = adresse.group(1).strip() if adresse else None # österreichischer Beleg führt sie als "SHELL TANKSTELLE, 6450 SÖLDEN"
station_address = ( # zusammen mit der Filialbezeichnung) und PLZ sind vier- *oder*
f"{adresse.group(2).strip()}, {adresse.group(3).strip()}" if adresse else None # fünfstellig (AT/DE). Deshalb nicht mehr drei feste Zeilen hintereinander
) # verlangen, sondern im Belegkopf die erste Zeile mit PLZ+Ort suchen; der
# Name ist die erste Textzeile darüber.
plz_zeile = None
for i, zeile in enumerate(zeilen[:12]):
if re.search(r"\b\d{4,5}\s+[A-Za-zÄÖÜäöüß]", zeile):
plz_zeile = i
break
station_name = None
station_address = None
if plz_zeile is not None:
station_address = zeilen[plz_zeile].strip()
for zeile in reversed(zeilen[:plz_zeile]):
if zeile.strip():
station_name = zeile.strip()
break
if station_name is None:
for zeile in zeilen[:5]:
if zeile.strip():
station_name = zeile.strip()
break
# ---- Gesamtbetrag ------------------------------------------------------ # ---- Gesamtbetrag ------------------------------------------------------
gesamt = suchen(r"(?:Gesamt\w*|Absolut\w*|Endbetrag|Summe|Total)\D{0,20}?(" + _ZAHL + r")\s*(?:€|EUR)?") # Zeilenweise statt über den ganzen Text, aus zwei Gründen, die beide an
if not gesamt: # einem echten Beleg aufgefallen sind: Bondrucker sperren Überschriften
# gern buchstabenweise ("G E S A M T BETRAG EUR: 92,60"), und ein frei
# laufendes Muster fand vorher die Spaltenüberschrift "SUMME-EUR" und
# las die Artikelnummer der Folgezeile als Betrag. Deshalb: Schlüsselwort
# auf der leerzeichenfreien Zeile prüfen, Betrag nur als echte Geldangabe
# (mit Nachkommastellen) akzeptieren und Steuerzeilen ausschließen.
kandidaten = []
for zeile in zeilen:
kompakt = re.sub(r"\s+", "", zeile).upper()
if not any(wort in kompakt for wort in _GESAMT_WOERTER):
continue
if any(wort in kompakt for wort in _STEUER_WOERTER):
continue
betraege = re.findall(_GELD, zeile)
if betraege:
kandidaten.append(_de_zahl(betraege[-1]))
if not kandidaten:
raise ValueError("Kein Gesamtbetrag gefunden (weder 'Gesamt' noch 'Absolut')") raise ValueError("Kein Gesamtbetrag gefunden (weder 'Gesamt' noch 'Absolut')")
fuel_total_eur = _de_zahl(gesamt.group(1)) # Mehrere Treffer (z. B. Summenzeile und Kartenzahlungszeile): der
# Rechnungsbetrag ist der größte - Teilbeträge sind nie größer.
fuel_total_eur = max(kandidaten)
# ---- Menge in Litern ---------------------------------------------------- # ---- Menge in Litern ----------------------------------------------------
menge = suchen(r"(?:Menge|Amount)\D{0,15}?(" + _ZAHL + r")\s*(?:l\b|L\b|Liter|Ltr\.?)") menge = suchen(r"(?:Menge|Amount)\D{0,15}?(" + _ZAHL + r")\s*(?:l\b|L\b|Liter|Ltr\.?)")
@@ -253,8 +298,10 @@ def _parsen_generisch(text):
berechneter_preis_l = round(fuel_total_eur / liters, 3) berechneter_preis_l = round(fuel_total_eur / liters, 3)
# ---- Aufgedruckter Preis/Liter - Abweichung verrät einen Rabatt ------- # ---- Aufgedruckter Preis/Liter - Abweichung verrät einen Rabatt -------
# "Preis/L" (ohne ausgeschriebenes "Liter") kommt auf echten Belegen vor,
# deshalb ist alles nach dem L optional.
aufgedruckt = suchen( aufgedruckt = suchen(
r"(?:Preis\s*/?\s*(?:je\s*)?Liter|Price\s*/?\s*L(?:iter)?|€\s*/\s*l|EUR\s*/\s*l)\D{0,10}?(" + _ZAHL + r")" r"(?:Preis\s*/?\s*(?:je\s*)?L(?:iter|tr\.?)?|Price\s*/?\s*L(?:iter)?|€\s*/\s*l|EUR\s*/\s*l)\D{0,10}?(" + _ZAHL + r")"
) )
list_price_per_l = _de_zahl(aufgedruckt.group(1)) if aufgedruckt else berechneter_preis_l list_price_per_l = _de_zahl(aufgedruckt.group(1)) if aufgedruckt else berechneter_preis_l
+11 -5
View File
@@ -103,14 +103,20 @@ def audi_dashboard_beleg_hochladen(pdf_base64=None, dateiname=None, tank_id=None
# task.executor entfällt hier bewusst: _pdf_speichern/_parser_aufrufen sind # task.executor entfällt hier bewusst: _pdf_speichern/_parser_aufrufen sind
# in dieser pyscript-Datei definiert, task.executor akzeptiert aber nur # in dieser pyscript-Datei definiert, task.executor akzeptiert aber nur
# echte externe Python-Funktionen (siehe Kopfkommentar in profil.py). # echte externe Python-Funktionen (siehe Kopfkommentar in profil.py).
inhalt_bytes = base64.b64decode(pdf_base64) #
dateiname = dateiname or f"beleg_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf" # Alles bis zum geparsten Ergebnis in einem try: eine unbehandelte
pdf_pfad = _pdf_speichern(inhalt_bytes, dateiname) # Exception hier (kaputtes Base64, volle Festplatte, ...) wäre für die
# Oberfläche unsichtbar geblieben - serviceRufen() im Frontend loggt einen
# rejizierten Service-Aufruf nur in die Browser-Konsole, zeigt aber nichts
# an (siehe audi-dashboard-app.js). Bisher war nur der Parser-Aufruf
# selbst so abgesichert; b64decode/_pdf_speichern fehlten.
try: try:
inhalt_bytes = base64.b64decode(pdf_base64)
dateiname = dateiname or f"beleg_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
pdf_pfad = _pdf_speichern(inhalt_bytes, dateiname)
geparst = _parser_aufrufen(pdf_pfad) geparst = _parser_aufrufen(pdf_pfad)
except Exception as exc: except Exception as exc:
log.error(f"audi_dashboard: Beleg konnte nicht geparst werden: {exc}") log.error(f"audi_dashboard: Beleg konnte nicht verarbeitet werden: {exc}")
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg konnte nicht gelesen werden", "tank_id": tank_id}) frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg konnte nicht gelesen werden", "tank_id": tank_id})
return return
@@ -77,16 +77,10 @@ OELWECHSEL_STRECKE_SENSOR = ""
NAECHSTE_INSPEKTION_SENSOR = "" NAECHSTE_INSPEKTION_SENSOR = ""
INSPEKTION_STRECKE_SENSOR = "" INSPEKTION_STRECKE_SENSOR = ""
# Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel). Erwartet # Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel). Breiten-/
# eine device_tracker.*-Entity mit "latitude"/"longitude"-Attributen (HA- # Längengrad als zwei eigene sensor-Entities (flespi liefert Koordinaten so,
# Konvention, siehe _standort() in frontend_veroeffentlichung.py) - jetzt # nicht als Attribute einer device_tracker-Entity - siehe _standort() in
# vom FMM003 geliefert. # frontend_veroeffentlichung.py).
STANDORT_TRACKER = "device_tracker.testzone_fmm003"
# Alternative zu STANDORT_TRACKER: manche Integrationen (z. B. flespi) liefern
# Breiten-/Längengrad nicht als Attribute einer einzelnen device_tracker-
# Entity, sondern als zwei eigene sensor-Entities. _standort() nutzt diese
# beiden nur, wenn STANDORT_TRACKER leer ist oder keine Koordinaten liefert.
STANDORT_LAT_SENSOR = "" STANDORT_LAT_SENSOR = ""
STANDORT_LON_SENSOR = "" STANDORT_LON_SENSOR = ""
+4 -8
View File
@@ -109,18 +109,14 @@ FELDER = [
"hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.", "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, "domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
"stichworte": ["inspektion", "inspection", "distance", "strecke"]}, "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"]},
{"key": "STANDORT_LAT_SENSOR", "label": "GPS-Standort: Breitengrad", "gruppe": "standort", {"key": "STANDORT_LAT_SENSOR", "label": "GPS-Standort: Breitengrad", "gruppe": "standort",
"hinweis": "Nur nötig, falls die Integration Breiten-/Längengrad als zwei eigene Sensoren statt als Attribute einer GPS-Standort-Entity liefert (z. B. flespi).", "hinweis": "Breitengrad als eigener Sensor (z. B. flespi).",
"domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False, "domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
"stichworte": ["breitengrad", "latitude", "lat"]}, "stichworte": ["breitengrad", "latitude", "lat", "standort", "gps", "position", "ortung"]},
{"key": "STANDORT_LON_SENSOR", "label": "GPS-Standort: Längengrad", "gruppe": "standort", {"key": "STANDORT_LON_SENSOR", "label": "GPS-Standort: Längengrad", "gruppe": "standort",
"hinweis": "Nur nötig, falls die Integration Breiten-/Längengrad als zwei eigene Sensoren statt als Attribute einer GPS-Standort-Entity liefert (z. B. flespi).", "hinweis": "Längengrad als eigener Sensor (z. B. flespi).",
"domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False, "domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
"stichworte": ["längengrad", "longitude", "lon", "lng"]}, "stichworte": ["längengrad", "longitude", "lon", "lng", "standort", "gps", "position", "ortung"]},
] ]
# Mehrere @state_trigger(f"...")-Dekoratoren (fahrterkennung.py, # Mehrere @state_trigger(f"...")-Dekoratoren (fahrterkennung.py,
@@ -88,59 +88,36 @@ def _abs_zahl(wert):
def _standort(): def _standort():
"""Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel). Zwei """Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel) - liest
mögliche Quellen, in dieser Reihenfolge versucht: (1) STANDORT_TRACKER, STANDORT_LAT_SENSOR/STANDORT_LON_SENSOR, zwei eigene sensor-Entities für
eine device_tracker-Entity mit latitude/longitude/gps_accuracy-Attributen Breiten-/Längengrad (flespi liefert Koordinaten so, nicht als Attribute
(HA-Konvention) - der Normalfall; (2) STANDORT_LAT_SENSOR/ einer einzelnen device_tracker-Entity). Fehlt eine der beiden Entity-IDs
STANDORT_LON_SENSOR, zwei eigene sensor-Entities für Breiten-/Längengrad - oder ist der Zustand (noch) nicht verfügbar, liefert diese Funktion
manche Integrationen (z. B. flespi) liefern Koordinaten so, statt als durchgehend None statt eines geratenen Werts - genau das
Attribute einer einzelnen Tracker-Entity. Fehlt beides oder ist (noch) zustand_oder_none()-Muster von oben, hier als eigene Funktion, weil
nicht verfügbar, liefert diese Funktion durchgehend None statt eines zusätzlich zum Zustand noch der Aktualisierungszeitpunkt gebraucht wird:
geratenen Werts - genau das zustand_oder_none()-Muster von oben, hier als state.get() liefert dafür ein StateVal (str-Unterklasse) mit
eigene Funktion, weil zusätzlich zum Zustand noch Attribute und der .last_updated als datetime-Attribut - kein eigenständiges
Aktualisierungszeitpunkt gebraucht werden: state.get() liefert dafür ein state.last_updated(), das gibt es in pyscript nicht."""
StateVal (str-Unterklasse) mit .last_updated als datetime-Attribut - kein
eigenständiges state.last_updated(), das gibt es in pyscript nicht."""
leer = {"lat": None, "lon": None, "genauigkeit_m": None, "zeit": None} leer = {"lat": None, "lon": None, "genauigkeit_m": None, "zeit": None}
tracker_id = einstellungen.STANDORT_TRACKER
if tracker_id:
try:
wert = state.get(tracker_id)
attribute = state.getattr(tracker_id)
except NameError:
attribute = None
if attribute and attribute.get("latitude") is not None:
zeit = None
try:
zeit = wert.last_updated.isoformat()
except AttributeError:
pass
return {
"lat": _zu_zahl(attribute.get("latitude")),
"lon": _zu_zahl(attribute.get("longitude")),
"genauigkeit_m": _zu_zahl(attribute.get("gps_accuracy")),
"zeit": zeit,
}
lat_id, lon_id = einstellungen.STANDORT_LAT_SENSOR, einstellungen.STANDORT_LON_SENSOR lat_id, lon_id = einstellungen.STANDORT_LAT_SENSOR, einstellungen.STANDORT_LON_SENSOR
if lat_id and lon_id: if not lat_id or not lon_id:
try: return leer
lat_wert = state.get(lat_id) try:
lon_wert = state.get(lon_id) lat_wert = state.get(lat_id)
except NameError: lon_wert = state.get(lon_id)
return leer except NameError:
lat, lon = _zu_zahl(lat_wert), _zu_zahl(lon_wert) return leer
if lat is None or lon is None: lat, lon = _zu_zahl(lat_wert), _zu_zahl(lon_wert)
return leer if lat is None or lon is None:
zeit = None return leer
try: zeit = None
zeit = lat_wert.last_updated.isoformat() try:
except AttributeError: zeit = lat_wert.last_updated.isoformat()
pass except AttributeError:
return {"lat": lat, "lon": lon, "genauigkeit_m": None, "zeit": zeit} pass
return {"lat": lat, "lon": lon, "genauigkeit_m": None, "zeit": zeit}
return leer
POSITIONEN = ["vorne links", "vorne rechts", "hinten links", "hinten rechts"] POSITIONEN = ["vorne links", "vorne rechts", "hinten links", "hinten rechts"]
+83 -33
View File
@@ -386,31 +386,57 @@ function ring(pct) {
/* ------------------------------------------------------------------ Karte /* ------------------------------------------------------------------ Karte
Leaflet weiterhin per CDN geladen (siehe Kopfkommentar). */ Leaflet weiterhin per CDN geladen (siehe Kopfkommentar). */
let MAP = null, TILE = null, LEAFLET_LADEND = null; let MAP = null, TILE = null, LEAFLET_LADEND = null;
const TILES = { /* Bewusst themeunabhaengig hell: Kartendienste (Google Maps & Co.) zeigen die
nacht: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", Standard-Strassenkarte immer im hellen Stil, weil Strassen, Beschriftungen
tag: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", und Wegfarben darauf lesbar bleiben. Die frueher bei Nacht eingeblendeten
}; dark_all-Kacheln liessen die Karte im Panel schwarz wirken. */
const TILE_URL = "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";
const ATTR = "&copy; OpenStreetMap &middot; CARTO"; const ATTR = "&copy; OpenStreetMap &middot; CARTO";
/* Satellitenansicht der Standort-Vollbildkarte (Layer-Knopf) - wie die /* Satellitenansicht der Standort-Vollbildkarte (Layer-Knopf) - wie die
CARTO-Straßenkacheln oben ohne eigenen Schlüssel nutzbar. */ CARTO-Straßenkacheln oben ohne eigenen Schlüssel nutzbar. */
const SAT_TILE_URL = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"; const SAT_TILE_URL = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}";
const SAT_ATTR = "Tiles &copy; Esri"; const SAT_ATTR = "Tiles &copy; Esri";
function leafletLaden() { const LEAFLET_CSS_URL = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css";
if (window.L) return Promise.resolve(); const LEAFLET_JS_URL = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js";
if (LEAFLET_LADEND) return LEAFLET_LADEND;
LEAFLET_LADEND = new Promise((resolve, reject) => { /* Das Leaflet-Stylesheet muss in den Shadow-Root, nicht in document.head:
const css1 = document.createElement("link"); Dokument-Styles greifen nicht in ein Shadow-DOM hinein. Lag es im Head,
css1.rel = "stylesheet"; fehlte drinnen u. a. .leaflet-tile{position:absolute} - die Kacheln flossen
css1.href = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css"; dann als gewoehnliche Bilder untereinander (bei 8 Kacheln ueber 1000 px
document.head.appendChild(css1); hoch), von der Karte war nur ein schmaler Streifen zu sehen und der
const script = document.createElement("script"); Fahrzeugmarker landete weit unterhalb des sichtbaren Ausschnitts. Das JS
script.src = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"; gehoert dagegen weiterhin in den Head, damit window.L global bleibt. */
script.onload = resolve; function leafletCssEinhaengen() {
script.onerror = reject; if (!ROOT || ROOT.querySelector("link[data-leaflet-css]")) return Promise.resolve();
document.head.appendChild(script); return new Promise((fertig) => {
const css = document.createElement("link");
css.rel = "stylesheet";
css.href = LEAFLET_CSS_URL;
css.dataset.leafletCss = "1";
/* Auch bei Ladefehler weitermachen statt haengen zu bleiben - dann sieht
die Karte zwar wieder falsch aus, aber die Ansicht baut sich auf. */
css.onload = fertig;
css.onerror = fertig;
ROOT.appendChild(css);
}); });
return LEAFLET_LADEND; }
function leafletLaden() {
/* Bewusst bei jedem Aufruf: haengt HA das Panel neu ein, entsteht ein neuer
Shadow-Root ohne das Stylesheet, waehrend window.L laengst gesetzt ist. */
const css = leafletCssEinhaengen();
if (window.L) return css;
if (!LEAFLET_LADEND) {
LEAFLET_LADEND = new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = LEAFLET_JS_URL;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
return Promise.all([css, LEAFLET_LADEND]);
} }
function mapBox() { return `<div class="mapbox"><div id="map"></div></div>`; } function mapBox() { return `<div class="mapbox"><div id="map"></div></div>`; }
@@ -437,7 +463,7 @@ function fakeTrack(a, b, n = 13) {
} }
function tiles() { function tiles() {
if (!MAP || !window.L) return; if (!MAP || !window.L) return;
const url = TILES[HOST.dataset.theme]; const url = TILE_URL;
if (TILE) MAP.removeLayer(TILE); if (TILE) MAP.removeLayer(TILE);
TILE = window.L.tileLayer(url, { maxZoom: 19, attribution: ATTR }).addTo(MAP); TILE = window.L.tileLayer(url, { maxZoom: 19, attribution: ATTR }).addTo(MAP);
} }
@@ -498,10 +524,15 @@ const STANDORT_PEEK_PX = 96; // sichtbarer Anteil im geschlossenen Zustand, muss
function standortBekannt() { return CAR.standortLat != null && CAR.standortLon != null; } function standortBekannt() { return CAR.standortLat != null && CAR.standortLon != null; }
/* Kartenmarkierung: poi-car aus dem Audi-CI-Satz (assets/icons/poi-car.svg). /* Kartenmarkierung: poi-car aus dem CI-Satz, in den beiden gelieferten
Die Spitze sitzt unten, deshalb ankert Leaflet am unteren Rand. */ Groessenvarianten - poi-car-l (48er-Raster, mehr Detail) ab 34 px, darunter
poi-car-s (24er-Raster). Die Nadelspitze sitzt unten, deshalb ankert Leaflet
am unteren Rand (siehe iconAnchor an den Aufrufstellen: Spitze bei y=45.25
im 48er- bzw. y=23.3 im 24er-viewBox). */
function fahrzeugMarkerSVG(px = 34) { function fahrzeugMarkerSVG(px = 34) {
return `<svg viewBox="0 0 24 24" width="${px}" height="${px}" fill="none" aria-hidden="true">${CI.poiCar}</svg>`; const gross = px >= 34;
const box = gross ? 48 : 24;
return `<svg viewBox="0 0 ${box} ${box}" width="${px}" height="${px}" fill="none" aria-hidden="true">${gross ? CI.poiCarL : CI.poiCar}</svg>`;
} }
/* Fahrzustand fürs Menü: "fährt" solange die aktuelle Fahrt noch offen ist /* Fahrzustand fürs Menü: "fährt" solange die aktuelle Fahrt noch offen ist
@@ -576,7 +607,7 @@ function standortErfassenFallsNoetig() {
} }
/* Reverse-Geocoding über die öffentliche Nominatim-API (OpenStreetMap) - /* Reverse-Geocoding über die öffentliche Nominatim-API (OpenStreetMap) -
wie die Kartenkacheln oben (TILES) ohne eigenen Schlüssel; bei diesem wie die Kartenkacheln oben (TILE_URL) ohne eigenen Schlüssel; bei diesem
Aufrufvolumen (ein Fahrzeug, nur bei geöffneter Standortansicht) im Rahmen Aufrufvolumen (ein Fahrzeug, nur bei geöffneter Standortansicht) im Rahmen
der Nominatim-Nutzungsbedingungen unproblematisch, genau wie das bereits der Nominatim-Nutzungsbedingungen unproblematisch, genau wie das bereits
dokumentierte Leaflet-über-CDN-Vorgehen (siehe AGENTS.md). Cache-Schlüssel dokumentierte Leaflet-über-CDN-Vorgehen (siehe AGENTS.md). Cache-Schlüssel
@@ -639,14 +670,14 @@ function standortTiles() {
if (SMAP_TILE) SMAP.removeLayer(SMAP_TILE); if (SMAP_TILE) SMAP.removeLayer(SMAP_TILE);
SMAP_TILE = SMAP_STIL === "satellit" SMAP_TILE = SMAP_STIL === "satellit"
? window.L.tileLayer(SAT_TILE_URL, { maxZoom: 19, attribution: SAT_ATTR }).addTo(SMAP) ? window.L.tileLayer(SAT_TILE_URL, { maxZoom: 19, attribution: SAT_ATTR }).addTo(SMAP)
: window.L.tileLayer(TILES[HOST.dataset.theme], { maxZoom: 19, attribution: ATTR }).addTo(SMAP); : window.L.tileLayer(TILE_URL, { maxZoom: 19, attribution: ATTR }).addTo(SMAP);
} }
function standortMarkerZeichnen() { function standortMarkerZeichnen() {
if (!SMAP || !window.L) return; if (!SMAP || !window.L) return;
if (FAHRZEUG_MARKER) { SMAP.removeLayer(FAHRZEUG_MARKER); FAHRZEUG_MARKER = null; } if (FAHRZEUG_MARKER) { SMAP.removeLayer(FAHRZEUG_MARKER); FAHRZEUG_MARKER = null; }
if (USER_MARKER) { SMAP.removeLayer(USER_MARKER); USER_MARKER = null; } if (USER_MARKER) { SMAP.removeLayer(USER_MARKER); USER_MARKER = null; }
if (standortBekannt()) { if (standortBekannt()) {
const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `<div class="fahrzeug-pin">${fahrzeugMarkerSVG(38)}</div>`, iconSize: [38, 38], iconAnchor: [18, 36] }); const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `<div class="fahrzeug-pin">${fahrzeugMarkerSVG(38)}</div>`, iconSize: [38, 38], iconAnchor: [19, 36] });
FAHRZEUG_MARKER = window.L.marker([CAR.standortLat, CAR.standortLon], { icon }).addTo(SMAP); FAHRZEUG_MARKER = window.L.marker([CAR.standortLat, CAR.standortLon], { icon }).addTo(SMAP);
FAHRZEUG_MARKER.on("click", () => standortMenuOeffnen()); FAHRZEUG_MARKER.on("click", () => standortMenuOeffnen());
} }
@@ -693,10 +724,10 @@ async function initVorschauMap() {
zoomControl: false, attributionControl: false, dragging: false, zoomControl: false, attributionControl: false, dragging: false,
scrollWheelZoom: false, doubleClickZoom: false, boxZoom: false, keyboard: false, touchZoom: false, scrollWheelZoom: false, doubleClickZoom: false, boxZoom: false, keyboard: false, touchZoom: false,
}); });
TMAP_TILE = window.L.tileLayer(TILES[HOST.dataset.theme], { maxZoom: 19, attribution: ATTR }).addTo(TMAP); TMAP_TILE = window.L.tileLayer(TILE_URL, { maxZoom: 19, attribution: ATTR }).addTo(TMAP);
if (standortBekannt()) { if (standortBekannt()) {
TMAP.setView([CAR.standortLat, CAR.standortLon], 14); TMAP.setView([CAR.standortLat, CAR.standortLon], 14);
const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `<div class="fahrzeug-pin">${fahrzeugMarkerSVG(32)}</div>`, iconSize: [32, 32], iconAnchor: [15, 30] }); const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `<div class="fahrzeug-pin">${fahrzeugMarkerSVG(32)}</div>`, iconSize: [32, 32], iconAnchor: [15, 31] });
window.L.marker([CAR.standortLat, CAR.standortLon], { icon }).addTo(TMAP); window.L.marker([CAR.standortLat, CAR.standortLon], { icon }).addTo(TMAP);
} else { } else {
TMAP.setView(kartenMittelpunkt(), 11); TMAP.setView(kartenMittelpunkt(), 11);
@@ -781,6 +812,9 @@ const CI = {
carSearch: '<path fill="currentColor" d="M1 7.8a6.16 6.16 0 0 0 6.1 6.1h.4A5.7 5.7 0 0 0 13 7.91V7.9a6.1 6.1 0 0 0-5.91-6A5.93 5.93 0 0 0 1 7.8m-1 0C0 3.92 3.23.8 7.11.9a7.1 7.1 0 0 1 6.89 7c.06 2.33-1 4.44-2.73 5.71L14.8 19H20v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-7.8l-1.9-1.9-.04-.06a55 55 0 0 0-2.84-4.71c-1.42-.1-2.49-.17-3.2-.2l.04-1c.77.04 1.93.1 3.48.22l.24.02.14.2a55 55 0 0 1 2.99 4.93L24 12.3v8.2c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V20h-3.55l1.67 2.54-.84.54L14.26 20 5 19.96v.54c0 .83-.67 1.5-1.5 1.5h-2A1.5 1.5 0 0 1 0 20.5v-7.08h1v7.08c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-1.54l9.6.02-3.17-4.83c-.87.45-1.85.72-2.92.75H7.1c-3.88 0-7-3.13-7.1-7.09zm5.47-2.75c1.86-.12 3.37-.2 4.52-.24l.03 1c-1.08.03-2.5.1-4.24.22L4.51 8c-.27.43-.6 1-1 1.7l-.87-.48c.4-.72.74-1.3 1.03-1.76q.6-.96 1.41-2.19l.14-.2z"/>', carSearch: '<path fill="currentColor" d="M1 7.8a6.16 6.16 0 0 0 6.1 6.1h.4A5.7 5.7 0 0 0 13 7.91V7.9a6.1 6.1 0 0 0-5.91-6A5.93 5.93 0 0 0 1 7.8m-1 0C0 3.92 3.23.8 7.11.9a7.1 7.1 0 0 1 6.89 7c.06 2.33-1 4.44-2.73 5.71L14.8 19H20v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-7.8l-1.9-1.9-.04-.06a55 55 0 0 0-2.84-4.71c-1.42-.1-2.49-.17-3.2-.2l.04-1c.77.04 1.93.1 3.48.22l.24.02.14.2a55 55 0 0 1 2.99 4.93L24 12.3v8.2c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V20h-3.55l1.67 2.54-.84.54L14.26 20 5 19.96v.54c0 .83-.67 1.5-1.5 1.5h-2A1.5 1.5 0 0 1 0 20.5v-7.08h1v7.08c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-1.54l9.6.02-3.17-4.83c-.87.45-1.85.72-2.92.75H7.1c-3.88 0-7-3.13-7.1-7.09zm5.47-2.75c1.86-.12 3.37-.2 4.52-.24l.03 1c-1.08.03-2.5.1-4.24.22L4.51 8c-.27.43-.6 1-1 1.7l-.87-.48c.4-.72.74-1.3 1.03-1.76q.6-.96 1.41-2.19l.14-.2z"/>',
gps: '<path fill="currentColor" d="M12.5 3.51V6.5h-1V3.51a8.46 8.46 0 0 0-7.99 7.99H6.5v1H3.51a8.46 8.46 0 0 0 7.99 7.99V17.5h1v2.99a8.46 8.46 0 0 0 7.99-7.99H17.5v-1h2.99a8.46 8.46 0 0 0-7.99-7.99m0-1a9.46 9.46 0 0 1 8.99 8.99h2.01v1h-2.01a9.46 9.46 0 0 1-8.99 8.99v2.01h-1v-2.01a9.46 9.46 0 0 1-8.99-8.99H.5v-1h2.01a9.46 9.46 0 0 1 8.99-8.99V.5h1z"/>', gps: '<path fill="currentColor" d="M12.5 3.51V6.5h-1V3.51a8.46 8.46 0 0 0-7.99 7.99H6.5v1H3.51a8.46 8.46 0 0 0 7.99 7.99V17.5h1v2.99a8.46 8.46 0 0 0 7.99-7.99H17.5v-1h2.99a8.46 8.46 0 0 0-7.99-7.99m0-1a9.46 9.46 0 0 1 8.99 8.99h2.01v1h-2.01a9.46 9.46 0 0 1-8.99 8.99v2.01h-1v-2.01a9.46 9.46 0 0 1-8.99-8.99H.5v-1h2.01a9.46 9.46 0 0 1 8.99-8.99V.5h1z"/>',
poiCar: '<path fill="currentColor" d="M3.8 8.6C3.8 4.33 7.32.8 11.5.8s7.7 3.53 7.7 7.8c0 1.11-.5 2.58-1.17 4.1a48 48 0 0 1-2.5 4.7 90 90 0 0 1-3.54 5.47l-.06.09-.02.02V23l-.41-.29-.4.29h-.01l-.02-.03-.06-.09a70 70 0 0 1-1.05-1.53 90 90 0 0 1-2.49-3.93 48 48 0 0 1-2.5-4.72C4.29 11.18 3.8 9.71 3.8 8.6m7.7 14.1-.4.29.4.57.4-.57zm0-.87a88 88 0 0 0 3.17-4.94 47 47 0 0 0 2.45-4.6A10.3 10.3 0 0 0 18.2 8.6c0-3.73-3.08-6.8-6.7-6.8a6.82 6.82 0 0 0-6.7 6.8c0 .89.4 2.18 1.08 3.69.67 1.48 1.55 3.1 2.45 4.6a89 89 0 0 0 3.17 4.94M8.77 4h5.47L17 7.35V11a1 1 0 0 1-1 1h-1.5a1 1 0 0 1-1-1h-4a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7.32zm.46 1L7 7.68V8h2v1H7v2h1.5v-1h6v1H16V9h-2V8h2v-.29L13.76 5z"/>', poiCar: '<path fill="currentColor" d="M3.8 8.6C3.8 4.33 7.32.8 11.5.8s7.7 3.53 7.7 7.8c0 1.11-.5 2.58-1.17 4.1a48 48 0 0 1-2.5 4.7 90 90 0 0 1-3.54 5.47l-.06.09-.02.02V23l-.41-.29-.4.29h-.01l-.02-.03-.06-.09a70 70 0 0 1-1.05-1.53 90 90 0 0 1-2.49-3.93 48 48 0 0 1-2.5-4.72C4.29 11.18 3.8 9.71 3.8 8.6m7.7 14.1-.4.29.4.57.4-.57zm0-.87a88 88 0 0 0 3.17-4.94 47 47 0 0 0 2.45-4.6A10.3 10.3 0 0 0 18.2 8.6c0-3.73-3.08-6.8-6.7-6.8a6.82 6.82 0 0 0-6.7 6.8c0 .89.4 2.18 1.08 3.69.67 1.48 1.55 3.1 2.45 4.6a89 89 0 0 0 3.17 4.94M8.77 4h5.47L17 7.35V11a1 1 0 0 1-1 1h-1.5a1 1 0 0 1-1-1h-4a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7.32zm.46 1L7 7.68V8h2v1H7v2h1.5v-1h6v1H16V9h-2V8h2v-.29L13.76 5z"/>',
/* poi-car-l - grosse Variante desselben CI-Symbols (48er-Raster), fuer die
Fahrzeugnadel auf der Vollbildkarte. Siehe fahrzeugMarkerSVG(). */
poiCarL: '<path fill="currentColor" d="M9 17.5C9 9.52 15.52 3 23.5 3S38 9.52 38 17.5c0 2.04-.92 4.78-2.23 7.67a90 90 0 0 1-4.84 8.98 170 170 0 0 1-6.98 10.59l-.03.04-.01.01-.41-.29-.4.3-.02-.02-.03-.04a66 66 0 0 1-.56-.8 166 166 0 0 1-6.42-9.8 90 90 0 0 1-4.84-8.97C9.92 22.27 9 19.54 9 17.5m14.5 27-.4.3.4.55.4-.56zm0-.86a132 132 0 0 0 1.77-2.56 170 170 0 0 0 4.8-7.45 89 89 0 0 0 4.79-8.87c1.3-2.89 2.14-5.45 2.14-7.26C37 10.08 30.92 4 23.5 4S10 10.08 10 17.5c0 1.8.83 4.37 2.14 7.26a89 89 0 0 0 4.79 8.87 170 170 0 0 0 6.57 10.01M17.96 7.98A63.3 63.3 0 0 1 29.05 8l.2.02.13.16c.6.7 1.1 1.36 1.46 1.94l1.31 2.08L34 14.31v7.19c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V21H18v.5c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5v-7.19l1.85-2.11 1.3-2.08c.37-.58.86-1.24 1.47-1.96l.13-.16.2-.02ZM15.48 13 14 14.69V15h3v1h-3v5.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V20h13v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V16h-3v-1h3v-.31L31.52 13zm15.36-1L30 10.65a16 16 0 0 0-1.25-1.67 62.4 62.4 0 0 0-10.5-.02 17 17 0 0 0-1.25 1.7L16.16 12zM19.3 15h8.42l1.4 1.4-.16.31c-.34.74-.65 1.32-.92 1.72-.14.2-.28.38-.42.5a.92.92 0 0 1-.61.27h-7a.92.92 0 0 1-.6-.27 2.4 2.4 0 0 1-.43-.5c-.27-.4-.58-.98-.92-1.72l-.15-.32zm.42 1-.6.6c.26.57.5.99.69 1.27.11.17.2.27.26.32l.01.01h6.86v-.01c.07-.05.16-.15.27-.32.2-.28.43-.7.7-1.27l-.6-.6h-7.6Z"/>',
selectAll: '<path fill="currentColor" d="M2.8 2H5v1H3v2H2V2.8a.76.76 0 0 1 .43-.71.9.9 0 0 1 .35-.09zv.5zM9 3H7V2h2zm4 0h-2V2h2zm4 0h-2V2h2zm4 0h-2V2h2.2v.5V2h.02a1 1 0 0 1 .1.01c.07.01.16.03.25.08a.76.76 0 0 1 .43.71V5h-1zM6 6h12v12H6zm1 1v10h10V7zM3 7v2H2V7zm19 0v2h-1V7zM3 11v2H2v-2zm19 0v2h-1v-2zM3 15v2H2v-2zm19 0v2h-1v-2zM3 21v-2H2v2.2a.76.76 0 0 0 .43.71.9.9 0 0 0 .35.09h.01l.01-.5v.5H5v-1zm19-2v2.2a.76.76 0 0 1-.43.71.9.9 0 0 1-.35.09h-.01l-.01-.49V22H19v-1h2v-2zM9 22H7v-1h2zm4 0h-2v-1h2zm4 0h-2v-1h2z"/>', selectAll: '<path fill="currentColor" d="M2.8 2H5v1H3v2H2V2.8a.76.76 0 0 1 .43-.71.9.9 0 0 1 .35-.09zv.5zM9 3H7V2h2zm4 0h-2V2h2zm4 0h-2V2h2zm4 0h-2V2h2.2v.5V2h.02a1 1 0 0 1 .1.01c.07.01.16.03.25.08a.76.76 0 0 1 .43.71V5h-1zM6 6h12v12H6zm1 1v10h10V7zM3 7v2H2V7zm19 0v2h-1V7zM3 11v2H2v-2zm19 0v2h-1v-2zM3 15v2H2v-2zm19 0v2h-1v-2zM3 21v-2H2v2.2a.76.76 0 0 0 .43.71.9.9 0 0 0 .35.09h.01l.01-.5v.5H5v-1zm19-2v2.2a.76.76 0 0 1-.43.71.9.9 0 0 1-.35.09h-.01l-.01-.49V22H19v-1h2v-2zM9 22H7v-1h2zm4 0h-2v-1h2zm4 0h-2v-1h2z"/>',
planRoute: '<path fill="currentColor" d="m18.3 7-3.65-3.65.7-.7 4.86 4.85-4.86 4.85-.7-.7L18.29 8H9.5C8.67 8 8 8.67 8 9.5V21H7V9.5A2.5 2.5 0 0 1 9.5 7z"/>', planRoute: '<path fill="currentColor" d="m18.3 7-3.65-3.65.7-.7 4.86 4.85-4.86 4.85-.7-.7L18.29 8H9.5C8.67 8 8 8.67 8 9.5V21H7V9.5A2.5 2.5 0 0 1 9.5 7z"/>',
upload: '<path fill="currentColor" d="M13 20h-1V9.7l-3.65 3.65-.7-.7 4.85-4.86 4.85 4.86-.7.7L13 9.71zM6 5V4h13v1z"/>', upload: '<path fill="currentColor" d="M13 20h-1V9.7l-3.65 3.65-.7-.7 4.85-4.86 4.85 4.86-.7.7L13 9.71zM6 5V4h13v1z"/>',
@@ -3103,11 +3137,9 @@ function ereignisseVerdrahten() {
ROOT.getElementById("theme").addEventListener("click", () => { ROOT.getElementById("theme").addEventListener("click", () => {
themeSetzen(HOST.dataset.theme === "nacht" ? "tag" : "nacht"); themeSetzen(HOST.dataset.theme === "nacht" ? "tag" : "nacht");
themeIcon(); themeIcon();
/* Neu zeichnen, damit auch die Leaflet-Kacheln den Stil wechseln: die /* Neu zeichnen, damit theme-abhaengige, in JS gesetzte Werte (z. B. die
Kachel-URL wird beim Aufbau aus TILES[theme] gewaehlt und nicht aus --red gelesene Routenfarbe in initMap) mitziehen. Die Kacheln selbst
nachtraeglich aktualisiert. Ohne dies blieb die Karte bis zum naechsten sind seit TILE_URL themeunabhaengig hell und brauchen das nicht mehr. */
Backend-Update im alten Stil - seit der Dauerkarte auf der Uebersicht
deutlich sichtbar. */
render(); render();
}); });
// Wischgeste über Pointer Events (nicht Touch Events), damit dieselbe // Wischgeste über Pointer Events (nicht Touch Events), damit dieselbe
@@ -3153,6 +3185,9 @@ function ereignisseVerdrahten() {
} }
e.preventDefault(); e.preventDefault();
swipeWarGezogen = true; swipeWarGezogen = true;
// Loeschknopf erst waehrend der Geste einblenden (siehe .swipe-delete in
// audi-dashboard.css) - sonst blitzt er dauerhaft an den Zeilenraendern durch.
swipeAktivWrap.classList.add("wischt");
const basis = swipeAktivWrap.classList.contains("swiped") ? -SWIPE_BREITE : 0; const basis = swipeAktivWrap.classList.contains("swiped") ? -SWIPE_BREITE : 0;
swipeDX = Math.max(-SWIPE_BREITE - 20, Math.min(0, basis + dx)); swipeDX = Math.max(-SWIPE_BREITE - 20, Math.min(0, basis + dx));
const inhalt = swipeAktivWrap.querySelector(".swipe-content"); const inhalt = swipeAktivWrap.querySelector(".swipe-content");
@@ -3163,6 +3198,7 @@ function ereignisseVerdrahten() {
if (!swipeAktivWrap) return; if (!swipeAktivWrap) return;
const inhalt = swipeAktivWrap.querySelector(".swipe-content"); const inhalt = swipeAktivWrap.querySelector(".swipe-content");
inhalt.style.transition = ""; inhalt.style.transform = ""; inhalt.style.transition = ""; inhalt.style.transform = "";
swipeAktivWrap.classList.remove("wischt");
if (swipeGeste === "x") { if (swipeGeste === "x") {
if (swipeDX <= -SWIPE_BREITE / 2) { schliesseAlleSwipes(); swipeAktivWrap.classList.add("swiped"); } if (swipeDX <= -SWIPE_BREITE / 2) { schliesseAlleSwipes(); swipeAktivWrap.classList.add("swiped"); }
else { swipeAktivWrap.classList.remove("swiped"); } else { swipeAktivWrap.classList.remove("swiped"); }
@@ -3510,7 +3546,21 @@ function ereignisseVerdrahten() {
const leser = new FileReader(); const leser = new FileReader();
leser.onload = () => { leser.onload = () => {
const base64 = leser.result.split(",")[1]; const base64 = leser.result.split(",")[1];
serviceRufen("audi_dashboard_beleg_hochladen", { pdf_base64: base64, dateiname: datei.name, tank_id: tankId }); // Kein serviceRufen() hier: dessen catch loggt nur in die Browser-
// Konsole (bei allen anderen Aufrufen ok, die reagieren auf
// datenLaden() statt auf den Rückgabewert) - ein rejizierter
// Service-Aufruf (Backend nicht erreichbar, Timeout, ...) blieb
// für den Beleg-Upload sonst komplett unsichtbar in der
// Oberfläche, obwohl der Backend-seitige Parser-Fehlerpfad selbst
// schon einen Fehler veröffentlicht (siehe belegverarbeitung.py).
HASS.callService("pyscript", "audi_dashboard_beleg_hochladen",
{ pdf_base64: base64, dateiname: datei.name, tank_id: tankId }
).catch((err) => {
console.error("audi_dashboard: audi_dashboard_beleg_hochladen", err);
belegFehler = { context: kontext, meldung: "Beleg konnte nicht hochgeladen werden" };
if (!tankId) belegEntwurf = null;
render();
});
}; };
leser.readAsDataURL(datei); leser.readAsDataURL(datei);
}; };
+5 -4
View File
@@ -140,10 +140,11 @@ main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smo
padding:8px 4px;color:var(--fg2)} padding:8px 4px;color:var(--fg2)}
/* Design-Audit 2026-08-13: ein <select> (z.B. Oelwechsel-Intervall "10.000 km"/ /* Design-Audit 2026-08-13: ein <select> (z.B. Oelwechsel-Intervall "10.000 km"/
"1 Jahr") sah wie reiner, nicht editierbarer Text aus, seit hier Rahmen/ "1 Jahr") sah wie reiner, nicht editierbarer Text aus, seit hier Rahmen/
Flaeche entfernt wurden - anders als bei freien Texteingaben zeigt iOS bei Flaeche entfernt wurden. Der Hinweis "hier gibt es eine Auswahl" laeuft
Picker-Feldern wie diesem ueblicherweise die Akzentfarbe statt Fliesstext- deshalb ueber dieselbe graue Flaeche wie bei .mitEinheit input (Feld
Grau als Hinweis "hier gibt es eine Auswahl". */ "Pause bis [15] Minuten") - ein Kasten liest sich als bedienbar, waehrend
.feld select{color:var(--ios-tint)} eingefaerbter Text auch bloss Hervorhebung sein koennte. */
.feld select{background:var(--ios-fill);padding:8px 10px}
.feld input:focus,.feld select:focus{background:var(--ios-fill);color:var(--fg)} .feld input:focus,.feld select:focus{background:var(--ios-fill);color:var(--fg)}
.mitEinheit input{background:var(--ios-fill);padding:8px 10px} .mitEinheit input{background:var(--ios-fill);padding:8px 10px}
.notiz{background:var(--ios-fill);border:0;border-radius:12px;font-size:16px} .notiz{background:var(--ios-fill);border:0;border-radius:12px;font-size:16px}
@@ -1 +1 @@
{"version": 1786752000} {"version": 1786765000}
+17 -5
View File
@@ -667,7 +667,15 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
/* -------------------------------------------------------------- Wischen */ /* -------------------------------------------------------------- Wischen */
.swipe-wrap { position: relative; overflow: hidden; } .swipe-wrap { position: relative; overflow: hidden; }
.swipe-delete { position: absolute; inset: 0 0 0 auto; width: 84px; display: flex; } /* Der rote Loeschknopf liegt dauerhaft hinter der Zeile. Zeilenhoehen sind
gebrochen (z. B. 66,28 px), dadurch fiel die Kante von .swipe-content nicht
aufs Geraetepixelraster und der rote Knopf blitzte als duenne Linie an den
Zeilenraendern durch - sichtbar als roter Teilrahmen um jede Listenzeile.
Er wird deshalb erst sichtbar, sobald wirklich gewischt wird ("wischt"
waehrend der Geste, "swiped" fuer die offen stehende Zeile). */
.swipe-delete { position: absolute; inset: 0 0 0 auto; width: 84px; display: flex; visibility: hidden; }
.swipe-wrap.wischt .swipe-delete,
.swipe-wrap.swiped .swipe-delete { visibility: visible; }
.swipe-loeschen { .swipe-loeschen {
width: 100%; width: 100%;
background: var(--bad); color: #fff; border: none; background: var(--bad); color: #fff; border: none;
@@ -702,12 +710,16 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
} }
.fahrzeug-pin-wrap { background: none; border: none; } .fahrzeug-pin-wrap { background: none; border: none; }
/* poi-car aus dem CI-Satz - rotes Symbol, weiss abgesetzt statt in einem /* poi-car aus dem CI-Satz ist eine reine Konturform in currentColor - auf
Kasten, damit die Spitze exakt auf der Position steht. */ Kartenkacheln waere sie ohne Trennung zum Untergrund kaum lesbar. Die
gestapelten weissen drop-shadows ziehen deshalb einen Halo um die Kontur,
der letzte Schatten gibt Tiefe. Feste Farbe statt Theme-Variable, damit die
Nadel in Tag- und Nachtansicht gleich aussieht. */
.fahrzeug-pin { .fahrzeug-pin {
color: var(--red);
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
filter: drop-shadow(0 0 1px #fff) drop-shadow(0 0 2px #fff) drop-shadow(0 2px 5px rgba(0,0,0,.45)); color: #182634;
filter: drop-shadow(0 0 1px #fff) drop-shadow(0 0 1px #fff)
drop-shadow(0 0 2px #fff) drop-shadow(0 2px 4px rgba(0,0,0,.4));
} }
.user-pin-wrap { background: none; border: none; } .user-pin-wrap { background: none; border: none; }
.user-pin { .user-pin {