Repository: FahrplanDatenGarten/pyhafas (language: python)

Issue:
Profile for NVV
I've added a Profile for NVV the Nordhessischer Verkehrs Verbund.
I wanted to ask as well if there are any plans to add support for further HaFAs instances in Germany.

Greetings
Leon
Profile for NVV
This PR adds a Profile for NVV the Nordhessischer Verkehrs Verbund.
Based on the work in #12 I added some tests to verify the working profile, so it can be added to pyhafas.


Discussion / hints:

Fix patch:
diff --git a/pyhafas/profile/__init__.py b/pyhafas/profile/__init__.py
index 00b8838..18f35de 100644
--- a/pyhafas/profile/__init__.py
+++ b/pyhafas/profile/__init__.py
@@ -5,3 +5,4 @@
 from .rkrp import RKRPProfile
 from .nasa import NASAProfile
 from .kvb import KVBProfile
+from .nvv import NVVProfile
diff --git a/pyhafas/profile/nvv/__init__.py b/pyhafas/profile/nvv/__init__.py
new file mode 100644
index 0000000..c533df3
--- /dev/null
+++ b/pyhafas/profile/nvv/__init__.py
@@ -0,0 +1,54 @@
+import pytz
+
+from pyhafas.profile.base import BaseProfile
+from pyhafas.profile.nvv.requests.journey import NVVJourneyRequest
+from pyhafas.profile.nvv.requests.journeys import NVVJourneysRequest
+
+
+class NVVProfile(NVVJourneyRequest, NVVJourneysRequest, BaseProfile):
+    """
+    Profile for the HaFAs of "Nordhessischer Verkehrs Verbund"
+    (NVV) - local transportation provider
+    """
+    baseUrl = "https://auskunft.nvv.de/bin/mgate.exe"
+    defaultUserAgent = "NVV Mobil/5.3.1 (iPhone; IOS 13.1.2; Scale/2.00)"
+
+    addMicMac = False
+    addChecksum = False
+
+    locale = "de-DE"
+    timezone = pytz.timezone("Europe/Berlin")
+
+    requestBody = {
+        "client": {
+            "id": "NVV",
+            "type": "WEB",
+            "name": "webapp"
+        },
+        "ver": "1.39",
+        "lang": "deu",
+        "auth":{
+            "type": "AID",
+            "aid": "R7aKWQLVBRSoVRtY"
+        }
+    }
+
+    availableProducts = {
+        'long_distance_express': [1],   # ICE
+        'long_distance': [2],           # IC/EC
+        'regional_express': [4],        # RE/RB
+        'tram': [32],                   # Tram
+        'bus': [64, 128],               # Bus
+        'anruf_sammel_taxi': [512],     # Group Taxi
+        'regio_tram': [1024]            # Tram / regional express hybrid
+    }
+
+    defaultProducts = [
+        'long_distance_express',
+        'long_distance',
+        'regional_express',
+        'bus',
+        'tram',
+        'anruf_sammel_taxi',
+        'regio_tram'
+    ]
diff --git a/pyhafas/profile/nvv/requests/__init__.py b/pyhafas/profile/nvv/requests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pyhafas/profile/nvv/requests/journey.py b/pyhafas/profile/nvv/requests/journey.py
new file mode 100644
index 0000000..c42b720
--- /dev/null
+++ b/pyhafas/profile/nvv/requests/journey.py
@@ -0,0 +1,45 @@
+from pyhafas.profile import ProfileInterface
+from pyhafas.profile.base import BaseJourneyRequest
+from pyhafas.profile.interfaces.requests.journey import JourneyRequestInterface
+from pyhafas.types.fptf import Journey
+from pyhafas.types.hafas_response import HafasResponse
+
+
+class NVVJourneyRequest(BaseJourneyRequest, JourneyRequestInterface):
+    def format_journey_request(
+            self: ProfileInterface,
+            journey: Journey) -> dict:
+        """
+        Creates the HAFAS (NVV-deployment) request for refreshing journey details
+
+        :param journey: Id of the journey (ctxRecon)
+        :return: Request for HAFAS (NVV-deployment)
+        """
+        return {
+            'req': {
+                'outReconL': [{
+                    'ctx': journey.id
+                }]
+            },
+            'meth': 'Reconstruction'
+        }
+
+    def parse_journey_request(
+            self: ProfileInterface,
+            data: HafasResponse) -> Journey:
+        """
+        Parses the HaFAS response for a journey request
+
+        :param data: Formatted HaFAS response
+        :return: List of Journey objects
+        """
+        date = self.parse_date(data.res['outConL'][0]['date'])
+        return Journey(
+            data.res['outConL'][0]['recon']['ctx'],
+            date=date,
+            duration=self.parse_timedelta(
+                data.res['outConL'][0]['dur']),
+            legs=self.parse_legs(
+                data.res['outConL'][0],
+                data.common,
+                date))
diff --git a/pyhafas/profile/nvv/requests/journeys.py b/pyhafas/profile/nvv/requests/journeys.py
new file mode 100644
index 0000000..9cd978c
--- /dev/null
+++ b/pyhafas/profile/nvv/requests/journeys.py
@@ -0,0 +1,13 @@
+from pyhafas.profile import ProfileInterface
+from pyhafas.profile.interfaces.requests.journeys import JourneysRequestInterface
+from pyhafas.profile.base import BaseJourneysRequest
+from pyhafas.types.hafas_response import HafasResponse
+
+
+class NVVJourneysRequest(BaseJourneysRequest, JourneysRequestInterface):
+    def parse_journeys_request(self:ProfileInterface, data: HafasResponse):
+        for jny in data.res["outConL"]:
+            jny["ctxRecon"] = jny["recon"]["ctx"]
+
+        return super().parse_journeys_request(data)
+        
\ No newline at end of file


