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.
Files changed (42) hide show
  1. openpublictransport/__init__.py +5 -0
  2. openpublictransport/const.py +75 -0
  3. openpublictransport/models.py +95 -0
  4. openpublictransport/parsers.py +122 -0
  5. openpublictransport/providers/__init__.py +127 -0
  6. openpublictransport/providers/avv.py +44 -0
  7. openpublictransport/providers/base.py +77 -0
  8. openpublictransport/providers/beg.py +46 -0
  9. openpublictransport/providers/bsvg.py +44 -0
  10. openpublictransport/providers/bvg.py +32 -0
  11. openpublictransport/providers/db.py +39 -0
  12. openpublictransport/providers/ding.py +44 -0
  13. openpublictransport/providers/efa_base.py +209 -0
  14. openpublictransport/providers/fptf_base.py +169 -0
  15. openpublictransport/providers/gtfsde.py +21 -0
  16. openpublictransport/providers/hvv.py +40 -0
  17. openpublictransport/providers/kvv.py +38 -0
  18. openpublictransport/providers/mvv.py +48 -0
  19. openpublictransport/providers/nta.py +288 -0
  20. openpublictransport/providers/nvbw.py +44 -0
  21. openpublictransport/providers/nwl.py +46 -0
  22. openpublictransport/providers/oebb.py +182 -0
  23. openpublictransport/providers/otp.py +378 -0
  24. openpublictransport/providers/otp_base.py +268 -0
  25. openpublictransport/providers/otp_custom.py +22 -0
  26. openpublictransport/providers/rmv.py +272 -0
  27. openpublictransport/providers/rvv.py +43 -0
  28. openpublictransport/providers/sbb.py +174 -0
  29. openpublictransport/providers/trafiklab.py +280 -0
  30. openpublictransport/providers/transitous.py +198 -0
  31. openpublictransport/providers/trias_base.py +323 -0
  32. openpublictransport/providers/vagfr.py +44 -0
  33. openpublictransport/providers/vbn.py +73 -0
  34. openpublictransport/providers/vgn.py +46 -0
  35. openpublictransport/providers/vrn.py +51 -0
  36. openpublictransport/providers/vrr.py +51 -0
  37. openpublictransport/providers/vvo.py +45 -0
  38. openpublictransport/providers/vvs.py +48 -0
  39. python_openpublictransport-0.1.0.dist-info/METADATA +14 -0
  40. python_openpublictransport-0.1.0.dist-info/RECORD +42 -0
  41. python_openpublictransport-0.1.0.dist-info/WHEEL +5 -0
  42. 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"