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,288 @@
|
|
|
1
|
+
"""NTA (National Transport Authority, Ireland) provider implementation."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from typing import Any, Dict, List, Optional, Union
|
|
7
|
+
from zoneinfo import ZoneInfo
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
from aiohttp import ClientConnectorError
|
|
11
|
+
|
|
12
|
+
from ..const import API_BASE_URL_NTA_GTFSR, NTA_TRANSPORTATION_TYPES, PROVIDER_NTA_IE
|
|
13
|
+
from ..models import UnifiedDeparture
|
|
14
|
+
from ..parsers import parse_departure_generic
|
|
15
|
+
from .base import BaseProvider
|
|
16
|
+
|
|
17
|
+
_LOGGER = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NTAProvider(BaseProvider):
|
|
21
|
+
"""NTA (National Transport Authority, Ireland) provider."""
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def provider_id(self) -> str:
|
|
25
|
+
return PROVIDER_NTA_IE
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def provider_name(self) -> str:
|
|
29
|
+
return "NTA (Ireland)"
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def requires_api_key(self) -> bool:
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
def get_timezone(self) -> str:
|
|
36
|
+
return "Europe/Dublin"
|
|
37
|
+
|
|
38
|
+
async def cleanup(self) -> None:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
async def fetch_departures(
|
|
42
|
+
self,
|
|
43
|
+
station_id: Optional[str],
|
|
44
|
+
place_dm: str,
|
|
45
|
+
name_dm: str,
|
|
46
|
+
departures_limit: int,
|
|
47
|
+
) -> Optional[Dict[str, Any]]:
|
|
48
|
+
if not self.api_key:
|
|
49
|
+
_LOGGER.error("NTA API key is required")
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
if not station_id:
|
|
53
|
+
_LOGGER.error("NTA requires a station ID (stop_id)")
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
url = f"{API_BASE_URL_NTA_GTFSR}/v2/TripUpdates"
|
|
57
|
+
params = {"format": "json"}
|
|
58
|
+
|
|
59
|
+
headers = {
|
|
60
|
+
"User-Agent": "Mozilla/5.0 (compatible; OpenPublicTransport NTA)",
|
|
61
|
+
"x-api-key": self.api_key,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
max_retries = 3
|
|
65
|
+
current_api_key = self.api_key
|
|
66
|
+
for attempt in range(1, max_retries + 1):
|
|
67
|
+
try:
|
|
68
|
+
headers["x-api-key"] = current_api_key
|
|
69
|
+
|
|
70
|
+
async with self.session.get(
|
|
71
|
+
url, params=params, headers=headers, timeout=aiohttp.ClientTimeout(total=15)
|
|
72
|
+
) as response:
|
|
73
|
+
if response.status == 200:
|
|
74
|
+
try:
|
|
75
|
+
json_data = await response.json()
|
|
76
|
+
|
|
77
|
+
if not isinstance(json_data, dict):
|
|
78
|
+
_LOGGER.warning("NTA API returned non-dict response: %s", type(json_data))
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
entities = json_data.get("entity", [])
|
|
82
|
+
if not isinstance(entities, list):
|
|
83
|
+
_LOGGER.debug("NTA API response missing or invalid 'entity' field")
|
|
84
|
+
return {"stopEvents": []}
|
|
85
|
+
|
|
86
|
+
entity_count = len(entities)
|
|
87
|
+
if entity_count == 0:
|
|
88
|
+
_LOGGER.debug("NTA API returned empty entities list")
|
|
89
|
+
return {"stopEvents": []}
|
|
90
|
+
|
|
91
|
+
_LOGGER.info(
|
|
92
|
+
"NTA API returned %d entities (processing for stop %s)", entity_count, station_id
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
stop_events = []
|
|
96
|
+
target_stop_id = station_id
|
|
97
|
+
max_departures = departures_limit * 3
|
|
98
|
+
processed_entities = 0
|
|
99
|
+
|
|
100
|
+
now = datetime.now(timezone.utc)
|
|
101
|
+
|
|
102
|
+
for entity in entities:
|
|
103
|
+
if not isinstance(entity, dict):
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
trip_update = entity.get("trip_update")
|
|
107
|
+
if not isinstance(trip_update, dict):
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
stop_time_updates = trip_update.get("stop_time_update", [])
|
|
111
|
+
if not isinstance(stop_time_updates, list) or len(stop_time_updates) == 0:
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
matching_stop_time = None
|
|
115
|
+
for stop_time_update in stop_time_updates:
|
|
116
|
+
if not isinstance(stop_time_update, dict):
|
|
117
|
+
continue
|
|
118
|
+
stop_id = stop_time_update.get("stop_id")
|
|
119
|
+
if stop_id == target_stop_id:
|
|
120
|
+
matching_stop_time = stop_time_update
|
|
121
|
+
break
|
|
122
|
+
|
|
123
|
+
if matching_stop_time is None:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
trip = trip_update.get("trip", {})
|
|
127
|
+
if not isinstance(trip, dict):
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
stop_time_update = matching_stop_time
|
|
131
|
+
|
|
132
|
+
route_id = trip.get("route_id", "")
|
|
133
|
+
trip_id = trip.get("trip_id", "")
|
|
134
|
+
stop_id = stop_time_update.get("stop_id", target_stop_id)
|
|
135
|
+
|
|
136
|
+
route_short_name = route_id.split("_")[0] if route_id else ""
|
|
137
|
+
|
|
138
|
+
route_type = 3
|
|
139
|
+
if route_short_name and route_short_name.lower() in ["red", "green", "luas"]:
|
|
140
|
+
route_type = 0
|
|
141
|
+
|
|
142
|
+
departure = stop_time_update.get("departure", {})
|
|
143
|
+
arrival = stop_time_update.get("arrival", {})
|
|
144
|
+
delay_seconds = departure.get("delay") or arrival.get("delay") or 0
|
|
145
|
+
|
|
146
|
+
schedule_relationship = stop_time_update.get("schedule_relationship", "SCHEDULED")
|
|
147
|
+
if schedule_relationship in ["CANCELED", "SKIPPED"]:
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
destination = route_short_name or "Unknown"
|
|
151
|
+
|
|
152
|
+
departure_time = departure.get("time")
|
|
153
|
+
arrival_time = arrival.get("time")
|
|
154
|
+
|
|
155
|
+
if departure_time:
|
|
156
|
+
try:
|
|
157
|
+
planned_time = datetime.fromtimestamp(departure_time, tz=now.tzinfo)
|
|
158
|
+
estimated_time = planned_time + timedelta(seconds=delay_seconds)
|
|
159
|
+
except (ValueError, OSError):
|
|
160
|
+
planned_time = now
|
|
161
|
+
estimated_time = now + timedelta(seconds=delay_seconds)
|
|
162
|
+
elif arrival_time:
|
|
163
|
+
try:
|
|
164
|
+
planned_time = datetime.fromtimestamp(arrival_time, tz=now.tzinfo)
|
|
165
|
+
estimated_time = planned_time + timedelta(seconds=delay_seconds)
|
|
166
|
+
except (ValueError, OSError):
|
|
167
|
+
planned_time = now
|
|
168
|
+
estimated_time = now + timedelta(seconds=delay_seconds)
|
|
169
|
+
else:
|
|
170
|
+
planned_time = now
|
|
171
|
+
estimated_time = now + timedelta(seconds=delay_seconds)
|
|
172
|
+
|
|
173
|
+
planned_time_str = planned_time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
174
|
+
estimated_time_str = estimated_time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
175
|
+
|
|
176
|
+
platform = (
|
|
177
|
+
stop_time_update.get("platform_code") or stop_time_update.get("platform") or ""
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
stop_event = {
|
|
181
|
+
"departureTimePlanned": planned_time_str,
|
|
182
|
+
"departureTimeEstimated": estimated_time_str,
|
|
183
|
+
"transportation": {
|
|
184
|
+
"number": route_short_name,
|
|
185
|
+
"description": "",
|
|
186
|
+
"destination": {"name": destination},
|
|
187
|
+
"product": {"class": route_type},
|
|
188
|
+
},
|
|
189
|
+
"platform": {"name": platform},
|
|
190
|
+
"realtimeStatus": ["MONITORED"] if delay_seconds != 0 else [],
|
|
191
|
+
"route_id": route_id,
|
|
192
|
+
"trip_id": trip_id,
|
|
193
|
+
"stop_id": stop_id,
|
|
194
|
+
"delay_seconds": delay_seconds,
|
|
195
|
+
}
|
|
196
|
+
stop_events.append(stop_event)
|
|
197
|
+
processed_entities += 1
|
|
198
|
+
|
|
199
|
+
if len(stop_events) >= max_departures:
|
|
200
|
+
break
|
|
201
|
+
|
|
202
|
+
_LOGGER.info(
|
|
203
|
+
"NTA: Processed %d/%d entities, found %d departures for stop %s",
|
|
204
|
+
processed_entities,
|
|
205
|
+
entity_count,
|
|
206
|
+
len(stop_events),
|
|
207
|
+
target_stop_id,
|
|
208
|
+
)
|
|
209
|
+
return {"stopEvents": stop_events}
|
|
210
|
+
|
|
211
|
+
except (ValueError, aiohttp.ContentTypeError) as e:
|
|
212
|
+
_LOGGER.warning("NTA API returned invalid JSON: %s", e)
|
|
213
|
+
return None
|
|
214
|
+
except Exception as e:
|
|
215
|
+
_LOGGER.warning("NTA API JSON parsing failed: %s", e, exc_info=True)
|
|
216
|
+
return None
|
|
217
|
+
elif response.status == 404:
|
|
218
|
+
_LOGGER.warning("NTA API endpoint not found (404)")
|
|
219
|
+
return None
|
|
220
|
+
elif response.status == 401:
|
|
221
|
+
if self.api_key_secondary and current_api_key == self.api_key:
|
|
222
|
+
_LOGGER.info("NTA Primary API key failed (401), trying Secondary key...")
|
|
223
|
+
current_api_key = self.api_key_secondary
|
|
224
|
+
continue
|
|
225
|
+
_LOGGER.warning("NTA API authentication failed (401) - check API key(s)")
|
|
226
|
+
return None
|
|
227
|
+
elif response.status >= 500:
|
|
228
|
+
_LOGGER.warning(
|
|
229
|
+
"NTA API server error (status %s) on attempt %d/%d",
|
|
230
|
+
response.status,
|
|
231
|
+
attempt,
|
|
232
|
+
max_retries,
|
|
233
|
+
)
|
|
234
|
+
if attempt < max_retries:
|
|
235
|
+
await asyncio.sleep(2**attempt)
|
|
236
|
+
continue
|
|
237
|
+
return None
|
|
238
|
+
else:
|
|
239
|
+
_LOGGER.warning(
|
|
240
|
+
"NTA API returned status %s on attempt %d/%d", response.status, attempt, max_retries
|
|
241
|
+
)
|
|
242
|
+
if attempt < max_retries:
|
|
243
|
+
await asyncio.sleep(2**attempt)
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
except asyncio.TimeoutError:
|
|
247
|
+
_LOGGER.warning("NTA API timeout on attempt %d/%d", attempt, max_retries)
|
|
248
|
+
if attempt < max_retries:
|
|
249
|
+
await asyncio.sleep(2**attempt)
|
|
250
|
+
continue
|
|
251
|
+
except ClientConnectorError as e:
|
|
252
|
+
_LOGGER.warning("NTA API connection error on attempt %d/%d: %s", attempt, max_retries, e)
|
|
253
|
+
if attempt < max_retries:
|
|
254
|
+
await asyncio.sleep(2**attempt)
|
|
255
|
+
continue
|
|
256
|
+
except Exception as e:
|
|
257
|
+
_LOGGER.warning("NTA API attempt %d/%d failed: %s", attempt, max_retries, e)
|
|
258
|
+
if attempt < max_retries:
|
|
259
|
+
await asyncio.sleep(2**attempt)
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
def parse_departure(
|
|
265
|
+
self, stop: Dict[str, Any], tz: Union[ZoneInfo, Any], now: datetime
|
|
266
|
+
) -> Optional[UnifiedDeparture]:
|
|
267
|
+
transportation = stop.get("transportation", {})
|
|
268
|
+
product = transportation.get("product", {})
|
|
269
|
+
route_type = product.get("class", 3)
|
|
270
|
+
transport_type = NTA_TRANSPORTATION_TYPES.get(route_type, "bus")
|
|
271
|
+
|
|
272
|
+
return parse_departure_generic(
|
|
273
|
+
stop,
|
|
274
|
+
tz,
|
|
275
|
+
now,
|
|
276
|
+
get_transport_type_fn=lambda t: transport_type,
|
|
277
|
+
get_platform_fn=lambda s: (
|
|
278
|
+
s.get("platform", {}).get("name", "")
|
|
279
|
+
if isinstance(s.get("platform"), dict)
|
|
280
|
+
else str(s.get("platform", ""))
|
|
281
|
+
),
|
|
282
|
+
get_realtime_fn=lambda s, est, plan: "MONITORED" in s.get("realtimeStatus", []),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
|
|
286
|
+
"""NTA stop search is not available without GTFS Static data."""
|
|
287
|
+
_LOGGER.warning("NTA stop search is not available without GTFS Static data. Please enter the stop_id directly.")
|
|
288
|
+
return []
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""NVBW (Nahverkehrsgesellschaft Baden-Württemberg) provider implementation."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from ..const import PROVIDER_NVBW
|
|
6
|
+
from .efa_base import EFABaseProvider
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NVBWProvider(EFABaseProvider):
|
|
10
|
+
"""NVBW (Baden-Württemberg) provider."""
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def provider_id(self) -> str:
|
|
14
|
+
return PROVIDER_NVBW
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def provider_name(self) -> str:
|
|
18
|
+
return "NVBW (Baden-Württemberg)"
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def dm_base_url(self) -> str:
|
|
22
|
+
return "https://www.efa-bw.de/nvbw/XML_DM_REQUEST"
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def sf_base_url(self) -> str:
|
|
26
|
+
return "https://www.efa-bw.de/nvbw/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
|
+
4: "tram", # Tram/Streetcar
|
|
36
|
+
5: "bus", # City bus
|
|
37
|
+
6: "bus", # Regional bus
|
|
38
|
+
7: "bus", # Express bus
|
|
39
|
+
8: "bus", # Night bus
|
|
40
|
+
13: "train", # Regionalzug (RE)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
def get_realtime_fn(self) -> Callable[[Dict[str, Any], Optional[str], Optional[str]], bool]:
|
|
44
|
+
return lambda s, est, plan: est != plan if est and plan else False
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""NWL (Nahverkehr Westfalen-Lippe) provider implementation."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from ..const import PROVIDER_NWL
|
|
6
|
+
from .efa_base import EFABaseProvider
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NWLProvider(EFABaseProvider):
|
|
10
|
+
"""NWL (Westfalen-Lippe) provider."""
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def provider_id(self) -> str:
|
|
14
|
+
return PROVIDER_NWL
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def provider_name(self) -> str:
|
|
18
|
+
return "NWL (Westfalen-Lippe)"
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def dm_base_url(self) -> str:
|
|
22
|
+
return "https://westfalenfahrplan.de/nwl-efa/XML_DM_REQUEST"
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def sf_base_url(self) -> str:
|
|
26
|
+
return "https://westfalenfahrplan.de/nwl-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
|
+
2: "subway", # U-Bahn
|
|
36
|
+
3: "subway", # U-Bahn variant
|
|
37
|
+
4: "tram", # Tram/Streetcar
|
|
38
|
+
5: "bus", # City bus
|
|
39
|
+
6: "bus", # Regional bus
|
|
40
|
+
7: "bus", # Express bus
|
|
41
|
+
8: "bus", # Night bus
|
|
42
|
+
13: "train", # Regionalzug (RE)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
def get_realtime_fn(self) -> Callable[[Dict[str, Any], Optional[str], Optional[str]], bool]:
|
|
46
|
+
return lambda s, est, plan: est != plan if est and plan else False
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""ÖBB (Österreichische Bundesbahnen) 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_OEBB
|
|
12
|
+
from ..models import UnifiedDeparture
|
|
13
|
+
from .base import BaseProvider
|
|
14
|
+
|
|
15
|
+
_LOGGER = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
API_BASE = "https://oebb.macistry.com/api"
|
|
18
|
+
|
|
19
|
+
PRODUCT_MAPPING = {
|
|
20
|
+
"nationalExpress": "train",
|
|
21
|
+
"national": "train",
|
|
22
|
+
"interregional": "train",
|
|
23
|
+
"regional": "train",
|
|
24
|
+
"suburban": "train",
|
|
25
|
+
"subway": "subway",
|
|
26
|
+
"tram": "tram",
|
|
27
|
+
"bus": "bus",
|
|
28
|
+
"ferry": "ferry",
|
|
29
|
+
"onCall": "bus",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parse_dt(s: str) -> Optional[datetime]:
|
|
34
|
+
try:
|
|
35
|
+
return datetime.fromisoformat(s)
|
|
36
|
+
except (ValueError, TypeError):
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OeBBProvider(BaseProvider):
|
|
41
|
+
"""ÖBB (Austria) provider using FPTF REST API."""
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def provider_id(self) -> str:
|
|
45
|
+
return PROVIDER_OEBB
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def provider_name(self) -> str:
|
|
49
|
+
return "ÖBB (Österreich)"
|
|
50
|
+
|
|
51
|
+
def get_timezone(self) -> str:
|
|
52
|
+
return "Europe/Vienna"
|
|
53
|
+
|
|
54
|
+
async def fetch_departures(
|
|
55
|
+
self,
|
|
56
|
+
station_id: Optional[str],
|
|
57
|
+
place_dm: str,
|
|
58
|
+
name_dm: str,
|
|
59
|
+
departures_limit: int,
|
|
60
|
+
) -> Optional[Dict[str, Any]]:
|
|
61
|
+
if not station_id:
|
|
62
|
+
_LOGGER.warning("ÖBB provider requires a station_id")
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
url = f"{API_BASE}/stops/{station_id}/departures?results={departures_limit}&duration=120"
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as response:
|
|
69
|
+
if response.status == 200:
|
|
70
|
+
data = await response.json()
|
|
71
|
+
if not isinstance(data, dict) or "departures" not in data:
|
|
72
|
+
return {"stopEvents": []}
|
|
73
|
+
return {"stopEvents": data["departures"]}
|
|
74
|
+
else:
|
|
75
|
+
_LOGGER.warning("ÖBB API returned status %s", response.status)
|
|
76
|
+
except aiohttp.ClientError as e:
|
|
77
|
+
_LOGGER.warning("ÖBB API request failed: %s", e)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
_LOGGER.warning("ÖBB API error: %s", e)
|
|
80
|
+
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def parse_departure(
|
|
84
|
+
self, stop: Dict[str, Any], tz: Union[ZoneInfo, Any], now: datetime
|
|
85
|
+
) -> Optional[UnifiedDeparture]:
|
|
86
|
+
try:
|
|
87
|
+
when_str = stop.get("when") or stop.get("plannedWhen")
|
|
88
|
+
planned_str = stop.get("plannedWhen")
|
|
89
|
+
if not when_str or not planned_str:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
when = _parse_dt(when_str)
|
|
93
|
+
planned = _parse_dt(planned_str)
|
|
94
|
+
if not when or not planned:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
when_local = when.astimezone(tz)
|
|
98
|
+
planned_local = planned.astimezone(tz)
|
|
99
|
+
|
|
100
|
+
delay_seconds = stop.get("delay") or 0
|
|
101
|
+
delay_minutes = int(delay_seconds / 60)
|
|
102
|
+
|
|
103
|
+
line_info = stop.get("line", {})
|
|
104
|
+
line_name = line_info.get("name", "")
|
|
105
|
+
product = line_info.get("product", "")
|
|
106
|
+
transport_type = PRODUCT_MAPPING.get(product, "unknown")
|
|
107
|
+
|
|
108
|
+
destination_info = stop.get("destination", {})
|
|
109
|
+
destination = destination_info.get("name", stop.get("direction", "Unknown"))
|
|
110
|
+
|
|
111
|
+
platform = stop.get("platform") or ""
|
|
112
|
+
planned_platform = stop.get("plannedPlatform") or ""
|
|
113
|
+
platform_changed = bool(platform and planned_platform and platform != planned_platform)
|
|
114
|
+
|
|
115
|
+
time_diff = when_local - now
|
|
116
|
+
minutes_until = max(0, int(time_diff.total_seconds() / 60))
|
|
117
|
+
|
|
118
|
+
is_realtime = stop.get("prognosisType") is not None
|
|
119
|
+
|
|
120
|
+
notices = []
|
|
121
|
+
for remark in stop.get("remarks", []):
|
|
122
|
+
if isinstance(remark, dict) and remark.get("type") == "warning":
|
|
123
|
+
text = remark.get("text") or remark.get("summary", "")
|
|
124
|
+
if text:
|
|
125
|
+
notices.append(text)
|
|
126
|
+
|
|
127
|
+
operator = line_info.get("operator", {})
|
|
128
|
+
agency = operator.get("name") if isinstance(operator, dict) else None
|
|
129
|
+
|
|
130
|
+
return UnifiedDeparture(
|
|
131
|
+
line=line_name,
|
|
132
|
+
destination=destination,
|
|
133
|
+
departure_time=when_local.strftime("%H:%M"),
|
|
134
|
+
planned_time=planned_local.strftime("%H:%M"),
|
|
135
|
+
delay=delay_minutes,
|
|
136
|
+
platform=platform,
|
|
137
|
+
transportation_type=transport_type,
|
|
138
|
+
is_realtime=is_realtime,
|
|
139
|
+
minutes_until_departure=minutes_until,
|
|
140
|
+
departure_time_obj=when_local,
|
|
141
|
+
description=stop.get("direction"),
|
|
142
|
+
agency=agency,
|
|
143
|
+
notices=notices if notices else None,
|
|
144
|
+
planned_platform=planned_platform if platform_changed else None,
|
|
145
|
+
platform_changed=platform_changed,
|
|
146
|
+
)
|
|
147
|
+
except Exception as e:
|
|
148
|
+
_LOGGER.debug("Error parsing ÖBB departure: %s", e)
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
|
|
152
|
+
url = f"{API_BASE}/locations?query={quote(search_term, safe='')}&results=15"
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as response:
|
|
156
|
+
if response.status == 200:
|
|
157
|
+
data = await response.json()
|
|
158
|
+
if not isinstance(data, list):
|
|
159
|
+
return []
|
|
160
|
+
|
|
161
|
+
results = []
|
|
162
|
+
for location in data:
|
|
163
|
+
if not isinstance(location, dict):
|
|
164
|
+
continue
|
|
165
|
+
if location.get("type") != "stop":
|
|
166
|
+
continue
|
|
167
|
+
name = location.get("name", "")
|
|
168
|
+
results.append(
|
|
169
|
+
{
|
|
170
|
+
"id": str(location.get("id", "")),
|
|
171
|
+
"name": name,
|
|
172
|
+
"place": "",
|
|
173
|
+
"area_type": "stop",
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
return results
|
|
177
|
+
else:
|
|
178
|
+
_LOGGER.error("ÖBB API returned status %s", response.status)
|
|
179
|
+
except Exception as e:
|
|
180
|
+
_LOGGER.error("Error searching ÖBB stops: %s", e)
|
|
181
|
+
|
|
182
|
+
return []
|