python-openpublictransport 0.1.2__tar.gz → 0.1.4__tar.gz
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.
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/PKG-INFO +1 -1
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/pyproject.toml +1 -1
- python_openpublictransport-0.1.4/src/openpublictransport/__init__.py +12 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/const.py +2 -0
- python_openpublictransport-0.1.4/src/openpublictransport/exceptions.py +9 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/__init__.py +6 -0
- python_openpublictransport-0.1.4/src/openpublictransport/providers/national_rail.py +315 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/nta.py +7 -4
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/otp_base.py +6 -0
- python_openpublictransport-0.1.4/src/openpublictransport/providers/rejseplanen.py +241 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/rmv.py +12 -2
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/trafiklab.py +10 -6
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/trias_base.py +6 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/python_openpublictransport.egg-info/PKG-INFO +1 -1
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/python_openpublictransport.egg-info/SOURCES.txt +3 -0
- python_openpublictransport-0.1.2/src/openpublictransport/__init__.py +0 -5
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/setup.cfg +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/models.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/parsers.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/avv.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/base.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/beg.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/bsvg.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/bvg.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/db.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/ding.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/efa_base.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/fptf_base.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/gtfsde.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/hvv.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/kvv.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/mvv.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/nvbw.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/nwl.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/oebb.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/otp.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/otp_custom.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/rvv.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/sbb.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/transitous.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/vagfr.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/vbn.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/vgn.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/vrn.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/vrr.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/vvo.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/openpublictransport/providers/vvs.py +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/python_openpublictransport.egg-info/dependency_links.txt +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/python_openpublictransport.egg-info/requires.txt +0 -0
- {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.4}/src/python_openpublictransport.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-openpublictransport"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.4"
|
|
8
8
|
description = "Python library for public transport APIs (EFA, OTP2, TRIAS, FPTF, HAFAS, GTFS-RT)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""python-openpublictransport — public transport API library."""
|
|
2
|
+
|
|
3
|
+
from .exceptions import AuthenticationError
|
|
4
|
+
from .providers import get_all_provider_ids, get_provider, get_provider_class, register_provider
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"get_provider",
|
|
8
|
+
"get_provider_class",
|
|
9
|
+
"get_all_provider_ids",
|
|
10
|
+
"register_provider",
|
|
11
|
+
"AuthenticationError",
|
|
12
|
+
]
|
|
@@ -29,6 +29,8 @@ PROVIDER_VBN_OTP = "vbn_otp"
|
|
|
29
29
|
PROVIDER_VBN_TRIAS = "vbn_trias"
|
|
30
30
|
PROVIDER_OPT = "openpublictransport"
|
|
31
31
|
PROVIDER_OTP_CUSTOM = "otp_custom"
|
|
32
|
+
PROVIDER_NATIONAL_RAIL = "national_rail"
|
|
33
|
+
PROVIDER_REJSEPLANEN = "rejseplanen"
|
|
32
34
|
|
|
33
35
|
# API base URLs
|
|
34
36
|
API_BASE_URL_VRR = "https://openservice-test.vrr.de/static03/XML_DM_REQUEST"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Exceptions for python-openpublictransport."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AuthenticationError(Exception):
|
|
5
|
+
"""Raised when API authentication fails (HTTP 401 or 403).
|
|
6
|
+
|
|
7
|
+
Signals that the configured API key is invalid or expired.
|
|
8
|
+
Callers should prompt the user to re-enter credentials rather than retrying.
|
|
9
|
+
"""
|
|
@@ -32,6 +32,8 @@ from ..const import (
|
|
|
32
32
|
PROVIDER_VRR,
|
|
33
33
|
PROVIDER_VVO,
|
|
34
34
|
PROVIDER_VVS,
|
|
35
|
+
PROVIDER_NATIONAL_RAIL,
|
|
36
|
+
PROVIDER_REJSEPLANEN,
|
|
35
37
|
)
|
|
36
38
|
from .avv import AVVProvider
|
|
37
39
|
from .base import BaseProvider
|
|
@@ -61,6 +63,8 @@ from .vrn import VRNProvider
|
|
|
61
63
|
from .vrr import VRRProvider
|
|
62
64
|
from .vvo import VVOProvider
|
|
63
65
|
from .vvs import VVSProvider
|
|
66
|
+
from .national_rail import NationalRailProvider
|
|
67
|
+
from .rejseplanen import RejseplanenProvider
|
|
64
68
|
|
|
65
69
|
_PROVIDER_REGISTRY: Dict[str, Type[BaseProvider]] = {}
|
|
66
70
|
|
|
@@ -134,3 +138,5 @@ register_provider(PROVIDER_VBN_OTP, VBNOTPProvider)
|
|
|
134
138
|
register_provider(PROVIDER_VBN_TRIAS, VBNTriasProvider)
|
|
135
139
|
register_provider(PROVIDER_OPT, OPTProvider)
|
|
136
140
|
register_provider(PROVIDER_OTP_CUSTOM, OTPCustomProvider)
|
|
141
|
+
register_provider(PROVIDER_NATIONAL_RAIL, NationalRailProvider)
|
|
142
|
+
register_provider(PROVIDER_REJSEPLANEN, RejseplanenProvider)
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""National Rail (UK) provider using OpenLDBWS SOAP API.
|
|
2
|
+
|
|
3
|
+
Stop search uses the Overpass API (OSM) to find UK railway stations with
|
|
4
|
+
ref:crs tags, returning the 3-letter CRS code used by OpenLDBWS.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
from xml.etree import ElementTree as ET
|
|
12
|
+
from zoneinfo import ZoneInfo
|
|
13
|
+
|
|
14
|
+
import aiohttp
|
|
15
|
+
|
|
16
|
+
from ..const import PROVIDER_NATIONAL_RAIL
|
|
17
|
+
from ..exceptions import AuthenticationError
|
|
18
|
+
from ..models import UnifiedDeparture
|
|
19
|
+
from .base import BaseProvider
|
|
20
|
+
|
|
21
|
+
_LOGGER = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_ENDPOINT = "https://lite.realtime.nationalrail.co.uk/OpenLDBWS/ldb11.asmx"
|
|
24
|
+
_SOAP_ACTION = "http://thalesgroup.com/RTTI/2017-10-01/ldb/GetDepartureBoard"
|
|
25
|
+
_OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
|
26
|
+
|
|
27
|
+
# UK bounding box (lat_min, lon_min, lat_max, lon_max)
|
|
28
|
+
_UK_BBOX = "49,-11,62,2"
|
|
29
|
+
|
|
30
|
+
_SOAP_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
|
|
31
|
+
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
|
|
32
|
+
xmlns:ldb="http://thalesgroup.com/RTTI/2017-10-01/ldb/"
|
|
33
|
+
xmlns:tok="http://thalesgroup.com/RTTI/2013-11-28/Token/types">
|
|
34
|
+
<soap:Header>
|
|
35
|
+
<tok:AccessToken>
|
|
36
|
+
<tok:TokenValue>{api_key}</tok:TokenValue>
|
|
37
|
+
</tok:AccessToken>
|
|
38
|
+
</soap:Header>
|
|
39
|
+
<soap:Body>
|
|
40
|
+
<ldb:GetDepartureBoardRequest>
|
|
41
|
+
<ldb:numRows>{num_rows}</ldb:numRows>
|
|
42
|
+
<ldb:crs>{crs}</ldb:crs>
|
|
43
|
+
</ldb:GetDepartureBoardRequest>
|
|
44
|
+
</soap:Body>
|
|
45
|
+
</soap:Envelope>"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _strip_namespaces(xml_string: str) -> str:
|
|
49
|
+
"""Remove XML namespace prefixes and declarations for simpler parsing."""
|
|
50
|
+
xml_string = re.sub(r' xmlns[^=]*="[^"]*"', "", xml_string)
|
|
51
|
+
xml_string = re.sub(r"<([a-zA-Z]+):", "<", xml_string)
|
|
52
|
+
xml_string = re.sub(r"</([a-zA-Z]+):", "</", xml_string)
|
|
53
|
+
return xml_string
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _text(el: Optional[ET.Element], tag: str, default: str = "") -> str:
|
|
57
|
+
"""Get text of a direct child element."""
|
|
58
|
+
if el is None:
|
|
59
|
+
return default
|
|
60
|
+
child = el.find(tag)
|
|
61
|
+
return child.text if child is not None and child.text else default
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class NationalRailProvider(BaseProvider):
|
|
65
|
+
"""National Rail (UK) provider via OpenLDBWS."""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
session: aiohttp.ClientSession,
|
|
70
|
+
api_key: Optional[str] = None,
|
|
71
|
+
api_key_secondary: Optional[str] = None,
|
|
72
|
+
custom_url: Optional[str] = None,
|
|
73
|
+
):
|
|
74
|
+
super().__init__(session, api_key=api_key, api_key_secondary=api_key_secondary, custom_url=custom_url)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def provider_id(self) -> str:
|
|
78
|
+
return PROVIDER_NATIONAL_RAIL
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def provider_name(self) -> str:
|
|
82
|
+
return "National Rail (UK)"
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def requires_api_key(self) -> bool:
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
def get_timezone(self) -> str:
|
|
89
|
+
return "Europe/London"
|
|
90
|
+
|
|
91
|
+
def get_transport_type_mapping(self) -> Dict:
|
|
92
|
+
return {}
|
|
93
|
+
|
|
94
|
+
async def fetch_departures(
|
|
95
|
+
self,
|
|
96
|
+
station_id: Optional[str],
|
|
97
|
+
place_dm: Optional[str],
|
|
98
|
+
name_dm: Optional[str],
|
|
99
|
+
departures_limit: int,
|
|
100
|
+
) -> Optional[Dict]:
|
|
101
|
+
"""Fetch departures via OpenLDBWS SOAP for a given CRS code."""
|
|
102
|
+
if not self.api_key or not station_id:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
crs = station_id.strip().upper()
|
|
106
|
+
body = _SOAP_TEMPLATE.format(
|
|
107
|
+
api_key=self.api_key,
|
|
108
|
+
num_rows=min(departures_limit, 150),
|
|
109
|
+
crs=crs,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
async with self.session.post(
|
|
114
|
+
_ENDPOINT,
|
|
115
|
+
data=body.encode("utf-8"),
|
|
116
|
+
headers={
|
|
117
|
+
"Content-Type": "text/xml; charset=utf-8",
|
|
118
|
+
"SOAPAction": _SOAP_ACTION,
|
|
119
|
+
},
|
|
120
|
+
timeout=aiohttp.ClientTimeout(total=15),
|
|
121
|
+
) as resp:
|
|
122
|
+
if resp.status in (401, 403):
|
|
123
|
+
raise AuthenticationError(
|
|
124
|
+
f"{self.provider_name}: authentication failed (HTTP {resp.status}) — check API key"
|
|
125
|
+
)
|
|
126
|
+
if resp.status != 200:
|
|
127
|
+
_LOGGER.warning("%s: HTTP %s for CRS %s", self.provider_name, resp.status, crs)
|
|
128
|
+
return None
|
|
129
|
+
text = await resp.text()
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
_LOGGER.warning("%s: request failed: %s", self.provider_name, exc)
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
root = ET.fromstring(_strip_namespaces(text))
|
|
136
|
+
except ET.ParseError as exc:
|
|
137
|
+
_LOGGER.warning("%s: XML parse error: %s", self.provider_name, exc)
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
services = root.findall(".//service")
|
|
141
|
+
if not services:
|
|
142
|
+
return {"stopEvents": []}
|
|
143
|
+
|
|
144
|
+
tz = ZoneInfo(self.get_timezone())
|
|
145
|
+
now = datetime.now(tz)
|
|
146
|
+
board_date = now.date()
|
|
147
|
+
|
|
148
|
+
stop_events = []
|
|
149
|
+
for svc in services:
|
|
150
|
+
event = self._service_to_dict(svc, board_date, tz, now)
|
|
151
|
+
if event:
|
|
152
|
+
stop_events.append(event)
|
|
153
|
+
|
|
154
|
+
return {"stopEvents": stop_events}
|
|
155
|
+
|
|
156
|
+
def _service_to_dict(
|
|
157
|
+
self,
|
|
158
|
+
svc: ET.Element,
|
|
159
|
+
board_date: Any,
|
|
160
|
+
tz: ZoneInfo,
|
|
161
|
+
now: datetime,
|
|
162
|
+
) -> Optional[Dict[str, Any]]:
|
|
163
|
+
"""Convert an XML service element to a plain dict."""
|
|
164
|
+
std = _text(svc, "std")
|
|
165
|
+
if not std:
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
etd = _text(svc, "etd")
|
|
169
|
+
platform = _text(svc, "platform") or None
|
|
170
|
+
operator_name = _text(svc, "operator")
|
|
171
|
+
operator_code = _text(svc, "operatorCode")
|
|
172
|
+
is_cancelled = _text(svc, "isCancelled") == "true"
|
|
173
|
+
delay_reason = _text(svc, "delayReason")
|
|
174
|
+
cancel_reason = _text(svc, "cancelReason")
|
|
175
|
+
|
|
176
|
+
destination = ""
|
|
177
|
+
dest_el = svc.find(".//destination")
|
|
178
|
+
if dest_el is not None:
|
|
179
|
+
loc = dest_el.find("location")
|
|
180
|
+
if loc is not None:
|
|
181
|
+
destination = _text(loc, "locationName")
|
|
182
|
+
if not destination:
|
|
183
|
+
destination = "Unknown"
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
"std": std,
|
|
187
|
+
"etd": etd,
|
|
188
|
+
"platform": platform,
|
|
189
|
+
"operator": operator_name,
|
|
190
|
+
"operatorCode": operator_code,
|
|
191
|
+
"isCancelled": is_cancelled,
|
|
192
|
+
"delayReason": delay_reason,
|
|
193
|
+
"cancelReason": cancel_reason,
|
|
194
|
+
"destination": destination,
|
|
195
|
+
"boardDate": board_date.isoformat(),
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
def parse_departure(
|
|
199
|
+
self,
|
|
200
|
+
stop: Dict[str, Any],
|
|
201
|
+
tz: Any,
|
|
202
|
+
now: datetime,
|
|
203
|
+
) -> Optional[UnifiedDeparture]:
|
|
204
|
+
"""Map a service dict to UnifiedDeparture."""
|
|
205
|
+
std = stop.get("std", "")
|
|
206
|
+
etd = stop.get("etd", "")
|
|
207
|
+
if not std:
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
board_date_str = stop.get("boardDate", "")
|
|
212
|
+
from datetime import date as date_cls
|
|
213
|
+
|
|
214
|
+
board_date = date_cls.fromisoformat(board_date_str) if board_date_str else datetime.now(tz).date()
|
|
215
|
+
except ValueError:
|
|
216
|
+
board_date = datetime.now(tz).date()
|
|
217
|
+
|
|
218
|
+
def _parse_hhmm(hhmm: str) -> Optional[datetime]:
|
|
219
|
+
try:
|
|
220
|
+
h, m = map(int, hhmm.strip().split(":"))
|
|
221
|
+
dt = datetime(board_date.year, board_date.month, board_date.day, h, m, tzinfo=tz)
|
|
222
|
+
if (now - dt).total_seconds() > 3600:
|
|
223
|
+
dt += timedelta(days=1)
|
|
224
|
+
return dt
|
|
225
|
+
except (ValueError, TypeError):
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
planned_dt = _parse_hhmm(std)
|
|
229
|
+
if planned_dt is None:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
is_cancelled = stop.get("isCancelled", False)
|
|
233
|
+
notices: List[str] = []
|
|
234
|
+
if stop.get("delayReason"):
|
|
235
|
+
notices.append(stop["delayReason"])
|
|
236
|
+
if stop.get("cancelReason"):
|
|
237
|
+
notices.append(stop["cancelReason"])
|
|
238
|
+
|
|
239
|
+
delay = 0
|
|
240
|
+
is_realtime = False
|
|
241
|
+
actual_dt = planned_dt
|
|
242
|
+
|
|
243
|
+
if is_cancelled:
|
|
244
|
+
notices.insert(0, "Cancelled")
|
|
245
|
+
is_realtime = True
|
|
246
|
+
elif etd == "On time":
|
|
247
|
+
is_realtime = True
|
|
248
|
+
elif etd == "Delayed":
|
|
249
|
+
is_realtime = True
|
|
250
|
+
notices.insert(0, "Delayed")
|
|
251
|
+
elif etd and ":" in etd:
|
|
252
|
+
actual_dt_parsed = _parse_hhmm(etd)
|
|
253
|
+
if actual_dt_parsed:
|
|
254
|
+
actual_dt = actual_dt_parsed
|
|
255
|
+
delay = max(0, int((actual_dt - planned_dt).total_seconds() / 60))
|
|
256
|
+
is_realtime = True
|
|
257
|
+
|
|
258
|
+
minutes_until = max(0, int((actual_dt - now).total_seconds() / 60))
|
|
259
|
+
line = stop.get("operatorCode", "") or stop.get("operator", "")
|
|
260
|
+
|
|
261
|
+
return UnifiedDeparture(
|
|
262
|
+
line=line,
|
|
263
|
+
destination=stop.get("destination", ""),
|
|
264
|
+
departure_time=actual_dt.strftime("%H:%M"),
|
|
265
|
+
planned_time=std,
|
|
266
|
+
delay=delay,
|
|
267
|
+
platform=stop.get("platform"),
|
|
268
|
+
transportation_type="train",
|
|
269
|
+
is_realtime=is_realtime,
|
|
270
|
+
minutes_until_departure=minutes_until,
|
|
271
|
+
departure_time_obj=actual_dt,
|
|
272
|
+
agency=stop.get("operator") or None,
|
|
273
|
+
notices=notices if notices else None,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
|
|
277
|
+
"""Find UK railway stations with CRS codes via Overpass API (OSM)."""
|
|
278
|
+
escaped = re.sub(r"[.*+?^${}()|[\]\\]", r"\\\g<0>", search_term)
|
|
279
|
+
|
|
280
|
+
query = f"""[out:json][timeout:15];
|
|
281
|
+
(
|
|
282
|
+
node["railway"="station"]["ref:crs"]["name"~"{escaped}",i]({_UK_BBOX});
|
|
283
|
+
node["railway"="station"]["ref:crs"]["official_name"~"{escaped}",i]({_UK_BBOX});
|
|
284
|
+
node["railway"="station"]["ref:crs"]["alt_name"~"{escaped}",i]({_UK_BBOX});
|
|
285
|
+
);
|
|
286
|
+
out 10;"""
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
async with self.session.post(
|
|
290
|
+
_OVERPASS_URL,
|
|
291
|
+
data={"data": query},
|
|
292
|
+
headers={
|
|
293
|
+
"User-Agent": "openpublictransport-homeassistant/1.0 (github.com/NerdySoftPaw/openpublictransport)"
|
|
294
|
+
},
|
|
295
|
+
timeout=aiohttp.ClientTimeout(total=15),
|
|
296
|
+
) as resp:
|
|
297
|
+
if resp.status != 200:
|
|
298
|
+
_LOGGER.warning("%s: Overpass HTTP %s", self.provider_name, resp.status)
|
|
299
|
+
return []
|
|
300
|
+
data = await resp.json(content_type=None)
|
|
301
|
+
except Exception as exc:
|
|
302
|
+
_LOGGER.warning("%s: stop search failed: %s", self.provider_name, exc)
|
|
303
|
+
return []
|
|
304
|
+
|
|
305
|
+
results = []
|
|
306
|
+
for element in data.get("elements", []):
|
|
307
|
+
tags = element.get("tags", {})
|
|
308
|
+
crs = tags.get("ref:crs", "").strip().upper()
|
|
309
|
+
name = tags.get("name") or tags.get("official_name", "")
|
|
310
|
+
if not crs or len(crs) != 3 or not name:
|
|
311
|
+
continue
|
|
312
|
+
operator = tags.get("operator") or tags.get("network", "")
|
|
313
|
+
results.append({"id": crs, "name": name, "place": operator, "area_type": "stop"})
|
|
314
|
+
|
|
315
|
+
return results[:10]
|
|
@@ -6,6 +6,8 @@ from datetime import datetime, timedelta, timezone
|
|
|
6
6
|
from typing import Any, Dict, List, Optional, Union
|
|
7
7
|
from zoneinfo import ZoneInfo
|
|
8
8
|
|
|
9
|
+
from ..exceptions import AuthenticationError
|
|
10
|
+
|
|
9
11
|
import aiohttp
|
|
10
12
|
from aiohttp import ClientConnectorError
|
|
11
13
|
|
|
@@ -217,13 +219,14 @@ class NTAProvider(BaseProvider):
|
|
|
217
219
|
elif response.status == 404:
|
|
218
220
|
_LOGGER.warning("NTA API endpoint not found (404)")
|
|
219
221
|
return None
|
|
220
|
-
elif response.status
|
|
222
|
+
elif response.status in (401, 403):
|
|
221
223
|
if self.api_key_secondary and current_api_key == self.api_key:
|
|
222
|
-
_LOGGER.info("NTA
|
|
224
|
+
_LOGGER.info("NTA primary key failed (HTTP %s), trying secondary...", response.status)
|
|
223
225
|
current_api_key = self.api_key_secondary
|
|
224
226
|
continue
|
|
225
|
-
|
|
226
|
-
|
|
227
|
+
raise AuthenticationError(
|
|
228
|
+
f"NTA: authentication failed (HTTP {response.status}) — check API key(s)"
|
|
229
|
+
)
|
|
227
230
|
elif response.status >= 500:
|
|
228
231
|
_LOGGER.warning(
|
|
229
232
|
"NTA API server error (status %s) on attempt %d/%d",
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
5
|
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
from ..exceptions import AuthenticationError
|
|
6
8
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
7
9
|
from urllib.parse import quote
|
|
8
10
|
from zoneinfo import ZoneInfo
|
|
@@ -86,6 +88,10 @@ class OTPBaseProvider(BaseProvider):
|
|
|
86
88
|
return await resp.json()
|
|
87
89
|
if resp.status == 204:
|
|
88
90
|
return None
|
|
91
|
+
if resp.status in (401, 403):
|
|
92
|
+
raise AuthenticationError(
|
|
93
|
+
f"{self.provider_name}: authentication failed (HTTP {resp.status}) — check API key"
|
|
94
|
+
)
|
|
89
95
|
_LOGGER.warning("%s OTP %s → HTTP %s", self.provider_name, url, resp.status)
|
|
90
96
|
except aiohttp.ClientError as exc:
|
|
91
97
|
_LOGGER.warning("%s OTP request failed: %s", self.provider_name, exc)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Rejseplanen (Denmark) provider using the HAFAS REST API.
|
|
2
|
+
|
|
3
|
+
Requires a free API key from labs.rejseplanen.dk (50k calls/month, non-commercial).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any, Dict, List, Optional, Union
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
from zoneinfo import ZoneInfo
|
|
11
|
+
|
|
12
|
+
import aiohttp
|
|
13
|
+
|
|
14
|
+
from ..const import PROVIDER_REJSEPLANEN
|
|
15
|
+
from ..exceptions import AuthenticationError
|
|
16
|
+
from ..models import UnifiedDeparture
|
|
17
|
+
from .base import BaseProvider
|
|
18
|
+
|
|
19
|
+
_LOGGER = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
_API_BASE = "https://www.rejseplanen.dk/api"
|
|
22
|
+
|
|
23
|
+
_PRODUCT_MAPPING: Dict[str, str] = {
|
|
24
|
+
"IC": "train",
|
|
25
|
+
"LYN": "train",
|
|
26
|
+
"RE": "train",
|
|
27
|
+
"REG": "train",
|
|
28
|
+
"S": "train",
|
|
29
|
+
"TOG": "train",
|
|
30
|
+
"M": "subway",
|
|
31
|
+
"BUS": "bus",
|
|
32
|
+
"EXB": "bus",
|
|
33
|
+
"NB": "bus",
|
|
34
|
+
"TB": "bus",
|
|
35
|
+
"F": "ferry",
|
|
36
|
+
"T": "tram",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_hafas_time(date_str: str, time_str: str, tz: Any) -> Optional[datetime]:
|
|
41
|
+
"""Parse Rejseplanen date (DD.MM.YY) and time (HH:MM) into a timezone-aware datetime."""
|
|
42
|
+
if not date_str or not time_str:
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
time_fmt = "%H:%M:%S" if time_str.count(":") == 2 else "%H:%M"
|
|
46
|
+
dt = datetime.strptime(f"{date_str} {time_str}", f"%d.%m.%y {time_fmt}")
|
|
47
|
+
return dt.replace(tzinfo=tz)
|
|
48
|
+
except ValueError:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RejseplanenProvider(BaseProvider):
|
|
53
|
+
"""Rejseplanen (Denmark) provider via HAFAS REST API."""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
session: aiohttp.ClientSession,
|
|
58
|
+
api_key: Optional[str] = None,
|
|
59
|
+
api_key_secondary: Optional[str] = None,
|
|
60
|
+
custom_url: Optional[str] = None,
|
|
61
|
+
):
|
|
62
|
+
super().__init__(session, api_key=api_key, api_key_secondary=api_key_secondary, custom_url=custom_url)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def provider_id(self) -> str:
|
|
66
|
+
return PROVIDER_REJSEPLANEN
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def provider_name(self) -> str:
|
|
70
|
+
return "Rejseplanen (Denmark)"
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def requires_api_key(self) -> bool:
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
def get_timezone(self) -> str:
|
|
77
|
+
return "Europe/Copenhagen"
|
|
78
|
+
|
|
79
|
+
def get_transport_type_mapping(self) -> Dict:
|
|
80
|
+
return _PRODUCT_MAPPING
|
|
81
|
+
|
|
82
|
+
async def fetch_departures(
|
|
83
|
+
self,
|
|
84
|
+
station_id: Optional[str],
|
|
85
|
+
place_dm: str,
|
|
86
|
+
name_dm: str,
|
|
87
|
+
departures_limit: int,
|
|
88
|
+
) -> Optional[Dict[str, Any]]:
|
|
89
|
+
"""Fetch departures from the Rejseplanen departureBoard endpoint."""
|
|
90
|
+
if not station_id:
|
|
91
|
+
_LOGGER.warning("%s: station_id required", self.provider_name)
|
|
92
|
+
return None
|
|
93
|
+
if not self.api_key:
|
|
94
|
+
_LOGGER.error("%s: API key required", self.provider_name)
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
url = (
|
|
98
|
+
f"{_API_BASE}/departureBoard"
|
|
99
|
+
f"?accessId={self.api_key}"
|
|
100
|
+
f"&id={station_id}"
|
|
101
|
+
f"&format=json"
|
|
102
|
+
f"&duration=120"
|
|
103
|
+
f"&maxJourneys={departures_limit}"
|
|
104
|
+
)
|
|
105
|
+
try:
|
|
106
|
+
async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
|
107
|
+
if resp.status in (401, 403):
|
|
108
|
+
raise AuthenticationError(
|
|
109
|
+
f"{self.provider_name}: authentication failed (HTTP {resp.status}) — check API key"
|
|
110
|
+
)
|
|
111
|
+
if resp.status != 200:
|
|
112
|
+
_LOGGER.warning("%s: HTTP %s for station %s", self.provider_name, resp.status, station_id)
|
|
113
|
+
return None
|
|
114
|
+
data = await resp.json(content_type=None)
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
_LOGGER.warning("%s: request failed: %s", self.provider_name, exc)
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
if not isinstance(data, dict):
|
|
120
|
+
return None
|
|
121
|
+
if "errorCode" in data:
|
|
122
|
+
_LOGGER.warning("%s: API error %s: %s", self.provider_name, data.get("errorCode"), data.get("errorText"))
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
board = data.get("DepartureBoard", {})
|
|
126
|
+
departures = board.get("Departure", [])
|
|
127
|
+
if isinstance(departures, dict):
|
|
128
|
+
departures = [departures]
|
|
129
|
+
|
|
130
|
+
return {"stopEvents": departures}
|
|
131
|
+
|
|
132
|
+
def parse_departure(
|
|
133
|
+
self,
|
|
134
|
+
stop: Dict[str, Any],
|
|
135
|
+
tz: Union[ZoneInfo, Any],
|
|
136
|
+
now: datetime,
|
|
137
|
+
) -> Optional[UnifiedDeparture]:
|
|
138
|
+
"""Parse a single Rejseplanen departure dict into UnifiedDeparture."""
|
|
139
|
+
try:
|
|
140
|
+
date_str = stop.get("date", "")
|
|
141
|
+
time_str = stop.get("time", "")
|
|
142
|
+
rt_date_str = stop.get("rtDate", "")
|
|
143
|
+
rt_time_str = stop.get("rtTime", "")
|
|
144
|
+
|
|
145
|
+
planned_dt = _parse_hafas_time(date_str, time_str, tz)
|
|
146
|
+
if planned_dt is None:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
if rt_date_str and rt_time_str:
|
|
150
|
+
actual_dt = _parse_hafas_time(rt_date_str, rt_time_str, tz) or planned_dt
|
|
151
|
+
is_realtime = True
|
|
152
|
+
else:
|
|
153
|
+
actual_dt = planned_dt
|
|
154
|
+
is_realtime = False
|
|
155
|
+
|
|
156
|
+
delay = max(0, int((actual_dt - planned_dt).total_seconds() / 60))
|
|
157
|
+
minutes_until = max(0, int((actual_dt - now).total_seconds() / 60))
|
|
158
|
+
|
|
159
|
+
type_str = stop.get("type", "").upper()
|
|
160
|
+
transport_type = _PRODUCT_MAPPING.get(type_str, "train")
|
|
161
|
+
|
|
162
|
+
line = stop.get("name", "")
|
|
163
|
+
destination = stop.get("direction", stop.get("finalStop", ""))
|
|
164
|
+
|
|
165
|
+
track = stop.get("track", "") or ""
|
|
166
|
+
rt_track = stop.get("rtTrack", "") or ""
|
|
167
|
+
platform = rt_track if rt_track else track
|
|
168
|
+
platform_changed = bool(rt_track and track and rt_track != track)
|
|
169
|
+
|
|
170
|
+
notices: List[str] = []
|
|
171
|
+
if stop.get("cancelled") == "true" or stop.get("cancelled") is True:
|
|
172
|
+
notices.append("Cancelled / Aflyst")
|
|
173
|
+
|
|
174
|
+
for msg in stop.get("Messages", {}).get("Message", []):
|
|
175
|
+
if isinstance(msg, dict):
|
|
176
|
+
text = msg.get("head", "") or msg.get("text", "")
|
|
177
|
+
if text:
|
|
178
|
+
notices.append(text)
|
|
179
|
+
|
|
180
|
+
return UnifiedDeparture(
|
|
181
|
+
line=line,
|
|
182
|
+
destination=destination,
|
|
183
|
+
departure_time=actual_dt.strftime("%H:%M"),
|
|
184
|
+
planned_time=planned_dt.strftime("%H:%M"),
|
|
185
|
+
delay=delay,
|
|
186
|
+
platform=platform or None,
|
|
187
|
+
transportation_type=transport_type,
|
|
188
|
+
is_realtime=is_realtime,
|
|
189
|
+
minutes_until_departure=minutes_until,
|
|
190
|
+
departure_time_obj=actual_dt,
|
|
191
|
+
notices=notices if notices else None,
|
|
192
|
+
planned_platform=track if platform_changed else None,
|
|
193
|
+
platform_changed=platform_changed,
|
|
194
|
+
)
|
|
195
|
+
except Exception as exc:
|
|
196
|
+
_LOGGER.debug("%s: parse error: %s", self.provider_name, exc)
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
|
|
200
|
+
"""Search for Danish stops using the Rejseplanen location.name endpoint."""
|
|
201
|
+
if not self.api_key:
|
|
202
|
+
_LOGGER.error("%s: API key required for stop search", self.provider_name)
|
|
203
|
+
return []
|
|
204
|
+
|
|
205
|
+
url = (
|
|
206
|
+
f"{_API_BASE}/location.name"
|
|
207
|
+
f"?accessId={self.api_key}"
|
|
208
|
+
f"&input={quote(search_term, safe='')}"
|
|
209
|
+
f"&format=json"
|
|
210
|
+
f"&maxNo=15"
|
|
211
|
+
f"&type=S"
|
|
212
|
+
)
|
|
213
|
+
try:
|
|
214
|
+
async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
|
215
|
+
if resp.status != 200:
|
|
216
|
+
_LOGGER.warning("%s: stop search HTTP %s", self.provider_name, resp.status)
|
|
217
|
+
return []
|
|
218
|
+
data = await resp.json(content_type=None)
|
|
219
|
+
except Exception as exc:
|
|
220
|
+
_LOGGER.warning("%s: stop search failed: %s", self.provider_name, exc)
|
|
221
|
+
return []
|
|
222
|
+
|
|
223
|
+
if not isinstance(data, dict):
|
|
224
|
+
return []
|
|
225
|
+
|
|
226
|
+
location_list = data.get("LocationList", {})
|
|
227
|
+
stops = location_list.get("StopLocation", [])
|
|
228
|
+
if isinstance(stops, dict):
|
|
229
|
+
stops = [stops]
|
|
230
|
+
|
|
231
|
+
results = []
|
|
232
|
+
for loc in stops:
|
|
233
|
+
if not isinstance(loc, dict):
|
|
234
|
+
continue
|
|
235
|
+
station_id = loc.get("extId") or loc.get("id", "")
|
|
236
|
+
name = loc.get("name", "")
|
|
237
|
+
if not station_id or not name:
|
|
238
|
+
continue
|
|
239
|
+
results.append({"id": str(station_id), "name": name, "place": "", "area_type": "stop"})
|
|
240
|
+
|
|
241
|
+
return results[:10]
|
|
@@ -6,6 +6,8 @@ from typing import Any, Dict, List, Optional, Union
|
|
|
6
6
|
from urllib.parse import quote
|
|
7
7
|
from zoneinfo import ZoneInfo
|
|
8
8
|
|
|
9
|
+
from ..exceptions import AuthenticationError
|
|
10
|
+
|
|
9
11
|
import aiohttp
|
|
10
12
|
|
|
11
13
|
from ..const import PROVIDER_RMV
|
|
@@ -126,8 +128,10 @@ class RMVProvider(BaseProvider):
|
|
|
126
128
|
if isinstance(departures, dict):
|
|
127
129
|
departures = [departures]
|
|
128
130
|
return {"stopEvents": departures}
|
|
129
|
-
elif response.status
|
|
130
|
-
|
|
131
|
+
elif response.status in (401, 403):
|
|
132
|
+
raise AuthenticationError(
|
|
133
|
+
f"RMV: authentication failed (HTTP {response.status}) — check API key"
|
|
134
|
+
)
|
|
131
135
|
else:
|
|
132
136
|
_LOGGER.warning("RMV API returned status %s", response.status)
|
|
133
137
|
except aiohttp.ClientError as e:
|
|
@@ -264,8 +268,14 @@ class RMVProvider(BaseProvider):
|
|
|
264
268
|
}
|
|
265
269
|
)
|
|
266
270
|
return results
|
|
271
|
+
elif response.status in (401, 403):
|
|
272
|
+
raise AuthenticationError(
|
|
273
|
+
f"RMV: authentication failed (HTTP {response.status}) — check API key"
|
|
274
|
+
)
|
|
267
275
|
else:
|
|
268
276
|
_LOGGER.error("RMV API returned status %s", response.status)
|
|
277
|
+
except AuthenticationError:
|
|
278
|
+
raise
|
|
269
279
|
except Exception as e:
|
|
270
280
|
_LOGGER.error("Error searching RMV stops: %s", e)
|
|
271
281
|
|
|
@@ -7,6 +7,8 @@ from typing import Any, Dict, List, Optional, Union
|
|
|
7
7
|
from urllib.parse import quote
|
|
8
8
|
from zoneinfo import ZoneInfo
|
|
9
9
|
|
|
10
|
+
from ..exceptions import AuthenticationError
|
|
11
|
+
|
|
10
12
|
import aiohttp
|
|
11
13
|
from aiohttp import ClientConnectorError
|
|
12
14
|
|
|
@@ -131,9 +133,10 @@ class TrafiklabProvider(BaseProvider):
|
|
|
131
133
|
elif response.status == 404:
|
|
132
134
|
_LOGGER.warning("Trafiklab API endpoint not found (404)")
|
|
133
135
|
return None
|
|
134
|
-
elif response.status
|
|
135
|
-
|
|
136
|
-
|
|
136
|
+
elif response.status in (401, 403):
|
|
137
|
+
raise AuthenticationError(
|
|
138
|
+
f"Trafiklab: authentication failed (HTTP {response.status}) — check API key"
|
|
139
|
+
)
|
|
137
140
|
elif response.status >= 500:
|
|
138
141
|
_LOGGER.warning(
|
|
139
142
|
"Trafiklab API server error (status %s) on attempt %d/%d",
|
|
@@ -243,9 +246,10 @@ class TrafiklabProvider(BaseProvider):
|
|
|
243
246
|
results.append(result)
|
|
244
247
|
|
|
245
248
|
return results
|
|
246
|
-
elif response.status
|
|
247
|
-
|
|
248
|
-
|
|
249
|
+
elif response.status in (401, 403):
|
|
250
|
+
raise AuthenticationError(
|
|
251
|
+
f"Trafiklab: authentication failed (HTTP {response.status}) — check API key"
|
|
252
|
+
)
|
|
249
253
|
elif response.status == 404:
|
|
250
254
|
_LOGGER.warning("Trafiklab API endpoint not found (404)")
|
|
251
255
|
return []
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import logging
|
|
4
4
|
from datetime import datetime
|
|
5
5
|
from typing import Any, Dict, List, Optional, Union
|
|
6
|
+
|
|
7
|
+
from ..exceptions import AuthenticationError
|
|
6
8
|
from xml.etree import ElementTree as ET
|
|
7
9
|
from zoneinfo import ZoneInfo
|
|
8
10
|
|
|
@@ -140,6 +142,10 @@ class TRIASBaseProvider(BaseProvider):
|
|
|
140
142
|
if response.status == 200:
|
|
141
143
|
text = await response.text()
|
|
142
144
|
return ET.fromstring(text)
|
|
145
|
+
elif response.status in (401, 403):
|
|
146
|
+
raise AuthenticationError(
|
|
147
|
+
f"{self.provider_name}: authentication failed (HTTP {response.status}) — check API key"
|
|
148
|
+
)
|
|
143
149
|
else:
|
|
144
150
|
_LOGGER.warning("%s TRIAS API returned status %s", self.provider_name, response.status)
|
|
145
151
|
except aiohttp.ClientError as e:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
pyproject.toml
|
|
2
2
|
src/openpublictransport/__init__.py
|
|
3
3
|
src/openpublictransport/const.py
|
|
4
|
+
src/openpublictransport/exceptions.py
|
|
4
5
|
src/openpublictransport/models.py
|
|
5
6
|
src/openpublictransport/parsers.py
|
|
6
7
|
src/openpublictransport/providers/__init__.py
|
|
@@ -17,6 +18,7 @@ src/openpublictransport/providers/gtfsde.py
|
|
|
17
18
|
src/openpublictransport/providers/hvv.py
|
|
18
19
|
src/openpublictransport/providers/kvv.py
|
|
19
20
|
src/openpublictransport/providers/mvv.py
|
|
21
|
+
src/openpublictransport/providers/national_rail.py
|
|
20
22
|
src/openpublictransport/providers/nta.py
|
|
21
23
|
src/openpublictransport/providers/nvbw.py
|
|
22
24
|
src/openpublictransport/providers/nwl.py
|
|
@@ -24,6 +26,7 @@ src/openpublictransport/providers/oebb.py
|
|
|
24
26
|
src/openpublictransport/providers/otp.py
|
|
25
27
|
src/openpublictransport/providers/otp_base.py
|
|
26
28
|
src/openpublictransport/providers/otp_custom.py
|
|
29
|
+
src/openpublictransport/providers/rejseplanen.py
|
|
27
30
|
src/openpublictransport/providers/rmv.py
|
|
28
31
|
src/openpublictransport/providers/rvv.py
|
|
29
32
|
src/openpublictransport/providers/sbb.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|