python-openpublictransport 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openpublictransport/__init__.py +5 -0
- openpublictransport/const.py +75 -0
- openpublictransport/models.py +95 -0
- openpublictransport/parsers.py +122 -0
- openpublictransport/providers/__init__.py +127 -0
- openpublictransport/providers/avv.py +44 -0
- openpublictransport/providers/base.py +77 -0
- openpublictransport/providers/beg.py +46 -0
- openpublictransport/providers/bsvg.py +44 -0
- openpublictransport/providers/bvg.py +32 -0
- openpublictransport/providers/db.py +39 -0
- openpublictransport/providers/ding.py +44 -0
- openpublictransport/providers/efa_base.py +209 -0
- openpublictransport/providers/fptf_base.py +169 -0
- openpublictransport/providers/gtfsde.py +21 -0
- openpublictransport/providers/hvv.py +40 -0
- openpublictransport/providers/kvv.py +38 -0
- openpublictransport/providers/mvv.py +48 -0
- openpublictransport/providers/nta.py +288 -0
- openpublictransport/providers/nvbw.py +44 -0
- openpublictransport/providers/nwl.py +46 -0
- openpublictransport/providers/oebb.py +182 -0
- openpublictransport/providers/otp.py +378 -0
- openpublictransport/providers/otp_base.py +268 -0
- openpublictransport/providers/otp_custom.py +22 -0
- openpublictransport/providers/rmv.py +272 -0
- openpublictransport/providers/rvv.py +43 -0
- openpublictransport/providers/sbb.py +174 -0
- openpublictransport/providers/trafiklab.py +280 -0
- openpublictransport/providers/transitous.py +198 -0
- openpublictransport/providers/trias_base.py +323 -0
- openpublictransport/providers/vagfr.py +44 -0
- openpublictransport/providers/vbn.py +73 -0
- openpublictransport/providers/vgn.py +46 -0
- openpublictransport/providers/vrn.py +51 -0
- openpublictransport/providers/vrr.py +51 -0
- openpublictransport/providers/vvo.py +45 -0
- openpublictransport/providers/vvs.py +48 -0
- python_openpublictransport-0.1.0.dist-info/METADATA +14 -0
- python_openpublictransport-0.1.0.dist-info/RECORD +42 -0
- python_openpublictransport-0.1.0.dist-info/WHEEL +5 -0
- python_openpublictransport-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Generic OTP2 provider base — used by the community server and custom instances."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import math
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
|
|
11
|
+
from .otp_base import OTPBaseProvider
|
|
12
|
+
|
|
13
|
+
_LOGGER = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
_CITY_PREFIXES: Dict[str, str] = {
|
|
16
|
+
"düsseldorf": "D-",
|
|
17
|
+
"duesseldorf": "D-",
|
|
18
|
+
"köln": "K-",
|
|
19
|
+
"koeln": "K-",
|
|
20
|
+
"cologne": "K-",
|
|
21
|
+
"dortmund": "Do-",
|
|
22
|
+
"essen": "E-",
|
|
23
|
+
"duisburg": "DU-",
|
|
24
|
+
"wuppertal": "W-",
|
|
25
|
+
"bochum": "BO-",
|
|
26
|
+
"bielefeld": "BI-",
|
|
27
|
+
"münster": "MS-",
|
|
28
|
+
"muenster": "MS-",
|
|
29
|
+
"aachen": "AC-",
|
|
30
|
+
"krefeld": "KR-",
|
|
31
|
+
"mönchengladbach": "MG-",
|
|
32
|
+
"moenchengladbach": "MG-",
|
|
33
|
+
"oberhausen": "OB-",
|
|
34
|
+
"hagen": "HA-",
|
|
35
|
+
"hamm": "HAM-",
|
|
36
|
+
"gelsenkirchen": "GE-",
|
|
37
|
+
"mülheim": "MH-",
|
|
38
|
+
"muelheim": "MH-",
|
|
39
|
+
"leverkusen": "LEV-",
|
|
40
|
+
"bonn": "BN-",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_GRAPHQL_STOP_SEARCH = (
|
|
44
|
+
'{ stops(name: "%s") { gtfsId name lat lon parentStation { gtfsId name } routes { agency { name } } } }'
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _smart_title(s: str) -> str:
|
|
49
|
+
return " ".join(w[0].upper() + w[1:] if w else w for w in s.split())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _primary_agency(stops: List[Dict[str, Any]]) -> str:
|
|
53
|
+
counts: Dict[str, int] = {}
|
|
54
|
+
for s in stops:
|
|
55
|
+
for route in s.get("routes") or []:
|
|
56
|
+
name = (route.get("agency") or {}).get("name", "")
|
|
57
|
+
if name:
|
|
58
|
+
counts[name] = counts.get(name, 0) + 1
|
|
59
|
+
return max(counts, key=lambda k: counts[k]) if counts else ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_GRAPHQL_NEAREST = """{
|
|
63
|
+
nearest(lat: %f, lon: %f, maxDistance: %d, filterByPlaceTypes: [STOP]) {
|
|
64
|
+
edges {
|
|
65
|
+
node {
|
|
66
|
+
place {
|
|
67
|
+
... on Stop {
|
|
68
|
+
gtfsId
|
|
69
|
+
name
|
|
70
|
+
lat
|
|
71
|
+
lon
|
|
72
|
+
parentStation { gtfsId name }
|
|
73
|
+
routes { agency { name } }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
distance
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}"""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _detect_city_prefix(search_term: str) -> Optional[str]:
|
|
84
|
+
words = search_term.strip().replace(",", " ").split()
|
|
85
|
+
for i, word in enumerate(words):
|
|
86
|
+
prefix = _CITY_PREFIXES.get(word.lower())
|
|
87
|
+
if prefix:
|
|
88
|
+
remaining = " ".join(w for j, w in enumerate(words) if j != i).strip()
|
|
89
|
+
if remaining:
|
|
90
|
+
return prefix + remaining
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
_MAX_PLATFORM_DISTANCE_M = 500
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
98
|
+
r = 6_371_000
|
|
99
|
+
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
|
100
|
+
dphi = math.radians(lat2 - lat1)
|
|
101
|
+
dlambda = math.radians(lon2 - lon1)
|
|
102
|
+
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
|
|
103
|
+
return r * 2 * math.asin(math.sqrt(a))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _cluster_by_proximity(stops: List[Dict[str, Any]]) -> List[List[Dict[str, Any]]]:
|
|
107
|
+
n = len(stops)
|
|
108
|
+
parent = list(range(n))
|
|
109
|
+
|
|
110
|
+
def find(i: int) -> int:
|
|
111
|
+
while parent[i] != i:
|
|
112
|
+
parent[i] = parent[parent[i]]
|
|
113
|
+
i = parent[i]
|
|
114
|
+
return i
|
|
115
|
+
|
|
116
|
+
for i in range(n):
|
|
117
|
+
for j in range(i + 1, n):
|
|
118
|
+
si, sj = stops[i], stops[j]
|
|
119
|
+
if si.get("lat") and si.get("lon") and sj.get("lat") and sj.get("lon"):
|
|
120
|
+
if _haversine_m(si["lat"], si["lon"], sj["lat"], sj["lon"]) <= _MAX_PLATFORM_DISTANCE_M:
|
|
121
|
+
parent[find(i)] = find(j)
|
|
122
|
+
|
|
123
|
+
groups: Dict[int, List[Dict[str, Any]]] = {}
|
|
124
|
+
for i, stop in enumerate(stops):
|
|
125
|
+
groups.setdefault(find(i), []).append(stop)
|
|
126
|
+
return list(groups.values())
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
_GRAPHQL_STOPTIMES = """{
|
|
130
|
+
stop(id: "%s") {
|
|
131
|
+
name
|
|
132
|
+
alerts {
|
|
133
|
+
alertHeaderText
|
|
134
|
+
alertDescriptionText
|
|
135
|
+
}
|
|
136
|
+
stoptimesWithoutPatterns(numberOfDepartures: %d, startTime: %d) {
|
|
137
|
+
serviceDay
|
|
138
|
+
scheduledDeparture
|
|
139
|
+
realtimeDeparture
|
|
140
|
+
departureDelay
|
|
141
|
+
realtime
|
|
142
|
+
headsign
|
|
143
|
+
trip {
|
|
144
|
+
alerts {
|
|
145
|
+
alertHeaderText
|
|
146
|
+
alertDescriptionText
|
|
147
|
+
}
|
|
148
|
+
route {
|
|
149
|
+
shortName
|
|
150
|
+
mode
|
|
151
|
+
color
|
|
152
|
+
textColor
|
|
153
|
+
agency {
|
|
154
|
+
name
|
|
155
|
+
}
|
|
156
|
+
alerts {
|
|
157
|
+
alertHeaderText
|
|
158
|
+
alertDescriptionText
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}"""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class OTPProvider(OTPBaseProvider):
|
|
168
|
+
"""Generic OTP2 provider — subclass and set otp_base_url, provider_id, provider_name."""
|
|
169
|
+
|
|
170
|
+
otp_base_url: str = ""
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def _effective_base_url(self) -> str:
|
|
174
|
+
return (self.custom_url or "").rstrip("/") or self.otp_base_url
|
|
175
|
+
|
|
176
|
+
def _auth_headers(self) -> Dict[str, str]:
|
|
177
|
+
headers = {"Accept": "application/json", "Content-Type": "application/json"}
|
|
178
|
+
if self.api_key:
|
|
179
|
+
headers["X-API-Key"] = self.api_key
|
|
180
|
+
return headers
|
|
181
|
+
|
|
182
|
+
async def _graphql(self, query: str) -> Optional[Dict[str, Any]]:
|
|
183
|
+
url = f"{self._effective_base_url}/index/graphql"
|
|
184
|
+
try:
|
|
185
|
+
async with self.session.post(
|
|
186
|
+
url,
|
|
187
|
+
json={"query": query},
|
|
188
|
+
headers=self._auth_headers(),
|
|
189
|
+
timeout=aiohttp.ClientTimeout(total=15),
|
|
190
|
+
) as resp:
|
|
191
|
+
if resp.status == 200:
|
|
192
|
+
return await resp.json()
|
|
193
|
+
_LOGGER.warning("%s GraphQL → HTTP %s", self.provider_name, resp.status)
|
|
194
|
+
except Exception as exc:
|
|
195
|
+
_LOGGER.warning("%s GraphQL request failed: %s", self.provider_name, exc)
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
def _raw_stops_from_body(self, body: Optional[Dict]) -> List[Dict[str, Any]]:
|
|
199
|
+
return [
|
|
200
|
+
s for s in (((body or {}).get("data") or {}).get("stops") or []) if isinstance(s, dict) and "gtfsId" in s
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
def _group_by_name(self, raw_stops: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
204
|
+
by_parent: Dict[str, List[Dict[str, Any]]] = {}
|
|
205
|
+
no_parent: List[Dict[str, Any]] = []
|
|
206
|
+
|
|
207
|
+
for s in raw_stops:
|
|
208
|
+
parent = s.get("parentStation")
|
|
209
|
+
if parent and parent.get("gtfsId"):
|
|
210
|
+
by_parent.setdefault(parent["gtfsId"], []).append(s)
|
|
211
|
+
else:
|
|
212
|
+
no_parent.append(s)
|
|
213
|
+
|
|
214
|
+
result = []
|
|
215
|
+
|
|
216
|
+
for stops in by_parent.values():
|
|
217
|
+
compound_id = "|".join(s["gtfsId"] for s in stops)
|
|
218
|
+
name = (stops[0].get("parentStation") or {}).get("name") or stops[0]["name"]
|
|
219
|
+
agency = _primary_agency(stops)
|
|
220
|
+
result.append({"id": compound_id, "name": name, "place": name, "agency": agency, "area_type": "stop"})
|
|
221
|
+
|
|
222
|
+
by_name: Dict[str, List[Dict[str, Any]]] = {}
|
|
223
|
+
for s in no_parent:
|
|
224
|
+
by_name.setdefault(s["name"], []).append(s)
|
|
225
|
+
for name, stops in by_name.items():
|
|
226
|
+
for cluster in _cluster_by_proximity(stops):
|
|
227
|
+
compound_id = "|".join(s["gtfsId"] for s in cluster)
|
|
228
|
+
agency = _primary_agency(cluster)
|
|
229
|
+
result.append({"id": compound_id, "name": name, "place": name, "agency": agency, "area_type": "stop"})
|
|
230
|
+
|
|
231
|
+
return result
|
|
232
|
+
|
|
233
|
+
async def _search_one(self, term: str) -> List[Dict[str, Any]]:
|
|
234
|
+
q = _GRAPHQL_STOP_SEARCH % term.replace('"', '\\"')
|
|
235
|
+
return self._raw_stops_from_body(await self._graphql(q))
|
|
236
|
+
|
|
237
|
+
async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
|
|
238
|
+
"""Search stops via OTP2 GraphQL with city-prefix fallback for VRR/NRW."""
|
|
239
|
+
ss_term = search_term.replace("ß", "ss") if "ß" in search_term else None
|
|
240
|
+
smart_term = _smart_title(search_term)
|
|
241
|
+
smart_term = smart_term if smart_term != search_term else None
|
|
242
|
+
|
|
243
|
+
for term in filter(None, [search_term, ss_term, smart_term]):
|
|
244
|
+
raw = await self._search_one(term)
|
|
245
|
+
if raw:
|
|
246
|
+
return self._group_by_name(raw)[:20]
|
|
247
|
+
|
|
248
|
+
detected = _detect_city_prefix(search_term)
|
|
249
|
+
if detected:
|
|
250
|
+
raw = await self._search_one(detected)
|
|
251
|
+
if raw:
|
|
252
|
+
return self._group_by_name(raw)[:20]
|
|
253
|
+
detected_ss = detected.replace("ß", "ss") if "ß" in detected else None
|
|
254
|
+
if detected_ss:
|
|
255
|
+
raw = await self._search_one(detected_ss)
|
|
256
|
+
if raw:
|
|
257
|
+
return self._group_by_name(raw)[:20]
|
|
258
|
+
|
|
259
|
+
prefixed = [p + search_term for p in _CITY_PREFIXES.values()]
|
|
260
|
+
if ss_term:
|
|
261
|
+
prefixed += [p + ss_term for p in _CITY_PREFIXES.values()]
|
|
262
|
+
seen_terms: set = set()
|
|
263
|
+
unique = [t for t in prefixed if not (t in seen_terms or seen_terms.add(t))] # type: ignore[func-returns-value]
|
|
264
|
+
|
|
265
|
+
bodies = await asyncio.gather(*[self._graphql(_GRAPHQL_STOP_SEARCH % t.replace('"', '\\"')) for t in unique])
|
|
266
|
+
all_raw: List[Dict[str, Any]] = []
|
|
267
|
+
seen_ids: set = set()
|
|
268
|
+
for body in bodies:
|
|
269
|
+
for stop in self._raw_stops_from_body(body):
|
|
270
|
+
if stop["gtfsId"] not in seen_ids:
|
|
271
|
+
seen_ids.add(stop["gtfsId"])
|
|
272
|
+
all_raw.append(stop)
|
|
273
|
+
if all_raw:
|
|
274
|
+
return self._group_by_name(all_raw)[:20]
|
|
275
|
+
|
|
276
|
+
coords = await self._geocode(search_term)
|
|
277
|
+
if coords is None:
|
|
278
|
+
_LOGGER.warning("%s: could not geocode '%s'", self.provider_name, search_term)
|
|
279
|
+
return []
|
|
280
|
+
lat, lon = coords
|
|
281
|
+
q = _GRAPHQL_NEAREST % (lat, lon, self.stop_search_radius)
|
|
282
|
+
body = await self._graphql(q)
|
|
283
|
+
edges = (((body or {}).get("data") or {}).get("nearest") or {}).get("edges") or []
|
|
284
|
+
raw = [
|
|
285
|
+
edge["node"]["place"]
|
|
286
|
+
for edge in edges
|
|
287
|
+
if isinstance((edge.get("node") or {}).get("place"), dict) and "gtfsId" in edge["node"]["place"]
|
|
288
|
+
]
|
|
289
|
+
if raw:
|
|
290
|
+
return self._group_by_name(raw)[:20]
|
|
291
|
+
return []
|
|
292
|
+
|
|
293
|
+
@staticmethod
|
|
294
|
+
def _alert_texts(alerts: Optional[List[Dict[str, Any]]]) -> List[str]:
|
|
295
|
+
seen: set = set()
|
|
296
|
+
result = []
|
|
297
|
+
for a in alerts or []:
|
|
298
|
+
text = (a.get("alertHeaderText") or a.get("alertDescriptionText") or "").strip()
|
|
299
|
+
if text and text not in seen:
|
|
300
|
+
seen.add(text)
|
|
301
|
+
result.append(text)
|
|
302
|
+
return result
|
|
303
|
+
|
|
304
|
+
def _stoptimes_to_events(self, stoptimes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
305
|
+
mode_mapping = self.get_mode_mapping()
|
|
306
|
+
events = []
|
|
307
|
+
for st in stoptimes:
|
|
308
|
+
trip = st.get("trip") or {}
|
|
309
|
+
route = trip.get("route") or {}
|
|
310
|
+
trip_notices = self._alert_texts(trip.get("alerts"))
|
|
311
|
+
route_notices = [t for t in self._alert_texts(route.get("alerts")) if t not in trip_notices]
|
|
312
|
+
notices = trip_notices + route_notices
|
|
313
|
+
raw_color = route.get("color") or ""
|
|
314
|
+
raw_text = route.get("textColor") or ""
|
|
315
|
+
events.append(
|
|
316
|
+
{
|
|
317
|
+
"routeName": route.get("shortName", ""),
|
|
318
|
+
"transportType": mode_mapping.get(route.get("mode", ""), "unknown"),
|
|
319
|
+
"agency": (route.get("agency") or {}).get("name", ""),
|
|
320
|
+
"notices": notices or None,
|
|
321
|
+
"serviceDay": st.get("serviceDay", 0),
|
|
322
|
+
"scheduledDeparture": st.get("scheduledDeparture", 0),
|
|
323
|
+
"realtimeDeparture": st.get("realtimeDeparture", 0),
|
|
324
|
+
"departureDelay": st.get("departureDelay", 0),
|
|
325
|
+
"realtime": st.get("realtime", False),
|
|
326
|
+
"headsign": st.get("headsign", ""),
|
|
327
|
+
"lineColor": f"#{raw_color}" if raw_color and not raw_color.startswith("#") else raw_color or None,
|
|
328
|
+
"lineTextColor": f"#{raw_text}" if raw_text and not raw_text.startswith("#") else raw_text or None,
|
|
329
|
+
}
|
|
330
|
+
)
|
|
331
|
+
return events
|
|
332
|
+
|
|
333
|
+
async def _fetch_one_stop(self, gtfs_id: str, limit: int, start_epoch: int) -> List[Dict[str, Any]]:
|
|
334
|
+
q = _GRAPHQL_STOPTIMES % (gtfs_id.replace('"', '\\"'), limit, start_epoch)
|
|
335
|
+
body = await self._graphql(q)
|
|
336
|
+
if body is None:
|
|
337
|
+
return []
|
|
338
|
+
stop_data = ((body.get("data") or {}).get("stop")) or {}
|
|
339
|
+
stoptimes = stop_data.get("stoptimesWithoutPatterns") or []
|
|
340
|
+
events = self._stoptimes_to_events(stoptimes)
|
|
341
|
+
stop_notices = self._alert_texts(stop_data.get("alerts"))
|
|
342
|
+
if stop_notices:
|
|
343
|
+
for ev in events:
|
|
344
|
+
existing = ev.get("notices") or []
|
|
345
|
+
ev["notices"] = stop_notices + [n for n in existing if n not in stop_notices]
|
|
346
|
+
return events
|
|
347
|
+
|
|
348
|
+
async def fetch_departures(
|
|
349
|
+
self,
|
|
350
|
+
station_id: Optional[str],
|
|
351
|
+
place_dm: str,
|
|
352
|
+
name_dm: str,
|
|
353
|
+
departures_limit: int,
|
|
354
|
+
) -> Optional[Dict[str, Any]]:
|
|
355
|
+
if not station_id:
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
gtfs_ids = station_id.split("|")
|
|
359
|
+
start_epoch = int(time.time())
|
|
360
|
+
|
|
361
|
+
if len(gtfs_ids) == 1:
|
|
362
|
+
events = await self._fetch_one_stop(gtfs_ids[0], departures_limit, start_epoch)
|
|
363
|
+
else:
|
|
364
|
+
results = await asyncio.gather(
|
|
365
|
+
*[self._fetch_one_stop(gid, departures_limit, start_epoch) for gid in gtfs_ids]
|
|
366
|
+
)
|
|
367
|
+
seen_keys: set = set()
|
|
368
|
+
merged = []
|
|
369
|
+
for batch in results:
|
|
370
|
+
for ev in batch:
|
|
371
|
+
key = (ev["serviceDay"], ev["realtimeDeparture"], ev["routeName"], ev["headsign"])
|
|
372
|
+
if key not in seen_keys:
|
|
373
|
+
seen_keys.add(key)
|
|
374
|
+
merged.append(ev)
|
|
375
|
+
merged.sort(key=lambda x: x["serviceDay"] + x["realtimeDeparture"])
|
|
376
|
+
events = merged[:departures_limit]
|
|
377
|
+
|
|
378
|
+
return {"stopEvents": events}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Base provider for OpenTripPlanner (OTP) REST API."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
from zoneinfo import ZoneInfo
|
|
9
|
+
|
|
10
|
+
import aiohttp
|
|
11
|
+
|
|
12
|
+
from ..models import UnifiedDeparture
|
|
13
|
+
from .base import BaseProvider
|
|
14
|
+
|
|
15
|
+
_LOGGER = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
|
|
18
|
+
_NOMINATIM_UA = "openpublictransport/1.0 (github.com/NerdySoftPaw/openpublictransport)"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _nominatim_candidates(term: str) -> List[str]:
|
|
22
|
+
"""Return progressively simpler Nominatim queries for a search term."""
|
|
23
|
+
candidates: List[str] = [term]
|
|
24
|
+
|
|
25
|
+
if "," in term:
|
|
26
|
+
parts = [p.strip() for p in term.split(",", 1)]
|
|
27
|
+
swapped = " ".join(reversed(parts))
|
|
28
|
+
if swapped not in candidates:
|
|
29
|
+
candidates.append(swapped)
|
|
30
|
+
|
|
31
|
+
words = term.replace(",", " ").split()
|
|
32
|
+
if len(words) > 2:
|
|
33
|
+
for i in range(len(words)):
|
|
34
|
+
shorter = " ".join(w for j, w in enumerate(words) if j != i)
|
|
35
|
+
if shorter not in candidates:
|
|
36
|
+
candidates.append(shorter)
|
|
37
|
+
|
|
38
|
+
return candidates
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
OTP_MODE_MAP: Dict[str, str] = {
|
|
42
|
+
"BUS": "bus",
|
|
43
|
+
"COACH": "bus",
|
|
44
|
+
"RAIL": "train",
|
|
45
|
+
"TRAM": "tram",
|
|
46
|
+
"SUBWAY": "subway",
|
|
47
|
+
"FERRY": "ferry",
|
|
48
|
+
"GONDOLA": "tram",
|
|
49
|
+
"FUNICULAR": "train",
|
|
50
|
+
"CABLE_CAR": "tram",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class OTPBaseProvider(BaseProvider):
|
|
55
|
+
"""Base class for OpenTripPlanner REST API providers."""
|
|
56
|
+
|
|
57
|
+
otp_base_url: str = ""
|
|
58
|
+
stop_search_radius: int = 500
|
|
59
|
+
|
|
60
|
+
def get_timezone(self) -> str:
|
|
61
|
+
return "Europe/Berlin"
|
|
62
|
+
|
|
63
|
+
def get_mode_mapping(self) -> Dict[str, str]:
|
|
64
|
+
return OTP_MODE_MAP
|
|
65
|
+
|
|
66
|
+
def _auth_headers(self) -> Dict[str, str]:
|
|
67
|
+
"""Request headers — override in subclasses to add API key auth."""
|
|
68
|
+
return {"Accept": "application/json"}
|
|
69
|
+
|
|
70
|
+
def _index_url(self, path: str) -> str:
|
|
71
|
+
return f"{self.otp_base_url}/index/{path}"
|
|
72
|
+
|
|
73
|
+
async def _get(
|
|
74
|
+
self,
|
|
75
|
+
url: str,
|
|
76
|
+
params: Optional[Dict] = None,
|
|
77
|
+
) -> Optional[Any]:
|
|
78
|
+
try:
|
|
79
|
+
async with self.session.get(
|
|
80
|
+
url,
|
|
81
|
+
params=params or {},
|
|
82
|
+
headers=self._auth_headers(),
|
|
83
|
+
timeout=aiohttp.ClientTimeout(total=15),
|
|
84
|
+
) as resp:
|
|
85
|
+
if resp.status == 200:
|
|
86
|
+
return await resp.json()
|
|
87
|
+
if resp.status == 204:
|
|
88
|
+
return None
|
|
89
|
+
_LOGGER.warning("%s OTP %s → HTTP %s", self.provider_name, url, resp.status)
|
|
90
|
+
except aiohttp.ClientError as exc:
|
|
91
|
+
_LOGGER.warning("%s OTP request failed: %s", self.provider_name, exc)
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
_LOGGER.warning("%s OTP error: %s", self.provider_name, exc)
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
async def _geocode(self, search_term: str) -> Optional[Tuple[float, float]]:
|
|
97
|
+
"""Resolve a stop name to (lat, lon) via Nominatim / OpenStreetMap."""
|
|
98
|
+
for i, candidate in enumerate(_nominatim_candidates(search_term)):
|
|
99
|
+
if i > 0:
|
|
100
|
+
await asyncio.sleep(0.3)
|
|
101
|
+
try:
|
|
102
|
+
async with self.session.get(
|
|
103
|
+
_NOMINATIM_URL,
|
|
104
|
+
params={"q": candidate, "format": "json", "limit": 1, "countrycodes": "de"},
|
|
105
|
+
headers={"User-Agent": _NOMINATIM_UA, "Accept": "application/json"},
|
|
106
|
+
timeout=aiohttp.ClientTimeout(total=10),
|
|
107
|
+
) as resp:
|
|
108
|
+
if resp.status == 200:
|
|
109
|
+
results = await resp.json(content_type=None)
|
|
110
|
+
if results:
|
|
111
|
+
if i > 0:
|
|
112
|
+
_LOGGER.debug(
|
|
113
|
+
"%s: Nominatim hit on simplified query '%s'", self.provider_name, candidate
|
|
114
|
+
)
|
|
115
|
+
return float(results[0]["lat"]), float(results[0]["lon"])
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
_LOGGER.debug("%s: Nominatim geocode error: %s", self.provider_name, exc)
|
|
118
|
+
_LOGGER.warning("%s: Nominatim found nothing for '%s'", self.provider_name, search_term)
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
|
|
122
|
+
"""Search stops by geocoding the term, then finding nearby OTP stops."""
|
|
123
|
+
coords = await self._geocode(search_term)
|
|
124
|
+
if coords is None:
|
|
125
|
+
_LOGGER.warning(
|
|
126
|
+
"%s: could not geocode '%s' — check your search term",
|
|
127
|
+
self.provider_name,
|
|
128
|
+
search_term,
|
|
129
|
+
)
|
|
130
|
+
return []
|
|
131
|
+
|
|
132
|
+
lat, lon = coords
|
|
133
|
+
data = await self._get(
|
|
134
|
+
self._index_url("stops"),
|
|
135
|
+
{"lat": lat, "lon": lon, "radius": self.stop_search_radius},
|
|
136
|
+
)
|
|
137
|
+
if not data:
|
|
138
|
+
return []
|
|
139
|
+
|
|
140
|
+
return [
|
|
141
|
+
{
|
|
142
|
+
"id": s["id"],
|
|
143
|
+
"name": s.get("name", ""),
|
|
144
|
+
"place": s.get("name", ""),
|
|
145
|
+
"area_type": "stop",
|
|
146
|
+
}
|
|
147
|
+
for s in sorted(data, key=lambda x: x.get("dist", 0))
|
|
148
|
+
if isinstance(s, dict) and "id" in s
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
async def fetch_departures(
|
|
152
|
+
self,
|
|
153
|
+
station_id: Optional[str],
|
|
154
|
+
place_dm: str,
|
|
155
|
+
name_dm: str,
|
|
156
|
+
departures_limit: int,
|
|
157
|
+
) -> Optional[Dict[str, Any]]:
|
|
158
|
+
if not station_id:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
encoded_id = quote(station_id, safe="")
|
|
162
|
+
mode_mapping = self.get_mode_mapping()
|
|
163
|
+
|
|
164
|
+
routes_data, alerts_data, stoptimes = await asyncio.gather(
|
|
165
|
+
self._get(self._index_url(f"stops/{encoded_id}/routes")),
|
|
166
|
+
self._get(self._index_url(f"stops/{encoded_id}/alerts")),
|
|
167
|
+
self._get(
|
|
168
|
+
self._index_url(f"stops/{encoded_id}/stoptimes"),
|
|
169
|
+
{
|
|
170
|
+
"timeRange": 7200,
|
|
171
|
+
"numberOfDepartures": max(departures_limit, 5),
|
|
172
|
+
"omitNonPickups": "true",
|
|
173
|
+
},
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
route_map: Dict[str, Dict[str, str]] = {}
|
|
178
|
+
if routes_data:
|
|
179
|
+
for r in routes_data:
|
|
180
|
+
if isinstance(r, dict) and "id" in r and r["id"] not in route_map:
|
|
181
|
+
agency = r.get("agencyName") or (r["agency"]["name"] if isinstance(r.get("agency"), dict) else None)
|
|
182
|
+
route_map[r["id"]] = {
|
|
183
|
+
"shortName": r.get("shortName") or r.get("longName", ""),
|
|
184
|
+
"mode": mode_mapping.get(r.get("mode", ""), "unknown"),
|
|
185
|
+
"agency": agency or "",
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
stop_notices: List[str] = []
|
|
189
|
+
if alerts_data:
|
|
190
|
+
seen: set = set()
|
|
191
|
+
for alert in alerts_data:
|
|
192
|
+
if not isinstance(alert, dict):
|
|
193
|
+
continue
|
|
194
|
+
text = alert.get("alertHeaderText") or alert.get("alertDescriptionText") or ""
|
|
195
|
+
if text and text not in seen:
|
|
196
|
+
stop_notices.append(text)
|
|
197
|
+
seen.add(text)
|
|
198
|
+
if stoptimes is None:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
stop_events = []
|
|
202
|
+
for group in stoptimes:
|
|
203
|
+
if not isinstance(group, dict):
|
|
204
|
+
continue
|
|
205
|
+
pattern = group.get("pattern", {})
|
|
206
|
+
route_id = pattern.get("routeId", "")
|
|
207
|
+
route_info = route_map.get(route_id, {})
|
|
208
|
+
|
|
209
|
+
for t in group.get("times", []):
|
|
210
|
+
if not isinstance(t, dict):
|
|
211
|
+
continue
|
|
212
|
+
stop_events.append(
|
|
213
|
+
{
|
|
214
|
+
"routeName": route_info.get("shortName") or pattern.get("desc", ""),
|
|
215
|
+
"transportType": route_info.get("mode", "unknown"),
|
|
216
|
+
"agency": route_info.get("agency", ""),
|
|
217
|
+
"notices": stop_notices or None,
|
|
218
|
+
"serviceDay": t.get("serviceDay", 0),
|
|
219
|
+
"scheduledDeparture": t.get("scheduledDeparture", 0),
|
|
220
|
+
"realtimeDeparture": t.get("realtimeDeparture", 0),
|
|
221
|
+
"departureDelay": t.get("departureDelay", 0),
|
|
222
|
+
"realtime": t.get("realtime", False),
|
|
223
|
+
"headsign": t.get("headsign", ""),
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
stop_events.sort(key=lambda x: x["serviceDay"] + x["realtimeDeparture"])
|
|
228
|
+
return {"stopEvents": stop_events[:departures_limit]}
|
|
229
|
+
|
|
230
|
+
def parse_departure(
|
|
231
|
+
self,
|
|
232
|
+
stop: Dict[str, Any],
|
|
233
|
+
tz: Union[ZoneInfo, Any],
|
|
234
|
+
now: datetime,
|
|
235
|
+
) -> Optional[UnifiedDeparture]:
|
|
236
|
+
try:
|
|
237
|
+
service_day: int = stop["serviceDay"]
|
|
238
|
+
planned = datetime.fromtimestamp(service_day + stop["scheduledDeparture"], tz=timezone.utc).astimezone(tz)
|
|
239
|
+
actual = datetime.fromtimestamp(service_day + stop["realtimeDeparture"], tz=timezone.utc).astimezone(tz)
|
|
240
|
+
|
|
241
|
+
delay_min = max(0, int(stop.get("departureDelay", 0) / 60))
|
|
242
|
+
minutes_until = max(0, int((actual - now).total_seconds() / 60))
|
|
243
|
+
|
|
244
|
+
agency = stop.get("agency") or None
|
|
245
|
+
notices = stop.get("notices") or None
|
|
246
|
+
|
|
247
|
+
return UnifiedDeparture(
|
|
248
|
+
line=stop.get("routeName", ""),
|
|
249
|
+
destination=stop.get("headsign", "Unknown"),
|
|
250
|
+
departure_time=actual.strftime("%H:%M"),
|
|
251
|
+
planned_time=planned.strftime("%H:%M"),
|
|
252
|
+
delay=delay_min,
|
|
253
|
+
platform="",
|
|
254
|
+
transportation_type=stop.get("transportType", "unknown"),
|
|
255
|
+
is_realtime=stop.get("realtime", False),
|
|
256
|
+
minutes_until_departure=minutes_until,
|
|
257
|
+
departure_time_obj=actual,
|
|
258
|
+
description=None,
|
|
259
|
+
agency=agency,
|
|
260
|
+
notices=notices,
|
|
261
|
+
planned_platform=None,
|
|
262
|
+
platform_changed=False,
|
|
263
|
+
line_color=stop.get("lineColor") or None,
|
|
264
|
+
line_text_color=stop.get("lineTextColor") or None,
|
|
265
|
+
)
|
|
266
|
+
except Exception as exc:
|
|
267
|
+
_LOGGER.debug("%s OTP parse_departure error: %s", self.provider_name, exc)
|
|
268
|
+
return None
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Generic OTP2 provider for user-hosted instances.
|
|
2
|
+
|
|
3
|
+
Users supply their own OTP2 base URL (e.g. http://192.168.1.10:8080/otp/routers/default)
|
|
4
|
+
and an optional X-API-Key. Stop search and departure logic are identical to the
|
|
5
|
+
community server — this is a thin subclass that sets no default URL.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .otp import OTPProvider
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OTPCustomProvider(OTPProvider):
|
|
12
|
+
"""User-provided OTP2 instance with configurable URL and optional API key."""
|
|
13
|
+
|
|
14
|
+
otp_base_url = "" # always overridden by self.custom_url set via constructor
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def provider_id(self) -> str:
|
|
18
|
+
return "otp_custom"
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def provider_name(self) -> str:
|
|
22
|
+
return "Custom OTP Server"
|