python-openpublictransport 0.1.14__tar.gz → 0.1.16__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.14 → python_openpublictransport-0.1.16}/PKG-INFO +1 -1
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/pyproject.toml +1 -1
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/const.py +1 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/models.py +5 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/parsers.py +41 -3
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/__init__.py +3 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/efa_base.py +59 -3
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/hvv.py +2 -4
- python_openpublictransport-0.1.16/src/openpublictransport/providers/hvv_gti.py +358 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/kvv.py +3 -2
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/python_openpublictransport.egg-info/PKG-INFO +1 -1
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/python_openpublictransport.egg-info/SOURCES.txt +3 -0
- python_openpublictransport-0.1.16/tests/test_efa_platform.py +190 -0
- python_openpublictransport-0.1.16/tests/test_hvv_gti.py +360 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/setup.cfg +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/__init__.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/data/uk_stations.json +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/exceptions.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/avv.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/bart.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/base.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/beg.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/bsvg.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/bvg.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/dart.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/db.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/ding.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/entur.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/fptf_base.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/gtfsde.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/hafas_base.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/hafas_mgate_base.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/irishrail.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/mobiliteit_lu.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/mvv.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/national_rail.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/ns.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/nta.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/nvbw.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/nwl.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/oebb.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/otp.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/otp_base.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/otp_custom.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/rejseplanen.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/rmv.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/rvv.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/sbb.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/tpg.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/trafiklab.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/transitous.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/trias_base.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/vagfr.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/vbn.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/vgn.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/vrn.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/vrr.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/vvo.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/vvs.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/openpublictransport/py.typed +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/python_openpublictransport.egg-info/dependency_links.txt +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/python_openpublictransport.egg-info/requires.txt +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/src/python_openpublictransport.egg-info/top_level.txt +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/tests/test_entur.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/tests/test_hafas_base.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/tests/test_hafas_mgate.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/tests/test_national_rail.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/tests/test_national_rail_parse.py +0 -0
- {python_openpublictransport-0.1.14 → python_openpublictransport-0.1.16}/tests/test_national_rail_static.py +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.16"
|
|
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" }
|
|
@@ -38,6 +38,9 @@ class UnifiedDeparture:
|
|
|
38
38
|
notices: Optional[list[str]] = None
|
|
39
39
|
planned_platform: Optional[str] = None
|
|
40
40
|
platform_changed: bool = False
|
|
41
|
+
# Human-readable form of `platform` when the provider offers one
|
|
42
|
+
# ("Gleis 3" next to the technical "3")
|
|
43
|
+
platform_name: Optional[str] = None
|
|
41
44
|
line_color: Optional[str] = None
|
|
42
45
|
line_text_color: Optional[str] = None
|
|
43
46
|
|
|
@@ -60,6 +63,8 @@ class UnifiedDeparture:
|
|
|
60
63
|
result["agency"] = self.agency
|
|
61
64
|
if self.notices:
|
|
62
65
|
result["notices"] = self.notices
|
|
66
|
+
if self.platform_name and self.platform_name != self.platform:
|
|
67
|
+
result["platform_name"] = self.platform_name
|
|
63
68
|
if self.platform_changed:
|
|
64
69
|
result["planned_platform"] = self.planned_platform
|
|
65
70
|
result["platform_changed"] = True
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Common parsing utilities for all providers."""
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
+
import re
|
|
4
5
|
from datetime import datetime, timezone
|
|
5
6
|
from typing import Any, Callable, Dict, Optional, Union
|
|
6
7
|
from zoneinfo import ZoneInfo
|
|
@@ -9,6 +10,29 @@ from .models import UnifiedDeparture
|
|
|
9
10
|
|
|
10
11
|
_LOGGER = logging.getLogger(__name__)
|
|
11
12
|
|
|
13
|
+
# Human-readable platform labels APIs prefix onto the bare track identifier:
|
|
14
|
+
# "Gleis 3", "Bstg. 12", "Steig B", "Platform 4", "Pl. 2", "Track 1".
|
|
15
|
+
_PLATFORM_LABEL_RE = re.compile(
|
|
16
|
+
r"^(?:gleis|bstg\.?|bahnsteig|steig|platform|plattform|pl\.|track|quai|voie|spor|perron)\s*",
|
|
17
|
+
re.IGNORECASE,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize_platform(value: Any) -> str:
|
|
22
|
+
"""Reduce a platform value to its bare identifier, lowercased.
|
|
23
|
+
|
|
24
|
+
``"Gleis 3"``, ``"gleis 3"`` and ``"3"`` all normalize to ``"3"``. Used to
|
|
25
|
+
compare a technical platform value against a human-readable one without
|
|
26
|
+
reporting a spurious platform change, and re-used by consumers that want to
|
|
27
|
+
match user input like ``platform: 3`` against whatever the provider returns.
|
|
28
|
+
"""
|
|
29
|
+
if value is None:
|
|
30
|
+
return ""
|
|
31
|
+
text = str(value).strip()
|
|
32
|
+
if not text:
|
|
33
|
+
return ""
|
|
34
|
+
return _PLATFORM_LABEL_RE.sub("", text).strip().casefold()
|
|
35
|
+
|
|
12
36
|
|
|
13
37
|
def _parse_dt(s: str) -> Optional[datetime]:
|
|
14
38
|
"""Parse an ISO datetime string, returning None on failure."""
|
|
@@ -18,6 +42,13 @@ def _parse_dt(s: str) -> Optional[datetime]:
|
|
|
18
42
|
return None
|
|
19
43
|
|
|
20
44
|
|
|
45
|
+
def _default_planned_platform_fn(stop: Dict[str, Any]) -> str:
|
|
46
|
+
"""Legacy planned-platform lookup used when a provider supplies none."""
|
|
47
|
+
platform = stop.get("platform")
|
|
48
|
+
planned_name = platform.get("plannedName") if isinstance(platform, dict) else None
|
|
49
|
+
return stop.get("plannedPlatformName") or planned_name or ""
|
|
50
|
+
|
|
51
|
+
|
|
21
52
|
def parse_departure_generic(
|
|
22
53
|
stop: Dict[str, Any],
|
|
23
54
|
tz: Union[ZoneInfo, Any],
|
|
@@ -25,6 +56,8 @@ def parse_departure_generic(
|
|
|
25
56
|
get_transport_type_fn: Callable[[Dict[str, Any]], str],
|
|
26
57
|
get_platform_fn: Callable[[Dict[str, Any]], str],
|
|
27
58
|
get_realtime_fn: Callable[[Dict[str, Any], Optional[str], Optional[str]], bool],
|
|
59
|
+
get_planned_platform_fn: Optional[Callable[[Dict[str, Any]], str]] = None,
|
|
60
|
+
get_platform_name_fn: Optional[Callable[[Dict[str, Any]], str]] = None,
|
|
28
61
|
) -> Optional[UnifiedDeparture]:
|
|
29
62
|
"""Generic parser for departure data — shared logic across all providers."""
|
|
30
63
|
try:
|
|
@@ -75,6 +108,7 @@ def parse_departure_generic(
|
|
|
75
108
|
|
|
76
109
|
transport_type = get_transport_type_fn(transportation)
|
|
77
110
|
platform = get_platform_fn(stop)
|
|
111
|
+
platform_name = get_platform_name_fn(stop) if get_platform_name_fn else ""
|
|
78
112
|
|
|
79
113
|
time_diff = estimated_local - now
|
|
80
114
|
minutes_until = max(0, int(time_diff.total_seconds() / 60))
|
|
@@ -93,10 +127,13 @@ def parse_departure_generic(
|
|
|
93
127
|
if text and isinstance(text, str):
|
|
94
128
|
notices.append(text.strip())
|
|
95
129
|
|
|
96
|
-
|
|
97
|
-
|
|
130
|
+
planned_fn = get_planned_platform_fn or _default_planned_platform_fn
|
|
131
|
+
planned_platform = planned_fn(stop)
|
|
132
|
+
# Compare normalized values: providers mix the technical identifier
|
|
133
|
+
# ("3") with the human-readable label ("Gleis 3") across the actual and
|
|
134
|
+
# planned fields, and those are the same platform, not a change.
|
|
98
135
|
platform_changed = bool(
|
|
99
|
-
planned_platform and
|
|
136
|
+
planned_platform and platform and normalize_platform(planned_platform) != normalize_platform(platform)
|
|
100
137
|
)
|
|
101
138
|
|
|
102
139
|
return UnifiedDeparture(
|
|
@@ -115,6 +152,7 @@ def parse_departure_generic(
|
|
|
115
152
|
notices=notices if notices else None,
|
|
116
153
|
planned_platform=str(planned_platform).strip() if planned_platform and platform_changed else None,
|
|
117
154
|
platform_changed=platform_changed,
|
|
155
|
+
platform_name=str(platform_name).strip() or None,
|
|
118
156
|
)
|
|
119
157
|
|
|
120
158
|
except Exception as e:
|
|
@@ -11,6 +11,7 @@ from ..const import (
|
|
|
11
11
|
PROVIDER_DB,
|
|
12
12
|
PROVIDER_DING,
|
|
13
13
|
PROVIDER_HVV,
|
|
14
|
+
PROVIDER_HVV_GTI,
|
|
14
15
|
PROVIDER_KVV,
|
|
15
16
|
PROVIDER_MVV,
|
|
16
17
|
PROVIDER_NTA_IE,
|
|
@@ -51,6 +52,7 @@ from .db import DBProvider
|
|
|
51
52
|
from .ding import DINGProvider
|
|
52
53
|
from .gtfsde import OPTProvider
|
|
53
54
|
from .hvv import HVVProvider
|
|
55
|
+
from .hvv_gti import HVVGTIProvider
|
|
54
56
|
from .kvv import KVVProvider
|
|
55
57
|
from .mvv import MVVProvider
|
|
56
58
|
from .nta import NTAProvider
|
|
@@ -161,3 +163,4 @@ register_provider(PROVIDER_BART_US, BARTProvider)
|
|
|
161
163
|
register_provider(PROVIDER_DART_US, DARTProvider)
|
|
162
164
|
register_provider(PROVIDER_IRISHRAIL_IE, IrishRailProvider)
|
|
163
165
|
register_provider(PROVIDER_TPG_CH, TPGProvider)
|
|
166
|
+
register_provider(PROVIDER_HVV_GTI, HVVGTIProvider)
|
|
@@ -4,7 +4,7 @@ import asyncio
|
|
|
4
4
|
import logging
|
|
5
5
|
from abc import abstractmethod
|
|
6
6
|
from datetime import datetime
|
|
7
|
-
from typing import Any, Callable, Dict, List, Optional, Union
|
|
7
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
8
8
|
from urllib.parse import quote
|
|
9
9
|
from zoneinfo import ZoneInfo
|
|
10
10
|
|
|
@@ -39,6 +39,52 @@ def _transport_type_from_name(name: str) -> str:
|
|
|
39
39
|
return "unknown"
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
def _location_and_properties(stop: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
43
|
+
"""Return (location, location.properties) from an EFA stop event."""
|
|
44
|
+
location = stop.get("location")
|
|
45
|
+
if not isinstance(location, dict):
|
|
46
|
+
return {}, {}
|
|
47
|
+
properties = location.get("properties")
|
|
48
|
+
return location, properties if isinstance(properties, dict) else {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _efa_platform(stop: Dict[str, Any]) -> str:
|
|
52
|
+
"""Technical platform/track of an EFA stop event.
|
|
53
|
+
|
|
54
|
+
EFA's RapidJSON carries the track under ``location.properties.platform``.
|
|
55
|
+
Prefer that technical value ("3") over the human-readable ``platformName``
|
|
56
|
+
/ ``disassembledName`` ("Gleis 3"): the readable form is only populated for
|
|
57
|
+
rail platforms, while bus and tram stops still have the technical
|
|
58
|
+
identifier (issue #56).
|
|
59
|
+
|
|
60
|
+
The last two fallbacks are the pre-RapidJSON shape, kept so older or
|
|
61
|
+
non-standard EFA deployments keep working.
|
|
62
|
+
"""
|
|
63
|
+
location, properties = _location_and_properties(stop)
|
|
64
|
+
legacy = stop.get("platform")
|
|
65
|
+
legacy_name = legacy.get("name") if isinstance(legacy, dict) else None
|
|
66
|
+
return (
|
|
67
|
+
properties.get("platform")
|
|
68
|
+
or properties.get("platformName")
|
|
69
|
+
or location.get("disassembledName")
|
|
70
|
+
or legacy_name
|
|
71
|
+
or stop.get("platformName")
|
|
72
|
+
or ""
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _efa_platform_name(stop: Dict[str, Any]) -> str:
|
|
77
|
+
"""Human-readable platform label ("Gleis 3"), when EFA supplies one."""
|
|
78
|
+
location, properties = _location_and_properties(stop)
|
|
79
|
+
return properties.get("platformName") or location.get("disassembledName") or ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _efa_planned_platform(stop: Dict[str, Any]) -> str:
|
|
83
|
+
"""Scheduled platform, for detecting a real-time platform change."""
|
|
84
|
+
_, properties = _location_and_properties(stop)
|
|
85
|
+
return properties.get("plannedPlatform") or properties.get("plannedPlatformName") or ""
|
|
86
|
+
|
|
87
|
+
|
|
42
88
|
class EFABaseProvider(BaseProvider):
|
|
43
89
|
"""Base class for all EFA-based providers (VRR, KVV, HVV, MVV, etc.)."""
|
|
44
90
|
|
|
@@ -53,8 +99,16 @@ class EFABaseProvider(BaseProvider):
|
|
|
53
99
|
"""Return the base URL for stop finder requests."""
|
|
54
100
|
|
|
55
101
|
def get_platform_fn(self) -> Callable[[Dict[str, Any]], str]:
|
|
56
|
-
"""Return function to extract platform from stop event."""
|
|
57
|
-
return
|
|
102
|
+
"""Return function to extract the technical platform from a stop event."""
|
|
103
|
+
return _efa_platform
|
|
104
|
+
|
|
105
|
+
def get_platform_name_fn(self) -> Callable[[Dict[str, Any]], str]:
|
|
106
|
+
"""Return function to extract the human-readable platform label."""
|
|
107
|
+
return _efa_platform_name
|
|
108
|
+
|
|
109
|
+
def get_planned_platform_fn(self) -> Callable[[Dict[str, Any]], str]:
|
|
110
|
+
"""Return function to extract the scheduled platform."""
|
|
111
|
+
return _efa_planned_platform
|
|
58
112
|
|
|
59
113
|
def get_realtime_fn(self) -> Callable[[Dict[str, Any], Optional[str], Optional[str]], bool]:
|
|
60
114
|
"""Return function to detect realtime data."""
|
|
@@ -163,6 +217,8 @@ class EFABaseProvider(BaseProvider):
|
|
|
163
217
|
get_transport_type_fn=determine_transport_type,
|
|
164
218
|
get_platform_fn=self.get_platform_fn(),
|
|
165
219
|
get_realtime_fn=self.get_realtime_fn(),
|
|
220
|
+
get_planned_platform_fn=self.get_planned_platform_fn(),
|
|
221
|
+
get_platform_name_fn=self.get_platform_name_fn(),
|
|
166
222
|
)
|
|
167
223
|
|
|
168
224
|
async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
|
|
@@ -31,10 +31,8 @@ class HVVProvider(EFABaseProvider):
|
|
|
31
31
|
def get_transport_type_mapping(self) -> Dict[Any, str]:
|
|
32
32
|
return HVV_TRANSPORTATION_TYPES
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
s.get("location", {}).get("properties", {}).get("platform") or s.get("location", {}).get("platformName", "")
|
|
37
|
-
)
|
|
34
|
+
# location.properties.platform is now the EFABaseProvider default, so this
|
|
35
|
+
# provider no longer needs its own override.
|
|
38
36
|
|
|
39
37
|
def get_realtime_fn(self) -> Callable[[Dict[str, Any], Optional[str], Optional[str]], bool]:
|
|
40
38
|
return lambda s, est, plan: est != plan if est and plan else False
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""HVV via the official Geofox Thin Interface (GTI).
|
|
2
|
+
|
|
3
|
+
The `hvv` provider talks to the public EFA endpoint. This one uses HOCHBAHN's
|
|
4
|
+
official API on behalf of the HVV, which carries real-time delays, cancellations
|
|
5
|
+
and platform changes that EFA does not expose.
|
|
6
|
+
|
|
7
|
+
Credentials are free but must be requested by email from api@hochbahn.de; the
|
|
8
|
+
API is documented at https://gti.geofox.de/html/GTIHandbuch_p.html.
|
|
9
|
+
|
|
10
|
+
Requested for https://github.com/NerdySoftPaw/openpublictransport/issues/61.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import hashlib
|
|
15
|
+
import hmac
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
from datetime import datetime, timedelta
|
|
19
|
+
from typing import Any, Dict, List, Optional, Union
|
|
20
|
+
from zoneinfo import ZoneInfo
|
|
21
|
+
|
|
22
|
+
import aiohttp
|
|
23
|
+
|
|
24
|
+
from ..const import PROVIDER_HVV_GTI
|
|
25
|
+
from ..exceptions import AuthenticationError
|
|
26
|
+
from ..models import UnifiedDeparture
|
|
27
|
+
from .base import BaseProvider
|
|
28
|
+
|
|
29
|
+
_LOGGER = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
GTI_BASE_URL = "https://gti.geofox.de/gti/public"
|
|
32
|
+
|
|
33
|
+
# The API version the request bodies below are written against. The server
|
|
34
|
+
# tailors its response to this number, so it is pinned rather than left to the
|
|
35
|
+
# server default (which is the long-obsolete version 1).
|
|
36
|
+
GTI_API_VERSION = 63
|
|
37
|
+
|
|
38
|
+
# A line's type carries a granular value (`shortInfo`, and sometimes a granular
|
|
39
|
+
# `simpleType`) plus a coarse `simpleType`. The granular one is authoritative;
|
|
40
|
+
# the coarse one flattens U-Bahn, S-Bahn and AKN all into TRAIN, so it is only
|
|
41
|
+
# consulted after the line name has had a chance to disambiguate.
|
|
42
|
+
_GRANULAR_TYPE_MAP = {
|
|
43
|
+
"UBAHN": "subway",
|
|
44
|
+
"U": "subway",
|
|
45
|
+
"U-BAHN": "subway",
|
|
46
|
+
"SBAHN": "train",
|
|
47
|
+
"S": "train",
|
|
48
|
+
"S-BAHN": "train",
|
|
49
|
+
"AKN": "train",
|
|
50
|
+
"A": "train",
|
|
51
|
+
"RBAHN": "train",
|
|
52
|
+
"R": "train",
|
|
53
|
+
"FERNBAHN": "train",
|
|
54
|
+
"ZUG": "train",
|
|
55
|
+
"STADTBUS": "bus",
|
|
56
|
+
"METROBUS": "bus",
|
|
57
|
+
"SCHNELLBUS": "bus",
|
|
58
|
+
"NACHTBUS": "bus",
|
|
59
|
+
"XPRESSBUS": "bus",
|
|
60
|
+
"EILBUS": "bus",
|
|
61
|
+
"FAEHRE": "ferry",
|
|
62
|
+
"FÄHRE": "ferry",
|
|
63
|
+
"AST": "taxi",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
_COARSE_TYPE_MAP = {
|
|
67
|
+
"BUS": "bus",
|
|
68
|
+
"TRAIN": "train",
|
|
69
|
+
"SHIP": "ferry",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def sign_request(password: str, body: bytes) -> str:
|
|
74
|
+
"""Return the geofox-auth-signature for a request body.
|
|
75
|
+
|
|
76
|
+
HMAC-SHA1 over the UTF-8 request body, keyed with the password, Base64
|
|
77
|
+
encoded — GTI handbook §3 "Authentifikation".
|
|
78
|
+
"""
|
|
79
|
+
digest = hmac.new(password.encode("utf-8"), body, hashlib.sha1).digest()
|
|
80
|
+
return base64.b64encode(digest).decode("ascii")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _transport_type(line: Dict[str, Any]) -> str:
|
|
84
|
+
"""Map a GTI line to a unified transportation type."""
|
|
85
|
+
line_type = line.get("type") if isinstance(line.get("type"), dict) else {}
|
|
86
|
+
short_info = str(line_type.get("shortInfo") or "").strip().upper()
|
|
87
|
+
simple_type = str(line_type.get("simpleType") or "").strip().upper()
|
|
88
|
+
|
|
89
|
+
for value in (short_info, simple_type):
|
|
90
|
+
if value in _GRANULAR_TYPE_MAP:
|
|
91
|
+
return _GRANULAR_TYPE_MAP[value]
|
|
92
|
+
|
|
93
|
+
# Only the coarse type is left, which lumps U-Bahn, S-Bahn and AKN together
|
|
94
|
+
# under TRAIN. Hamburg's line names are unambiguous, so use them first.
|
|
95
|
+
name = str(line.get("name") or "").strip().upper()
|
|
96
|
+
if len(name) > 1 and name[1].isdigit():
|
|
97
|
+
if name[0] == "U":
|
|
98
|
+
return "subway"
|
|
99
|
+
if name[0] in ("S", "A", "R"):
|
|
100
|
+
return "train"
|
|
101
|
+
|
|
102
|
+
if simple_type in _COARSE_TYPE_MAP:
|
|
103
|
+
return _COARSE_TYPE_MAP[simple_type]
|
|
104
|
+
|
|
105
|
+
_LOGGER.debug("HVV GTI: unmapped line type %s for line %r", line_type, line.get("name"))
|
|
106
|
+
return "unknown"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class HVVGTIProvider(BaseProvider):
|
|
110
|
+
"""HVV via the official Geofox GTI API (real-time)."""
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def provider_id(self) -> str:
|
|
114
|
+
return PROVIDER_HVV_GTI
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def provider_name(self) -> str:
|
|
118
|
+
return "HVV Geofox GTI (Hamburg)"
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def requires_api_key(self) -> bool:
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
def get_timezone(self) -> str:
|
|
125
|
+
return "Europe/Berlin"
|
|
126
|
+
|
|
127
|
+
# ── transport ────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
async def _post(self, method: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
130
|
+
"""Call one GTI method, signing the exact bytes that are sent.
|
|
131
|
+
|
|
132
|
+
The signature covers the serialized body, so the body is serialized
|
|
133
|
+
once and handed to aiohttp as raw bytes — letting aiohttp re-serialize
|
|
134
|
+
it via ``json=`` could change the bytes and invalidate the signature.
|
|
135
|
+
"""
|
|
136
|
+
if not self.api_key or not self.api_key_secondary:
|
|
137
|
+
_LOGGER.error("%s: username and password are required", self.provider_name)
|
|
138
|
+
raise AuthenticationError(f"{self.provider_name}: username and password are required")
|
|
139
|
+
|
|
140
|
+
body = json.dumps(
|
|
141
|
+
{"version": GTI_API_VERSION, "language": "de", **payload},
|
|
142
|
+
ensure_ascii=False,
|
|
143
|
+
separators=(",", ":"),
|
|
144
|
+
).encode("utf-8")
|
|
145
|
+
|
|
146
|
+
headers = {
|
|
147
|
+
"geofox-auth-type": "HmacSHA1",
|
|
148
|
+
"geofox-auth-user": self.api_key,
|
|
149
|
+
"geofox-auth-signature": sign_request(self.api_key_secondary, body),
|
|
150
|
+
"Content-Type": "application/json;charset=UTF-8",
|
|
151
|
+
"Accept": "application/json",
|
|
152
|
+
"X-Platform": "web",
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
url = f"{GTI_BASE_URL}/{method}"
|
|
156
|
+
try:
|
|
157
|
+
async with self.session.post(
|
|
158
|
+
url, data=body, headers=headers, timeout=aiohttp.ClientTimeout(total=15)
|
|
159
|
+
) as response:
|
|
160
|
+
if response.status in (401, 403):
|
|
161
|
+
raise AuthenticationError(
|
|
162
|
+
f"{self.provider_name}: authentication failed (HTTP {response.status}) — "
|
|
163
|
+
"check the GTI username and password"
|
|
164
|
+
)
|
|
165
|
+
if response.status != 200:
|
|
166
|
+
_LOGGER.warning("%s: %s → HTTP %s", self.provider_name, method, response.status)
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
data = await response.json(content_type=None)
|
|
170
|
+
except AuthenticationError:
|
|
171
|
+
raise
|
|
172
|
+
except aiohttp.ClientError as exc:
|
|
173
|
+
_LOGGER.warning("%s: %s request failed: %s", self.provider_name, method, exc)
|
|
174
|
+
return None
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
_LOGGER.warning("%s: %s error: %s", self.provider_name, method, exc)
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
if not isinstance(data, dict):
|
|
180
|
+
_LOGGER.warning("%s: %s returned %s, expected an object", self.provider_name, method, type(data))
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
return_code = data.get("returnCode")
|
|
184
|
+
if return_code != "OK":
|
|
185
|
+
# GTI signals a bad key with a return code rather than an HTTP status.
|
|
186
|
+
if return_code in ("ERROR_COMM", "ERROR_TEXT") and "auth" in str(data.get("errorDevInfo", "")).lower():
|
|
187
|
+
raise AuthenticationError(f"{self.provider_name}: {data.get('errorText') or return_code}")
|
|
188
|
+
_LOGGER.warning(
|
|
189
|
+
"%s: %s returned %s — %s (%s)",
|
|
190
|
+
self.provider_name,
|
|
191
|
+
method,
|
|
192
|
+
return_code,
|
|
193
|
+
data.get("errorText") or "no message",
|
|
194
|
+
data.get("errorDevInfo") or "",
|
|
195
|
+
)
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
return data
|
|
199
|
+
|
|
200
|
+
# ── stop search ──────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
async def search_stops(self, search_term: str) -> List[Dict[str, Any]]:
|
|
203
|
+
"""Search stations via the checkName method."""
|
|
204
|
+
data = await self._post(
|
|
205
|
+
"checkName",
|
|
206
|
+
{
|
|
207
|
+
"theName": {"name": search_term, "type": "STATION"},
|
|
208
|
+
"maxList": 25,
|
|
209
|
+
"coordinateType": "EPSG_4326",
|
|
210
|
+
},
|
|
211
|
+
)
|
|
212
|
+
if not data:
|
|
213
|
+
return []
|
|
214
|
+
|
|
215
|
+
stops = []
|
|
216
|
+
for result in data.get("results") or []:
|
|
217
|
+
if not isinstance(result, dict) or result.get("type") != "STATION" or not result.get("id"):
|
|
218
|
+
continue
|
|
219
|
+
stops.append(
|
|
220
|
+
{
|
|
221
|
+
"id": result["id"],
|
|
222
|
+
"name": result.get("name", ""),
|
|
223
|
+
"place": result.get("city", ""),
|
|
224
|
+
"area_type": "stop",
|
|
225
|
+
}
|
|
226
|
+
)
|
|
227
|
+
return stops
|
|
228
|
+
|
|
229
|
+
# ── departures ───────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
async def fetch_departures(
|
|
232
|
+
self,
|
|
233
|
+
station_id: Optional[str],
|
|
234
|
+
place_dm: str,
|
|
235
|
+
name_dm: str,
|
|
236
|
+
departures_limit: int,
|
|
237
|
+
) -> Optional[Dict[str, Any]]:
|
|
238
|
+
"""Fetch a departure board via the departureList method."""
|
|
239
|
+
if not station_id:
|
|
240
|
+
_LOGGER.warning("%s: a station ID is required", self.provider_name)
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
now = datetime.now(ZoneInfo(self.get_timezone()))
|
|
244
|
+
data = await self._post(
|
|
245
|
+
"departureList",
|
|
246
|
+
{
|
|
247
|
+
"station": {"id": station_id, "type": "STATION"},
|
|
248
|
+
"time": {"date": now.strftime("%d.%m.%Y"), "time": now.strftime("%H:%M")},
|
|
249
|
+
"maxList": max(departures_limit, 5),
|
|
250
|
+
"maxTimeOffset": 200,
|
|
251
|
+
"useRealtime": True,
|
|
252
|
+
"returnFilters": False,
|
|
253
|
+
"coordinateType": "EPSG_4326",
|
|
254
|
+
},
|
|
255
|
+
)
|
|
256
|
+
if not data:
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
# Departures are offsets from the board's own reference time, not
|
|
260
|
+
# absolute timestamps, so it has to be resolved here while we still
|
|
261
|
+
# have the response that produced them.
|
|
262
|
+
reference = self._reference_time(data.get("time"), now)
|
|
263
|
+
|
|
264
|
+
stop_events = []
|
|
265
|
+
for departure in data.get("departures") or []:
|
|
266
|
+
event = self._to_stop_event(departure, reference)
|
|
267
|
+
if event:
|
|
268
|
+
stop_events.append(event)
|
|
269
|
+
|
|
270
|
+
stop_events.sort(key=lambda event: event["estimated"])
|
|
271
|
+
return {"stopEvents": stop_events[:departures_limit]}
|
|
272
|
+
|
|
273
|
+
def _reference_time(self, gti_time: Any, fallback: datetime) -> datetime:
|
|
274
|
+
"""Parse the response's GTITime, falling back to the request time."""
|
|
275
|
+
if isinstance(gti_time, dict) and gti_time.get("date") and gti_time.get("time"):
|
|
276
|
+
try:
|
|
277
|
+
return datetime.strptime(
|
|
278
|
+
f"{gti_time['date']} {gti_time['time']}", "%d.%m.%Y %H:%M"
|
|
279
|
+
).replace(tzinfo=ZoneInfo(self.get_timezone()))
|
|
280
|
+
except ValueError:
|
|
281
|
+
_LOGGER.debug("%s: unparseable reference time %s", self.provider_name, gti_time)
|
|
282
|
+
return fallback
|
|
283
|
+
|
|
284
|
+
def _to_stop_event(self, departure: Any, reference: datetime) -> Optional[Dict[str, Any]]:
|
|
285
|
+
"""Normalise one GTI departure into this provider's stop-event shape."""
|
|
286
|
+
if not isinstance(departure, dict):
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
line = departure.get("line") if isinstance(departure.get("line"), dict) else {}
|
|
290
|
+
time_offset = departure.get("timeOffset")
|
|
291
|
+
if not isinstance(time_offset, int):
|
|
292
|
+
_LOGGER.debug("%s: departure without a timeOffset: %s", self.provider_name, departure)
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
# planned = board reference + timeOffset; the real-time deviation is a
|
|
296
|
+
# separate value in seconds on top (GTI handbook §2.4.2).
|
|
297
|
+
delay_seconds = departure.get("delay") if isinstance(departure.get("delay"), int) else 0
|
|
298
|
+
planned = reference + timedelta(minutes=time_offset)
|
|
299
|
+
estimated = planned + timedelta(seconds=delay_seconds)
|
|
300
|
+
|
|
301
|
+
platform = departure.get("realtimePlatform") or departure.get("platform") or ""
|
|
302
|
+
planned_platform = departure.get("platform") or ""
|
|
303
|
+
|
|
304
|
+
notices = []
|
|
305
|
+
for attribute in departure.get("attributes") or []:
|
|
306
|
+
if isinstance(attribute, dict):
|
|
307
|
+
text = attribute.get("value") or attribute.get("title") or ""
|
|
308
|
+
if text:
|
|
309
|
+
notices.append(str(text).strip())
|
|
310
|
+
if departure.get("cancelled"):
|
|
311
|
+
notices.append("Fahrt fällt aus")
|
|
312
|
+
if departure.get("extra"):
|
|
313
|
+
notices.append("Verstärkerfahrt")
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
"line": str(line.get("name") or ""),
|
|
317
|
+
"destination": str(line.get("direction") or "Unknown"),
|
|
318
|
+
"transportType": _transport_type(line),
|
|
319
|
+
"planned": planned,
|
|
320
|
+
"estimated": estimated,
|
|
321
|
+
"delaySeconds": delay_seconds,
|
|
322
|
+
"isRealtime": departure.get("delay") is not None,
|
|
323
|
+
"platform": str(platform),
|
|
324
|
+
"plannedPlatform": str(planned_platform),
|
|
325
|
+
"cancelled": bool(departure.get("cancelled")),
|
|
326
|
+
"notices": notices or None,
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
def parse_departure(
|
|
330
|
+
self, stop: Dict[str, Any], tz: Union[ZoneInfo, Any], now: datetime
|
|
331
|
+
) -> Optional[UnifiedDeparture]:
|
|
332
|
+
"""Build a UnifiedDeparture from a stop event produced above."""
|
|
333
|
+
try:
|
|
334
|
+
planned = stop["planned"].astimezone(tz)
|
|
335
|
+
estimated = stop["estimated"].astimezone(tz)
|
|
336
|
+
|
|
337
|
+
platform = stop.get("platform") or ""
|
|
338
|
+
planned_platform = stop.get("plannedPlatform") or ""
|
|
339
|
+
platform_changed = bool(platform and planned_platform and platform != planned_platform)
|
|
340
|
+
|
|
341
|
+
return UnifiedDeparture(
|
|
342
|
+
line=stop.get("line", ""),
|
|
343
|
+
destination=stop.get("destination", "Unknown"),
|
|
344
|
+
departure_time=estimated.strftime("%H:%M"),
|
|
345
|
+
planned_time=planned.strftime("%H:%M"),
|
|
346
|
+
delay=int(stop.get("delaySeconds", 0) / 60),
|
|
347
|
+
platform=platform,
|
|
348
|
+
transportation_type=stop.get("transportType", "unknown"),
|
|
349
|
+
is_realtime=bool(stop.get("isRealtime")),
|
|
350
|
+
minutes_until_departure=max(0, int((estimated - now).total_seconds() / 60)),
|
|
351
|
+
departure_time_obj=estimated,
|
|
352
|
+
notices=stop.get("notices"),
|
|
353
|
+
planned_platform=planned_platform if platform_changed else None,
|
|
354
|
+
platform_changed=platform_changed,
|
|
355
|
+
)
|
|
356
|
+
except Exception as exc:
|
|
357
|
+
_LOGGER.debug("%s parse_departure error: %s", self.provider_name, exc)
|
|
358
|
+
return None
|
|
@@ -31,8 +31,9 @@ class KVVProvider(EFABaseProvider):
|
|
|
31
31
|
def get_transport_type_mapping(self) -> Dict[Any, str]:
|
|
32
32
|
return KVV_TRANSPORTATION_TYPES
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
# Platform comes from EFABaseProvider: location.properties.platform, with
|
|
35
|
+
# location.disassembledName ("Gleis 3") still a fallback and now exposed
|
|
36
|
+
# separately as platform_name.
|
|
36
37
|
|
|
37
38
|
def get_realtime_fn(self) -> Callable[[Dict[str, Any], Optional[str], Optional[str]], bool]:
|
|
38
39
|
return lambda s, est, plan: s.get("isRealtimeControlled", False)
|
|
@@ -23,6 +23,7 @@ src/openpublictransport/providers/gtfsde.py
|
|
|
23
23
|
src/openpublictransport/providers/hafas_base.py
|
|
24
24
|
src/openpublictransport/providers/hafas_mgate_base.py
|
|
25
25
|
src/openpublictransport/providers/hvv.py
|
|
26
|
+
src/openpublictransport/providers/hvv_gti.py
|
|
26
27
|
src/openpublictransport/providers/irishrail.py
|
|
27
28
|
src/openpublictransport/providers/kvv.py
|
|
28
29
|
src/openpublictransport/providers/mobiliteit_lu.py
|
|
@@ -56,9 +57,11 @@ src/python_openpublictransport.egg-info/SOURCES.txt
|
|
|
56
57
|
src/python_openpublictransport.egg-info/dependency_links.txt
|
|
57
58
|
src/python_openpublictransport.egg-info/requires.txt
|
|
58
59
|
src/python_openpublictransport.egg-info/top_level.txt
|
|
60
|
+
tests/test_efa_platform.py
|
|
59
61
|
tests/test_entur.py
|
|
60
62
|
tests/test_hafas_base.py
|
|
61
63
|
tests/test_hafas_mgate.py
|
|
64
|
+
tests/test_hvv_gti.py
|
|
62
65
|
tests/test_national_rail.py
|
|
63
66
|
tests/test_national_rail_parse.py
|
|
64
67
|
tests/test_national_rail_static.py
|