python-openpublictransport 0.1.15__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.15 → python_openpublictransport-0.1.16}/PKG-INFO +1 -1
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/pyproject.toml +1 -1
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/const.py +1 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/__init__.py +3 -0
- python_openpublictransport-0.1.16/src/openpublictransport/providers/hvv_gti.py +358 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/python_openpublictransport.egg-info/PKG-INFO +1 -1
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/python_openpublictransport.egg-info/SOURCES.txt +2 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/tests/test_efa_platform.py +4 -2
- python_openpublictransport-0.1.16/tests/test_hvv_gti.py +360 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/setup.cfg +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/__init__.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/data/uk_stations.json +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/exceptions.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/models.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/parsers.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/avv.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/bart.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/base.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/beg.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/bsvg.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/bvg.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/dart.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/db.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/ding.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/efa_base.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/entur.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/fptf_base.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/gtfsde.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/hafas_base.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/hafas_mgate_base.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/hvv.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/irishrail.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/kvv.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/mobiliteit_lu.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/mvv.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/national_rail.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/ns.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/nta.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/nvbw.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/nwl.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/oebb.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/otp.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/otp_base.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/otp_custom.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/rejseplanen.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/rmv.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/rvv.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/sbb.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/tpg.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/trafiklab.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/transitous.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/trias_base.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/vagfr.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/vbn.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/vgn.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/vrn.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/vrr.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/vvo.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/providers/vvs.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/openpublictransport/py.typed +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/python_openpublictransport.egg-info/dependency_links.txt +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/python_openpublictransport.egg-info/requires.txt +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/src/python_openpublictransport.egg-info/top_level.txt +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/tests/test_entur.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/tests/test_hafas_base.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/tests/test_hafas_mgate.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/tests/test_national_rail.py +0 -0
- {python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/tests/test_national_rail_parse.py +0 -0
- {python_openpublictransport-0.1.15 → 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" }
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -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
|
|
@@ -60,6 +61,7 @@ tests/test_efa_platform.py
|
|
|
60
61
|
tests/test_entur.py
|
|
61
62
|
tests/test_hafas_base.py
|
|
62
63
|
tests/test_hafas_mgate.py
|
|
64
|
+
tests/test_hvv_gti.py
|
|
63
65
|
tests/test_national_rail.py
|
|
64
66
|
tests/test_national_rail_parse.py
|
|
65
67
|
tests/test_national_rail_static.py
|
{python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/tests/test_efa_platform.py
RENAMED
|
@@ -183,6 +183,8 @@ def test_normalize_platform():
|
|
|
183
183
|
assert normalize_platform("Track 1") == "1"
|
|
184
184
|
assert normalize_platform(None) == ""
|
|
185
185
|
assert normalize_platform("") == ""
|
|
186
|
-
# A platform genuinely named "Gleis" keeps a usable value
|
|
187
|
-
assert normalize_platform("Gleis") == ""
|
|
188
186
|
assert normalize_platform(3) == "3"
|
|
187
|
+
# A bare label with no identifier after it normalizes away to nothing.
|
|
188
|
+
# Harmless: an empty value never takes part in a platform_changed
|
|
189
|
+
# decision, which requires both sides to be non-empty.
|
|
190
|
+
assert normalize_platform("Gleis") == ""
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Tests for the HVV Geofox GTI provider.
|
|
2
|
+
|
|
3
|
+
The official HOCHBAHN/HVV API. Payloads follow the examples in the GTI handbook
|
|
4
|
+
(https://gti.geofox.de/html/GTIHandbuch_p.html).
|
|
5
|
+
See https://github.com/NerdySoftPaw/openpublictransport/issues/61.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import hashlib
|
|
10
|
+
import hmac
|
|
11
|
+
import json
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from types import SimpleNamespace
|
|
14
|
+
from zoneinfo import ZoneInfo
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from openpublictransport.exceptions import AuthenticationError
|
|
19
|
+
from openpublictransport.providers import get_provider
|
|
20
|
+
from openpublictransport.providers.hvv_gti import HVVGTIProvider, sign_request
|
|
21
|
+
|
|
22
|
+
TZ = ZoneInfo("Europe/Berlin")
|
|
23
|
+
NOW = datetime(2026, 8, 1, 21, 28, tzinfo=TZ)
|
|
24
|
+
|
|
25
|
+
# From the handbook's departureList example, extended with the real-time fields.
|
|
26
|
+
DEPARTURE_LIST_RESPONSE = {
|
|
27
|
+
"returnCode": "OK",
|
|
28
|
+
"time": {"date": "01.08.2026", "time": "21:28"},
|
|
29
|
+
"departures": [
|
|
30
|
+
{
|
|
31
|
+
"line": {
|
|
32
|
+
"name": "476",
|
|
33
|
+
"direction": "Ahrensburg, Auestieg",
|
|
34
|
+
"type": {"simpleType": "BUS", "shortInfo": "Bus"},
|
|
35
|
+
"id": "VHH:476_VHH",
|
|
36
|
+
},
|
|
37
|
+
"timeOffset": 20,
|
|
38
|
+
"serviceId": 200023670,
|
|
39
|
+
"platform": "5",
|
|
40
|
+
"delay": 120,
|
|
41
|
+
"extra": False,
|
|
42
|
+
"cancelled": False,
|
|
43
|
+
"realtimePlatform": "5",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"line": {
|
|
47
|
+
"name": "U1",
|
|
48
|
+
"direction": "Norderstedt Mitte",
|
|
49
|
+
"type": {"simpleType": "TRAIN", "shortInfo": "U"},
|
|
50
|
+
"id": "HHA-U:U1_HHA-U",
|
|
51
|
+
},
|
|
52
|
+
"timeOffset": 4,
|
|
53
|
+
"serviceId": 200023671,
|
|
54
|
+
"platform": "2",
|
|
55
|
+
"realtimePlatform": "3",
|
|
56
|
+
"delay": 0,
|
|
57
|
+
"extra": False,
|
|
58
|
+
"cancelled": False,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"line": {
|
|
62
|
+
"name": "S1",
|
|
63
|
+
"direction": "Wedel",
|
|
64
|
+
"type": {"simpleType": "TRAIN", "shortInfo": "S"},
|
|
65
|
+
"id": "DBAG-S:S1_DBAG-S",
|
|
66
|
+
},
|
|
67
|
+
"timeOffset": 9,
|
|
68
|
+
"serviceId": 200023672,
|
|
69
|
+
"platform": "3",
|
|
70
|
+
"cancelled": True,
|
|
71
|
+
"attributes": [{"title": "Hinweis", "value": "Bauarbeiten"}],
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
"serviceTypes": ["BUS", "TRAIN"],
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
CHECK_NAME_RESPONSE = {
|
|
78
|
+
"returnCode": "OK",
|
|
79
|
+
"results": [
|
|
80
|
+
{
|
|
81
|
+
"name": "Christuskirche",
|
|
82
|
+
"city": "Hamburg",
|
|
83
|
+
"combinedName": "Christuskirche",
|
|
84
|
+
"id": "Master:84902",
|
|
85
|
+
"type": "STATION",
|
|
86
|
+
"coordinate": {"x": 9.93454, "y": 53.552405},
|
|
87
|
+
"serviceTypes": ["bus", "u"],
|
|
88
|
+
"hasStationInformation": True,
|
|
89
|
+
},
|
|
90
|
+
# Non-station suggestions must be filtered out
|
|
91
|
+
{"name": "Christuskirche 1", "city": "Hamburg", "id": "Coord:9.9,53.5", "type": "ADDRESS"},
|
|
92
|
+
],
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _FakeResponse:
|
|
97
|
+
def __init__(self, status, payload):
|
|
98
|
+
self.status = status
|
|
99
|
+
self._payload = payload
|
|
100
|
+
|
|
101
|
+
async def json(self, content_type=None):
|
|
102
|
+
return self._payload
|
|
103
|
+
|
|
104
|
+
async def __aenter__(self):
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
async def __aexit__(self, *exc):
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _FakeSession:
|
|
112
|
+
"""Records the exact bytes and headers of each POST."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, status=200, payload=None):
|
|
115
|
+
self.status = status
|
|
116
|
+
self.payload = payload if payload is not None else {"returnCode": "OK"}
|
|
117
|
+
self.calls = []
|
|
118
|
+
|
|
119
|
+
def post(self, url, data=None, headers=None, timeout=None):
|
|
120
|
+
self.calls.append(SimpleNamespace(url=url, body=data, headers=headers))
|
|
121
|
+
return _FakeResponse(self.status, self.payload)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _provider(session) -> HVVGTIProvider:
|
|
125
|
+
return HVVGTIProvider(session, api_key="testuser", api_key_secondary="s3cr3t")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ── authentication ────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_sign_request_matches_hmac_sha1_base64():
|
|
132
|
+
body = b'{"version":63}'
|
|
133
|
+
expected = base64.b64encode(hmac.new(b"s3cr3t", body, hashlib.sha1).digest()).decode()
|
|
134
|
+
|
|
135
|
+
assert sign_request("s3cr3t", body) == expected
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def test_signature_covers_the_exact_bytes_sent():
|
|
139
|
+
"""The body must be signed and transmitted as the same bytes.
|
|
140
|
+
|
|
141
|
+
Re-serialising the payload (e.g. handing aiohttp `json=`) would change the
|
|
142
|
+
bytes and the server would reject the signature.
|
|
143
|
+
"""
|
|
144
|
+
session = _FakeSession(payload=CHECK_NAME_RESPONSE)
|
|
145
|
+
await _provider(session).search_stops("Christuskirche")
|
|
146
|
+
|
|
147
|
+
call = session.calls[0]
|
|
148
|
+
assert isinstance(call.body, bytes)
|
|
149
|
+
assert call.headers["geofox-auth-signature"] == sign_request("s3cr3t", call.body)
|
|
150
|
+
assert call.headers["geofox-auth-type"] == "HmacSHA1"
|
|
151
|
+
assert call.headers["geofox-auth-user"] == "testuser"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def test_request_pins_the_api_version():
|
|
155
|
+
session = _FakeSession(payload=CHECK_NAME_RESPONSE)
|
|
156
|
+
await _provider(session).search_stops("Christuskirche")
|
|
157
|
+
|
|
158
|
+
body = json.loads(session.calls[0].body.decode("utf-8"))
|
|
159
|
+
assert body["version"] == 63
|
|
160
|
+
assert body["language"] == "de"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def test_missing_credentials_raise():
|
|
164
|
+
provider = HVVGTIProvider(_FakeSession(), api_key="testuser", api_key_secondary=None)
|
|
165
|
+
|
|
166
|
+
with pytest.raises(AuthenticationError):
|
|
167
|
+
await provider.fetch_departures("Master:84902", "", "", 10)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@pytest.mark.parametrize("status", [401, 403])
|
|
171
|
+
async def test_http_401_403_raise_authentication_error(status):
|
|
172
|
+
provider = _provider(_FakeSession(status=status))
|
|
173
|
+
|
|
174
|
+
with pytest.raises(AuthenticationError):
|
|
175
|
+
await provider.search_stops("Christuskirche")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def test_non_ok_return_code_is_not_fatal():
|
|
179
|
+
session = _FakeSession(payload={"returnCode": "ERROR_TEXT", "errorText": "Unbekannte Haltestelle"})
|
|
180
|
+
|
|
181
|
+
assert await _provider(session).search_stops("Nirgendwo") == []
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def test_http_error_returns_no_departures():
|
|
185
|
+
provider = _provider(_FakeSession(status=500))
|
|
186
|
+
|
|
187
|
+
assert await provider.fetch_departures("Master:84902", "", "", 10) is None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ── stop search ───────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def test_search_stops_keeps_only_stations():
|
|
194
|
+
session = _FakeSession(payload=CHECK_NAME_RESPONSE)
|
|
195
|
+
|
|
196
|
+
stops = await _provider(session).search_stops("Christuskirche")
|
|
197
|
+
|
|
198
|
+
assert stops == [
|
|
199
|
+
{"id": "Master:84902", "name": "Christuskirche", "place": "Hamburg", "area_type": "stop"}
|
|
200
|
+
]
|
|
201
|
+
assert session.calls[0].url == "https://gti.geofox.de/gti/public/checkName"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ── departures ────────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def _departures(limit=10):
|
|
208
|
+
session = _FakeSession(payload=DEPARTURE_LIST_RESPONSE)
|
|
209
|
+
provider = _provider(session)
|
|
210
|
+
data = await provider.fetch_departures("Master:84902", "", "", limit)
|
|
211
|
+
return provider, session, data
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def test_departure_list_request_shape():
|
|
215
|
+
_, session, _ = await _departures()
|
|
216
|
+
|
|
217
|
+
body = json.loads(session.calls[0].body.decode("utf-8"))
|
|
218
|
+
assert session.calls[0].url == "https://gti.geofox.de/gti/public/departureList"
|
|
219
|
+
assert body["station"] == {"id": "Master:84902", "type": "STATION"}
|
|
220
|
+
assert body["useRealtime"] is True
|
|
221
|
+
assert body["maxTimeOffset"] == 200
|
|
222
|
+
# GTITime is dd.MM.yyyy / HH:mm, not ISO
|
|
223
|
+
assert len(body["time"]["date"].split(".")) == 3
|
|
224
|
+
assert ":" in body["time"]["time"]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
async def test_station_id_is_required():
|
|
228
|
+
provider = _provider(_FakeSession(payload=DEPARTURE_LIST_RESPONSE))
|
|
229
|
+
|
|
230
|
+
assert await provider.fetch_departures(None, "Hamburg", "Christuskirche", 10) is None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
async def test_departures_are_sorted_and_limited():
|
|
234
|
+
_, _, data = await _departures(limit=2)
|
|
235
|
+
|
|
236
|
+
assert [event["line"] for event in data["stopEvents"]] == ["U1", "S1"]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
async def test_time_offset_minutes_plus_delay_seconds():
|
|
240
|
+
"""planned = board reference + timeOffset; estimated adds the delay.
|
|
241
|
+
|
|
242
|
+
Matches how Home Assistant core's own hvv_departures integration reads
|
|
243
|
+
these two fields.
|
|
244
|
+
"""
|
|
245
|
+
provider, _, data = await _departures()
|
|
246
|
+
bus = next(event for event in data["stopEvents"] if event["line"] == "476")
|
|
247
|
+
|
|
248
|
+
departure = provider.parse_departure(bus, TZ, NOW)
|
|
249
|
+
|
|
250
|
+
assert departure.planned_time == "21:48" # 21:28 + 20 min
|
|
251
|
+
assert departure.departure_time == "21:50" # + 120 s
|
|
252
|
+
assert departure.delay == 2
|
|
253
|
+
assert departure.is_realtime is True
|
|
254
|
+
assert departure.minutes_until_departure == 22
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
async def test_realtime_platform_wins_and_flags_a_change():
|
|
258
|
+
provider, _, data = await _departures()
|
|
259
|
+
subway = next(event for event in data["stopEvents"] if event["line"] == "U1")
|
|
260
|
+
|
|
261
|
+
departure = provider.parse_departure(subway, TZ, NOW)
|
|
262
|
+
|
|
263
|
+
assert departure.platform == "3"
|
|
264
|
+
assert departure.planned_platform == "2"
|
|
265
|
+
assert departure.platform_changed is True
|
|
266
|
+
assert departure.to_dict()["platform_changed"] is True
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
async def test_unchanged_platform_is_not_a_change():
|
|
270
|
+
provider, _, data = await _departures()
|
|
271
|
+
bus = next(event for event in data["stopEvents"] if event["line"] == "476")
|
|
272
|
+
|
|
273
|
+
departure = provider.parse_departure(bus, TZ, NOW)
|
|
274
|
+
|
|
275
|
+
assert departure.platform == "5"
|
|
276
|
+
assert departure.platform_changed is False
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
async def test_cancellation_and_attributes_become_notices():
|
|
280
|
+
provider, _, data = await _departures()
|
|
281
|
+
s_bahn = next(event for event in data["stopEvents"] if event["line"] == "S1")
|
|
282
|
+
|
|
283
|
+
departure = provider.parse_departure(s_bahn, TZ, NOW)
|
|
284
|
+
|
|
285
|
+
assert "Bauarbeiten" in departure.notices
|
|
286
|
+
assert "Fahrt fällt aus" in departure.notices
|
|
287
|
+
# No `delay` field at all → not real-time controlled
|
|
288
|
+
assert departure.is_realtime is False
|
|
289
|
+
assert departure.delay == 0
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@pytest.mark.parametrize(
|
|
293
|
+
("line", "expected"),
|
|
294
|
+
[
|
|
295
|
+
({"name": "U1", "type": {"simpleType": "TRAIN", "shortInfo": "U"}}, "subway"),
|
|
296
|
+
({"name": "S1", "type": {"simpleType": "TRAIN", "shortInfo": "S"}}, "train"),
|
|
297
|
+
({"name": "A1", "type": {"simpleType": "TRAIN", "shortInfo": "AKN"}}, "train"),
|
|
298
|
+
({"name": "476", "type": {"simpleType": "BUS"}}, "bus"),
|
|
299
|
+
({"name": "X35", "type": {"simpleType": "BUS", "shortInfo": "XpressBus"}}, "bus"),
|
|
300
|
+
({"name": "62", "type": {"simpleType": "SHIP", "shortInfo": "Fähre"}}, "ferry"),
|
|
301
|
+
({"name": "8000", "type": {"simpleType": "BUS", "shortInfo": "AST"}}, "taxi"),
|
|
302
|
+
# Coarse TRAIN with no shortInfo — the line name settles it
|
|
303
|
+
({"name": "U3", "type": {"simpleType": "TRAIN"}}, "subway"),
|
|
304
|
+
({"name": "S31", "type": {"simpleType": "TRAIN"}}, "train"),
|
|
305
|
+
({"name": "???", "type": {}}, "unknown"),
|
|
306
|
+
],
|
|
307
|
+
)
|
|
308
|
+
def test_transport_type_mapping(line, expected):
|
|
309
|
+
from openpublictransport.providers.hvv_gti import _transport_type
|
|
310
|
+
|
|
311
|
+
assert _transport_type(line) == expected
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
async def test_departure_without_time_offset_is_skipped():
|
|
315
|
+
session = _FakeSession(
|
|
316
|
+
payload={
|
|
317
|
+
"returnCode": "OK",
|
|
318
|
+
"time": {"date": "01.08.2026", "time": "21:28"},
|
|
319
|
+
"departures": [{"line": {"name": "476"}}],
|
|
320
|
+
}
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
data = await _provider(session).fetch_departures("Master:84902", "", "", 10)
|
|
324
|
+
|
|
325
|
+
assert data == {"stopEvents": []}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
async def test_unparseable_reference_time_falls_back():
|
|
329
|
+
session = _FakeSession(
|
|
330
|
+
payload={
|
|
331
|
+
"returnCode": "OK",
|
|
332
|
+
"time": {"date": "nonsense", "time": "??"},
|
|
333
|
+
"departures": [{"line": {"name": "476", "type": {"simpleType": "BUS"}}, "timeOffset": 5}],
|
|
334
|
+
}
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
data = await _provider(session).fetch_departures("Master:84902", "", "", 10)
|
|
338
|
+
|
|
339
|
+
assert len(data["stopEvents"]) == 1
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ── registry ──────────────────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_provider_is_registered_and_needs_a_key():
|
|
346
|
+
provider = get_provider("hvv_gti", None, api_key="u", api_key_secondary="p")
|
|
347
|
+
|
|
348
|
+
assert isinstance(provider, HVVGTIProvider)
|
|
349
|
+
assert provider.requires_api_key is True
|
|
350
|
+
assert provider.get_timezone() == "Europe/Berlin"
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def test_efa_hvv_provider_is_untouched():
|
|
354
|
+
"""The existing keyless `hvv` provider must keep working — the IDs are load-bearing."""
|
|
355
|
+
from openpublictransport.providers.hvv import HVVProvider
|
|
356
|
+
|
|
357
|
+
provider = get_provider("hvv", None)
|
|
358
|
+
|
|
359
|
+
assert isinstance(provider, HVVProvider)
|
|
360
|
+
assert provider.requires_api_key is False
|
|
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
|
|
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
|
{python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/tests/test_hafas_base.py
RENAMED
|
File without changes
|
{python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/tests/test_hafas_mgate.py
RENAMED
|
File without changes
|
{python_openpublictransport-0.1.15 → python_openpublictransport-0.1.16}/tests/test_national_rail.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|