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