Test patch:
diff --git a/tests/nvv/__init__.py b/tests/nvv/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/nvv/parsing/__init__.py b/tests/nvv/parsing/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/nvv/parsing/departures_raw.json b/tests/nvv/parsing/departures_raw.json
new file mode 100644
index 0000000..d0a0e9a
--- /dev/null
+++ b/tests/nvv/parsing/departures_raw.json
@@ -0,0 +1,1 @@
+{"ver":"1.39","ext":"NVV.6.0","lang":"deu","id":"nm6x8zq226xbw24k","err":"OK","graph":{"id":"standard","index":0},"subGraph":{"id":"global","index":0},"view":{"id":"standard","index":0,"type":"WGS84"},"svcResL":[{"id":"1|7|","meth":"StationBoard","err":"OK","res":{"common":{"locL":[{"lid":"A=1@O=Kassel Keilsbergstraße@X=9455942@Y=51279199@U=80@L=2200048@","type":"S","name":"Kassel Keilsbergstraße","icoX":6,"extId":"2200048","state":"F","crd":{"x":9455942,"y":51279199,"floor":0},"pCls":1636,"pRefL":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15],"msgL":[{"type":"REM","remX":0,"sty":"I","dspl":"U","tagL":["RES_LOC_L"],"sort":684195840}],"gidL":["A×de:06611:200048"],"chgTime":"000500"},{"lid":"A=1@O=Baunatal-Großenritte Bahnhof@X=9391130@Y=51251890@U=80@L=2203069@","type":"S","name":"Baunatal-Großenritte Bahnhof","icoX":6,"extId":"2203069","state":"F","crd":{"x":9391130,"y":51251890,"floor":0},"pCls":672,"msgL":[{"type":"REM","remX":1,"sty":"I","dspl":"U","tagL":["RES_LOC_L"],"sort":684195840}],"gidL":["A×de:06633:203069"],"chgTime":"000500"}],"prodL":[{"pid":"L::5::Tram::B3736316743::de:nvv:Tram|5:::*","name":"Tram 5","nameS":"5","number":"5","icoX":0,"cls":32,"oprX":0,"prodCtx":{"name":"Tram 5  ","num":"677","line":"5","lineId":"de:nvv:Tram|5:","matchId":"5","catOut":"Tram    ","catOutS":"T","catOutL":"Straßenbahn","catIn":"T","catCode":"5","admin":"KTR---"},"himIdL":["HIM_FREETEXT_7223","HIM_FREETEXT_7229"]},{"name":"RB5","nameS":"RB5","icoX":3,"cls":4,"prodCtx":{"name":"RB5","line":"RB5","lineId":"de:nvv:RB|RB5","catOut":"RB      ","catOutS":"RBU","catOutL":"RB"}},{"name":"RB38","nameS":"RB38","icoX":3,"cls":4,"prodCtx":{"name":"RB38","line":"RB38","lineId":"de:nvv:RB|RB38","catOut":"RB      ","catOutS":"DDB","catOutL":"RB"}},{"name":"RB39","nameS":"RB39","icoX":3,"cls":4,"prodCtx":{"name":"RB39","line":"RB39","lineId":"de:nvv:RB|RB39","catOut":"RB      ","catOutS":"DDB","catOutL":"RB"}},{"name":"RE5","nameS":"RE5","icoX":3,"cls":4,"prodCtx":{"name":"RE5","line":"RE5","lineId":"de:nvv:RE|RE5","catOut":"RE      ","catOutS":"DOE","catOutL":"RE"}},{"name":"RE30","nameS":"RE30","icoX":3,"cls":4,"prodCtx":{"name":"RE30","line":"RE30","lineId":"de:rmv:00001298:","catOut":"RE      ","catOutS":"DOE","catOutL":"RE"}},{"name":"RE98","nameS":"RE98","icoX":3,"cls":4,"prodCtx":{"name":"RE98","line":"RE98","lineId":"de:nvv:RE|RE98","catOut":"RB      ","catOutS":"FHB","catOutL":"RB"}},{"name":"RT5","nameS":"RT5","icoX":0,"cls":1024,"prodCtx":{"name":"RT5","line":"RT5","lineId":"de:nvv:RT|RT5:","catOut":"RT      ","catOutS":"RT","catOutL":"RegioTram"}},{"name":"Tram 5","nameS":"5","icoX":0,"cls":32,"prodCtx":{"name":"Tram 5","line":"5","lineId":"de:nvv:Tram|5:","catOut":"Tram    ","catOutS":"T","catOutL":"Straßenbahn"}},{"name":"Tram 6","nameS":"6","icoX":0,"cls":32,"prodCtx":{"name":"Tram 6","line":"6","lineId":"de:nvv:Tram|6:","catOut":"Tram    ","catOutS":"T","catOutL":"Straßenbahn"}},{"name":"Bus 91","nameS":"91","icoX":4,"cls":64,"prodCtx":{"name":"Bus 91","line":"91","lineId":"de:nvv:AST|91:","catOut":"Bus     ","catOutS":"NF","catOutL":"Niederflurbus"}},{"name":"Bus 92","nameS":"92","icoX":4,"cls":64,"prodCtx":{"name":"Bus 92","line":"92","lineId":"de:nvv:AST|92:","catOut":"Bus     ","catOutS":"NF","catOutL":"Niederflurbus"}},{"name":"Bus EV","nameS":"EV","icoX":4,"cls":64,"prodCtx":{"name":"Bus EV","line":"EV","lineId":"SEVK4__EV","catOut":"Bus     ","catOutS":"1aE","catOutL":"Niederflurbus"}},{"name":"BusEVRT5","nameS":"EVRT5","icoX":4,"cls":64,"prodCtx":{"name":"BusEVRT5","line":"EVRT5","lineId":"de:nvv:RT|RT5:SEV","catOut":"Bus     ","catOutS":"NF","catOutL":"Niederflurbus"}},{"name":"AST 91","nameS":"91","icoX":5,"cls":512,"prodCtx":{"name":"AST 91","line":"91","lineId":"de:nvv:AST|91:","catOut":"AST     ","catOutS":"AST","catOutL":"AST"}},{"name":"AST 92","nameS":"92","icoX":5,"cls":512,"prodCtx":{"name":"AST 92","line":"92","lineId":"de:nvv:AST|92:","catOut":"AST     ","catOutS":"AST","catOutL":"AST"}}],"opL":[{"name":"KVG Tram","icoX":1,"id":"711"}],"remL":[{"type":"I","code":"KT","icoX":2,"txtN":"https://haltestellen.nvv.de/hms-nvv/detailspage?dhid=de:06611:200048"},{"type":"I","code":"KT","icoX":2,"txtN":"https://haltestellen.nvv.de/hms-nvv/detailspage?dhid=de:06633:203069"}],"icoL":[{"res":"","fg":{"r":255,"g":255,"b":255},"bg":{"r":193,"g":0,"b":31},"shp":"R","sty":"B"},{"res":"KVG","txt":"KVG Tram"},{"res":"NVV_HMS"},{"res":"","fg":{"r":255,"g":255,"b":255},"bg":{"r":86,"g":147,"b":135},"shp":"R","sty":"B"},{"res":"","fg":{"r":255,"g":255,"b":255},"bg":{"r":117,"g":22,"b":115},"shp":"C","sty":"B"},{"res":"","fg":{"r":241,"g":209,"b":29},"bg":{"r":26,"g":23,"b":27},"shp":"C","sty":"B"},{"res":"STA_HCIRCLE"},{"res":"rt_ont","txtA":"pünktlich"},{"res":"rt_cnf"}],"lDrawStyleL":[{"sIcoX":0,"type":"SOLID","bg":{"r":193,"g":0,"b":31}},{"type":"SOLID","bg":{"r":193,"g":0,"b":31}}]},"type":"DEP","jnyL":[{"jid":"2|#VN#1#ST#1688407889#PI#0#ZI#16504#TA#2#DA#50723#1S#2200012#1T#808#LS#2203069#LT#854#PU#80#RT#1#CA#T#ZE#5#ZB#Tram 5  #PC#5#FR#2200012#FT#808#TO#2203069#TT#854#","date":"20230705","prodX":0,"dirTxt":"Baunatal","dirFlg":"2","status":"P","isRchbl":true,"stbStop":{"locX":0,"idx":18,"dProdX":0,"dTimeS":"083700","dTimeR":"083700","dProgType":"PROGNOSED","type":"N"},"pos":{"x":9455861,"y":51279415},"subscr":"F","prodL":[{"prodX":0,"fLocX":0,"tLocX":1,"fIdx":18,"tIdx":30}],"sumLDrawStyleX":0,"resLDrawStyleX":1,"trainStartDate":"20230705"}],"fpB":"20230406","fpE":"20231209","planrtTS":"1688538967","sD":"20230705","sT":"083642","locRefL":[0]}}]}
\ No newline at end of file
diff --git a/tests/nvv/parsing/departures_test.py b/tests/nvv/parsing/departures_test.py
new file mode 100644
index 0000000..c2ec0fa
--- /dev/null
+++ b/tests/nvv/parsing/departures_test.py
@@ -0,0 +1,30 @@
+import datetime
+import os
+
+from pyhafas.profile import NVVProfile
+from pyhafas.types.fptf import Station, StationBoardLeg
+
+from tests.types import PyTestHafasResponse
+
+
+def test_nvv_departures_parsing():
+    directory = os.path.dirname(os.path.realpath(__file__))
+    raw_hafas_json_file = open(directory + "/departures_raw.json", "r")
+    hafas_response = PyTestHafasResponse(raw_hafas_json_file.read())
+    raw_hafas_json_file.close()
+    correct_station_board_legs = [StationBoardLeg(
+        id='2|#VN#1#ST#1688407889#PI#0#ZI#16504#TA#2#DA#50723#1S#2200012#1T#808#LS#2203069#LT#854#PU#80#RT#1#CA#T#ZE#5#ZB#Tram 5  #PC#5#FR#2200012#FT#808#TO#2203069#TT#854#',
+        name='Tram 5',
+        direction='Baunatal',
+        station=Station(
+            id='2200048',
+            lid='A=1@O=Kassel Keilsbergstraße@X=9455942@Y=51279199@U=80@L=2200048@',
+            name='Kassel Keilsbergstraße',
+            latitude=51.279199,
+            longitude=9.455942),
+        date_time=NVVProfile().timezone.localize(datetime.datetime(2023, 7, 5, 8, 37)),
+        cancelled=False,
+        delay=datetime.timedelta(
+            seconds=0))]
+    assert NVVProfile().parse_station_board_request(
+        hafas_response, "d") == correct_station_board_legs
diff --git a/tests/nvv/request/__init__.py b/tests/nvv/request/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/nvv/request/arrivals_test.py b/tests/nvv/request/arrivals_test.py
new file mode 100644
index 0000000..7885b3d
--- /dev/null
+++ b/tests/nvv/request/arrivals_test.py
@@ -0,0 +1,16 @@
+import datetime
+
+from pyhafas import HafasClient
+from pyhafas.profile import NVVProfile
+
+
+def test_kvb_arrivals_request():
+    client = HafasClient(NVVProfile())
+    arrivals = client.arrivals(
+        station="2200001",
+        date=datetime.datetime.now(),
+        max_trips=5
+    )
+    assert arrivals
+
+
diff --git a/tests/nvv/request/departures_test.py b/tests/nvv/request/departures_test.py
new file mode 100644
index 0000000..636744e
--- /dev/null
+++ b/tests/nvv/request/departures_test.py
@@ -0,0 +1,14 @@
+import datetime
+
+from pyhafas import HafasClient
+from pyhafas.profile import NVVProfile
+
+
+def test_kvb_departures_request():
+    client = HafasClient(NVVProfile())
+    departures = client.departures(
+        station="2200001",
+        date=datetime.datetime.now(),
+        max_trips=5
+    )
+    assert departures
diff --git a/tests/nvv/request/journey_test.py b/tests/nvv/request/journey_test.py
new file mode 100644
index 0000000..3ad2d8b
--- /dev/null
+++ b/tests/nvv/request/journey_test.py
@@ -0,0 +1,11 @@
+from pyhafas import HafasClient
+from pyhafas.profile import NVVProfile
+from pyhafas.types.fptf import Journey
+
+
+def test_nvv_journey_request():
+    client = HafasClient(NVVProfile())
+    journey = client.journey(
+        journey="¶HKI¶T$A=1@O=Rotenburg (Fulda) Bahnhof@L=2202508@a=128@$A=1@O=Kassel Hauptbahnhof@L=2200001@a=128@$202307050837$202307050921$ RE5$$1$$$$$$¶KRCC¶#VE#1#¶PC¶eyJncnBJZCI6IkdST1VQX1BUIn0="
+    )
+    assert isinstance(journey, Journey)
diff --git a/tests/nvv/request/journeys_test.py b/tests/nvv/request/journeys_test.py
new file mode 100644
index 0000000..dbe3d10
--- /dev/null
+++ b/tests/nvv/request/journeys_test.py
@@ -0,0 +1,16 @@
+import datetime
+
+from pyhafas import HafasClient
+from pyhafas.profile import NVVProfile
+
+
+def test_nvv_journeys_request():
+    client = HafasClient(NVVProfile())
+    journeys = client.journeys(
+        destination="2202508",
+        origin="2200001",
+        date=datetime.datetime.now(),
+        min_change_time=0,
+        max_changes=-1
+    )
+    assert journeys
diff --git a/tests/nvv/request/locations_test.py b/tests/nvv/request/locations_test.py
new file mode 100644
index 0000000..a05578f
--- /dev/null
+++ b/tests/nvv/request/locations_test.py
@@ -0,0 +1,8 @@
+from pyhafas import HafasClient
+from pyhafas.profile import NVVProfile
+
+
+def test_kvb_locations_request():
+    client = HafasClient(NVVProfile())
+    locations = client.locations(term="Kassel Hbf")
+    assert len(locations) >= 1