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,272 @@
1
+ """RMV (Rhein-Main-Verkehrsverbund) provider implementation."""
2
+
3
+ import logging
4
+ from datetime import datetime
5
+ from typing import Any, Dict, List, Optional, Union
6
+ from urllib.parse import quote
7
+ from zoneinfo import ZoneInfo
8
+
9
+ import aiohttp
10
+
11
+ from ..const import PROVIDER_RMV
12
+ from ..models import UnifiedDeparture
13
+ from .base import BaseProvider
14
+
15
+ _LOGGER = logging.getLogger(__name__)
16
+
17
+ API_BASE = "https://www.rmv.de/hapi"
18
+
19
+ PRODUCT_MAPPING = {
20
+ "ICE": "train",
21
+ "IC": "train",
22
+ "EC": "train",
23
+ "RE": "train",
24
+ "RB": "train",
25
+ "S": "train",
26
+ "U": "subway",
27
+ "Tram": "tram",
28
+ "Bus": "bus",
29
+ "AST": "bus",
30
+ "Fäh": "ferry",
31
+ }
32
+
33
+
34
+ def _parse_dt(s: str) -> Optional[datetime]:
35
+ try:
36
+ return datetime.fromisoformat(s)
37
+ except (ValueError, TypeError):
38
+ return None
39
+
40
+
41
+ def _determine_transport_type(product: Dict[str, Any]) -> str:
42
+ cat_out = product.get("catOut", "").strip()
43
+ cat_code = product.get("catCode")
44
+
45
+ for key, transport_type in PRODUCT_MAPPING.items():
46
+ if cat_out.startswith(key):
47
+ return transport_type
48
+
49
+ if cat_code is not None:
50
+ try:
51
+ code = int(cat_code)
52
+ if code in (1, 2):
53
+ return "train"
54
+ elif code in (4, 8):
55
+ return "train"
56
+ elif code == 16:
57
+ return "train"
58
+ elif code == 32:
59
+ return "bus"
60
+ elif code == 64:
61
+ return "ferry"
62
+ elif code == 128:
63
+ return "subway"
64
+ elif code == 256:
65
+ return "tram"
66
+ except (ValueError, TypeError):
67
+ pass
68
+
69
+ return "unknown"
70
+
71
+
72
+ class RMVProvider(BaseProvider):
73
+ """RMV (Frankfurt/Rhine-Main) provider using HAFAS REST API."""
74
+
75
+ @property
76
+ def provider_id(self) -> str:
77
+ return PROVIDER_RMV
78
+
79
+ @property
80
+ def provider_name(self) -> str:
81
+ return "RMV (Frankfurt)"
82
+
83
+ @property
84
+ def requires_api_key(self) -> bool:
85
+ return True
86
+
87
+ def get_timezone(self) -> str:
88
+ return "Europe/Berlin"
89
+
90
+ async def fetch_departures(
91
+ self,
92
+ station_id: Optional[str],
93
+ place_dm: str,
94
+ name_dm: str,
95
+ departures_limit: int,
96
+ ) -> Optional[Dict[str, Any]]:
97
+ if not station_id:
98
+ _LOGGER.warning("RMV provider requires a station_id")
99
+ return None
100
+
101
+ if not self.api_key:
102
+ _LOGGER.error("RMV provider requires an API key")
103
+ return None
104
+
105
+ url = (
106
+ f"{API_BASE}/departureBoard"
107
+ f"?accessId={self.api_key}"
108
+ f"&id={station_id}"
109
+ f"&format=json"
110
+ f"&duration=120"
111
+ f"&maxJourneys={departures_limit}"
112
+ )
113
+
114
+ try:
115
+ async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as response:
116
+ if response.status == 200:
117
+ data = await response.json()
118
+ if not isinstance(data, dict):
119
+ return None
120
+
121
+ if "errorCode" in data:
122
+ _LOGGER.warning("RMV API error: %s", data.get("errorText", "unknown"))
123
+ return None
124
+
125
+ departures = data.get("Departure", [])
126
+ if isinstance(departures, dict):
127
+ departures = [departures]
128
+ return {"stopEvents": departures}
129
+ elif response.status == 401:
130
+ _LOGGER.error("RMV API: invalid API key")
131
+ else:
132
+ _LOGGER.warning("RMV API returned status %s", response.status)
133
+ except aiohttp.ClientError as e:
134
+ _LOGGER.warning("RMV API request failed: %s", e)
135
+ except Exception as e:
136
+ _LOGGER.warning("RMV API error: %s", e)
137
+
138
+ return None
139
+
140
+ def parse_departure(
141
+ self, stop: Dict[str, Any], tz: Union[ZoneInfo, Any], now: datetime
142
+ ) -> Optional[UnifiedDeparture]:
143
+ try:
144
+ date_str = stop.get("date")
145
+ time_str = stop.get("time")
146
+ rt_date_str = stop.get("rtDate")
147
+ rt_time_str = stop.get("rtTime")
148
+
149
+ if not date_str or not time_str:
150
+ return None
151
+
152
+ planned_dt_str = f"{date_str}T{time_str}"
153
+ planned = _parse_dt(planned_dt_str)
154
+ if not planned:
155
+ return None
156
+ planned_local = planned.replace(tzinfo=tz) if planned.tzinfo is None else planned.astimezone(tz)
157
+
158
+ if rt_date_str and rt_time_str:
159
+ rt_dt_str = f"{rt_date_str}T{rt_time_str}"
160
+ rt = _parse_dt(rt_dt_str)
161
+ if rt:
162
+ when_local = rt.replace(tzinfo=tz) if rt.tzinfo is None else rt.astimezone(tz)
163
+ is_realtime = True
164
+ else:
165
+ when_local = planned_local
166
+ is_realtime = False
167
+ else:
168
+ when_local = planned_local
169
+ is_realtime = False
170
+
171
+ delay_minutes = int((when_local - planned_local).total_seconds() / 60)
172
+
173
+ product = stop.get("ProductAtStop", stop.get("Product", {}))
174
+ if isinstance(product, list):
175
+ product = product[0] if product else {}
176
+ line_name = product.get("line", product.get("name", ""))
177
+ transport_type = _determine_transport_type(product)
178
+
179
+ direction = stop.get("direction", "Unknown")
180
+ platform = stop.get("track", "")
181
+
182
+ rt_track = stop.get("rtTrack", "")
183
+ planned_track = platform
184
+ platform_changed = bool(rt_track and planned_track and rt_track != planned_track)
185
+ if rt_track:
186
+ platform = rt_track
187
+
188
+ time_diff = when_local - now
189
+ minutes_until = max(0, int(time_diff.total_seconds() / 60))
190
+
191
+ notices = []
192
+ for msg in stop.get("Messages", {}).get("Message", []):
193
+ if isinstance(msg, dict):
194
+ text = msg.get("head", "") or msg.get("text", "")
195
+ if text:
196
+ notices.append(text)
197
+
198
+ operator = product.get("operator", product.get("operatorName", ""))
199
+
200
+ return UnifiedDeparture(
201
+ line=line_name,
202
+ destination=direction,
203
+ departure_time=when_local.strftime("%H:%M"),
204
+ planned_time=planned_local.strftime("%H:%M"),
205
+ delay=delay_minutes,
206
+ platform=platform,
207
+ transportation_type=transport_type,
208
+ is_realtime=is_realtime,
209
+ minutes_until_departure=minutes_until,
210
+ departure_time_obj=when_local,
211
+ description=product.get("catOutL"),
212
+ agency=operator if operator else None,
213
+ notices=notices if notices else None,
214
+ planned_platform=planned_track if platform_changed else None,
215
+ platform_changed=platform_changed,
216
+ )
217
+ except Exception as e:
218
+ _LOGGER.debug("Error parsing RMV departure: %s", e)
219
+ return None
220
+
221
+ async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
222
+ if not self.api_key:
223
+ _LOGGER.error("RMV provider requires an API key for stop search")
224
+ return []
225
+
226
+ url = (
227
+ f"{API_BASE}/location.name"
228
+ f"?accessId={self.api_key}"
229
+ f"&input={quote(search_term, safe='')}"
230
+ f"&format=json"
231
+ f"&maxNo=15"
232
+ f"&type=S"
233
+ )
234
+
235
+ try:
236
+ async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as response:
237
+ if response.status == 200:
238
+ data = await response.json()
239
+ if not isinstance(data, dict):
240
+ return []
241
+
242
+ stop_locations = data.get("stopLocationOrCoordLocation", [])
243
+ results = []
244
+
245
+ for item in stop_locations:
246
+ if not isinstance(item, dict):
247
+ continue
248
+ loc = item.get("StopLocation", {})
249
+ if not loc:
250
+ continue
251
+
252
+ name = loc.get("name", "")
253
+ place = ""
254
+ if "," in name:
255
+ parts = name.split(",", 1)
256
+ place = parts[0].strip()
257
+
258
+ results.append(
259
+ {
260
+ "id": loc.get("extId", loc.get("id", "")),
261
+ "name": name,
262
+ "place": place,
263
+ "area_type": "stop",
264
+ }
265
+ )
266
+ return results
267
+ else:
268
+ _LOGGER.error("RMV API returned status %s", response.status)
269
+ except Exception as e:
270
+ _LOGGER.error("Error searching RMV stops: %s", e)
271
+
272
+ return []
@@ -0,0 +1,43 @@
1
+ """RVV (Regensburger Verkehrsverbund) provider implementation."""
2
+
3
+ from typing import Any, Callable, Dict, Optional
4
+
5
+ from ..const import PROVIDER_RVV
6
+ from .efa_base import EFABaseProvider
7
+
8
+
9
+ class RVVProvider(EFABaseProvider):
10
+ """RVV (Regensburg) provider."""
11
+
12
+ @property
13
+ def provider_id(self) -> str:
14
+ return PROVIDER_RVV
15
+
16
+ @property
17
+ def provider_name(self) -> str:
18
+ return "RVV (Regensburg)"
19
+
20
+ @property
21
+ def dm_base_url(self) -> str:
22
+ return "https://efa.rvv.de/efa/XML_DM_REQUEST"
23
+
24
+ @property
25
+ def sf_base_url(self) -> str:
26
+ return "https://efa.rvv.de/efa/XML_STOPFINDER_REQUEST"
27
+
28
+ def get_timezone(self) -> str:
29
+ return "Europe/Berlin"
30
+
31
+ def get_transport_type_mapping(self) -> Dict[Any, str]:
32
+ return {
33
+ 0: "train", # High-speed trains (ICE, IC, EC)
34
+ 1: "train", # Regional trains (RE, RB)
35
+ 5: "bus", # City bus
36
+ 6: "bus", # Regional bus
37
+ 7: "bus", # Express bus
38
+ 8: "bus", # Night bus
39
+ 13: "train", # Regionalzug (RE)
40
+ }
41
+
42
+ def get_realtime_fn(self) -> Callable[[Dict[str, Any], Optional[str], Optional[str]], bool]:
43
+ return lambda s, est, plan: est != plan if est and plan else False
@@ -0,0 +1,174 @@
1
+ """SBB (Swiss Federal Railways) provider implementation."""
2
+
3
+ import logging
4
+ from datetime import datetime, timedelta
5
+ from typing import Any, Dict, List, Optional, Union
6
+ from urllib.parse import quote
7
+ from zoneinfo import ZoneInfo
8
+
9
+ import aiohttp
10
+
11
+ from ..const import PROVIDER_SBB
12
+ from ..models import UnifiedDeparture
13
+ from .base import BaseProvider
14
+
15
+ _LOGGER = logging.getLogger(__name__)
16
+
17
+ API_BASE = "https://transport.opendata.ch/v1"
18
+
19
+ CATEGORY_MAPPING = {
20
+ "ICE": "train",
21
+ "IC": "train",
22
+ "IR": "train",
23
+ "EC": "train",
24
+ "RE": "train",
25
+ "S": "train",
26
+ "TGV": "train",
27
+ "RJ": "train",
28
+ "T": "tram",
29
+ "B": "bus",
30
+ "NFB": "bus",
31
+ "BUS": "bus",
32
+ "BAT": "ferry",
33
+ "FAE": "ferry",
34
+ "M": "subway",
35
+ "FUN": "train",
36
+ }
37
+
38
+
39
+ def _parse_dt(s: str) -> Optional[datetime]:
40
+ try:
41
+ return datetime.fromisoformat(s)
42
+ except (ValueError, TypeError):
43
+ return None
44
+
45
+
46
+ class SBBProvider(BaseProvider):
47
+ """SBB (Swiss Federal Railways) provider."""
48
+
49
+ @property
50
+ def provider_id(self) -> str:
51
+ return PROVIDER_SBB
52
+
53
+ @property
54
+ def provider_name(self) -> str:
55
+ return "SBB (Schweiz)"
56
+
57
+ def get_timezone(self) -> str:
58
+ return "Europe/Zurich"
59
+
60
+ async def fetch_departures(
61
+ self,
62
+ station_id: Optional[str],
63
+ place_dm: str,
64
+ name_dm: str,
65
+ departures_limit: int,
66
+ ) -> Optional[Dict[str, Any]]:
67
+ if station_id:
68
+ url = f"{API_BASE}/stationboard?id={station_id}&limit={departures_limit}"
69
+ else:
70
+ station_name = f"{name_dm}, {place_dm}" if place_dm else name_dm
71
+ url = f"{API_BASE}/stationboard?station={quote(station_name, safe='')}&limit={departures_limit}"
72
+
73
+ try:
74
+ async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as response:
75
+ if response.status == 200:
76
+ data = await response.json()
77
+ if not isinstance(data, dict):
78
+ return None
79
+ stationboard = data.get("stationboard", [])
80
+ return {"stopEvents": stationboard}
81
+ else:
82
+ _LOGGER.warning("SBB API returned status %s", response.status)
83
+ except aiohttp.ClientError as e:
84
+ _LOGGER.warning("SBB API request failed: %s", e)
85
+ except Exception as e:
86
+ _LOGGER.warning("SBB API error: %s", e)
87
+
88
+ return None
89
+
90
+ def parse_departure(
91
+ self, stop: Dict[str, Any], tz: Union[ZoneInfo, Any], now: datetime
92
+ ) -> Optional[UnifiedDeparture]:
93
+ try:
94
+ stop_info = stop.get("stop", {})
95
+ dep_str = stop_info.get("departure")
96
+ if not dep_str:
97
+ return None
98
+
99
+ dep_dt = _parse_dt(dep_str)
100
+ if not dep_dt:
101
+ return None
102
+
103
+ dep_local = dep_dt.astimezone(tz)
104
+ delay_min = stop_info.get("delay") or 0
105
+
106
+ planned_local = dep_local - timedelta(minutes=delay_min)
107
+
108
+ category = stop.get("category", "")
109
+ number = stop.get("number", "")
110
+ line = f"{category}{number}"
111
+
112
+ transport_type = CATEGORY_MAPPING.get(category, "unknown")
113
+ destination = stop.get("to", "Unknown")
114
+ platform = stop_info.get("platform", "")
115
+
116
+ time_diff = dep_local - now
117
+ minutes_until = max(0, int(time_diff.total_seconds() / 60))
118
+
119
+ is_realtime = stop_info.get("prognosis", {}).get("departure") is not None
120
+
121
+ prognosis_platform = stop_info.get("prognosis", {}).get("platform")
122
+ platform_changed = bool(prognosis_platform and platform and prognosis_platform != platform)
123
+ planned_platform = platform if platform_changed else None
124
+ if prognosis_platform:
125
+ platform = prognosis_platform
126
+
127
+ return UnifiedDeparture(
128
+ line=line,
129
+ destination=destination,
130
+ departure_time=dep_local.strftime("%H:%M"),
131
+ planned_time=planned_local.strftime("%H:%M"),
132
+ delay=delay_min,
133
+ platform=platform,
134
+ transportation_type=transport_type,
135
+ is_realtime=is_realtime,
136
+ minutes_until_departure=minutes_until,
137
+ departure_time_obj=dep_local,
138
+ description=stop.get("operator", ""),
139
+ notices=None,
140
+ planned_platform=planned_platform,
141
+ platform_changed=platform_changed,
142
+ )
143
+ except Exception as e:
144
+ _LOGGER.debug("Error parsing SBB departure: %s", e)
145
+ return None
146
+
147
+ async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
148
+ url = f"{API_BASE}/locations?query={quote(search_term, safe='')}&type=station"
149
+
150
+ try:
151
+ async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as response:
152
+ if response.status == 200:
153
+ data = await response.json()
154
+ stations = data.get("stations", [])
155
+ results = []
156
+ for station in stations:
157
+ if not isinstance(station, dict) or not station.get("id"):
158
+ continue
159
+ name = station.get("name", "")
160
+ results.append(
161
+ {
162
+ "id": str(station.get("id", "")),
163
+ "name": name,
164
+ "place": "",
165
+ "area_type": "stop",
166
+ }
167
+ )
168
+ return results
169
+ else:
170
+ _LOGGER.error("SBB API returned status %s", response.status)
171
+ except Exception as e:
172
+ _LOGGER.error("Error searching SBB stops: %s", e)
173
+
174
+ return []