From d5757a00df9ab6f9d16f6a11bad1577dd6970c0a Mon Sep 17 00:00:00 2001 From: Honey Date: Wed, 22 Jul 2026 23:40:08 +0200 Subject: [PATCH] Fix Journey location search: use EFA's own stopfinder, not Open-Meteo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open-Meteo's geocoder is built for city-level weather lookups -- it knows "Münster" but has no idea what "Münster Hauptbahnhof" means, so any specific station name failed to resolve. Reused it from Weather without checking it actually fit this use case. The db_info reference project sidesteps this entirely by requiring the user to pick existing Home Assistant person/zone entities, which already carry precise coordinates from HA's own location system -- no equivalent here, so this needed real geocoding. Switched geocode() to query the same EFA instances' XML_STOPFINDER_REQUEST endpoint instead -- the same system that answers the trip request, so location resolution is guaranteed consistent with what trip planning understands. Verified live: correctly resolves specific stations ("Münster Hauptbahnhof" -> "Münster (Westf), Hbf", even the "Muenster" ue-spelling the user actually typed), plain city names (falls back to the main station), and district/neighborhood names (e.g. "Hiltrup" -> the actual Hiltrup stop, not just the vague suburb entry) -- all via matchQuality scoring and the API's own isBest flag. Re-verified the full pipeline end-to-end through the real Flask route afterward. --- screens/journey.py | 73 +++++++++++++++++++++++++++++++++------------- 1 file changed, 52 insertions(+), 21 deletions(-) diff --git a/screens/journey.py b/screens/journey.py index b54d8ac..7e74008 100644 --- a/screens/journey.py +++ b/screens/journey.py @@ -26,20 +26,25 @@ from screens.render_utils import load_font, text_width, truncate_to_width logger = logging.getLogger(__name__) HTTP_TIMEOUT = 10 -GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search" +# Each EFA instance exposes both endpoints under the same base path -- +# XML_TRIP_REQUEST2 for routing, XML_STOPFINDER_REQUEST for resolving a +# place name to a stop + coordinates. Using the stopfinder from the same +# system that will answer the trip request (rather than a generic +# city-level geocoder) is what makes searching for e.g. "Münster +# Hauptbahnhof" -- a specific station, not just a city -- work at all. EFA_SOURCES = [ { "name": "vrr", - "url": "https://www.vrr.de/vrr-efa/XML_TRIP_REQUEST2", + "base": "https://www.vrr.de/vrr-efa/", # vrr.de hangs until timeout for trips outside its home region # instead of returning a fast error, so it's only queried when both # ends of the trip roughly fall within North Rhine-Westphalia. "bounds": {"lat_min": 50.3, "lat_max": 52.6, "lon_min": 5.7, "lon_max": 9.5}, }, - {"name": "bahnland-bayern", "url": "https://bahnland-bayern.de/efa/XML_TRIP_REQUEST2", "bounds": None}, - {"name": "efa", "url": "https://www.efa.de/hit-efa/XML_TRIP_REQUEST2", "bounds": None}, - {"name": "efa-bw", "url": "http://www.efa-bw.de/nvbw/XML_TRIP_REQUEST2", "bounds": None}, + {"name": "bahnland-bayern", "base": "https://bahnland-bayern.de/efa/", "bounds": None}, + {"name": "efa", "base": "https://www.efa.de/hit-efa/", "bounds": None}, + {"name": "efa-bw", "base": "http://www.efa-bw.de/nvbw/", "bounds": None}, ] # Some EFA instances reject the default requests/python User-Agent as bot @@ -91,23 +96,49 @@ class JourneyScreen(Screen): return max(1, minutes) * 60 def geocode(self, query: str): - """Resolve a place name to coordinates -- same free Open-Meteo - geocoding WeatherScreen uses, no key needed.""" - response = requests.get( - GEOCODE_URL, params={"name": query, "count": 1, "language": "de"}, timeout=HTTP_TIMEOUT - ) - response.raise_for_status() - results = response.json().get("results") or [] - if not results: - return None - top = results[0] - label_parts = [top.get("name"), top.get("admin1"), top.get("country")] - return { - "latitude": top["latitude"], - "longitude": top["longitude"], - "label": ", ".join(p for p in label_parts if p), + """Resolve a place/station name to coordinates via the same EFA + instances used for trip requests (XML_STOPFINDER_REQUEST) -- not a + generic city-level geocoder, which has no idea what "Hauptbahnhof" + means. Tries each source in order (no bounds pre-filter here, since + we don't have coordinates yet to check bounds against); returns the + first usable match.""" + params = { + "outputFormat": "rapidJSON", + "language": "de", + "type_sf": "any", + "name_sf": query, + "anyMaxSizeHitList": 5, + "coordOutputFormat": "WGS84[dd.ddddd]", } + for source in EFA_SOURCES: + try: + response = requests.get( + source["base"] + "XML_STOPFINDER_REQUEST", + params=params, headers=REQUEST_HEADERS, timeout=HTTP_TIMEOUT, + ) + response.raise_for_status() + data = response.json() + except (requests.exceptions.RequestException, ValueError) as exc: + logger.warning("Stopfinder source '%s' failed, trying next: %s", source["name"], exc) + continue + + match = self._best_location(data) + if match: + return match + + return None + + def _best_location(self, data: dict): + locations = data.get("locations") or [] + if not locations: + return None + best = next((loc for loc in locations if loc.get("isBest")), locations[0]) + coord = best.get("coord") + if not coord or len(coord) < 2: + return None + return {"latitude": coord[0], "longitude": coord[1], "label": best.get("name") or ""} + def check(self, settings: dict): o_lat, o_lon = settings.get("origin_lat"), settings.get("origin_lon") d_lat, d_lon = settings.get("destination_lat"), settings.get("destination_lon") @@ -126,7 +157,7 @@ class JourneyScreen(Screen): continue try: response = requests.get( - source["url"], params=params, headers=REQUEST_HEADERS, timeout=HTTP_TIMEOUT + source["base"] + "XML_TRIP_REQUEST2", params=params, headers=REQUEST_HEADERS, timeout=HTTP_TIMEOUT ) response.raise_for_status() data = response.json()