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.
Files changed (48) hide show
  1. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/PKG-INFO +1 -1
  2. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/pyproject.toml +1 -1
  3. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/const.py +2 -0
  4. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/__init__.py +6 -0
  5. python_openpublictransport-0.1.3/src/openpublictransport/providers/national_rail.py +310 -0
  6. python_openpublictransport-0.1.3/src/openpublictransport/providers/rejseplanen.py +236 -0
  7. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/python_openpublictransport.egg-info/PKG-INFO +1 -1
  8. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/python_openpublictransport.egg-info/SOURCES.txt +2 -0
  9. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/setup.cfg +0 -0
  10. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/__init__.py +0 -0
  11. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/models.py +0 -0
  12. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/parsers.py +0 -0
  13. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/avv.py +0 -0
  14. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/base.py +0 -0
  15. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/beg.py +0 -0
  16. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/bsvg.py +0 -0
  17. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/bvg.py +0 -0
  18. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/db.py +0 -0
  19. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/ding.py +0 -0
  20. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/efa_base.py +0 -0
  21. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/fptf_base.py +0 -0
  22. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/gtfsde.py +0 -0
  23. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/hvv.py +0 -0
  24. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/kvv.py +0 -0
  25. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/mvv.py +0 -0
  26. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/nta.py +0 -0
  27. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/nvbw.py +0 -0
  28. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/nwl.py +0 -0
  29. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/oebb.py +0 -0
  30. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/otp.py +0 -0
  31. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/otp_base.py +0 -0
  32. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/otp_custom.py +0 -0
  33. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/rmv.py +0 -0
  34. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/rvv.py +0 -0
  35. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/sbb.py +0 -0
  36. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/trafiklab.py +0 -0
  37. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/transitous.py +0 -0
  38. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/trias_base.py +0 -0
  39. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/vagfr.py +0 -0
  40. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/vbn.py +0 -0
  41. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/vgn.py +0 -0
  42. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/vrn.py +0 -0
  43. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/vrr.py +0 -0
  44. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/vvo.py +0 -0
  45. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/openpublictransport/providers/vvs.py +0 -0
  46. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/python_openpublictransport.egg-info/dependency_links.txt +0 -0
  47. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/python_openpublictransport.egg-info/requires.txt +0 -0
  48. {python_openpublictransport-0.1.2 → python_openpublictransport-0.1.3}/src/python_openpublictransport.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-openpublictransport
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Python library for public transport APIs (EFA, OTP2, TRIAS, FPTF, HAFAS, GTFS-RT)
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-openpublictransport"
7
- version = "0.1.2"
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]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-openpublictransport
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Python library for public transport APIs (EFA, OTP2, TRIAS, FPTF, HAFAS, GTFS-RT)
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -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