/** * Tankstelle eines Belegs: Anzeige in zwei Zeilen und der Weg in den * Kartendienst des Geräts. * * WARUM ZWEI ZEILEN * ----------------- * Bis zum 04.09.2026 stand die Tankstelle als gewöhnliche Wertezeile rechts in * der schmalen Wertespalte: Name oben, die komplette Anschrift als Kleintext * darunter. Gemessen an der echten Kachel blieben dafür 120 px - "Hermann * Mogler Mineralölg. GmbH, Heidenheimerstr.6, 89551 Königsbronn" brach dort in * vier bis fünf Zeilen um. Die Aufteilung hier ist die vom Eigentümer * verlangte: erste Zeile Tankstelle und Straße, zweite Zeile der Ort. * * WIE DIE ANSCHRIFT GETEILT WIRD * ------------------------------ * Der Belegparser setzt sie als `", "` zusammen * (shell_beleg_parser.py). Geteilt wird deshalb am LETZTEN Komma: ein Name wie * "Str. 5, Gewerbepark, 85057 Ingolstadt" behält so seinen Zusatz bei der * Straße statt beim Ort. Ohne Komma bleibt alles in der ersten Zeile - lieber * ein zu langer erster Eintrag als ein erfundener Ort. * * KARTENDIENST STATT EINES FESTEN ANBIETERS * ----------------------------------------- * "Klick auf die Tankstelle soll den Standard-Kartendienst öffnen" (Eigentümer, * 04.09.2026). Auf Apple-Geräten ist das Karten, sonst Google Maps - beides * über die jeweilige Web-Adresse, die das System an die installierte App * weiterreicht. Ein `geo:`-Verweis wäre der sauberere Weg, aber Safari kennt * ihn nicht, und ein Verweis, der auf dem Hauptgerät ins Leere läuft, ist * keiner. */ export interface Koordinate { lat: number lon: number } /* Kopfmarken des deutschsprachigen Marktes. WORTGLEICH mit `_MARKEN` in custom_components/audi_dashboard/shell_beleg_parser.py (und mit MARKEN im Panel) - der Parser erkennt die Marke beim Einlesen eines Belegs, diese Liste erkennt sie nachträglich in dem, was gespeichert ist. Laufen die beiden auseinander, zeigt die App eine andere Marke als der Beleg. Reihenfolge zählt: "TotalEnergies" muss vor "Total" stehen. */ const MARKEN = [ "TotalEnergies", "Total", "Aral", "Shell", "Eni", "Agip", "Esso", "OMV", "Avia", "Turmöl", "Turmoel", "Orlen", "Tamoil", "Westfalen", "Allguth", "Classic", "Sprint", "Raiffeisen", "BayWa", "Elan", "HEM", "Star", "JET", "bft", "BP", "Q1", ] /** * Die Kopfmarke aus dem gespeicherten Stationsnamen - `null`, wenn keine * bekannte darin steht. * * WARUM ÜBERHAUPT NACHTRÄGLICH: `station_name` trägt je nach Alter des Belegs * Verschiedenes. Seit dem 16.08.2026 setzt der Parser "Marke, Straße, Ort" * zusammen ("Shell, SÖLDEN"); ältere Belege - und jeder ohne erkennbare * Marke - tragen dort die Firmierung des Betreibers ("A. Zrenner GmbH", * "Hermann Mogler Mineralölg. GmbH"). Die ist nach außen nicht sichtbar und * gehört deshalb nicht in die Anzeige (Vorgabe des Eigentümers, 04.09.2026); * sichtbar ist die Kopfmarke. * * Wortgrenzen sind Absicht: "Star" darf nicht in "Starnberg" treffen. */ export function markeErkennen(name: string | null | undefined): string | null { const roh = (name ?? "").trim() if (!roh) return null for (const marke of MARKEN) { const muster = new RegExp(`\\b${marke.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i") if (muster.test(roh)) return marke } return null } /* Kennzeichen einer Firmierung. Ein Teil, der so etwas enthaelt, ist der BETREIBER und gehoert nach Vorgabe des Eigentuemers nicht in die Anzeige - "TC Sengül GmbH" oder "Hermann Mogler Mineralölg. GmbH" stehen auf keinem Preisschild und in keiner Karte. */ const FIRMA = /\b(gmbh|mbh|ohg|kg|ag|e\.\s?k\.|inh\.|& co)\b/i /* "91522 Ansbach" - vier Stellen wegen Oesterreich (der Belegparser hat denselben Fall, siehe shell_beleg_parser.py). */ const PLZ_ORT = /^(\d{4,5})\s+(.+)$/ /* Strasse mit Hausnummer am Ende. Greedy bis zum letzten Nicht-Ziffernzeichen, damit auch die zusammengeschriebene Form der Belege trifft: "Nürnberger Str.74" -> "Nürnberger Str." + "74". */ const STRASSE_NR = /^(.*\D)\s*(\d+\s*[a-zA-Z]?)$/ /** * "Nürnberger Straße" → "Nürnberger Str.", "Heidenheimerstraße" → * "Heidenheimerstr.". Vorgabe des Eigentuemers fuer die Uebersicht; die Belege * liefern beide Schreibweisen, je nach Kasse. * * Die Grossschreibung des Treffers entscheidet, ob "Str." oder "str." daraus * wird - sonst stuende mitten im Wort ein grosses S. */ export function strasseKuerzen(strasse: string): string { return strasse.replace(/stra(?:ß|ss)e\b\.?/gi, (treffer) => treffer[0] === treffer[0]?.toUpperCase() ? "Str." : "str.", ) } export interface Tankstellenteile { /** Kopfmarke, sofern erkennbar. Nie die Firmierung des Betreibers. */ marke: string /** Ohne Hausnummer, in der kurzen Schreibweise. */ strasse: string hausnummer: string plz: string ort: string /** Der gespeicherte Name, unveraendert - der letzte Rueckfall. */ roh: string } /** * Zerlegt, was ein Beleg ueber die Tankstelle hergibt. * * WARUM BEIDE FELDER GELESEN WERDEN * --------------------------------- * Der Bestand hat zwei Bauformen, am 04.09.2026 an den echten Datensaetzen * nachgesehen: * * bis 2025: station_name = "TC Sengül GmbH" (der Betreiber!) * station_address = "Nürnberger Str.74, 91522 Ansbach" * ab 08.2026: station_name = "Shell, Pascalstr. 8, Ingolstadt" * station_address = null * * Die Markenerkennung lief bis hierher nur ueber `station_name`. Bei den * aelteren Belegen steht die Marke dort gar nicht - deshalb blieb in der * Uebersicht die Firmierung stehen ("...GmbH", Nutzerbefund 04.09.2026), * waehrend die Anschrift daneben ungenutzt blieb. Gelesen werden deshalb * beide Felder: die Anschrift zuerst (sie ist die strukturierte Quelle), der * Name danach fuer Marke und alles, was die Anschrift nicht hergibt. */ export function tankstelleTeile( name: string | null | undefined, adresse: string | null | undefined, ): Tankstellenteile { const teile: Tankstellenteile = { marke: markeErkennen(name) ?? "", strasse: "", hausnummer: "", plz: "", ort: "", roh: (name ?? "").trim(), } const quellen: string[] = [] for (const stueck of (adresse ?? "").split(",")) quellen.push(stueck) for (const stueck of (name ?? "").split(",")) { const t = stueck.trim() // Die Marke steht schon fest; als Ort taugt sie nicht. if (teile.marke && t.toLowerCase() === teile.marke.toLowerCase()) continue quellen.push(t) } for (const stueck of quellen) { const t = stueck.trim() if (!t || FIRMA.test(t)) continue const plzOrt = PLZ_ORT.exec(t) if (plzOrt) { if (!teile.plz) teile.plz = plzOrt[1] ?? "" if (!teile.ort) teile.ort = (plzOrt[2] ?? "").trim() continue } const mitNummer = STRASSE_NR.exec(t) if (mitNummer) { if (!teile.strasse) { teile.strasse = strasseKuerzen((mitNummer[1] ?? "").trim()) teile.hausnummer = (mitNummer[2] ?? "").replace(/\s+/g, "") } continue } // Weder Postleitzahl noch Hausnummer: der Ort. Eine Strasse ohne Nummer // laesst sich davon nicht sicher unterscheiden - im Zweifel der Ort, weil // er in der Uebersicht die wichtigere Angabe ist. if (!teile.ort) teile.ort = t } return teile } /** * Die Zeile der Uebersicht: **Marke, Strasse ohne Hausnummer, Ort ohne * Postleitzahl** (Vorgabe des Eigentuemers, 04.09.2026). * * Ergibt die Zerlegung gar nichts - etwa wenn nur eine Firmierung ohne * Anschrift gespeichert ist - bleibt der rohe Name stehen. Ihn dann * wegzulassen hiesse, eine bekannte Tankstelle "unbekannt" zu nennen. */ export function tankstelleKurz( name: string | null | undefined, adresse: string | null | undefined, ): string { const t = tankstelleTeile(name, adresse) const zeile = [t.marke, t.strasse, t.ort].filter(Boolean).join(", ") return zeile || t.roh || "unbekannt" } /** * Die beiden Zeilen im Einzelbeleg (Vorgabe des Eigentuemers, 04.09.2026): * oben **Marke, Strasse mit Hausnummer**, unten **Postleitzahl und Ort**. * * Geschrieben wie eine Anschrift, also "Shell, Pascalstr. 8" und "85057 * Ingolstadt" - das Komma trennt die Marke ab, zwischen Strasse und Nummer * und zwischen Postleitzahl und Ort steht keins. * * `oben` ist nie leer: gibt der Beleg nur einen Ort her, rueckt der nach oben, * und kennt er gar nichts, heisst es dort "unbekannt". */ export function tankstelleZeilen( name: string | null | undefined, adresse: string | null | undefined, ): { oben: string; unten: string } { const t = tankstelleTeile(name, adresse) const strasse = [t.strasse, t.hausnummer].filter(Boolean).join(" ") let oben = [t.marke, strasse].filter(Boolean).join(", ") let unten = [t.plz, t.ort].filter(Boolean).join(" ") if (!oben && !unten) return { oben: t.roh || "unbekannt", unten: "" } if (!oben) { oben = unten unten = "" } return { oben, unten } } /** * Suchbegriffe für die Geokodierung, in der Reihenfolge, in der sie versucht * werden: erst die Anschrift, dann der Name. * * NICHT beides zusammen. Am 04.09.2026 gemessen: `"Pascalstr. 8, 85057 * Ingolstadt"` findet Nominatim, `"A. Zrenner GmbH, Pascalstr. 8, 85057 * Ingolstadt"` nicht — der Betreibername steht in keiner Karte und lässt die * Freitextsuche ins Leere laufen. Genau daran ist die erste Fassung dieser * Funktion gescheitert, sichtbar als „Keine Position zur Tankstelle" bei einem * Beleg, dessen Anschrift sich vorher hat auflösen lassen. * * Der Name allein ist trotzdem wertvoll: `"Shell, SÖLDEN"` findet Nominatim * (46.9756/11.0111) — deshalb der zweite Versuch statt gar keinem. * * Und als dritter Versuch der Name OHNE die führende Marke. Seit dem 16.08.2026 * setzt der Belegparser den Namen als „Marke, Straße, Ort" zusammen; steht darin * keine Postleitzahl, kippt die Marke die Suche. Am 04.09.2026 gemessen: * `"Shell, Pascalstr. 8, Ingolstadt"` findet Nominatim NICHT, * `"Pascalstr. 8, Ingolstadt"` dagegen schon. Genau daran blieb die Karte auf * der realen Instanz leer, während dieselbe Tankstelle in der Testinstanz * (dort steht die Anschrift getrennt daneben) gefunden wurde. */ export function tankstelleKandidaten( name: string | null | undefined, adresse: string | null | undefined, ): string[] { const n = (name ?? "").trim() const marke = markeErkennen(n) // Nur abschneiden, wenn die Marke wirklich vorn steht und noch etwas folgt. const ohneMarke = marke && n.toLowerCase().startsWith(marke.toLowerCase()) ? n.slice(marke.length).replace(/^[ ,]+/, "").trim() : "" const liste = [(adresse ?? "").trim(), n, ohneMarke].filter(Boolean) return [...new Set(liste)] } /** Der erste Suchbegriff - leer heißt: der Beleg nennt gar nichts. */ export function tankstelleSuchtext( name: string | null | undefined, adresse: string | null | undefined, ): string { return tankstelleKandidaten(name, adresse)[0] ?? "" } /** * Das Ziel für den Kartendienst: Kopfmarke UND Anschrift. Apple Karten und * Google Maps kennen Tankstellenmarken und treffen damit die Zapfsäule statt * des Nachbargebäudes - beide sind darin deutlich besser als Nominatims * Freitextsuche. Die Firmierung des Betreibers hilft dagegen nirgends; ohne * erkennbare Marke geht die Anschrift allein hin. */ export function tankstelleZiel( name: string | null | undefined, adresse: string | null | undefined, ): string { const a = (adresse ?? "").trim() if (!a) return (name ?? "").trim() return [markeErkennen(name) ?? "", a].filter(Boolean).join(", ") } /** Apple-Gerät? Dort ist Karten der Standarddienst. */ function apfelgeraet(): boolean { if (typeof navigator === "undefined") return false const kennung = `${navigator.userAgent || ""} ${navigator.platform || ""}` return /iPhone|iPad|iPod|Macintosh|Mac OS X/i.test(kennung) } /** * "route" berechnet den Weg dorthin, "zeigen" setzt nur eine Nadel. Der * Tankstellen-Verweis im Einzelbeleg will das Zweite (Vorgabe des Eigentümers, * 04.09.2026): erst sehen, wo die Tankstelle liegt - eine Route erwartet dort * niemand, und sie ist auf dem Telefon nur einen weiteren Tipp entfernt. */ export type Kartenmodus = "route" | "zeigen" /** * Verweis in den Kartendienst - `null`, wenn nichts bekannt ist, wohin. * * Mit Koordinaten wird auf sie gezielt und der Name nur als Beschriftung * mitgegeben; ohne sie muss die Anschrift als Suchtext genügen. */ export function kartendienstUrl( name: string | null | undefined, adresse: string | null | undefined, pos?: Koordinate | null, modus: Kartenmodus = "route", ): string | null { const beschriftung = (name ?? "").trim() const ziel = pos ? `${pos.lat},${pos.lon}` : tankstelleZiel(name, adresse) if (!ziel) return null if (apfelgeraet()) { if (modus === "zeigen") { // Mit Koordinaten der Punkt selbst, sonst der Suchtext - beides zeigt // Karten nur an, ohne eine Route zu berechnen. return pos ? `https://maps.apple.com/?ll=${ziel}&q=${encodeURIComponent(beschriftung || "Ziel")}` : `https://maps.apple.com/?q=${encodeURIComponent(ziel)}` } const q = pos && beschriftung ? `&q=${encodeURIComponent(beschriftung)}` : "" return `https://maps.apple.com/?daddr=${encodeURIComponent(ziel)}${q}` } return modus === "zeigen" ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(ziel)}` : `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(ziel)}` } /** Wie oben, aber für einen reinen Punkt (Fahrzeugstandort, Autohaus). */ export function kartendienstUrlZuPunkt(pos: Koordinate | null | undefined): string | null { if (!pos) return null return kartendienstUrl(null, null, pos) }