Fix Journey location search: use EFA's own stopfinder, not Open-Meteo

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.
This commit is contained in:
Honey committed 2026-07-22 23:40:08 +02:00
1 parent c182fb2f19
commit d5757a00df
1 file changed
+49 -18
+49 -18
View File
@@ -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,22 +96,48 @@ 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."""
"""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(
GEOCODE_URL, params={"name": query, "count": 1, "language": "de"}, timeout=HTTP_TIMEOUT
source["base"] + "XML_STOPFINDER_REQUEST",
params=params, headers=REQUEST_HEADERS, timeout=HTTP_TIMEOUT,
)
response.raise_for_status()
results = response.json().get("results") or []
if not results:
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
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),
}
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")
@@ -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()