python-openpublictransport 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openpublictransport/__init__.py +5 -0
- openpublictransport/const.py +75 -0
- openpublictransport/models.py +95 -0
- openpublictransport/parsers.py +122 -0
- openpublictransport/providers/__init__.py +127 -0
- openpublictransport/providers/avv.py +44 -0
- openpublictransport/providers/base.py +77 -0
- openpublictransport/providers/beg.py +46 -0
- openpublictransport/providers/bsvg.py +44 -0
- openpublictransport/providers/bvg.py +32 -0
- openpublictransport/providers/db.py +39 -0
- openpublictransport/providers/ding.py +44 -0
- openpublictransport/providers/efa_base.py +209 -0
- openpublictransport/providers/fptf_base.py +169 -0
- openpublictransport/providers/gtfsde.py +21 -0
- openpublictransport/providers/hvv.py +40 -0
- openpublictransport/providers/kvv.py +38 -0
- openpublictransport/providers/mvv.py +48 -0
- openpublictransport/providers/nta.py +288 -0
- openpublictransport/providers/nvbw.py +44 -0
- openpublictransport/providers/nwl.py +46 -0
- openpublictransport/providers/oebb.py +182 -0
- openpublictransport/providers/otp.py +378 -0
- openpublictransport/providers/otp_base.py +268 -0
- openpublictransport/providers/otp_custom.py +22 -0
- openpublictransport/providers/rmv.py +272 -0
- openpublictransport/providers/rvv.py +43 -0
- openpublictransport/providers/sbb.py +174 -0
- openpublictransport/providers/trafiklab.py +280 -0
- openpublictransport/providers/transitous.py +198 -0
- openpublictransport/providers/trias_base.py +323 -0
- openpublictransport/providers/vagfr.py +44 -0
- openpublictransport/providers/vbn.py +73 -0
- openpublictransport/providers/vgn.py +46 -0
- openpublictransport/providers/vrn.py +51 -0
- openpublictransport/providers/vrr.py +51 -0
- openpublictransport/providers/vvo.py +45 -0
- openpublictransport/providers/vvs.py +48 -0
- python_openpublictransport-0.1.0.dist-info/METADATA +14 -0
- python_openpublictransport-0.1.0.dist-info/RECORD +42 -0
- python_openpublictransport-0.1.0.dist-info/WHEEL +5 -0
- python_openpublictransport-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Deutsche Bahn provider implementation.
|
|
2
|
+
|
|
3
|
+
Uses the v6.db.transport.rest API (FPTF format).
|
|
4
|
+
No API key required.
|
|
5
|
+
|
|
6
|
+
NOTE: This API is a community-maintained proxy (by derhuerst).
|
|
7
|
+
It is free and open but not officially supported by Deutsche Bahn.
|
|
8
|
+
Availability is not guaranteed — the API may experience occasional downtime.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from ..const import PROVIDER_DB
|
|
12
|
+
from .fptf_base import FPTFBaseProvider
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DBProvider(FPTFBaseProvider):
|
|
16
|
+
"""Deutsche Bahn provider using v6.db.transport.rest API."""
|
|
17
|
+
|
|
18
|
+
API_BASE = "https://v6.db.transport.rest"
|
|
19
|
+
|
|
20
|
+
PRODUCT_MAPPING = {
|
|
21
|
+
"nationalExpress": "train", # ICE
|
|
22
|
+
"national": "train", # IC/EC
|
|
23
|
+
"regionalExpress": "train", # RE
|
|
24
|
+
"regional": "train", # RB
|
|
25
|
+
"suburban": "train", # S-Bahn
|
|
26
|
+
"subway": "subway", # U-Bahn
|
|
27
|
+
"tram": "tram",
|
|
28
|
+
"bus": "bus",
|
|
29
|
+
"ferry": "ferry",
|
|
30
|
+
"taxi": "taxi",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def provider_id(self) -> str:
|
|
35
|
+
return PROVIDER_DB
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def provider_name(self) -> str:
|
|
39
|
+
return "DB (Deutsche Bahn)"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""DING (Donau-Iller-Nahverkehrsverbund) provider implementation."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from ..const import PROVIDER_DING
|
|
6
|
+
from .efa_base import EFABaseProvider
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DINGProvider(EFABaseProvider):
|
|
10
|
+
"""DING (Ulm) provider."""
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def provider_id(self) -> str:
|
|
14
|
+
return PROVIDER_DING
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def provider_name(self) -> str:
|
|
18
|
+
return "DING (Ulm)"
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def dm_base_url(self) -> str:
|
|
22
|
+
return "https://www.ding.eu/ding3/XML_DM_REQUEST"
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def sf_base_url(self) -> str:
|
|
26
|
+
return "https://www.ding.eu/ding3/XML_STOPFINDER_REQUEST"
|
|
27
|
+
|
|
28
|
+
def get_timezone(self) -> str:
|
|
29
|
+
return "Europe/Berlin"
|
|
30
|
+
|
|
31
|
+
def get_transport_type_mapping(self) -> Dict[Any, str]:
|
|
32
|
+
return {
|
|
33
|
+
0: "train", # High-speed trains (ICE, IC, EC)
|
|
34
|
+
1: "train", # Regional trains (RE, RB, S-Bahn)
|
|
35
|
+
4: "tram", # Straßenbahn
|
|
36
|
+
5: "bus", # City bus
|
|
37
|
+
6: "bus", # Regional bus
|
|
38
|
+
7: "bus", # Express bus
|
|
39
|
+
8: "bus", # Night bus
|
|
40
|
+
13: "train", # Regionalzug (RE)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
def get_realtime_fn(self) -> Callable[[Dict[str, Any], Optional[str], Optional[str]], bool]:
|
|
44
|
+
return lambda s, est, plan: est != plan if est and plan else False
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Base class for EFA (Electronic Fahrplan-Auskunft) providers."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from abc import abstractmethod
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
from zoneinfo import ZoneInfo
|
|
10
|
+
|
|
11
|
+
import aiohttp
|
|
12
|
+
|
|
13
|
+
from ..models import UnifiedDeparture
|
|
14
|
+
from ..parsers import parse_departure_generic
|
|
15
|
+
from .base import BaseProvider
|
|
16
|
+
|
|
17
|
+
_LOGGER = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EFABaseProvider(BaseProvider):
|
|
21
|
+
"""Base class for all EFA-based providers (VRR, KVV, HVV, MVV, etc.)."""
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def dm_base_url(self) -> str:
|
|
26
|
+
"""Return the base URL for departure monitor requests."""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def sf_base_url(self) -> str:
|
|
31
|
+
"""Return the base URL for stop finder requests."""
|
|
32
|
+
|
|
33
|
+
def get_platform_fn(self) -> Callable[[Dict[str, Any]], str]:
|
|
34
|
+
"""Return function to extract platform from stop event."""
|
|
35
|
+
return lambda s: s.get("platform", {}).get("name") or s.get("platformName", "")
|
|
36
|
+
|
|
37
|
+
def get_realtime_fn(self) -> Callable[[Dict[str, Any], Optional[str], Optional[str]], bool]:
|
|
38
|
+
"""Return function to detect realtime data."""
|
|
39
|
+
return lambda s, est, plan: "MONITORED" in s.get("realtimeStatus", [])
|
|
40
|
+
|
|
41
|
+
async def fetch_departures(
|
|
42
|
+
self,
|
|
43
|
+
station_id: Optional[str],
|
|
44
|
+
place_dm: str,
|
|
45
|
+
name_dm: str,
|
|
46
|
+
departures_limit: int,
|
|
47
|
+
) -> Optional[Dict[str, Any]]:
|
|
48
|
+
"""Fetch departure data from EFA API."""
|
|
49
|
+
if station_id:
|
|
50
|
+
params = (
|
|
51
|
+
f"outputFormat=RapidJSON&"
|
|
52
|
+
f"stateless=1&"
|
|
53
|
+
f"type_dm=any&"
|
|
54
|
+
f"name_dm={station_id}&"
|
|
55
|
+
f"mode=direct&"
|
|
56
|
+
f"useRealtime=1&"
|
|
57
|
+
f"limit={departures_limit}"
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
params = (
|
|
61
|
+
f"outputFormat=RapidJSON&"
|
|
62
|
+
f"place_dm={place_dm}&"
|
|
63
|
+
f"type_dm=stop&"
|
|
64
|
+
f"name_dm={name_dm}&"
|
|
65
|
+
f"mode=direct&"
|
|
66
|
+
f"useRealtime=1&"
|
|
67
|
+
f"limit={departures_limit}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
url = f"{self.dm_base_url}?{params}"
|
|
71
|
+
name = self.provider_name
|
|
72
|
+
|
|
73
|
+
headers = {"User-Agent": f"Mozilla/5.0 (compatible; OpenPublicTransport {self.provider_id.upper()})"}
|
|
74
|
+
|
|
75
|
+
max_retries = 3
|
|
76
|
+
for attempt in range(1, max_retries + 1):
|
|
77
|
+
try:
|
|
78
|
+
async with self.session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as response:
|
|
79
|
+
if response.status == 200:
|
|
80
|
+
try:
|
|
81
|
+
json_data = await response.json()
|
|
82
|
+
if not isinstance(json_data, dict):
|
|
83
|
+
_LOGGER.warning("%s API returned non-dict response: %s", name, type(json_data))
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
if "stopEvents" not in json_data:
|
|
87
|
+
_LOGGER.debug("%s API response missing 'stopEvents' field", name)
|
|
88
|
+
return {"stopEvents": []}
|
|
89
|
+
|
|
90
|
+
return json_data
|
|
91
|
+
except (ValueError, aiohttp.ContentTypeError) as e:
|
|
92
|
+
_LOGGER.warning("%s API returned invalid JSON: %s", name, e)
|
|
93
|
+
return None
|
|
94
|
+
except Exception as e:
|
|
95
|
+
_LOGGER.warning("%s API JSON parsing failed: %s", name, e)
|
|
96
|
+
return None
|
|
97
|
+
elif response.status == 404:
|
|
98
|
+
_LOGGER.warning("%s API endpoint not found (404)", name)
|
|
99
|
+
return None
|
|
100
|
+
elif response.status >= 500:
|
|
101
|
+
_LOGGER.warning("%s API server error (status %s)", name, response.status)
|
|
102
|
+
else:
|
|
103
|
+
_LOGGER.warning("%s API returned status %s", name, response.status)
|
|
104
|
+
|
|
105
|
+
except asyncio.TimeoutError:
|
|
106
|
+
_LOGGER.warning("%s API timeout on attempt %s", name, attempt)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
_LOGGER.warning("%s attempt %s failed: %s", name, attempt, e)
|
|
109
|
+
|
|
110
|
+
if attempt < max_retries:
|
|
111
|
+
await asyncio.sleep(2**attempt)
|
|
112
|
+
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
def parse_departure(
|
|
116
|
+
self, stop: Dict[str, Any], tz: Union[ZoneInfo, Any], now: datetime
|
|
117
|
+
) -> Optional[UnifiedDeparture]:
|
|
118
|
+
"""Parse a single departure from EFA API response."""
|
|
119
|
+
type_mapping = self.get_transport_type_mapping()
|
|
120
|
+
|
|
121
|
+
def determine_transport_type(transportation: Dict[str, Any]) -> str:
|
|
122
|
+
product = transportation.get("product", {})
|
|
123
|
+
product_class = product.get("class", 0)
|
|
124
|
+
transport_type = type_mapping.get(product_class, "unknown")
|
|
125
|
+
if transport_type == "unknown":
|
|
126
|
+
_LOGGER.debug(
|
|
127
|
+
"Unknown transport class %s for line %s",
|
|
128
|
+
product_class,
|
|
129
|
+
transportation.get("number", "unknown"),
|
|
130
|
+
)
|
|
131
|
+
return transport_type
|
|
132
|
+
|
|
133
|
+
return parse_departure_generic(
|
|
134
|
+
stop,
|
|
135
|
+
tz,
|
|
136
|
+
now,
|
|
137
|
+
get_transport_type_fn=determine_transport_type,
|
|
138
|
+
get_platform_fn=self.get_platform_fn(),
|
|
139
|
+
get_realtime_fn=self.get_realtime_fn(),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
|
|
143
|
+
"""Search for stops using EFA Stopfinder API."""
|
|
144
|
+
if "," in search_term:
|
|
145
|
+
parts = search_term.split(",", 1)
|
|
146
|
+
stop_name = parts[0].strip()
|
|
147
|
+
place_name = parts[1].strip()
|
|
148
|
+
params = (
|
|
149
|
+
f"outputFormat=RapidJSON&"
|
|
150
|
+
f"locationServerActive=1&"
|
|
151
|
+
f"type_sf=any&"
|
|
152
|
+
f"name_sf={quote(stop_name, safe='')}&"
|
|
153
|
+
f"place_sf={quote(place_name, safe='')}&"
|
|
154
|
+
f"SpEncId=0"
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
params = (
|
|
158
|
+
f"outputFormat=RapidJSON&"
|
|
159
|
+
f"locationServerActive=1&"
|
|
160
|
+
f"type_sf=stop&"
|
|
161
|
+
f"name_sf={quote(search_term, safe='')}&"
|
|
162
|
+
f"SpEncId=0"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
url = f"{self.sf_base_url}?{params}"
|
|
166
|
+
name = self.provider_name
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as response:
|
|
170
|
+
if response.status == 200:
|
|
171
|
+
try:
|
|
172
|
+
data = await response.json()
|
|
173
|
+
except (ValueError, aiohttp.ContentTypeError) as e:
|
|
174
|
+
_LOGGER.error("Invalid JSON response from %s API: %s", name, e)
|
|
175
|
+
return []
|
|
176
|
+
|
|
177
|
+
if not isinstance(data, dict):
|
|
178
|
+
_LOGGER.error("%s API returned non-dict response: %s", name, type(data))
|
|
179
|
+
return []
|
|
180
|
+
|
|
181
|
+
locations = data.get("locations", [])
|
|
182
|
+
results = []
|
|
183
|
+
|
|
184
|
+
for location in locations:
|
|
185
|
+
if not isinstance(location, dict):
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
disassembled_name = location.get("disassembledName", "")
|
|
189
|
+
place = ""
|
|
190
|
+
if "," in disassembled_name:
|
|
191
|
+
parts = disassembled_name.rsplit(",", 1)
|
|
192
|
+
place = parts[-1].strip() if len(parts) > 1 else ""
|
|
193
|
+
|
|
194
|
+
results.append(
|
|
195
|
+
{
|
|
196
|
+
"id": location.get("id", ""),
|
|
197
|
+
"name": location.get("name", ""),
|
|
198
|
+
"place": place,
|
|
199
|
+
"area_type": location.get("type", ""),
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return results
|
|
204
|
+
else:
|
|
205
|
+
_LOGGER.error("%s API returned status %s", name, response.status)
|
|
206
|
+
except Exception as e:
|
|
207
|
+
_LOGGER.error("Error searching %s stops: %s", name, e, exc_info=True)
|
|
208
|
+
|
|
209
|
+
return []
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Base provider for FPTF (Friendly Public Transport Format) APIs."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Dict, List, Optional, Union
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
from zoneinfo import ZoneInfo
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
|
|
11
|
+
from ..models import UnifiedDeparture
|
|
12
|
+
from .base import BaseProvider
|
|
13
|
+
|
|
14
|
+
_LOGGER = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_dt(s: str) -> Optional[datetime]:
|
|
18
|
+
"""Parse an ISO datetime string, returning None on failure."""
|
|
19
|
+
try:
|
|
20
|
+
return datetime.fromisoformat(s)
|
|
21
|
+
except (ValueError, TypeError):
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FPTFBaseProvider(BaseProvider):
|
|
26
|
+
"""Base class for FPTF-based providers (transport.rest APIs)."""
|
|
27
|
+
|
|
28
|
+
API_BASE: str = ""
|
|
29
|
+
PRODUCT_MAPPING: Dict[str, str] = {}
|
|
30
|
+
DEFAULT_TRANSPORT_TYPE: str = "train"
|
|
31
|
+
|
|
32
|
+
def get_timezone(self) -> str:
|
|
33
|
+
return "Europe/Berlin"
|
|
34
|
+
|
|
35
|
+
async def fetch_departures(
|
|
36
|
+
self,
|
|
37
|
+
station_id: Optional[str],
|
|
38
|
+
place_dm: str,
|
|
39
|
+
name_dm: str,
|
|
40
|
+
departures_limit: int,
|
|
41
|
+
) -> Optional[Dict[str, Any]]:
|
|
42
|
+
if not station_id:
|
|
43
|
+
_LOGGER.warning("%s provider requires a station_id", self.provider_name)
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
url = f"{self.API_BASE}/stops/{station_id}/departures?results={departures_limit}&duration=120"
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as response:
|
|
50
|
+
if response.status == 200:
|
|
51
|
+
data = await response.json()
|
|
52
|
+
if not isinstance(data, dict) or "departures" not in data:
|
|
53
|
+
_LOGGER.warning("%s API unexpected response format", self.provider_name)
|
|
54
|
+
return {"stopEvents": []}
|
|
55
|
+
return {"stopEvents": data["departures"]}
|
|
56
|
+
else:
|
|
57
|
+
_LOGGER.warning("%s API returned status %s", self.provider_name, response.status)
|
|
58
|
+
except aiohttp.ClientError as e:
|
|
59
|
+
_LOGGER.warning("%s API request failed: %s", self.provider_name, e)
|
|
60
|
+
except Exception as e:
|
|
61
|
+
_LOGGER.warning("%s API error: %s", self.provider_name, e)
|
|
62
|
+
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
def parse_departure(
|
|
66
|
+
self, stop: Dict[str, Any], tz: Union[ZoneInfo, Any], now: datetime
|
|
67
|
+
) -> Optional[UnifiedDeparture]:
|
|
68
|
+
try:
|
|
69
|
+
when_str = stop.get("when") or stop.get("plannedWhen")
|
|
70
|
+
planned_str = stop.get("plannedWhen")
|
|
71
|
+
if not when_str or not planned_str:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
when = _parse_dt(when_str)
|
|
75
|
+
planned = _parse_dt(planned_str)
|
|
76
|
+
if not when or not planned:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
when_local = when.astimezone(tz)
|
|
80
|
+
planned_local = planned.astimezone(tz)
|
|
81
|
+
|
|
82
|
+
delay_seconds = stop.get("delay") or 0
|
|
83
|
+
delay_minutes = int(delay_seconds / 60)
|
|
84
|
+
|
|
85
|
+
line_info = stop.get("line", {})
|
|
86
|
+
line_name = line_info.get("name", "")
|
|
87
|
+
product = line_info.get("product", "")
|
|
88
|
+
transport_type = self.PRODUCT_MAPPING.get(product, self.DEFAULT_TRANSPORT_TYPE)
|
|
89
|
+
|
|
90
|
+
destination_info = stop.get("destination", {})
|
|
91
|
+
destination = destination_info.get("name", stop.get("direction", "Unknown"))
|
|
92
|
+
|
|
93
|
+
platform = stop.get("platform") or ""
|
|
94
|
+
planned_platform = stop.get("plannedPlatform") or ""
|
|
95
|
+
platform_changed = bool(platform and planned_platform and platform != planned_platform)
|
|
96
|
+
|
|
97
|
+
time_diff = when_local - now
|
|
98
|
+
minutes_until = max(0, int(time_diff.total_seconds() / 60))
|
|
99
|
+
|
|
100
|
+
is_realtime = stop.get("prognosisType") is not None
|
|
101
|
+
|
|
102
|
+
notices = []
|
|
103
|
+
for remark in stop.get("remarks", []):
|
|
104
|
+
if isinstance(remark, dict) and remark.get("type") == "warning":
|
|
105
|
+
text = remark.get("text") or remark.get("summary", "")
|
|
106
|
+
if text:
|
|
107
|
+
notices.append(text)
|
|
108
|
+
|
|
109
|
+
operator = line_info.get("operator", {})
|
|
110
|
+
agency = operator.get("name") if isinstance(operator, dict) else None
|
|
111
|
+
|
|
112
|
+
return UnifiedDeparture(
|
|
113
|
+
line=line_name,
|
|
114
|
+
destination=destination,
|
|
115
|
+
departure_time=when_local.strftime("%H:%M"),
|
|
116
|
+
planned_time=planned_local.strftime("%H:%M"),
|
|
117
|
+
delay=delay_minutes,
|
|
118
|
+
platform=platform,
|
|
119
|
+
transportation_type=transport_type,
|
|
120
|
+
is_realtime=is_realtime,
|
|
121
|
+
minutes_until_departure=minutes_until,
|
|
122
|
+
departure_time_obj=when_local,
|
|
123
|
+
description=stop.get("direction"),
|
|
124
|
+
agency=agency,
|
|
125
|
+
notices=notices if notices else None,
|
|
126
|
+
planned_platform=planned_platform if platform_changed else None,
|
|
127
|
+
platform_changed=platform_changed,
|
|
128
|
+
)
|
|
129
|
+
except Exception as e:
|
|
130
|
+
_LOGGER.debug("Error parsing %s departure: %s", self.provider_name, e)
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
|
|
134
|
+
url = f"{self.API_BASE}/locations?query={quote(search_term, safe='')}&results=15"
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as response:
|
|
138
|
+
if response.status == 200:
|
|
139
|
+
data = await response.json()
|
|
140
|
+
if not isinstance(data, list):
|
|
141
|
+
return []
|
|
142
|
+
|
|
143
|
+
results = []
|
|
144
|
+
for location in data:
|
|
145
|
+
if not isinstance(location, dict):
|
|
146
|
+
continue
|
|
147
|
+
if location.get("type") not in ("stop", "station"):
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
name = location.get("name", "")
|
|
151
|
+
place = ""
|
|
152
|
+
if "(" in name and name.endswith(")"):
|
|
153
|
+
place = name[name.rfind("(") + 1 : -1]
|
|
154
|
+
|
|
155
|
+
results.append(
|
|
156
|
+
{
|
|
157
|
+
"id": location.get("id", ""),
|
|
158
|
+
"name": name,
|
|
159
|
+
"place": place,
|
|
160
|
+
"area_type": "stop",
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
return results
|
|
164
|
+
else:
|
|
165
|
+
_LOGGER.error("%s API returned status %s", self.provider_name, response.status)
|
|
166
|
+
except Exception as e:
|
|
167
|
+
_LOGGER.error("Error searching %s stops: %s", self.provider_name, e)
|
|
168
|
+
|
|
169
|
+
return []
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Community OTP2 provider — api.openpublictransport.net (GTFS.DE data, Germany-wide)."""
|
|
2
|
+
|
|
3
|
+
from .otp import OTPProvider
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OPTProvider(OTPProvider):
|
|
7
|
+
"""Community server at api.openpublictransport.net."""
|
|
8
|
+
|
|
9
|
+
otp_base_url = "https://api.openpublictransport.net/otp/routers/default"
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
def provider_id(self) -> str:
|
|
13
|
+
return "openpublictransport"
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def provider_name(self) -> str:
|
|
17
|
+
return "openpublictransport.net (Deutschlandweit)"
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def requires_api_key(self) -> bool:
|
|
21
|
+
return True
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""HVV (Hamburger Verkehrsverbund) provider implementation."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from ..const import HVV_TRANSPORTATION_TYPES, PROVIDER_HVV
|
|
6
|
+
from .efa_base import EFABaseProvider
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HVVProvider(EFABaseProvider):
|
|
10
|
+
"""HVV (Hamburger Verkehrsverbund) provider."""
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def provider_id(self) -> str:
|
|
14
|
+
return PROVIDER_HVV
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def provider_name(self) -> str:
|
|
18
|
+
return "HVV (Hamburg)"
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def dm_base_url(self) -> str:
|
|
22
|
+
return "https://hvv.efa.de/efa/XML_DM_REQUEST"
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def sf_base_url(self) -> str:
|
|
26
|
+
return "https://hvv.efa.de/efa/XML_STOPFINDER_REQUEST"
|
|
27
|
+
|
|
28
|
+
def get_timezone(self) -> str:
|
|
29
|
+
return "Europe/Berlin"
|
|
30
|
+
|
|
31
|
+
def get_transport_type_mapping(self) -> Dict[Any, str]:
|
|
32
|
+
return HVV_TRANSPORTATION_TYPES
|
|
33
|
+
|
|
34
|
+
def get_platform_fn(self) -> Callable[[Dict[str, Any]], str]:
|
|
35
|
+
return lambda s: (
|
|
36
|
+
s.get("location", {}).get("properties", {}).get("platform") or s.get("location", {}).get("platformName", "")
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def get_realtime_fn(self) -> Callable[[Dict[str, Any], Optional[str], Optional[str]], bool]:
|
|
40
|
+
return lambda s, est, plan: est != plan if est and plan else False
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""KVV (Karlsruher Verkehrsverbund) provider implementation."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from ..const import KVV_TRANSPORTATION_TYPES, PROVIDER_KVV
|
|
6
|
+
from .efa_base import EFABaseProvider
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class KVVProvider(EFABaseProvider):
|
|
10
|
+
"""KVV (Karlsruher Verkehrsverbund) provider."""
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def provider_id(self) -> str:
|
|
14
|
+
return PROVIDER_KVV
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def provider_name(self) -> str:
|
|
18
|
+
return "KVV (Karlsruhe)"
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def dm_base_url(self) -> str:
|
|
22
|
+
return "https://projekte.kvv-efa.de/sl3-alone/XSLT_DM_REQUEST"
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def sf_base_url(self) -> str:
|
|
26
|
+
return "https://projekte.kvv-efa.de/sl3-alone/XML_STOPFINDER_REQUEST"
|
|
27
|
+
|
|
28
|
+
def get_timezone(self) -> str:
|
|
29
|
+
return "Europe/Berlin"
|
|
30
|
+
|
|
31
|
+
def get_transport_type_mapping(self) -> Dict[Any, str]:
|
|
32
|
+
return KVV_TRANSPORTATION_TYPES
|
|
33
|
+
|
|
34
|
+
def get_platform_fn(self) -> Callable[[Dict[str, Any]], str]:
|
|
35
|
+
return lambda s: s.get("location", {}).get("disassembledName") or s.get("platformName", "")
|
|
36
|
+
|
|
37
|
+
def get_realtime_fn(self) -> Callable[[Dict[str, Any], Optional[str], Optional[str]], bool]:
|
|
38
|
+
return lambda s, est, plan: s.get("isRealtimeControlled", False)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""MVV (Münchner Verkehrs- und Tarifverbund) provider implementation."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from ..const import PROVIDER_MVV
|
|
6
|
+
from .efa_base import EFABaseProvider
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MVVProvider(EFABaseProvider):
|
|
10
|
+
"""MVV (München) provider."""
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def provider_id(self) -> str:
|
|
14
|
+
return PROVIDER_MVV
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def provider_name(self) -> str:
|
|
18
|
+
return "MVV (München)"
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def dm_base_url(self) -> str:
|
|
22
|
+
return "https://efa.mvv-muenchen.de/ng/XML_DM_REQUEST"
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def sf_base_url(self) -> str:
|
|
26
|
+
return "https://efa.mvv-muenchen.de/ng/XML_STOPFINDER_REQUEST"
|
|
27
|
+
|
|
28
|
+
def get_timezone(self) -> str:
|
|
29
|
+
return "Europe/Berlin"
|
|
30
|
+
|
|
31
|
+
def get_transport_type_mapping(self) -> Dict[Any, str]:
|
|
32
|
+
return {
|
|
33
|
+
0: "train", # Fernverkehr (ICE, IC, EC)
|
|
34
|
+
1: "train", # S-Bahn
|
|
35
|
+
2: "subway", # U-Bahn
|
|
36
|
+
3: "subway", # U-Bahn variant
|
|
37
|
+
4: "tram", # Tram
|
|
38
|
+
5: "bus", # Stadtbus
|
|
39
|
+
6: "bus", # Regionalbus
|
|
40
|
+
7: "bus", # Schnellbus
|
|
41
|
+
8: "bus", # Nachtbus
|
|
42
|
+
9: "ferry", # Fähre
|
|
43
|
+
10: "taxi", # Rufbus/Taxi
|
|
44
|
+
13: "train", # Regionalzug (RE/RB)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
def get_realtime_fn(self) -> Callable[[Dict[str, Any], Optional[str], Optional[str]], bool]:
|
|
48
|
+
return lambda s, est, plan: est != plan if est and plan else False
|