Skip to content

Commit 946763f

Browse files
committed
Add a profile for GVH (Großraumverkehr Hannover) and tests for it. FahrplanDatenGarten#54
The GVH doesn't use plain HAFAS but a HAMM, so e.g. the ID format is different. The data format was reverse engineered from the web app at: https://gvh.hafas.de
1 parent 4e91ffd commit 946763f

13 files changed

+503
-0
lines changed

pyhafas/profile/gvh/__init__.py

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import pytz
2+
3+
from pyhafas.profile import BaseProfile
4+
from pyhafas.profile.gvh.helper.parse_lid import GVHParseLidHelper
5+
from pyhafas.profile.gvh.requests.journey import GVHJourneyRequest
6+
from pyhafas.profile.gvh.requests.journeys import GVHJourneysRequest
7+
from pyhafas.profile.gvh.requests.station_board import GVHStationBoardRequest
8+
9+
10+
class GVHProfile(BaseProfile, GVHParseLidHelper, GVHStationBoardRequest, GVHJourneysRequest, GVHJourneyRequest):
11+
"""
12+
Profile of the HaFAS of Großraumverkehr Hannover (GVH) - regional in Hannover area
13+
"""
14+
baseUrl = "https://gvh.hafas.de/hamm"
15+
defaultUserAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0"
16+
17+
locale = 'de-DE'
18+
timezone = pytz.timezone('Europe/Berlin')
19+
20+
requestBody = {
21+
'client': {
22+
'id': 'HAFAS',
23+
'l': 'vs_webapp',
24+
'name': 'webapp',
25+
'type': 'WEB',
26+
'v': '10109'
27+
},
28+
'ver': '1.62',
29+
'lang': 'deu',
30+
'auth': {
31+
'type': 'AID',
32+
'aid': 'IKSEvZ1SsVdfIRSK'
33+
}
34+
}
35+
36+
availableProducts = {
37+
"ice": [1],
38+
"ic-ec": [2, 4],
39+
"re-rb": [8],
40+
"s-bahn": [16],
41+
"stadtbahn": [256],
42+
"bus": [32],
43+
"on-demand": [512]
44+
}
45+
46+
defaultProducts = [
47+
"ice",
48+
"ic-ec",
49+
"re-rb",
50+
"s-bahn",
51+
"stadtbahn",
52+
"bus",
53+
"on-demand"
54+
]
+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from pyhafas.profile import ProfileInterface
2+
from pyhafas.profile.base import BaseParseLidHelper
3+
from pyhafas.types.fptf import Station
4+
5+
6+
class GVHParseLidHelper(BaseParseLidHelper):
7+
def parse_lid(self: ProfileInterface, lid: str) -> dict:
8+
"""
9+
Converts the LID given by HaFAS
10+
11+
This implementation only returns the LID inside a dict
12+
because GVH doesn't have normal HaFAS IDs but only HAMM IDs.
13+
14+
:param lid: Location identifier (given by HaFAS)
15+
:return: Dict wrapping the given LID
16+
"""
17+
return {"lid": lid}
18+
19+
def parse_lid_to_station(
20+
self: ProfileInterface,
21+
lid: str,
22+
name: str = "",
23+
latitude: float = 0,
24+
longitude: float = 0) -> Station:
25+
"""
26+
Parses the LID given by HaFAS to a station object
27+
28+
:param lid: Location identifier (given by HaFAS)
29+
:param name: Station name (optional, if not given, empty string is used)
30+
:param latitude: Latitude of the station (optional, if not given, 0 is used)
31+
:param longitude: Longitude of the station (optional, if not given, 0 is used)
32+
:return: Parsed LID as station object
33+
"""
34+
return Station(
35+
id=lid,
36+
lid=lid,
37+
name=name,
38+
latitude=latitude,
39+
longitude=longitude
40+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from typing import Union
2+
3+
4+
def find(station_name_by_lid: dict[str, str], lid: str, id: str) -> Union[str, None]:
5+
to_search = lid if lid else id
6+
for entry in station_name_by_lid.items():
7+
if to_search.startswith(entry[0]):
8+
return entry[1]
9+
return None
+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from io import UnsupportedOperation
2+
3+
from pyhafas.profile import ProfileInterface
4+
from pyhafas.profile.base import BaseJourneyRequest
5+
from pyhafas.profile.gvh.helper.station_names import find
6+
from pyhafas.profile.interfaces.requests.journey import JourneyRequestInterface
7+
from pyhafas.types.fptf import Journey
8+
from pyhafas.types.hafas_response import HafasResponse
9+
10+
11+
class GVHJourneyRequest(BaseJourneyRequest):
12+
def format_journey_request(
13+
self: ProfileInterface,
14+
journey: Journey) -> dict:
15+
"""
16+
Creates the HAFAS / HAMM request for refreshing journey details
17+
:param journey: Id of the journey (ctxRecon)
18+
:return: Request for HAFAS (KVB-deployment)
19+
"""
20+
return {
21+
'req': {
22+
'outReconL': [{
23+
'ctx': journey.id
24+
}]
25+
},
26+
'meth': 'Reconstruction'
27+
}
28+
29+
def parse_journey_request(self: ProfileInterface, data: HafasResponse) -> Journey:
30+
"""
31+
Parses the HaFAS response for a journey request
32+
:param data: Formatted HaFAS response
33+
:return: List of Journey objects
34+
"""
35+
date = self.parse_date(data.res['outConL'][0]['date'])
36+
37+
# station details
38+
station_name_by_lid = dict()
39+
for loc in data.common['locL']:
40+
station_name_by_lid[loc['lid']] = loc['name']
41+
42+
journey = Journey(data.res['outConL'][0]['recon']['ctx'], date=date,
43+
duration=self.parse_timedelta(data.res['outConL'][0]['dur']),
44+
legs=self.parse_legs(data.res['outConL'][0], data.common, date))
45+
for leg in journey.legs:
46+
leg.origin.name = find(station_name_by_lid, leg.origin.lid, leg.origin.id)
47+
leg.destination.name = find(station_name_by_lid, leg.destination.lid, leg.destination.id)
48+
for stopover in leg.stopovers:
49+
stopover.stop.name = find(station_name_by_lid, stopover.stop.lid, stopover.stop.id)
50+
return journey
+139
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import datetime
2+
from typing import Dict, List, Union
3+
4+
from pyhafas.profile import ProfileInterface
5+
from pyhafas.profile.base import BaseJourneysRequest
6+
from pyhafas.profile.gvh.helper.station_names import find
7+
from pyhafas.profile.interfaces.requests.journeys import \
8+
JourneysRequestInterface
9+
from pyhafas.types.fptf import Journey, Station, Leg
10+
from pyhafas.types.hafas_response import HafasResponse
11+
12+
13+
class GVHJourneysRequest(BaseJourneysRequest):
14+
def format_journeys_request(
15+
self: ProfileInterface,
16+
origin: Station,
17+
destination: Station,
18+
via: List[Station],
19+
date: datetime.datetime,
20+
min_change_time: int,
21+
max_changes: int,
22+
products: Dict[str, bool],
23+
max_journeys: int
24+
) -> dict:
25+
"""
26+
Creates the HaFAS request body for a journeys request
27+
28+
:param origin: Origin station
29+
:param destination: Destionation station
30+
:param via: Via stations, maybe empty list)
31+
:param date: Date and time to search journeys for
32+
:param min_change_time: Minimum transfer/change time at each station
33+
:param max_changes: Maximum number of changes
34+
:param products: Allowed products (a product is a mean of transport like ICE,IC)
35+
:param max_journeys: Maximum number of returned journeys
36+
:return: Request body for HaFAS
37+
"""
38+
return {
39+
'req': {
40+
'arrLocL': [{
41+
'lid': destination.lid if destination.lid else destination.id
42+
}],
43+
'viaLocL': [{
44+
'loc': {
45+
'lid': via_station.lid if via_station.lid else via_station.id
46+
}
47+
} for via_station in via],
48+
'depLocL': [{
49+
'lid': origin.lid if origin.lid else origin.id
50+
}],
51+
'outDate': date.strftime("%Y%m%d"),
52+
'outTime': date.strftime("%H%M%S"),
53+
'jnyFltrL': [
54+
self.format_products_filter(products)
55+
],
56+
'minChgTime': min_change_time,
57+
'maxChg': max_changes,
58+
'numF': max_journeys,
59+
},
60+
'meth': 'TripSearch'
61+
}
62+
63+
def format_search_from_leg_request(
64+
self: ProfileInterface,
65+
origin: Leg,
66+
destination: Station,
67+
via: List[Station],
68+
min_change_time: int,
69+
max_changes: int,
70+
products: Dict[str, bool],
71+
) -> dict:
72+
"""
73+
Creates the HaFAS request body for a journeys request
74+
75+
:param origin: Origin leg
76+
:param destination: Destionation station
77+
:param via: Via stations, maybe empty list)
78+
:param min_change_time: Minimum transfer/change time at each station
79+
:param max_changes: Maximum number of changes
80+
:param products: Allowed products (a product is a mean of transport like ICE,IC)
81+
:return: Request body for HaFAS
82+
"""
83+
return {
84+
'req': {
85+
'arrLocL': [{
86+
'lid': destination.lid if destination.lid else destination.id
87+
}],
88+
'viaLocL': [{
89+
'loc': {
90+
'lid': via_station.lid if via_station.lid else via_station.id
91+
}
92+
} for via_station in via],
93+
'locData': {
94+
'loc': {
95+
'lid': origin.lid if origin.lid else origin.id
96+
},
97+
'type': 'DEP',
98+
'date': origin.departure.strftime("%Y%m%d"),
99+
'time': origin.departure.strftime("%H%M%S")
100+
},
101+
'jnyFltrL': [
102+
self.format_products_filter(products)
103+
],
104+
'minChgTime': min_change_time,
105+
'maxChg': max_changes,
106+
'jid': origin.id,
107+
'sotMode': 'JI'
108+
},
109+
'meth': 'SearchOnTrip'
110+
}
111+
112+
def parse_journeys_request(
113+
self: ProfileInterface,
114+
data: HafasResponse) -> List[Journey]:
115+
"""
116+
Parses the HaFAS response for a journeys request
117+
118+
:param data: Formatted HaFAS response
119+
:return: List of Journey objects
120+
"""
121+
journeys = []
122+
123+
# station details
124+
station_name_by_lid = dict()
125+
for loc in data.common['locL']:
126+
station_name_by_lid[loc['lid']] = loc['name']
127+
128+
# journeys
129+
for jny in data.res['outConL']:
130+
date = self.parse_date(jny['date'])
131+
journey = Journey(jny['recon']['ctx'], date=date, duration=self.parse_timedelta(jny['dur']),
132+
legs=self.parse_legs(jny, data.common, date))
133+
for leg in journey.legs:
134+
leg.origin.name = find(station_name_by_lid, leg.origin.lid, leg.origin.id)
135+
leg.destination.name = find(station_name_by_lid, leg.destination.lid, leg.destination.id)
136+
for stopover in leg.stopovers:
137+
stopover.stop.name = find(station_name_by_lid, stopover.stop.lid, stopover.stop.id)
138+
journeys.append(journey)
139+
return journeys
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import datetime
2+
from typing import Dict, Optional, List
3+
4+
from pyhafas.profile import ProfileInterface
5+
from pyhafas.profile.base import BaseStationBoardRequest
6+
from pyhafas.types.fptf import Station, StationBoardLeg
7+
from pyhafas.types.hafas_response import HafasResponse
8+
from pyhafas.types.station_board_request import StationBoardRequestType
9+
10+
11+
class GVHStationBoardRequest(BaseStationBoardRequest):
12+
def format_station_board_request(
13+
self: ProfileInterface,
14+
station: Station,
15+
request_type: StationBoardRequestType,
16+
date: datetime.datetime,
17+
max_trips: int,
18+
duration: int,
19+
products: Dict[str, bool],
20+
direction: Optional[Station]
21+
) -> dict:
22+
"""
23+
Creates the HaFAS request for a station board request (departure/arrival)
24+
25+
:param station: Station to get departures/arrivals for
26+
:param request_type: ARRIVAL or DEPARTURE
27+
:param date: Date and time to get departures/arrival for
28+
:param max_trips: Maximum number of trips that can be returned
29+
:param products: Allowed products (e.g. ICE,IC)
30+
:param duration: Time in which trips are searched
31+
:param direction: Direction (end) station of the train. If none, filter will not be applied
32+
:return: Request body for HaFAS
33+
"""
34+
return {
35+
'req': {
36+
'type': request_type.value,
37+
'stbLoc': {
38+
'lid': station.lid if station.lid else station.id
39+
},
40+
'dirLoc': {
41+
'lid': direction.lid if direction.lid else direction.id
42+
} if direction is not None else None,
43+
'maxJny': max_trips,
44+
'date': date.strftime("%Y%m%d"),
45+
'time': date.strftime("%H%M%S"),
46+
'dur': duration,
47+
'jnyFltrL': [
48+
self.format_products_filter(products)
49+
],
50+
},
51+
'meth': 'StationBoard'
52+
}
53+
54+
def parse_station_board_request(
55+
self: ProfileInterface,
56+
data: HafasResponse,
57+
departure_arrival_prefix: str) -> List[StationBoardLeg]:
58+
"""
59+
Parses the HaFAS data for a station board request
60+
61+
:param data: Formatted HaFAS response
62+
:param departure_arrival_prefix: Prefix for specifying whether its for arrival or departure (either "a" or "d")
63+
:return: List of StationBoardLeg objects
64+
"""
65+
legs = []
66+
if not data.res.get('jnyL', False):
67+
return legs
68+
else:
69+
for raw_leg in data.res['jnyL']:
70+
date = self.parse_date(raw_leg['date'])
71+
72+
try:
73+
platform = raw_leg['stbStop'][departure_arrival_prefix + 'PltfR']['txt'] if \
74+
raw_leg['stbStop'].get(departure_arrival_prefix + 'PltfR') is not None else \
75+
raw_leg['stbStop'][departure_arrival_prefix + 'PltfS']['txt']
76+
except KeyError:
77+
platform = raw_leg['stbStop'].get(
78+
departure_arrival_prefix + 'PlatfR',
79+
raw_leg['stbStop'].get(
80+
departure_arrival_prefix + 'PlatfS',
81+
None))
82+
83+
legs.append(StationBoardLeg(
84+
id=raw_leg['jid'],
85+
name=data.common['prodL'][raw_leg['prodX']]['name'],
86+
direction=raw_leg.get('dirTxt'),
87+
date_time=self.parse_datetime(
88+
raw_leg['stbStop'][departure_arrival_prefix + 'TimeS'],
89+
date
90+
),
91+
station=self.parse_lid_to_station(data.common['locL'][raw_leg['stbStop']['locX']]['lid'],
92+
name=data.common['locL'][raw_leg['stbStop']['locX']]['name']),
93+
platform=platform,
94+
delay=self.parse_datetime(
95+
raw_leg['stbStop'][departure_arrival_prefix + 'TimeR'],
96+
date) - self.parse_datetime(
97+
raw_leg['stbStop'][departure_arrival_prefix + 'TimeS'],
98+
date) if raw_leg['stbStop'].get(departure_arrival_prefix + 'TimeR') is not None else None,
99+
cancelled=bool(raw_leg['stbStop'].get(departure_arrival_prefix + 'Cncl', False))
100+
))
101+
return legs

tests/gvh/__init__.py

Whitespace-only changes.

tests/gvh/request/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)