From 50d5622e79e4b992cc47a6a167f32c0a1496ce00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20K=C3=BCper?= Date: Sun, 28 Nov 2021 18:34:30 +0100 Subject: [PATCH] [deutschebahn] Initial contribution: New binding for DeutscheBahn Fahrplan (#11384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Created binding for DeutscheBahn Timetable API. Signed-off-by: Sönke Küper * Disabled schema validation and used original schema. Added tests for hannover hbf which has non schema conforming responses. Signed-off-by: Sönke Küper * Added information about UNDEF and NULL channel values. Signed-off-by: Sönke Küper * Added sample widget and screenshot Signed-off-by: Sönke Küper * Filtering duplicate messages Signed-off-by: Sönke Küper * Fixed some typos. Signed-off-by: Sönke Küper * Updated to jUnit5 Signed-off-by: Sönke Küper * Applied review remarks in Readme Signed-off-by: Sönke Küper * Applied some review remarks Signed-off-by: Sönke Küper * 0000: Fixed compile warnings Signed-off-by: Sönke Küper Co-authored-by: Sönke Küper --- CODEOWNERS | 1 + bom/openhab-addons/pom.xml | 5 + .../org.openhab.binding.deutschebahn/NOTICE | 13 + .../README.md | 345 ++ .../doc/Abfahrten_HannoverHBF.png | Bin 0 -> 142067 bytes .../org.openhab.binding.deutschebahn/pom.xml | 54 + .../src/main/feature/feature.xml | 9 + .../AbstractDtoAttributeSelector.java | 101 + .../internal/AttributeSelection.java | 33 + .../DeutscheBahnBindingConstants.java | 40 + .../internal/DeutscheBahnHandlerFactory.java | 60 + .../DeutscheBahnTimetableConfiguration.java | 46 + .../DeutscheBahnTimetableHandler.java | 302 + .../DeutscheBahnTrainConfiguration.java | 29 + .../internal/DeutscheBahnTrainHandler.java | 188 + .../deutschebahn/internal/EventAttribute.java | 427 ++ .../internal/EventAttributeSelection.java | 52 + .../deutschebahn/internal/EventType.java | 64 + .../deutschebahn/internal/MessageCodes.java | 134 + .../internal/TimetableStopFilter.java | 57 + .../internal/TripLabelAttribute.java | 119 + .../internal/timetable/TimetableLoader.java | 300 + .../timetable/TimetableStopComparator.java | 66 + .../timetable/TimetableStopMerger.java | 70 + .../internal/timetable/TimetablesV1Api.java | 101 + .../timetable/TimetablesV1ApiFactory.java | 36 + .../internal/timetable/TimetablesV1Impl.java | 215 + .../main/resources/OH-INF/binding/binding.xml | 9 + .../OH-INF/i18n/deutschebahn_de.properties | 85 + .../resources/OH-INF/thing/thing-types.xml | 342 ++ .../main/resources/xsd/Timetables_REST.xsd | 441 ++ .../DeutscheBahnTimetableHandlerTest.java | 187 + .../DeutscheBahnTrainHandlerTest.java | 225 + .../internal/EventAttributeTest.java | 282 + .../internal/TripLabelAttributeTest.java | 103 + .../internal/timetable/TimeproviderStub.java | 40 + .../timetable/TimetableLoaderTest.java | 229 + .../timetable/TimetableStubHttpCallable.java | 151 + .../timetable/TimetablesApiTestModule.java | 71 + .../timetable/TimetablesV1ApiStub.java | 71 + .../timetable/TimetablesV1ImplTest.java | 69 + .../timetable/TimetablesV1ImplTestHelper.java | 45 + .../resources/timetablesData/fchg/8000152.xml | 5401 +++++++++++++++++ .../resources/timetablesData/fchg/8000226.xml | 6 + .../timetablesData/plan/8000152/211014/11.xml | 282 + .../timetablesData/plan/8000226/210816/07.xml | 39 + .../timetablesData/plan/8000226/210816/08.xml | 39 + .../timetablesData/plan/8000226/210816/09.xml | 39 + .../timetablesData/plan/8000226/210816/10.xml | 39 + .../timetablesData/plan/8000226/210816/11.xml | 39 + .../timetablesData/plan/8000226/210816/12.xml | 39 + .../timetablesData/plan/8000226/210816/13.xml | 39 + .../timetablesData/plan/8000226/210816/14.xml | 39 + .../timetablesData/plan/8000226/210816/15.xml | 39 + .../timetablesData/plan/8000226/210816/16.xml | 39 + .../timetablesData/plan/8000226/210816/17.xml | 39 + .../timetablesData/plan/8000226/210816/18.xml | 39 + .../timetablesData/plan/8000226/210816/19.xml | 39 + .../timetablesData/plan/8000226/210816/20.xml | 39 + .../timetablesData/plan/8000226/210816/21.xml | 39 + .../timetablesData/plan/8000226/210816/22.xml | 39 + .../timetablesData/plan/8000226/210816/23.xml | 39 + .../timetablesData/plan/8000226/210817/00.xml | 25 + .../timetablesData/plan/8000226/210817/01.xml | 16 + .../timetablesData/plan/8000226/210817/02.xml | 2 + .../timetablesData/plan/8000226/210817/03.xml | 2 + bundles/pom.xml | 1 + 67 files changed, 11615 insertions(+) create mode 100644 bundles/org.openhab.binding.deutschebahn/NOTICE create mode 100644 bundles/org.openhab.binding.deutschebahn/README.md create mode 100644 bundles/org.openhab.binding.deutschebahn/doc/Abfahrten_HannoverHBF.png create mode 100644 bundles/org.openhab.binding.deutschebahn/pom.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/feature/feature.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/AbstractDtoAttributeSelector.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/AttributeSelection.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnBindingConstants.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnHandlerFactory.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTimetableConfiguration.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTimetableHandler.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTrainConfiguration.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTrainHandler.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/EventAttribute.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/EventAttributeSelection.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/EventType.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/MessageCodes.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/TimetableStopFilter.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/TripLabelAttribute.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetableLoader.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetableStopComparator.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetableStopMerger.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1Api.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1ApiFactory.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1Impl.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/resources/OH-INF/binding/binding.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/resources/OH-INF/i18n/deutschebahn_de.properties create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/resources/OH-INF/thing/thing-types.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/main/resources/xsd/Timetables_REST.xsd create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTimetableHandlerTest.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTrainHandlerTest.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/EventAttributeTest.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/TripLabelAttributeTest.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimeproviderStub.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetableLoaderTest.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetableStubHttpCallable.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesApiTestModule.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1ApiStub.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1ImplTest.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1ImplTestHelper.java create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/fchg/8000152.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/fchg/8000226.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000152/211014/11.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/07.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/08.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/09.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/10.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/11.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/12.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/13.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/14.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/15.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/16.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/17.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/18.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/19.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/20.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/21.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/22.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/23.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210817/00.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210817/01.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210817/02.xml create mode 100644 bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210817/03.xml diff --git a/CODEOWNERS b/CODEOWNERS index 44df2624eaa7a..f8022d6d83321 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -64,6 +64,7 @@ /bundles/org.openhab.binding.dbquery/ @lujop /bundles/org.openhab.binding.deconz/ @openhab/add-ons-maintainers /bundles/org.openhab.binding.denonmarantz/ @jwveldhuis +/bundles/org.openhab.binding.deutschebahn/ @soenkekueper /bundles/org.openhab.binding.digiplex/ @rmichalak /bundles/org.openhab.binding.digitalstrom/ @MichaelOchel @msiegele /bundles/org.openhab.binding.dlinksmarthome/ @MikeJMajor diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index d672adddd5321..9a6dd839a540b 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -311,6 +311,11 @@ org.openhab.binding.denonmarantz ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.deutschebahn + ${project.version} + org.openhab.addons.bundles org.openhab.binding.digiplex diff --git a/bundles/org.openhab.binding.deutschebahn/NOTICE b/bundles/org.openhab.binding.deutschebahn/NOTICE new file mode 100644 index 0000000000000..38d625e349232 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/NOTICE @@ -0,0 +1,13 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons diff --git a/bundles/org.openhab.binding.deutschebahn/README.md b/bundles/org.openhab.binding.deutschebahn/README.md new file mode 100644 index 0000000000000..23184d523fea2 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/README.md @@ -0,0 +1,345 @@ +# Deutsche Bahn Binding + +The Deutsche Bahn Binding provides the latest timetable information for all trains that arrive or depart at a specific train station, including live information for delays and changes in timetable. +The information are requested from the timetable api of Deutsche Bahn developer portal, so you'll need a (free) developer account to use this binding. + +## Supported Things + +- **timetable** The timetable bridge connects to the timetable api and provides information for the next trains that will arrive or depart at the configured station. +- **train** The train thing represents one trains within the configured timetable. This may be an arrival or a departure. + +## Thing Configuration + +### Generate Access-Key for timetable API + +To configure a timetable you first need to register at Deutsche Bahn developer portal and register for timetable API to get an access key. + +1. Go to [Deutsche Bahn Developer](https://developer.deutschebahn.com) +2. Register new account or login with an existing one +3. If no application is configured yet (check Tab "Meine Anwendungen") create a new application. Only the name is required, any other fields can be left blank. +4. Go to APIs - Timetables v1 (may be displayed on second page) +5. Choose your previously created application and hit "Abonnieren" +6. In confirmation-dialog choose "Wechsel zu meine Abonnements" +7. Create an access key for the production environment by hitting "Schlüssel Erstellen" +8. Copy the "Zugangstoken". This is required to access the api from openHAB. + +### Determine the EVA-No of your station + +For the selection of the station within openHAB you need the eva no. of the station. +You can look up the number within the csv file available at [Haltestellendaten](https://data.deutschebahn.com/dataset.tags.EVA-Nr..html). + +### Configure timetable bridge + +With access key for developer portal and eva no. of your station you're ready to configure a timetable (bridge) for this station. +In addition you can configure if only arrivals, only departures or all trains should be contained within the timetable. + +**timetable** parameters: + +| Property | Default | Required | Description | +|-|-|-|-| +| `accessToken` | | Yes | The access token for the timetable api within the developer portal of Deutsche Bahn. | +| `evaNo` | | Yes | The eva nr. of the train station for which the timetable will be requested.| +| `trainFilter` | | Yes | Selects the trains that will be displayed in the timetable. Either only arrivals, only departures or all trains can be displayed. | + + +### Configuring the trains + +Once you've created the timetable you can add train-things that represent the trains within this timetable. +Each train represents one position within the timetable. For example: If you configure a train with position 1 this will be +the next train that arrives / departs at the given station. Position 2 will be the second one, and so on. If you want to +show the next 4 trains for a station, create 4 things with positions 1 to 4. + +**Attention:** The timetable api only provides data for the next 18 hours. If the timetable contains less train entries than you've created +train things, the channels of these trains will be undefined. + +**train** parameters: + +| Property | Default | Required | Description | +|-|-|-|-| +| `position` | | Yes | The position of the train within the timetable. | + + +## Channels + +Each train has a set of channels, that provides access to any information served by the timetable API. A detailed description of the values and their meaning can be found within +the [Timetables V1 API Description](https://developer.deutschebahn.com/store/apis/info?name=Timetables&version=v1&provider=DBOpenData&). +The information are grouped into three channel-groups: +The first channel group (trip) contains all information for the trip of the train, for example the category (like ICE, RE, S). +The second and third channel group contains information about the the arrival and the departure of the train at the given station. +Both of the groups may provide an 'UNDEF' channel value, when the train does not arrive / depart at this station +(due it starts or ends at the given station). If you have configured your timetable to contain only departures (with property trainFilter) the departure channel values will always be defined +and if you have selected only arrivals the arrival channel values will always be defined. +Channels will have a 'NULL' channel value, when the corresponding attribute is not set. + +Basically most information are available as planned and changed value. This allows to easy display changed values (for example the delay or changed platform). + + +**Channels for trip information** +| channel | type | description | +|----------|--------|------------------------------| +| category | String | Provides the category of the trip, e.g. "ICE" or "RE". | +| number | String | Provides the trip/train number, e.g. "4523". | +| filter-flags | String | Provides the filter flags. | +| trip-type | String | Provides the type of the trip. | +| owner | String | Provides the owner of the train. A unique short-form and only intended to map a trip to specific evu (EisenbahnVerkehrsUnternehmen). | + + +**Channels for arrival / departure** +| channel | type | description | +|----------|--------|------------------------------| +| planned-path | String | Provides the planned path of a train. | +| changed-path | String | Provides the changed path of a train. | +| planned-platform | String | Provides the planned platform of a train. | +| changed-platform | String | Provides the changed platform of a train. | +| planned-time | DateTime | Provides the planned time of a train. | +| changed-time | DateTime | Provides the changed time of a train. | +| planned-status | String | Provides the planned status (planned, added, cancelled) of a train. | +| changed-status | String | Provides the changed status (planned, added, cancelled) of a train. | +| cancellation-time | DateTime | Time when the cancellation of this stop was created. | +| line | String | The line of the train. | +| messages | String | Messages for this train. Contains all translated codes from the messages of the selected train stop. Multiple messages will be separated with a single dash. | +| hidden | Switch | On if the event should not be shown because travellers are not supposed to enter or exit the train at this stop. | +| wings | String | A sequence of trip id separated by pipe symbols. | +| transition | String | Trip id of the next or previous train of a shared train. At the start stop this references the previous trip, at the last stop it references the next trip. | +| planned-distant-endpoint | String | Planned distant endpoint of a train. | +| changed-distant-endpoint | String | Changed distant endpoint of a train. | +| distant-change | Number | Distant change | +| planned-final-station | String | Planned final station of the train. For arrivals the starting station is returned, for departures the target station is returned. | +| planned-intermediate-stations | String | Returns the planned stations this train came from (for arrivals) or the stations this train will go to (for departures). Stations will be separated by single dash. | +| changed-final-station | String | Changed final station of the train. For arrivals the starting station is returned, for departures the target station is returned. | +| changed-intermediate-stations | String | Returns the changed stations this train came from (for arrivals) or the stations this train will go to (for departures). Stations will be separated by single dash. | + +## Full Example + +timetable.things + +``` +Bridge deutschebahn:timetable:timetableLehrte "Fahrplan Lehrte" [ accessToken="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", trainFilter="departures", evaNo="8000226" ] { + Thing deutschebahn:train:timetableLehrte:lehrteZug1 "Zug 1" [ position="1" ] + Thing deutschebahn:train:timetableLehrte:lehrteZug2 "Zug 2" [ position="2" ] +} +``` + +timetable.items + +``` +// Groups +Group zug1 "Zug 1" +Group zug1Fahrt "Zug 1 Fahrt" (zug1) +Group zug1Ankunft "Zug 1 Ankunft" (zug1) +Group zug1Abfahrt "Zug 1 Abfahrt" (zug1) + +// Trip Information +String Zug1_Trip_Category "Kategorie" (zug1Fahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:trip#category"} +String Zug1_Trip_Number "Nummer" (zug1Fahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:trip#number"} +String Zug1_Trip_FilterFlags "Filter" (zug1Fahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:trip#filter-flags"} +String Zug1_Trip_TripType "Fahrttyp" (zug1Fahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:trip#trip-type"} +String Zug1_Trip_Owner "Unternehmen" (zug1Fahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:trip#owner"} + + +// Arrival Information +DateTime Zug1_Arrival_Plannedtime "Geplante Zeit" (zug1Ankunft) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:arrival#planned-time"} +DateTime Zug1_Arrival_Changedtime "Geänderte Zeit" (zug1Ankunft) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:arrival#changed-time"} +String Zug1_Arrival_Plannedplatform "Geplantes Gleis" (zug1Ankunft) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:arrival#planned-platform"} +String Zug1_Arrival_Changedplatform "Geändertes Gleis" (zug1Ankunft) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:arrival#changed-platform"} +String Zug1_Arrival_Line "Linie" (zug1Ankunft) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:arrival#line"} +String Zug1_Arrival_Plannedintermediatestations "Geplante Halte" (zug1Ankunft) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:arrival#planned-intermediate-stations"} +String Zug1_Arrival_Changedintermediatestations "Geänderte Halte" (zug1Ankunft) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:arrival#changed-intermediate-stations"} +String Zug1_Arrival_Plannedfinalstation "Geplanter Start-/Zielbahnhof" (zug1Ankunft) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:arrival#planned-final-station"} +String Zug1_Arrival_Changedfinalstation "Geänderter Start-/Zielbahnhof" (zug1Ankunft) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:arrival#changed-final-station"} +String Zug1_Arrival_Messages "Meldungen" (zug1Ankunft) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:arrival#messages"} +String Zug1_Arrival_Plannedstatus "Geplanter Status" (zug1Ankunft) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:arrival#planned-status"} +String Zug1_Arrival_Changedstatus "Geänderter Status" (zug1Ankunft) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:arrival#changed-status"} +DateTime Zug1_Arrival_Cancellationtime "Stornierungs-Zeitpunkt" (zug1Ankunft) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:arrival#cancellation-time"} + +// Arrival advanced information +String Zug1_Arrival_Planneddistantendpoint "Geplanter entfernter Endpunkt" (zug1Ankunft) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:arrival#planned-distant-endpoint"} +String Zug1_Arrival_Changeddistantendpoint "Geänderter entfernter Endpunkt" (zug1Ankunft) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:arrival#changed-distant-endpoint"} +String Zug1_Arrival_Plannedpath "Geplante Route" (zug1Ankunft) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:arrival#planned-path"} +String Zug1_Arrival_Changedpath "Geändert Route" (zug1Ankunft) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:arrival#changed-path"} +Number Zug1_Arrival_Distantchange "Geänderter Zielbahnhof" (zug1Ankunft) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:arrival#distant-change"} +Switch Zug1_Arrival_Hidden "Versteckt" (zug1Ankunft) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:arrival#hidden"} +String Zug1_Arrival_Transition "Übergang" (zug1Ankunft) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:arrival#transition"} +String Zug1_Arrival_Wings "Wings" (zug1Ankunft) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:arrival#wings"} + +// Departure Information +DateTime Zug1_Departure_Plannedtime "Geplante Zeit" (zug1Abfahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:departure#planned-time"} +DateTime Zug1_Departure_Changedtime "Geänderte Zeit" (zug1Abfahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:departure#changed-time"} +String Zug1_Departure_Plannedplatform "Geplantes Gleis" (zug1Abfahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:departure#planned-platform"} +String Zug1_Departure_Changedplatform "Geändertes Gleis" (zug1Abfahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:departure#changed-platform"} +String Zug1_Departure_Line "Linie" (zug1Abfahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:departure#line"} +String Zug1_Departure_Plannedintermediatestations "Geplante Halte" (zug1Abfahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:departure#planned-intermediate-stations"} +String Zug1_Departure_Changedintermediatestations "Geänderte Halte" (zug1Abfahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:departure#changed-intermediate-stations"} +String Zug1_Departure_Plannedfinalstation "Geplanter Start-/Zielbahnhof" (zug1Abfahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:departure#planned-final-station"} +String Zug1_Departure_Changedfinalstation "Geänderter Start-/Zielbahnhof" (zug1Abfahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:departure#changed-final-station"} +String Zug1_Departure_Messages "Meldungen" (zug1Abfahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:departure#messages"} +String Zug1_Departure_Plannedstatus "Geplanter Status" (zug1Abfahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:departure#planned-status"} +String Zug1_Departure_Changedstatus "Geänderter Status" (zug1Abfahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:departure#changed-status"} +DateTime Zug1_Departure_Cancellationtime "Stornierungs-Zeitpunkt" (zug1Abfahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:departure#cancellation-time"} + +// Departure advanced information +String Zug1_Departure_Planneddistantendpoint "Geplanter entfernter Endpunkt" (zug1Abfahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:departure#planned-distant-endpoint"} +String Zug1_Departure_Changeddistantendpoint "Geänderter entfernter Endpunkt" (zug1Abfahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:departure#changed-distant-endpoint"} +String Zug1_Departure_Plannedpath "Geplante Route" (zug1Abfahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:departure#planned-path"} +String Zug1_Departure_Changedpath "Geändert Route" (zug1Abfahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:departure#changed-path"} +Number Zug1_Departure_Distantchange "Geänderter Zielbahnhof" (zug1Abfahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:departure#distant-change"} +Switch Zug1_Departure_Hidden "Versteckt" (zug1Abfahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:departure#hidden"} +String Zug1_Departure_Transition "Übergang" (zug1Abfahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:departure#transition"} +String Zug1_Departure_Wings "Wings" (zug1Abfahrt) {channel="deutschebahn:train:timetableLehrte:lehrteZug1:departure#wings"} + +``` + +Example widget for displaying train details + +``` +uid: timetable_train_details +tags: + - card +props: + parameters: + - context: item + label: Geplante Zeit + name: planned_time + required: true + type: TEXT + - context: item + label: Geänderte Zeit + name: changed_time + required: true + type: TEXT + - context: item + label: Geplantes Gleis + name: planned_platform + required: true + type: TEXT + - context: item + label: Geändertes Gleis + name: changed_platform + required: true + type: TEXT + - context: item + label: Linie + name: line + required: true + type: TEXT + - context: item + label: Meldungen + name: messages + required: true + type: TEXT + - context: item + label: Geplanter Start-/Zielbahnhof + name: planned_final_station + required: true + type: TEXT + - context: item + label: Geplante Halte + name: planned_intermediate_stations + required: true + type: TEXT + - context: item + label: Geändeter Start-/Zielbahnhof + name: changed_final_station + required: true + type: TEXT + - context: item + label: Geänderte Halte + name: changed_intermediate_stations + required: true + type: TEXT + - context: item + label: Geänderter Status + name: changed_state + required: true + type: TEXT + - context: item + label: Kategorie + name: category + required: true + type: TEXT + - context: item + label: Nummer + name: number + required: true + type: TEXT + parameterGroups: [] +timestamp: Oct 14, 2021, 11:24:45 AM +component: f7-card +config: + style: + padding: 10px +slots: + default: + - component: f7-row + slots: + default: + - component: f7-col + config: + width: 15 + slots: + default: + - component: Label + config: + text: "=items[props.planned_time].displayState + (items[props.changed_time].state != 'NULL' && items[props.changed_time].state != items[props.planned_time].state ? ' (' + items[props.changed_time].displayState + ')' : '')" + style: + color: "=items[props.changed_time].state != 'NULL' && items[props.changed_time].state != items[props.planned_time].state ? 'red' : ''" + - component: f7-col + config: + width: 75 + slots: + default: + - component: Label + config: + text: "=(items[props.changed_state].state == 'c' ? 'Zug fällt aus - ' : '') + (items[props.messages].state != 'NULL' ? items[props.messages].state : '')" + style: + color: red + - component: f7-col + config: + width: 10 + slots: + default: + - component: Label + config: + text: "=items[props.changed_platform].state != 'NULL' ? items[props.changed_platform].state : items[props.planned_platform].state" + style: + color: "=items[props.changed_platform].state != 'NULL' ? 'red' : ''" + text-align: right + - component: f7-row + slots: + default: + - component: f7-col + config: + width: 15 + slots: + default: + - component: Label + config: + text: "=items[props.line].state != 'NULL' ? (items[props.category].state + ' ' + items[props.line].state) : (items[props.category].state + ' ' + items[props.number].state)" + - component: f7-col + config: + width: 50 + slots: + default: + - component: Label + config: + text: "=items[props.changed_intermediate_stations].state != 'NULL' ? items[props.changed_intermediate_stations].state : items[props.planned_intermediate_stations].state" + style: + color: "=items[props.changed_intermediate_stations].state != 'NULL' ? 'red' : ''" + - component: f7-col + config: + width: 35 + slots: + default: + - component: Label + config: + text: "=items[props.changed_final_station].state != 'NULL' ? items[props.changed_final_station].state : items[props.planned_final_station].state" + style: + color: "=items[props.changed_final_station].state != 'NULL' ? 'red' : ''" + font-weight: bold + text-align: right +``` + + +Using the widget for displaying the next four departures: + +![Departures Hannover HBF](doc/Abfahrten_HannoverHBF.png "openHAB page with four widgets displaying the next departures at Hannover HBF") diff --git a/bundles/org.openhab.binding.deutschebahn/doc/Abfahrten_HannoverHBF.png b/bundles/org.openhab.binding.deutschebahn/doc/Abfahrten_HannoverHBF.png new file mode 100644 index 0000000000000000000000000000000000000000..2bc58850ac17db5bbdae1ce4f344854608bbed57 GIT binary patch literal 142067 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%0DbtpEMIj=Z00z#eHmwmt21ytBPteB9Vww~U9X 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 SZ+tiKkD9WkQn`Xfz`p?ULQ^LI literal 0 HcmV?d00001 diff --git a/bundles/org.openhab.binding.deutschebahn/pom.xml b/bundles/org.openhab.binding.deutschebahn/pom.xml new file mode 100644 index 0000000000000..48ddb0006be01 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/pom.xml @@ -0,0 +1,54 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 3.2.0-SNAPSHOT + + + org.openhab.binding.deutschebahn + + openHAB Add-ons :: Bundles :: Deutsche Bahn Binding + + + + + org.jvnet.jaxb2.maven2 + maven-jaxb2-plugin + 0.14.0 + + + generate-jaxb-sources + + generate + + + + + org.openhab.binding.deutschebahn.internal.timetable.dto + src/main/resources/xsd + true + en + false + true + + -Xxew + -Xxew:instantiate early + + + + com.github.jaxb-xew-plugin + jaxb-xew-plugin + 1.10 + + + + + + + + diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/feature/feature.xml b/bundles/org.openhab.binding.deutschebahn/src/main/feature/feature.xml new file mode 100644 index 0000000000000..4269910d79c44 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/feature/feature.xml @@ -0,0 +1,9 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.deutschebahn/${project.version} + + diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/AbstractDtoAttributeSelector.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/AbstractDtoAttributeSelector.java new file mode 100644 index 0000000000000..b5c6db1040b75 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/AbstractDtoAttributeSelector.java @@ -0,0 +1,101 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal; + +import java.util.function.BiConsumer; +import java.util.function.Function; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.deutschebahn.internal.timetable.dto.JaxbEntity; +import org.openhab.core.types.State; + +/** + * Accessor for attribute value of an DTO-Object. + * + * @author Sönke Küper - Initial contribution. + * + * @param type of value in Bean. + * @param type of value in Bean. + * @param type of state. + */ +@NonNullByDefault +public abstract class AbstractDtoAttributeSelector { + + private final Function getter; + private final BiConsumer setter; + private final Function getState; + private final String channelTypeName; + private final Class stateType; + + /** + * Creates an new {@link EventAttribute}. + * + * @param getter Function to get the raw value. + * @param setter Function to set the raw value. + * @param getState Function to get the Value as {@link State}. + */ + protected AbstractDtoAttributeSelector(final String channelTypeName, // + final Function getter, // + final BiConsumer setter, // + final Function getState, // + final Class stateType) { + this.channelTypeName = channelTypeName; + this.getter = getter; + this.setter = setter; + this.getState = getState; + this.stateType = stateType; + } + + /** + * Returns the type of the state value. + */ + public final Class getStateType() { + return this.stateType; + } + + /** + * Returns the name of the corresponding channel-type. + */ + public final String getChannelTypeName() { + return this.channelTypeName; + } + + /** + * Returns the {@link State} for the selected attribute from the given DTO object + * Returns null if the value is null. + */ + @Nullable + public final STATE_TYPE getState(final DTO_TYPE object) { + final VALUE_TYPE value = this.getValue(object); + if (value == null) { + return null; + } + return this.getState.apply(value); + } + + /** + * Returns the value for the selected attribute from the given DTO object. + */ + @Nullable + public final VALUE_TYPE getValue(final DTO_TYPE object) { + return this.getter.apply(object); + } + + /** + * Sets the value for the selected attribute in the given DTO object + */ + public final void setValue(final DTO_TYPE event, final VALUE_TYPE object) { + this.setter.accept(event, object); + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/AttributeSelection.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/AttributeSelection.java new file mode 100644 index 0000000000000..6c0d767066949 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/AttributeSelection.java @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.deutschebahn.internal.timetable.dto.TimetableStop; +import org.openhab.core.types.State; + +/** + * Selection of an attribute within an {@link TimetableStop} that provides a channel {@link State}. + * + * @author Sönke Küper - Initial contribution + */ +@NonNullByDefault +public interface AttributeSelection { + + /** + * Returns the {@link State} that should be set for the channels'value for this attribute. + */ + @Nullable + public abstract State getState(TimetableStop stop); +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnBindingConstants.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnBindingConstants.java new file mode 100644 index 0000000000000..539b22e738f61 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnBindingConstants.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link DeutscheBahnBindingConstants} class defines common constants, which are used across the whole binding. + * + * @author Sönke Küper - Initial contribution + */ +@NonNullByDefault +public class DeutscheBahnBindingConstants { + + /** + * Binding-ID. + */ + public static final String BINDING_ID = "deutschebahn"; + + /** + * {@link ThingTypeUID} for Timetable-API Bridge. + */ + public static final ThingTypeUID TIMETABLE_TYPE = new ThingTypeUID(BINDING_ID, "timetable"); + + /** + * {@link ThingTypeUID} for Train. + */ + public static final ThingTypeUID TRAIN_TYPE = new ThingTypeUID(BINDING_ID, "train"); +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnHandlerFactory.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnHandlerFactory.java new file mode 100644 index 0000000000000..059f6b4dc53df --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnHandlerFactory.java @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal; + +import static org.openhab.binding.deutschebahn.internal.DeutscheBahnBindingConstants.TIMETABLE_TYPE; +import static org.openhab.binding.deutschebahn.internal.DeutscheBahnBindingConstants.TRAIN_TYPE; + +import java.util.Date; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.deutschebahn.internal.timetable.TimetablesV1Impl; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.binding.BaseThingHandlerFactory; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.osgi.service.component.annotations.Component; + +/** + * The {@link DeutscheBahnHandlerFactory} is responsible for creating things and thing handlers. + * + * @author Sönke Küper - Initial contribution + */ +@NonNullByDefault +@Component(configurationPid = "binding.deutschebahn", service = ThingHandlerFactory.class) +public class DeutscheBahnHandlerFactory extends BaseThingHandlerFactory { + + private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(TIMETABLE_TYPE, TRAIN_TYPE); + + @Override + public boolean supportsThingType(final ThingTypeUID thingTypeUID) { + return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); + } + + @Override + protected @Nullable ThingHandler createHandler(final Thing thing) { + final ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + + if (TIMETABLE_TYPE.equals(thingTypeUID)) { + return new DeutscheBahnTimetableHandler((Bridge) thing, TimetablesV1Impl::new, Date::new); + } else if (TRAIN_TYPE.equals(thingTypeUID)) { + return new DeutscheBahnTrainHandler(thing); + } + + return null; + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTimetableConfiguration.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTimetableConfiguration.java new file mode 100644 index 0000000000000..ee93c69650e1b --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTimetableConfiguration.java @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link DeutscheBahnTimetableConfiguration} for the Timetable bridge-type. + * + * @author Sönke Küper - Initial contribution + */ +@NonNullByDefault +public class DeutscheBahnTimetableConfiguration { + + /** + * Access-Token. + */ + public String accessToken = ""; + + /** + * evaNo of the station to be queried. + */ + public String evaNo = ""; + + /** + * Filter for timetable stops. + */ + public String trainFilter = ""; + + /** + * Returns the {@link TimetableStopFilter}. + */ + public TimetableStopFilter getTimetableStopFilter() { + return TimetableStopFilter.valueOf(this.trainFilter.toUpperCase()); + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTimetableHandler.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTimetableHandler.java new file mode 100644 index 0000000000000..616493a999157 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTimetableHandler.java @@ -0,0 +1,302 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Supplier; + +import javax.xml.bind.JAXBException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.deutschebahn.internal.timetable.TimetableLoader; +import org.openhab.binding.deutschebahn.internal.timetable.TimetablesV1Api; +import org.openhab.binding.deutschebahn.internal.timetable.TimetablesV1ApiFactory; +import org.openhab.binding.deutschebahn.internal.timetable.dto.TimetableStop; +import org.openhab.core.io.net.http.HttpUtil; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Channel; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.binding.BaseBridgeHandler; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.UnDefType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.xml.sax.SAXException; + +/** + * The {@link DeutscheBahnTimetableHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Sönke Küper - Initial contribution + */ +@NonNullByDefault +public class DeutscheBahnTimetableHandler extends BaseBridgeHandler { + + /** + * Wrapper containing things grouped by their position and calculates the max. required position. + */ + private static final class GroupedThings { + + private int maxPosition = 0; + private final Map> thingsPerPosition = new HashMap<>(); + + public void addThing(Thing thing) { + if (isTrain(thing)) { + int position = thing.getConfiguration().as(DeutscheBahnTrainConfiguration.class).position; + this.maxPosition = Math.max(this.maxPosition, position); + List thingsAtPosition = this.thingsPerPosition.get(position); + if (thingsAtPosition == null) { + thingsAtPosition = new ArrayList<>(); + this.thingsPerPosition.put(position, thingsAtPosition); + } + thingsAtPosition.add(thing); + } + } + + /** + * Returns the things at the given position. + */ + @Nullable + public List getThingsAtPosition(int position) { + return this.thingsPerPosition.get(position); + } + + /** + * Returns the max. configured position. + */ + public int getMaxPosition() { + return this.maxPosition; + } + } + + private static final long UPDATE_INTERVAL_SECONDS = 30; + + private final Lock monitor = new ReentrantLock(); + private @Nullable ScheduledFuture updateJob; + + private final Logger logger = LoggerFactory.getLogger(DeutscheBahnTimetableHandler.class); + private @Nullable TimetableLoader loader; + + private TimetablesV1ApiFactory timetablesV1ApiFactory; + + private Supplier currentTimeProvider; + + /** + * Creates an new {@link DeutscheBahnTimetableHandler}. + */ + public DeutscheBahnTimetableHandler( // + final Bridge bridge, // + final TimetablesV1ApiFactory timetablesV1ApiFactory, // + final Supplier currentTimeProvider) { + super(bridge); + this.timetablesV1ApiFactory = timetablesV1ApiFactory; + this.currentTimeProvider = currentTimeProvider; + } + + private List loadTimetable() { + final TimetableLoader currentLoader = this.loader; + if (currentLoader == null) { + this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR); + return Collections.emptyList(); + } + + try { + final List stops = currentLoader.getTimetableStops(); + this.updateStatus(ThingStatus.ONLINE); + return stops; + } catch (final IOException e) { + this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + return Collections.emptyList(); + } + } + + /** + * The Bridge-Handler does not handle any commands. + */ + @Override + public void handleCommand(final ChannelUID channelUID, final Command command) { + } + + @Override + public void initialize() { + final DeutscheBahnTimetableConfiguration config = this.getConfigAs(DeutscheBahnTimetableConfiguration.class); + + try { + final TimetablesV1Api api = this.timetablesV1ApiFactory.create(config.accessToken, HttpUtil::executeUrl); + + final TimetableStopFilter stopFilter = config.getTimetableStopFilter(); + + final EventType eventSelection = stopFilter == TimetableStopFilter.ARRIVALS ? EventType.ARRIVAL + : EventType.ARRIVAL; + + this.loader = new TimetableLoader( // + api, // + stopFilter, // + eventSelection, // + currentTimeProvider, // + config.evaNo, // + 1); // will be updated on first call + + this.updateStatus(ThingStatus.UNKNOWN); + + this.scheduler.execute(() -> { + this.updateChannels(); + this.restartJob(); + }); + } catch (JAXBException | SAXException | URISyntaxException e) { + this.logger.error("Error initializing api", e); + this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + } + } + + @Override + public void dispose() { + this.stopUpdateJob(); + } + + /** + * Schedules an job that updates the timetable every 30 seconds. + */ + private void restartJob() { + this.logger.debug("Restarting jobs for bridge {}", this.getThing().getUID()); + this.monitor.lock(); + try { + this.stopUpdateJob(); + if (this.getThing().getStatus() == ThingStatus.ONLINE) { + this.updateJob = this.scheduler.scheduleWithFixedDelay(// + this::updateChannels, // + 0L, // + UPDATE_INTERVAL_SECONDS, // + TimeUnit.SECONDS // + ); + + this.logger.debug("Scheduled {} update of deutsche bahn timetable", this.updateJob); + } + } finally { + this.monitor.unlock(); + } + } + + /** + * Stops the update job. + */ + private void stopUpdateJob() { + this.monitor.lock(); + try { + final ScheduledFuture job = this.updateJob; + if (job != null) { + job.cancel(true); + } + this.updateJob = null; + } finally { + this.monitor.unlock(); + } + } + + private void updateChannels() { + final TimetableLoader currentLoader = this.loader; + if (currentLoader == null) { + this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR); + return; + } + final GroupedThings groupedThings = this.groupThingsPerPosition(); + currentLoader.setStopCount(groupedThings.getMaxPosition()); + final List timetableStops = this.loadTimetable(); + if (timetableStops.isEmpty()) { + updateThingsToUndefined(groupedThings); + return; + } + + this.logger.debug("Retrieved {} timetable stops.", timetableStops.size()); + this.updateThings(groupedThings, timetableStops); + } + + /** + * No data was retrieved, so update all channel values to undefined. + */ + private void updateThingsToUndefined(GroupedThings groupedThings) { + for (List things : groupedThings.thingsPerPosition.values()) { + for (Thing thing : things) { + updateChannelsToUndefined(thing); + } + } + } + + private void updateChannelsToUndefined(Thing thing) { + for (Channel channel : thing.getChannels()) { + this.updateState(channel.getUID(), UnDefType.UNDEF); + } + } + + private void updateThings(GroupedThings groupedThings, final List timetableStops) { + int position = 1; + for (final TimetableStop stop : timetableStops) { + final List thingsAtPosition = groupedThings.getThingsAtPosition(position); + + if (thingsAtPosition != null) { + for (Thing thing : thingsAtPosition) { + final ThingHandler thingHandler = thing.getHandler(); + if (thingHandler != null) { + assert thingHandler instanceof DeutscheBahnTrainHandler; + ((DeutscheBahnTrainHandler) thingHandler).updateChannels(stop); + } + } + } + position++; + } + + // Update all things to undefined, for which no data was received. + while (position <= groupedThings.getMaxPosition()) { + final List thingsAtPosition = groupedThings.getThingsAtPosition(position); + if (thingsAtPosition != null) { + for (Thing thing : thingsAtPosition) { + updateChannelsToUndefined(thing); + } + } + position++; + } + } + + /** + * Returns an map containing the things grouped by timetable stop position. + */ + private GroupedThings groupThingsPerPosition() { + final GroupedThings groupedThings = new GroupedThings(); + for (Thing child : this.getThing().getThings()) { + groupedThings.addThing(child); + } + return groupedThings; + } + + private static boolean isTrain(Thing thing) { + final ThingTypeUID thingTypeUid = thing.getThingTypeUID(); + return thingTypeUid.equals(DeutscheBahnBindingConstants.TRAIN_TYPE); + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTrainConfiguration.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTrainConfiguration.java new file mode 100644 index 0000000000000..196d6acca37ca --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTrainConfiguration.java @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link DeutscheBahnTrainConfiguration} for the train thing type. + * + * @author Sönke Küper - Initial contribution + */ +@NonNullByDefault +public class DeutscheBahnTrainConfiguration { + + /** + * Position of the train in the timetable. + */ + public int position = 0; +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTrainHandler.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTrainHandler.java new file mode 100644 index 0000000000000..e04b95ce48c8f --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTrainHandler.java @@ -0,0 +1,188 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.deutschebahn.internal.timetable.dto.TimetableStop; +import org.openhab.core.thing.Channel; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Handler for an Train-Thing in DeutscheBahn Binding. + * + * Represents an Train that arrives / departs at the station selected by the DeutscheBahnTimetable-Bridge. + * + * @author Sönke Küper - Initial contribution + */ +@NonNullByDefault +public class DeutscheBahnTrainHandler extends BaseThingHandler { + + /** + * Wraps the Channel-UID with the configured {@link AttributeSelection}. + */ + private final class ChannelWithConfig { + + private final ChannelUID channelUid; + private final AttributeSelection attributeSelection; + + /** + * Creates an new ChannelWithConfig. + * + * @param channelUid The UID of the channel + * @param configuration Configuration for the given channel. + * @param attributeSelection The attribute that provides the state that will be displayed. + */ + public ChannelWithConfig( // + final ChannelUID channelUid, // + final AttributeSelection attributeSelection) { + this.channelUid = channelUid; + this.attributeSelection = attributeSelection; + } + + /** + * Updates the value for the channel from given {@link TimetableStop}. + */ + public void updateChannelValue(final TimetableStop stop) { + final State newState = this.determineState(stop); + if (newState != null) { + DeutscheBahnTrainHandler.this.updateState(this.channelUid, newState); + } else { + DeutscheBahnTrainHandler.this.updateState(this.channelUid, UnDefType.NULL); + } + } + + @Nullable + private State determineState(final TimetableStop stop) { + return this.attributeSelection.getState(stop); + } + + @Override + public String toString() { + return this.channelUid.toString(); + } + } + + private final Logger logger = LoggerFactory.getLogger(DeutscheBahnTrainHandler.class); + private final List configuredChannels = new ArrayList<>(); + + /** + * Creates an new {@link DeutscheBahnTrainHandler}. + */ + public DeutscheBahnTrainHandler(Thing thing) { + super(thing); + } + + @Override + public void initialize() { + this.updateStatus(ThingStatus.UNKNOWN); + + if (this.getBridge() == null) { + this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Please select bridge"); + return; + } + + this.createChannelMapping(); + this.updateStatus(ThingStatus.ONLINE); + } + + private void createChannelMapping() { + this.configuredChannels.clear(); + for (Channel channel : this.getThing().getChannelsOfGroup("trip")) { + this.createTripChannelConfiguration(channel); + } + for (Channel channel : this.getThing().getChannelsOfGroup("arrival")) { + this.createEventChannelConfiguration(EventType.ARRIVAL, channel); + } + for (Channel channel : this.getThing().getChannelsOfGroup("departure")) { + this.createEventChannelConfiguration(EventType.DEPARTURE, channel); + } + this.logger.debug("Created {} configured channels for thing {}.", this.configuredChannels.size(), + this.getThing().getUID()); + } + + /** + * Creates an {@link ChannelWithConfig} for an channel that represents an attribute of an + * {@link org.openhab.binding.deutschebahn.internal.timetable.dto.TripLabel}. + */ + private void createTripChannelConfiguration(Channel channel) { + final ChannelUID channelUid = channel.getUID(); + final String attributeName = getAttributeName(channelUid); + final TripLabelAttribute attribute = TripLabelAttribute.getByChannelName(attributeName); + if (attribute == null) { + this.logger.warn("Could not find trip attribute {} of channel: {} .", attribute, channelUid.getId()); + return; + } + final ChannelWithConfig channelWithConfig = new ChannelWithConfig( // + channelUid, // + attribute); + this.configuredChannels.add(channelWithConfig); + } + + /** + * Creates the {@link ChannelWithConfig} for an channel that represents an attribute of an + * {@link org.openhab.binding.deutschebahn.internal.timetable.dto.Event}.} + */ + private void createEventChannelConfiguration(EventType eventType, Channel channel) { + final ChannelUID channelUid = channel.getUID(); + final String attributeName = getAttributeName(channelUid); + final EventAttribute attribute = EventAttribute.getByChannelName(attributeName, eventType); + if (attribute == null) { + this.logger.warn("Could not find event attribute {} of channel: {} .", attribute, channelUid.getId()); + return; + } + final ChannelWithConfig channelWithConfig = new ChannelWithConfig( // + channelUid, // + new EventAttributeSelection(eventType, attribute)); + this.configuredChannels.add(channelWithConfig); + } + + /** + * Strips the attribute name from the channel-UID. + */ + private static String getAttributeName(ChannelUID channelUid) { + final String channelId = channelUid.getId(); + int hashIndex = channelId.indexOf("#"); + assert hashIndex > 0; + final String attributeName = channelId.substring(hashIndex + 1); + return attributeName; + } + + /** + * Does not handle any commands. + */ + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + } + + /** + * Updates the value for the channels of this train from the given {@link TimetableStop}. + */ + void updateChannels(TimetableStop stop) { + for (ChannelWithConfig channel : this.configuredChannels) { + channel.updateChannelValue(stop); + } + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/EventAttribute.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/EventAttribute.java new file mode 100644 index 0000000000000..26ad3e5a098ca --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/EventAttribute.java @@ -0,0 +1,427 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.deutschebahn.internal.timetable.dto.Event; +import org.openhab.binding.deutschebahn.internal.timetable.dto.EventStatus; +import org.openhab.binding.deutschebahn.internal.timetable.dto.Message; +import org.openhab.core.library.types.DateTimeType; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.types.State; + +/** + * Selector for the Attribute of an {@link Event}. + * + * chapter "1.2.11 Event" in Technical Interface Description for external Developers + * + * @see https://developer.deutschebahn.com/store/apis/info?name=Timetables&version=v1&provider=DBOpenData&#tab1 + * + * @author Sönke Küper - initial contribution + * + * @param type of value in Bean. + * @param type of state. + */ +@NonNullByDefault +public final class EventAttribute + extends AbstractDtoAttributeSelector { + + /** + * Planned Path. + */ + public static final EventAttribute PPTH = new EventAttribute<>("planned-path", Event::getPpth, + Event::setPpth, StringType::new, StringType.class); + + /** + * Changed Path. + */ + public static final EventAttribute CPTH = new EventAttribute<>("changed-path", Event::getCpth, + Event::setCpth, StringType::new, StringType.class); + /** + * Planned platform. + */ + public static final EventAttribute PP = new EventAttribute<>("planned-platform", Event::getPp, + Event::setPp, StringType::new, StringType.class); + /** + * Changed platform. + */ + public static final EventAttribute CP = new EventAttribute<>("changed-platform", Event::getCp, + Event::setCp, StringType::new, StringType.class); + /** + * Planned time. + */ + public static final EventAttribute PT = new EventAttribute<>("planned-time", + getDate(Event::getPt), setDate(Event::setPt), EventAttribute::createDateTimeType, DateTimeType.class); + /** + * Changed time. + */ + public static final EventAttribute CT = new EventAttribute<>("changed-time", + getDate(Event::getCt), setDate(Event::setCt), EventAttribute::createDateTimeType, DateTimeType.class); + /** + * Planned status. + */ + public static final EventAttribute PS = new EventAttribute<>("planned-status", + Event::getPs, Event::setPs, EventAttribute::fromEventStatus, StringType.class); + /** + * Changed status. + */ + public static final EventAttribute CS = new EventAttribute<>("changed-status", + Event::getCs, Event::setCs, EventAttribute::fromEventStatus, StringType.class); + /** + * Hidden. + */ + public static final EventAttribute HI = new EventAttribute<>("hidden", Event::getHi, + Event::setHi, EventAttribute::parseHidden, OnOffType.class); + /** + * Cancellation time. + */ + public static final EventAttribute CLT = new EventAttribute<>("cancellation-time", + getDate(Event::getClt), setDate(Event::setClt), EventAttribute::createDateTimeType, DateTimeType.class); + /** + * Wing. + */ + public static final EventAttribute WINGS = new EventAttribute<>("wings", Event::getWings, + Event::setWings, StringType::new, StringType.class); + /** + * Transition. + */ + public static final EventAttribute TRA = new EventAttribute<>("transition", Event::getTra, + Event::setTra, StringType::new, StringType.class); + /** + * Planned distant endpoint. + */ + public static final EventAttribute PDE = new EventAttribute<>("planned-distant-endpoint", + Event::getPde, Event::setPde, StringType::new, StringType.class); + /** + * Changed distant endpoint. + */ + public static final EventAttribute CDE = new EventAttribute<>("changed-distant-endpoint", + Event::getCde, Event::setCde, StringType::new, StringType.class); + /** + * Distant change. + */ + public static final EventAttribute DC = new EventAttribute<>("distant-change", Event::getDc, + Event::setDc, DecimalType::new, DecimalType.class); + /** + * Line. + */ + public static final EventAttribute L = new EventAttribute<>("line", Event::getL, Event::setL, + StringType::new, StringType.class); + + /** + * Messages. + */ + public static final EventAttribute, StringType> MESSAGES = new EventAttribute<>("messages", + EventAttribute.getMessages(), EventAttribute::setMessages, EventAttribute::mapMessages, StringType.class); + + /** + * Planned Start station. + */ + public static final EventAttribute PLANNED_START_STATION = new EventAttribute<>( + "planned-start-station", EventAttribute.getSingleStationFromPath(Event::getPpth, true), + EventAttribute.voidSetter(), StringType::new, StringType.class); + + /** + * Planned Previous stations. + */ + public static final EventAttribute PLANNED_PREVIOUS_STATIONS = new EventAttribute<>( + "planned-previous-stations", EventAttribute.getIntermediateStationsFromPath(Event::getPpth, true), + EventAttribute.voidSetter(), StringType::new, StringType.class); + + /** + * Planned Target station. + */ + public static final EventAttribute PLANNED_TARGET_STATION = new EventAttribute<>( + "planned-target-station", EventAttribute.getSingleStationFromPath(Event::getPpth, false), + EventAttribute.voidSetter(), StringType::new, StringType.class); + + /** + * Planned Following stations. + */ + public static final EventAttribute PLANNED_FOLLOWING_STATIONS = new EventAttribute<>( + "planned-following-stations", EventAttribute.getIntermediateStationsFromPath(Event::getPpth, false), + EventAttribute.voidSetter(), StringType::new, StringType.class); + + /** + * Changed Start station. + */ + public static final EventAttribute CHANGED_START_STATION = new EventAttribute<>( + "changed-start-station", EventAttribute.getSingleStationFromPath(Event::getCpth, true), + EventAttribute.voidSetter(), StringType::new, StringType.class); + + /** + * Changed Previous stations. + */ + public static final EventAttribute CHANGED_PREVIOUS_STATIONS = new EventAttribute<>( + "changed-previous-stations", EventAttribute.getIntermediateStationsFromPath(Event::getCpth, true), + EventAttribute.voidSetter(), StringType::new, StringType.class); + + /** + * Changed Target station. + */ + public static final EventAttribute CHANGED_TARGET_STATION = new EventAttribute<>( + "changed-target-station", EventAttribute.getSingleStationFromPath(Event::getCpth, false), + EventAttribute.voidSetter(), StringType::new, StringType.class); + + /** + * Changed Following stations. + */ + public static final EventAttribute CHANGED_FOLLOWING_STATIONS = new EventAttribute<>( + "changed-following-stations", EventAttribute.getIntermediateStationsFromPath(Event::getCpth, false), + EventAttribute.voidSetter(), StringType::new, StringType.class); + + /** + * List containing all known {@link EventAttribute}. + */ + public static final List> ALL_ATTRIBUTES = Arrays.asList(PPTH, CPTH, PP, CP, PT, CT, PS, CS, + HI, CLT, WINGS, TRA, PDE, CDE, DC, L, MESSAGES); + + private static final SimpleDateFormat DATETIME_FORMAT = new SimpleDateFormat("yyMMddHHmm"); + + /** + * Creates an new {@link EventAttribute}. + * + * @param getter Function to get the raw value. + * @param setter Function to set the raw value. + * @param getState Function to get the Value as {@link State}. + */ + private EventAttribute(final String channelTypeName, // + final Function getter, // + final BiConsumer setter, // + final Function getState, // + final Class stateType) { + super(channelTypeName, getter, setter, getState, stateType); + } + + private static StringType fromEventStatus(final EventStatus value) { + return new StringType(value.value()); + } + + private static OnOffType parseHidden(@Nullable Integer value) { + return OnOffType.from(value != null && value == 1); + } + + private static Function getDate(final Function getValue) { + return (final Event event) -> { + return parseDate(getValue.apply(event)); + }; + } + + private static BiConsumer setDate(final BiConsumer setter) { + return (final Event event, final Date value) -> { + synchronized (DATETIME_FORMAT) { + String formattedDate = DATETIME_FORMAT.format(value); + setter.accept(event, formattedDate); + } + }; + } + + private static void setMessages(Event event, List messages) { + event.getM().clear(); + event.getM().addAll(messages); + } + + @Nullable + private static synchronized Date parseDate(@Nullable final String dateValue) { + if ((dateValue == null) || dateValue.isEmpty()) { + return null; + } + try { + synchronized (DATETIME_FORMAT) { + return DATETIME_FORMAT.parse(dateValue); + } + } catch (final ParseException e) { + return null; + } + } + + @Nullable + private static DateTimeType createDateTimeType(final @Nullable Date value) { + if (value == null) { + return null; + } else { + final ZonedDateTime d = ZonedDateTime.ofInstant(value.toInstant(), ZoneId.systemDefault()); + return new DateTimeType(d); + } + } + + /** + * Maps the status codes from the messages into status texts. + */ + @Nullable + private static StringType mapMessages(final @Nullable List messages) { + if (messages == null || messages.isEmpty()) { + return StringType.EMPTY; + } else { + final String messageTexts = messages // + .stream()// + .filter((Message message) -> message.getC() != null) // + .map(Message::getC) // + .distinct() // + .map(MessageCodes::getMessage) // + .filter((String messageText) -> !messageText.isEmpty()) // + .collect(Collectors.joining(" - ")); + + return new StringType(messageTexts); + } + } + + private static Function> getMessages() { + return new Function>() { + + @Override + public @Nullable List apply(Event t) { + if (t.getM().isEmpty()) { + return null; + } else { + return t.getM(); + } + } + }; + } + + /** + * Returns an single station from an path value (i.e. pipe separated value of stations). + * + * @param getPath Getter for the path. + * @param returnFirst if true the first value will be returned, false will return the last + * value. + */ + private static Function getSingleStationFromPath( + final Function getPath, boolean returnFirst) { + return (final Event event) -> { + String path = getPath.apply(event); + if (path == null || path.isEmpty()) { + return null; + } + + final String[] stations = splitPath(path); + if (returnFirst) { + return stations[0]; + } else { + return stations[stations.length - 1]; + } + }; + } + + /** + * Returns all intermediate stations from an path. The first or last station will be omitted. The values will be + * separated by an single dash -. + * + * @param getPath Getter for the path. + * @param removeFirst if true the first value will be removed, false will remove the last + * value. + */ + private static Function getIntermediateStationsFromPath( + final Function getPath, boolean removeFirst) { + return (final Event event) -> { + final String path = getPath.apply(event); + if (path == null || path.isEmpty()) { + return null; + } + final String[] stationValues = splitPath(path); + Stream stations = Arrays.stream(stationValues); + if (removeFirst) { + stations = stations.skip(1); + } else { + stations = stations.limit(stationValues.length - 1); + } + return stations.collect(Collectors.joining(" - ")); + }; + } + + /** + * Setter that does nothing. + * Used for derived attributes that can't be set. + */ + private static BiConsumer voidSetter() { + return new BiConsumer() { + + @Override + public void accept(Event t, VALUE_TYPE u) { + } + }; + } + + private static String[] splitPath(final String path) { + return path.split("\\|"); + } + + /** + * Returns an {@link EventAttribute} for the given channel-type and {@link EventType}. + */ + @Nullable + public static EventAttribute getByChannelName(final String channelName, EventType eventType) { + switch (channelName) { + case "planned-path": + return PPTH; + case "changed-path": + return CPTH; + case "planned-platform": + return PP; + case "changed-platform": + return CP; + case "planned-time": + return PT; + case "changed-time": + return CT; + case "planned-status": + return PS; + case "changed-status": + return CS; + case "hidden": + return HI; + case "cancellation-time": + return CLT; + case "wings": + return WINGS; + case "transition": + return TRA; + case "planned-distant-endpoint": + return PDE; + case "changed-distant-endpoint": + return CDE; + case "distant-change": + return DC; + case "line": + return L; + case "messages": + return MESSAGES; + case "planned-final-station": + return eventType == EventType.ARRIVAL ? PLANNED_START_STATION : PLANNED_TARGET_STATION; + case "planned-intermediate-stations": + return eventType == EventType.ARRIVAL ? PLANNED_PREVIOUS_STATIONS : PLANNED_FOLLOWING_STATIONS; + case "changed-final-station": + return eventType == EventType.ARRIVAL ? CHANGED_START_STATION : CHANGED_TARGET_STATION; + case "changed-intermediate-stations": + return eventType == EventType.ARRIVAL ? CHANGED_PREVIOUS_STATIONS : CHANGED_FOLLOWING_STATIONS; + default: + return null; + } + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/EventAttributeSelection.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/EventAttributeSelection.java new file mode 100644 index 0000000000000..51224949f9a10 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/EventAttributeSelection.java @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.deutschebahn.internal.timetable.dto.Event; +import org.openhab.binding.deutschebahn.internal.timetable.dto.TimetableStop; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; + +/** + * Selection that returns the value of an {@link EventAttribute}. The required {@link Event} is + * selected with the given {@link EventType}. + * + * @author Sönke Küper - Initial contribution + */ +@NonNullByDefault +public final class EventAttributeSelection implements AttributeSelection { + + private final EventType eventType; + private final EventAttribute eventAttribute; + + /** + * Creates an new {@link EventAttributeSelection}. + */ + public EventAttributeSelection(EventType eventType, EventAttribute eventAttribute) { + this.eventType = eventType; + this.eventAttribute = eventAttribute; + } + + @Nullable + @Override + public State getState(TimetableStop stop) { + final Event event = eventType.getEvent(stop); + if (event == null) { + return UnDefType.UNDEF; + } else { + return this.eventAttribute.getState(event); + } + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/EventType.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/EventType.java new file mode 100644 index 0000000000000..a8422aabced52 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/EventType.java @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal; + +import java.util.function.Function; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.deutschebahn.internal.timetable.dto.Event; +import org.openhab.binding.deutschebahn.internal.timetable.dto.TimetableStop; + +/** + * Type of an {@link Event} within an {@link TimetableStop}. + * + * @author Sönke Küper - initial contribution + */ +@NonNullByDefault +public enum EventType { + + /** + * Selects the Arrival-Element (i.e. ar). + */ + ARRIVAL(TimetableStop::getAr, TimetableStop::getDp), + + /** + * Selects the departure element (i.e. dp). + */ + DEPARTURE(TimetableStop::getDp, TimetableStop::getAr); + + private final Function getter; + private final Function oppositeGetter; + + private EventType(Function getter, + Function oppositeGetter) { + this.getter = getter; + this.oppositeGetter = oppositeGetter; + } + + /** + * Returns the selected event from the given {@link TimetableStop}. + */ + @Nullable + public final Event getEvent(TimetableStop stop) { + return this.getter.apply(stop); + } + + /** + * Returns the opposite event from the given {@link TimetableStop}. + */ + @Nullable + public final Event getOppositeEvent(TimetableStop stop) { + return this.oppositeGetter.apply(stop); + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/MessageCodes.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/MessageCodes.java new file mode 100644 index 0000000000000..fae86487fed87 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/MessageCodes.java @@ -0,0 +1,134 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Class containing the mappings for all message status codes. + * + * chapter "2 List of all codes" in Technical Interface Description for external Developers + * + * @see https://developer.deutschebahn.com/store/apis/info?name=Timetables&version=v1&provider=DBOpenData&#tab1 + * + * @author Sönke Küper - initial contribution + */ +@NonNullByDefault +public final class MessageCodes { + + private static Map codes = new HashMap<>(); + static { + codes.put(0, "keine Verspätungsbegründung"); + codes.put(2, "Polizeiliche Ermittlung"); + codes.put(3, "Feuerwehreinsatz an der Strecke"); + codes.put(4, "kurzfristiger Personalausfall"); + codes.put(5, "ärztliche Versorgung eines Fahrgastes"); + codes.put(6, "Betätigen der Notbremse"); + codes.put(7, "Personen im Gleis"); + codes.put(8, "Notarzteinsatz am Gleis"); + codes.put(9, "Streikauswirkungen"); + codes.put(10, "Tiere im Gleis"); + codes.put(11, "Unwetter"); + codes.put(12, "Warten auf ein verspätetes Schiff"); + codes.put(13, "Pass- und Zollkontrolle"); + codes.put(14, "Technische Störung am Bahnhof"); + codes.put(15, "Beeinträchtigung durch Vandalismus"); + codes.put(16, "Entschärfung einer Fliegerbombe"); + codes.put(17, "Beschädigung einer Brücke"); + codes.put(18, "umgestürzter Baum im Gleis"); + codes.put(19, "Unfall an einem Bahnübergang"); + codes.put(20, "Tiere im Gleis"); + codes.put(21, "Warten auf Fahrgäste aus einem anderen Zug"); + codes.put(22, "Witterungsbedingte Störung"); + codes.put(23, "Feuerwehreinsatz auf Bahngelände"); + codes.put(24, "Verspätung im Ausland"); + codes.put(25, "Warten auf weitere Wagen"); + codes.put(28, "Gegenstände im Gleis"); + codes.put(29, "Ersatzverkehr mit Bus ist eingerichtet"); + codes.put(31, "Bauarbeiten"); + codes.put(32, "Verzögerung beim Ein-/Ausstieg"); + codes.put(33, "Oberleitungsstörung"); + codes.put(34, "Signalstörung"); + codes.put(35, "Streckensperrung"); + codes.put(36, "technische Störung am Zug"); + codes.put(38, "technische Störung an der Strecke"); + codes.put(39, "Anhängen von zusätzlichen Wagen"); + codes.put(40, "Stellwerksstörung /-ausfall"); + codes.put(41, "Störung an einem Bahnübergang"); + codes.put(42, "außerplanmäßige Geschwindigkeitsbeschränkung"); + codes.put(43, "Verspätung eines vorausfahrenden Zuges"); + codes.put(44, "Warten auf einen entgegenkommenden Zug"); + codes.put(45, "Überholung"); + codes.put(46, "Warten auf freie Einfahrt"); + codes.put(47, "verspätete Bereitstellung des Zuges"); + codes.put(48, "Verspätung aus vorheriger Fahrt"); + codes.put(55, "technische Störung an einem anderen Zug"); + codes.put(56, "Warten auf Fahrgäste aus einem Bus"); + codes.put(57, "Zusätzlicher Halt zum Ein-/Ausstieg für Reisende"); + codes.put(58, "Umleitung des Zuges"); + codes.put(59, "Schnee und Eis"); + codes.put(60, "Reduzierte Geschwindigkeit wegen Sturm"); + codes.put(61, "Türstörung"); + codes.put(62, "behobene technische Störung am Zug"); + codes.put(63, "technische Untersuchung am Zug"); + codes.put(64, "Weichenstörung"); + codes.put(65, "Erdrutsch"); + codes.put(66, "Hochwasser"); + codes.put(70, "WLAN im gesamten Zug nicht verfügbar"); + codes.put(71, "WLAN in einem/mehreren Wagen nicht verfügbar"); + codes.put(72, "Info-/Entertainment nicht verfügbar"); + codes.put(73, "Heute: Mehrzweckabteil vorne"); + codes.put(74, "Heute: Mehrzweckabteil hinten"); + codes.put(75, "Heute: 1. Klasse vorne"); + codes.put(76, "Heute: 1. Klasse hinten"); + codes.put(77, "ohne 1. Klasse"); + codes.put(79, "ohne Mehrzweckabteil"); + codes.put(80, "andere Reihenfolge der Wagen"); + codes.put(82, "mehrere Wagen fehlen"); + codes.put(83, "Störung fahrzeuggebundene Einstiegshilfe"); + codes.put(84, "Zug verkehrt richtig gereiht"); + codes.put(85, "ein Wagen fehlt"); + codes.put(86, "gesamter Zug ohne Reservierung"); + codes.put(87, "einzelne Wagen ohne Reservierung"); + codes.put(88, "keine Qualitätsmängel"); + codes.put(89, "Reservierungen sind wieder vorhanden"); + codes.put(90, "kein gastronomisches Angebot"); + codes.put(91, "fehlende Fahrradbeförderung"); + codes.put(92, "Eingeschränkte Fahrradbeförderung"); + codes.put(93, "keine behindertengerechte Einrichtung"); + codes.put(94, "Ersatzbewirtschaftung"); + codes.put(95, "Ohne behindertengerechtes WC"); + codes.put(96, "Überbesetzung mit Kulanzleistungen"); + codes.put(97, "Überbesetzung ohne Kulanzleistungen"); + codes.put(98, "sonstige Qualitätsmängel"); + codes.put(99, "Verzögerungen im Betriebsablauf"); + } + + private MessageCodes() { + } + + /** + * Returns the message for the given code or emtpy string if not present. + */ + public static String getMessage(final int code) { + final String message = codes.get(code); + if (message == null) { + return ""; + } else { + return message; + } + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/TimetableStopFilter.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/TimetableStopFilter.java new file mode 100644 index 0000000000000..e0256f42453e9 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/TimetableStopFilter.java @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal; + +import java.util.function.Predicate; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.deutschebahn.internal.timetable.dto.TimetableStop; + +/** + * Filter that selects {@link TimetableStop}, if they have an departure or an arrival element (or both). + * + * @author Sönke Küper - initial contribution. + */ +@NonNullByDefault +public enum TimetableStopFilter implements Predicate { + + /** + * Selects all entries. + */ + ALL { + @Override + public boolean test(TimetableStop t) { + return true; + } + }, + + /** + * Selects only stops with an departure. + */ + DEPARTURES { + @Override + public boolean test(TimetableStop t) { + return t.getDp() != null; + } + }, + + /** + * Selects only stops with an arrival. + */ + ARRIVALS { + @Override + public boolean test(TimetableStop t) { + return t.getAr() != null; + } + }; +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/TripLabelAttribute.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/TripLabelAttribute.java new file mode 100644 index 0000000000000..2acbaeaab5e40 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/TripLabelAttribute.java @@ -0,0 +1,119 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal; + +import java.util.function.BiConsumer; +import java.util.function.Function; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.deutschebahn.internal.timetable.dto.TimetableStop; +import org.openhab.binding.deutschebahn.internal.timetable.dto.TripLabel; +import org.openhab.binding.deutschebahn.internal.timetable.dto.TripType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; + +/** + * Selection that returns the value of an {@link TripLabel}. + * + * chapter "1.2.7 TripLabel" in Technical Interface Description for external Developers + * + * @see https://developer.deutschebahn.com/store/apis/info?name=Timetables&version=v1&provider=DBOpenData&#tab1 + * + * @author Sönke Küper - Initial contribution. + * + * @param type of value in Bean. + * @param type of state. + */ +@NonNullByDefault +public final class TripLabelAttribute extends + AbstractDtoAttributeSelector implements AttributeSelection { + + /** + * Trip category. + */ + public static final TripLabelAttribute C = new TripLabelAttribute<>("category", TripLabel::getC, + TripLabel::setC, StringType::new, StringType.class); + + /** + * Number. + */ + public static final TripLabelAttribute N = new TripLabelAttribute<>("number", TripLabel::getN, + TripLabel::setN, StringType::new, StringType.class); + + /** + * Filter flags. + */ + public static final TripLabelAttribute F = new TripLabelAttribute<>("filter-flags", + TripLabel::getF, TripLabel::setF, StringType::new, StringType.class); + /** + * Trip Type. + */ + public static final TripLabelAttribute T = new TripLabelAttribute<>("trip-type", + TripLabel::getT, TripLabel::setT, TripLabelAttribute::fromTripType, StringType.class); + /** + * Owner. + */ + public static final TripLabelAttribute O = new TripLabelAttribute<>("owner", TripLabel::getO, + TripLabel::setO, StringType::new, StringType.class); + + /** + * Creates an new {@link TripLabelAttribute}. + * + * @param getter Function to get the raw value. + * @param setter Function to set the raw value. + * @param getState Function to get the Value as {@link State}. + */ + private TripLabelAttribute(final String channelTypeName, // + final Function getter, // + final BiConsumer setter, // + final Function getState, // + final Class stateType) { + super(channelTypeName, getter, setter, getState, stateType); + } + + @Nullable + @Override + public State getState(TimetableStop stop) { + if (stop.getTl() == null) { + return UnDefType.UNDEF; + } + return super.getState(stop.getTl()); + } + + private static StringType fromTripType(final TripType value) { + return new StringType(value.value()); + } + + /** + * Returns an {@link TripLabelAttribute} for the given channel-name. + */ + @Nullable + public static TripLabelAttribute getByChannelName(final String channelName) { + switch (channelName) { + case "category": + return C; + case "number": + return N; + case "filter-flags": + return F; + case "trip-type": + return T; + case "owner": + return O; + default: + return null; + } + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetableLoader.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetableLoader.java new file mode 100644 index 0000000000000..96d1cf38639dc --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetableLoader.java @@ -0,0 +1,300 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal.timetable; + +import java.io.IOException; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.deutschebahn.internal.EventAttribute; +import org.openhab.binding.deutschebahn.internal.EventType; +import org.openhab.binding.deutschebahn.internal.TimetableStopFilter; +import org.openhab.binding.deutschebahn.internal.timetable.dto.Event; +import org.openhab.binding.deutschebahn.internal.timetable.dto.Timetable; +import org.openhab.binding.deutschebahn.internal.timetable.dto.TimetableStop; +import org.openhab.core.library.types.DateTimeType; + +/** + * Helper for loading the required amount of {@link TimetableStop} via an {@link TimetablesV1Api}. + * This consists of a series of calls. + * + * @author Sönke Küper - initial contribution + */ +@NonNullByDefault +public final class TimetableLoader { + + // The api provides at most 18 hours in advance. + private static final int MAX_ADVANCE_HOUR = 18; + + // The recent changes only contains all changes done within the last 2 minutes. + private static final int MAX_RECENT_CHANGE_UPDATE = 120; + + // The min. request interval for recent changes is 30 seconds. + private static final int MIN_RECENT_CHANGE_INTERVAL = 30; + + // Cache containing the TimetableStops per ID + private final Map cachedStopsPerId; + private final Map cachedChanges; + + private final TimetablesV1Api api; + private final TimetableStopFilter stopFilter; + private final TimetableStopComparator comparator; + private final Supplier currentTimeProvider; + private int stopCount; + + private final String evaNo; + + @Nullable + private Date lastRequestedPlan; + @Nullable + private Date lastRequestedChanges; + + /** + * Creates an new {@link TimetableLoader}. + * + * @param api {@link TimetablesV1Api} to use. + * @param stopFilter Filter for selection of loaded {@link TimetableStop}. + * @param requestedStopCount Count of stops to be loaded on each call. + * @param currentTimeProvider {@link Supplier} for the current time. + */ + public TimetableLoader(final TimetablesV1Api api, final TimetableStopFilter stopFilter, final EventType eventToSort, + final Supplier currentTimeProvider, final String evaNo, final int requestedStopCount) { + this.api = api; + this.stopFilter = stopFilter; + this.currentTimeProvider = currentTimeProvider; + this.evaNo = evaNo; + this.stopCount = requestedStopCount; + this.comparator = new TimetableStopComparator(eventToSort); + this.cachedStopsPerId = new HashMap<>(); + this.cachedChanges = new HashMap<>(); + this.lastRequestedChanges = null; + this.lastRequestedPlan = null; + } + + /** + * Sets the count of needed {@link TimetableStop} that is required at each call of {@link #getTimetableStops()}. + */ + public void setStopCount(int stopCount) { + this.stopCount = stopCount; + } + + /** + * Updates the cache with current data from plan and changes and returns the {@link TimetableStop}. + */ + public List getTimetableStops() throws IOException { + this.updateCache(); + final List result = new ArrayList<>(this.cachedStopsPerId.values()); + Collections.sort(result, this.comparator); + return result; + } + + /** + * Updates the cached {@link TimetableStop} to ensure that the requested amount of stops is available. + */ + private void updateCache() throws IOException { + final Date currentTime = this.currentTimeProvider.get(); + + // First update the changes. This will merge them into the existing plan data + // or cache them, if no corresponding stop is available. + this.updateChanges(currentTime); + + // Remove all stops that are in the past + this.removeOldStops(currentTime); + + // Finally fill up plan until required amount of data is available. + this.updatePlan(currentTime); + } + + /** + * Removes all stops from the cache with planned and changed time after the current time. + */ + private void removeOldStops(final Date currentTime) { + final Iterator> it = this.cachedStopsPerId.entrySet().iterator(); + while (it.hasNext()) { + final Entry currentEntry = it.next(); + final TimetableStop stop = currentEntry.getValue(); + + // Remove entry if planned and changed time are in the past + if (isInPast(stop, currentTime)) { + it.remove(); + } + } + } + + /** + * Returns true if the planned and changed time from arrival and departure are in the past. + */ + private static boolean isInPast(TimetableStop stop, Date currentTime) { + return isBefore(EventAttribute.PT, stop.getAr(), currentTime) // + && isBefore(EventAttribute.CT, stop.getAr(), currentTime) // + && isBefore(EventAttribute.PT, stop.getDp(), currentTime) // + && isBefore(EventAttribute.PT, stop.getDp(), currentTime); + } + + /** + * Checks if the value of the given {@link EventAttribute} is either null or before + * the given compareTime. + * If the {@link Event} is null it will return true. + */ + private static boolean isBefore( // + final EventAttribute attribute, // + final @Nullable Event event, // + final Date toCompare) { + if (event == null) { + return true; + } + final Date value = attribute.getValue(event); + if (value == null) { + return true; + } else { + return value.before(toCompare); + } + } + + /** + * Checks if enough plan entries are available and loads them from the backing {@link TimetablesV1Api} if required. + */ + private void updatePlan(final Date currentTime) throws IOException { + // If enough stops are available in cache do nothing. + if (this.cachedStopsPerId.size() >= this.stopCount) { + return; + } + + // start requesting at last request time. + final GregorianCalendar requestTime = new GregorianCalendar(); + if (this.lastRequestedPlan != null) { + requestTime.setTime(this.lastRequestedPlan); + requestTime.set(Calendar.HOUR_OF_DAY, requestTime.get(Calendar.HOUR_OF_DAY) + 1); + } else { + requestTime.setTime(currentTime); + } + + // Determine the max. time for which an plan is available + final GregorianCalendar maxRequestTime = new GregorianCalendar(); + maxRequestTime.setTime(currentTime); + maxRequestTime.set(Calendar.HOUR_OF_DAY, maxRequestTime.get(Calendar.HOUR_OF_DAY) + MAX_ADVANCE_HOUR); + + // load until required amount of stops is present or no more data is available. + while ((this.cachedStopsPerId.size() < this.stopCount) && requestTime.before(maxRequestTime)) { + final Timetable timetable = this.api.getPlan(this.evaNo, requestTime.getTime()); + this.lastRequestedPlan = requestTime.getTime(); + + // Filter only stops that are selected by given filter + final List stops = timetable // + .getS() // + .stream() // + .filter(this.stopFilter) // + .collect(Collectors.toList()); + + // Merge the loaded stops with the cached changes and put them into the plan cache. + this.processLoadedPlan(stops, currentTime); + + // Move request time one hour ahead. + requestTime.set(Calendar.HOUR_OF_DAY, requestTime.get(Calendar.HOUR_OF_DAY) + 1); + } + } + + /** + * Merges the loaded plan stops with the previously cached changes. + * The result will be cached as plan data, if not in the past. + */ + private void processLoadedPlan(List stops, Date currentTime) { + for (final TimetableStop stop : stops) { + + // Check if an change for the stop was cached and apply it + final TimetableStop change = this.cachedChanges.remove(stop.getId()); + if (change != null) { + TimetableStopMerger.merge(stop, change); + } + + // Check if stop is in past after applying changes and put + // into cached plan if not. + if (!isInPast(stop, currentTime)) { + this.cachedStopsPerId.put(stop.getId(), stop); + } + } + } + + /** + * Loads the changes from the api and merges them into the cached plan entries. + */ + private void updateChanges(final Date currentTime) throws IOException { + final List changes = this.loadChanges(currentTime); + this.processChanges(changes); + } + + /** + * Merges the given {@link TimetableStop} into the cached plan. + * If no stop in the plan for the change exist it will be put into the changes cache. + */ + private void processChanges(final List changes) { + for (final TimetableStop change : changes) { + + final TimetableStop existingEntry = this.cachedStopsPerId.get(change.getId()); + if (existingEntry != null) { + TimetableStopMerger.merge(existingEntry, change); + } else { + this.cachedChanges.put(change.getId(), change); + } + } + } + + /** + * Loads the full or recent changes depending on last request time. + */ + private List loadChanges(final Date currentTime) throws IOException { + boolean fullChanges = false; + final long secondsSinceLastUpdate = this.getSecondsSinceLastRequestedChanges(currentTime); + + // The recent changes are updated every 30 seconds, so if last update is less than 30 seconds do nothing. + if (secondsSinceLastUpdate < MIN_RECENT_CHANGE_INTERVAL) { + return Collections.emptyList(); + } + + // The recent changes are only available for 120 seconds, so if last update is older perform an full update. + if (secondsSinceLastUpdate >= MAX_RECENT_CHANGE_UPDATE) { + fullChanges = true; + } + + Timetable changes; + if (fullChanges) { + changes = this.api.getFullChanges(this.evaNo); + } else { + changes = this.api.getRecentChanges(this.evaNo); + } + this.lastRequestedChanges = currentTime; + return changes.getS(); + } + + @SuppressWarnings("null") + private long getSecondsSinceLastRequestedChanges(final Date currentTime) { + if (this.lastRequestedChanges == null) { + return Long.MAX_VALUE; + } else { + return ChronoUnit.SECONDS.between(this.lastRequestedChanges.toInstant(), currentTime.toInstant()); + } + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetableStopComparator.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetableStopComparator.java new file mode 100644 index 0000000000000..520430fb61534 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetableStopComparator.java @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal.timetable; + +import java.util.Comparator; +import java.util.Date; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.deutschebahn.internal.EventAttribute; +import org.openhab.binding.deutschebahn.internal.EventType; +import org.openhab.binding.deutschebahn.internal.timetable.dto.Event; +import org.openhab.binding.deutschebahn.internal.timetable.dto.TimetableStop; + +/** + * {@link Comparator} that sorts the {@link TimetableStop} according planned date and time. + * + * @author Sönke Küper - initial contribution + */ +@NonNullByDefault +public class TimetableStopComparator implements Comparator { + + private final EventType eventSelection; + + /** + * Creates an new {@link TimetableStopComparator} that sorts {@link TimetableStop} according the Event selected + * selected by the given {@link EventType}. + */ + public TimetableStopComparator(EventType eventSelection) { + this.eventSelection = eventSelection; + } + + @Override + public int compare(TimetableStop o1, TimetableStop o2) { + return determinePlannedDate(o1, this.eventSelection).compareTo(determinePlannedDate(o2, this.eventSelection)); + } + + /** + * Returns the planned-Time for the given {@link TimetableStop}. + * The time will be returned from the {@link Event} selected by the given {@link EventType}. + * If the {@link TimetableStop} has no according {@link Event} the other Event will be used. + */ + private static Date determinePlannedDate(TimetableStop stop, EventType eventSelection) { + Event selectedEvent = eventSelection.getEvent(stop); + if (selectedEvent == null) { + selectedEvent = eventSelection.getOppositeEvent(stop); + } + if (selectedEvent == null) { + throw new AssertionError("one event is always present"); + } + final Date value = EventAttribute.PT.getValue(selectedEvent); + if (value == null) { + throw new AssertionError("planned time cannot be null"); + } + return value; + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetableStopMerger.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetableStopMerger.java new file mode 100644 index 0000000000000..e5ca984b8be64 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetableStopMerger.java @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal.timetable; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.deutschebahn.internal.EventAttribute; +import org.openhab.binding.deutschebahn.internal.timetable.dto.Event; +import org.openhab.binding.deutschebahn.internal.timetable.dto.TimetableStop; + +/** + * Utility for merging timetable stops. + * This is required, thus first only the plan is returned from the API and afterwards the loaded timetable-stops must be + * merged with the fetched changes. + * + * @author Sönke Küper - initial contribution + */ +@NonNullByDefault +final class TimetableStopMerger { + + /** + * Merges the {@link TimetableStop} inplace to the first TimetableStop. + */ + public static void merge(final TimetableStop first, final TimetableStop second) { + mergeStopAttributes(first, second); + } + + /** + * Updates all values from the second {@link TimetableStop} into the first one. + */ + private static void mergeStopAttributes(final TimetableStop first, final TimetableStop second) { + mergeEventAttributes(first.getAr(), second.getAr()); + mergeEventAttributes(first.getDp(), second.getDp()); + } + + /** + * Updates all values from the second Event into the first one. + */ + private static void mergeEventAttributes(@Nullable final Event first, @Nullable final Event second) { + if ((first == null) || (second == null)) { + return; + } + + for (final EventAttribute attribute : EventAttribute.ALL_ATTRIBUTES) { + updateAttribute(attribute, first, second); + } + } + + /** + * Sets the value of the given {@link EventAttribute} from the second Event in the first event, if not + * null. + */ + private static void updateAttribute(final EventAttribute attribute, final Event first, + final Event second) { + final @Nullable VALUE_TYPE value = attribute.getValue(second); + if (value != null) { + attribute.setValue(first, value); + } + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1Api.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1Api.java new file mode 100644 index 0000000000000..fa5ec52ddda45 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1Api.java @@ -0,0 +1,101 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal.timetable; + +import java.io.IOException; +import java.util.Date; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.deutschebahn.internal.timetable.dto.Timetable; + +/** + * Interface for timetables API in V1. + * + * @see https://developer.deutschebahn.com/store/apis/info?name=Timetables&version=v1&provider=DBOpenData + * + * @author Sönke Küper - initial contribution + */ +@NonNullByDefault +public interface TimetablesV1Api { + + /** + * Requests the timetable with the planned data for the given station and time. + * Calls the "/plan" endpoint of the rest-service. + * + * REST-endpoint documentation: (from + * {@see https://developer.deutschebahn.com/store/apis/info?name=Timetables&version=v1&provider=DBOpenData}). + * Returns a Timetable object (see Timetable) that contains planned data for the specified station (evaNo) + * within the hourly time slice given by date (format YYMMDD) and hour (format HH). The data includes stops + * for all trips that arrive or depart within that slice. There is a small overlap between slices since some + * trips arrive in one slice and depart in another. + * + * Planned data does never contain messages. On event level, planned data contains the 'plannned' attributes pt, pp, + * ps and ppth + * while the 'changed' attributes ct, cp, cs and cpth are absent. + * + * Planned data is generated many hours in advance and is static, i.e. it does never change. + * It should be cached by web caches.public interface allows access to information about a station. + * + * @param evaNo The Station EVA-number. + * @param time The time for which the timetable is requested. It will be requested for the given day and hour. + * + * @return The {@link Timetable} containing the planned arrivals and departues. + */ + public abstract Timetable getPlan(String evaNo, Date time) throws IOException; + + /** + * Requests all known changes in the timetable for the given station. + * Calls the "/fchg" endpoint of the rest-service. + * + * REST-endpoint documentation: (from + * {@see https://developer.deutschebahn.com/store/apis/info?name=Timetables&version=v1&provider=DBOpenData}). + * Returns a Timetable object (see Timetable) that contains all known changes for the station given by evaNo. + * + * The data includes all known changes from now on until undefinitely into the future. Once changes become obsolete + * (because their trip departs from the station) they are removed from this resource. + * + * Changes may include messages. On event level, they usually contain one or more of the 'changed' attributes ct, + * cp, cs or cpth. + * Changes may also include 'planned' attributes if there is no associated planned data for the change (e.g. an + * unplanned stop or trip). + * + * Full changes are updated every 30s and should be cached for that period by web caches. + * + * @param evaNo The Station EVA-number. + * + * @return The {@link Timetable} containing all known changes for the given station. + */ + public abstract Timetable getFullChanges(String evaNo) throws IOException; + + /** + * Requests the timetable with the planned data for the given station and time. + * Calls the "/plan" endpoint of the rest-service. + * + * REST-endpoint documentation: (from + * {@see https://developer.deutschebahn.com/store/apis/info?name=Timetables&version=v1&provider=DBOpenData}). + * Returns a Timetable object (see Timetable) that contains all recent changes for the station given by evaNo. + * Recent changes are always a subset of the full changes. They may equal full changes but are typically much + * smaller. + * Data includes only those changes that became known within the last 2 minutes. + * + * A client that updates its state in intervals of less than 2 minutes should load full changes initially and then + * proceed to periodically load only the recent changes in order to save bandwidth. + * + * Recent changes are updated every 30s as well and should be cached for that period by web caches. + * + * @param evaNo The Station EVA-number. + * + * @return The {@link Timetable} containing recent changes (from last two minutes) for the given station. + */ + public abstract Timetable getRecentChanges(String evaNo) throws IOException; +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1ApiFactory.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1ApiFactory.java new file mode 100644 index 0000000000000..5eaa552029aba --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1ApiFactory.java @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal.timetable; + +import java.net.URISyntaxException; + +import javax.xml.bind.JAXBException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.deutschebahn.internal.timetable.TimetablesV1Impl.HttpCallable; +import org.xml.sax.SAXException; + +/** + * Factory for {@link TimetablesV1Api}. + * + * @author Sönke Küper - Initial contribution. + */ +@NonNullByDefault +public interface TimetablesV1ApiFactory { + + /** + * Creates an new instance of the {@link TimetablesV1Api}. + */ + public abstract TimetablesV1Api create(final String authToken, final HttpCallable httpCallable) + throws JAXBException, SAXException, URISyntaxException; +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1Impl.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1Impl.java new file mode 100644 index 0000000000000..e4eccc5370b67 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1Impl.java @@ -0,0 +1,215 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal.timetable; + +import java.io.IOException; +import java.io.InputStream; +import java.io.StringReader; +import java.net.URISyntaxException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Properties; +import java.util.concurrent.TimeUnit; + +import javax.xml.bind.JAXBContext; +import javax.xml.bind.JAXBElement; +import javax.xml.bind.JAXBException; +import javax.xml.bind.Unmarshaller; +import javax.xml.validation.Schema; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.http.HttpHeader; +import org.openhab.binding.deutschebahn.internal.timetable.dto.Timetable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.xml.sax.SAXException; + +/** + * Default Implementation of {@link TimetablesV1Api}. + * + * @author Sönke Küper - Initial contribution + */ +@NonNullByDefault +public final class TimetablesV1Impl implements TimetablesV1Api { + + /** + * Interface for stubbing HTTP-Calls in jUnit tests. + */ + public interface HttpCallable { + + /** + * Executes the given url with the given httpMethod. + * Furthermore the http.proxyXXX System variables are read and + * set into the {@link org.eclipse.jetty.client.HttpClient}. + * + * @param httpMethod the HTTP method to use + * @param url the url to execute + * @param httpHeaders optional http request headers which has to be sent within request + * @param content the content to be sent to the given url or null if no content should + * be sent. + * @param contentType the content type of the given content + * @param timeout the socket timeout in milliseconds to wait for data + * @return the response body or NULL when the request went wrong + * @throws IOException when the request execution failed, timed out or it was interrupted + */ + public abstract String executeUrl(String httpMethod, String url, Properties httpHeaders, + @Nullable InputStream content, @Nullable String contentType, int timeout) throws IOException; + } + + private static final String PLAN_URL = "https://api.deutschebahn.com/timetables/v1/plan/%evaNo%/%date%/%hour%"; + private static final String FCHG_URL = "https://api.deutschebahn.com/timetables/v1/fchg/%evaNo%"; + private static final String RCHG_URL = "https://api.deutschebahn.com/timetables/v1/rchg/%evaNo%"; + + private static final int REQUEST_TIMEOUT_MS = (int) TimeUnit.SECONDS.toMillis(30); + private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyMMdd"); + private static final SimpleDateFormat HOUR_FORMAT = new SimpleDateFormat("HH"); + + private final String authToken; + private final HttpCallable httpCallable; + + private final Logger logger = LoggerFactory.getLogger(TimetablesV1Impl.class); + private JAXBContext jaxbContext; + // private Schema schema; + + /** + * Creates an new {@link TimetablesV1Impl}. + * + * @param authToken The authentication token for timetable api on developer.deutschebahn.com. + */ + public TimetablesV1Impl(final String authToken, final HttpCallable httpCallable) + throws JAXBException, SAXException, URISyntaxException { + this.authToken = authToken; + this.httpCallable = httpCallable; + + // The results from webservice does not conform to the schema provided. The triplabel-Element (tl) is expected + // to occour as + // last Element within an timetableStop (s) element. But it is the first element when requesting the plan. + // When requesting the changes it is the last element, so the schema can't just be corrected. + // If written to developer support, but got no response yet, so schema validation is disabled at the moment. + + // final SchemaFactory schemaFactory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI); + // final URL schemaURL = getClass().getResource("/xsd/Timetables_REST.xsd"); + // assert schemaURL != null; + // this.schema = schemaFactory.newSchema(schemaURL); + this.jaxbContext = JAXBContext.newInstance(Timetable.class.getPackageName(), Timetable.class.getClassLoader()); + } + + @Override + public Timetable getPlan(final String evaNo, final Date time) throws IOException { + return this.performHttpApiRequest(buildPlanRequestURL(evaNo, time)); + } + + @Override + public Timetable getFullChanges(final String evaNo) throws IOException { + return this.performHttpApiRequest(buildFchgRequestURL(evaNo)); + } + + @Override + public Timetable getRecentChanges(final String evaNo) throws IOException { + return this.performHttpApiRequest(buildRchgRequestURL(evaNo)); + } + + private Timetable performHttpApiRequest(final String url) throws IOException { + this.logger.debug("Performing http request to timetable api with url {}", url); + + String response; + try { + response = this.httpCallable.executeUrl( // + "GET", // + url, // + this.createHeaders(), // + null, // + null, // + REQUEST_TIMEOUT_MS); + return this.mapResponseToTimetable(response); + } catch (IOException e) { + logger.debug("Error getting data from webservice.", e); + throw e; + } + } + + /** + * Parses and creates the {@link Timetable} from the response or + * returns an empty {@link Timetable} if response was empty. + */ + private Timetable mapResponseToTimetable(final String response) throws IOException { + if (response.isEmpty()) { + return new Timetable(); + } + + try { + return unmarshal(response, Timetable.class); + } catch (JAXBException | SAXException e) { + this.logger.error("Error parsing response from timetable api.", e); + throw new IOException(e); + } + } + + /** + * Creates the HTTP-Headers required for http requests. + */ + private Properties createHeaders() { + final Properties headers = new Properties(); + headers.put(HttpHeader.ACCEPT.asString(), "application/xml"); + headers.put(HttpHeader.AUTHORIZATION.asString(), "Bearer " + this.authToken); + return headers; + } + + private T unmarshal(final String xmlContent, final Class clazz) throws JAXBException, SAXException { + return unmarshal( // + jaxbContext, // + null, // Provide no schema, due webservice results are not schema-valid. + xmlContent, // + clazz // + ); + } + + @SuppressWarnings("unchecked") + private static T unmarshal(final JAXBContext jaxbContext, @Nullable final Schema schema, + final String xmlContent, final Class clss) throws JAXBException { + final Unmarshaller unmarshaller = jaxbContext.createUnmarshaller(); + unmarshaller.setSchema(schema); + final JAXBElement resultObject = (JAXBElement) unmarshaller.unmarshal(new StringReader(xmlContent)); + return resultObject.getValue(); + } + + /** + * Build rest endpoint URL for request the planned timetable. + */ + private String buildPlanRequestURL(final String evaNr, final Date date) { + synchronized (this) { + final String dateParam = DATE_FORMAT.format(date); + final String hourParam = HOUR_FORMAT.format(date); + + return PLAN_URL // + .replace("%evaNo%", evaNr) // + .replace("%date%", dateParam) // + .replace("%hour%", hourParam); + } + } + + /** + * Build rest endpoint URL for request all known changes in the timetable. + */ + private static String buildFchgRequestURL(final String evaNr) { + return FCHG_URL.replace("%evaNo%", evaNr); + } + + /** + * Build rest endpoint URL for request all known changes in the timetable. + */ + private static String buildRchgRequestURL(final String evaNr) { + return RCHG_URL.replace("%evaNo%", evaNr); + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/resources/OH-INF/binding/binding.xml b/bundles/org.openhab.binding.deutschebahn/src/main/resources/OH-INF/binding/binding.xml new file mode 100644 index 0000000000000..7deb3797ee87c --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/resources/OH-INF/binding/binding.xml @@ -0,0 +1,9 @@ + + + + Deutsche Bahn Binding + This binding provides timetable information for train stations of Deutsche Bahn. + + diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/resources/OH-INF/i18n/deutschebahn_de.properties b/bundles/org.openhab.binding.deutschebahn/src/main/resources/OH-INF/i18n/deutschebahn_de.properties new file mode 100644 index 0000000000000..80181986adecd --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/resources/OH-INF/i18n/deutschebahn_de.properties @@ -0,0 +1,85 @@ +# binding +binding.deutschebahn.name = DeutscheBahn +binding.deutschebahn.description = Anbindung an die OpenData Schnittstelle der DeutschenBahn f�r den Abruf von Fahrplaninformationen. + +# thing type timetable +thing-type.deutschebahn.timetable.label = DeutscheBahn Fahrplan +thing-type.deutschebahn.timetable.description = Verbindung zur Webserivce-API der DeutschenBahn f�r den Abruf des Fahrplans. Die bereitgestellten Daten k�nnen dann �ber ein Thing "Zug" dargestellt werden. + +# thing type timetable config description +thing-type.config.deutschebahn.timetable.accessToken.label = Zugriffsschl�ssel +thing-type.config.deutschebahn.timetable.accessToken.description = Zugriffsschl�ssel f�r die Timetable V1 API aus dem Developer-Portal der DeutschenBahn. +thing-type.config.deutschebahn.timetable.evaNo.label = eva Nr des Bahnhofs +thing-type.config.deutschebahn.timetable.evaNo.description = evaNr des Bahnhofs, f�r den der Fahrplan abgerufen wird. Siehe https://data.deutschebahn.com/dataset.tags.EVA-Nr..html. +thing-type.config.deutschebahn.timetable.trainFilter.label = Zugfilter +thing-type.config.deutschebahn.timetable.trainFilter.description = Selektiert die Z�ge (Ank�nfte / Abfahrten), die in dem Fahrplan enthalten sein sollen. Wenn nicht angegeben werden nur die Abfahrten angezeigt. + +# thing type train +thing-type.deutschebahn.train.label = Zug +thing-type.deutschebahn.train.description = Stellt einen Zug im Fahrplan dar, der an dem konfigurierten Bahnhof ankommt oder abf�hrt. +thing-type.deutschebahn.train.group.trip.label = Fahrtinformationen +thing-type.deutschebahn.train.group.trip.description = Enth�lt alle Informationen �ber die Fahrt des Zuges. +thing-type.deutschebahn.train.group.arrival.label = Ankunft +thing-type.deutschebahn.train.group.arrival.description = Enth�lt alle Informationen �ber die Ankunft des Zuges. +thing-type.deutschebahn.train.group.departure.label = Abfahrt +thing-type.deutschebahn.train.group.departure.description = Enth�lt alle Informationen �ber die Abfahrt des Zuges. + +# thing type train config description +thing-type.config.deutschebahn.train.position.label = Position +thing-type.config.deutschebahn.train.position.description = Gibt die Position des Zuges im Fahrplan an. z.B. wird mit 1 der erste Zug im Fahrplan selektiert, mit 2 der Zweite usw. + +# trip information channel types +channel-type.deutschebahn.category.label = Kateogrie +channel-type.deutschebahn.category.description = Die Kategorie des Zuges, z.B. "ICE" oder "RE". +channel-type.deutschebahn.number.label = Zugnummer +channel-type.deutschebahn.number.description = Die Zugnummer, z.B. "4523". +channel-type.deutschebahn.filter-flags.label = Filter +channel-type.deutschebahn.filter-flags.description = Filter f�r die Fahrt. +channel-type.deutschebahn.trip-type.label = Fahrttyp +channel-type.deutschebahn.trip-type.description = Gibt den Typ der Fahrt an. +channel-type.deutschebahn.owner.label = Eigent�mer +channel-type.deutschebahn.owner.description = Gibt die eindeutige Kurzbezeichnung des EisenbahnVerkehrsUnternehmen des Zuges an. + +# event channel types +channel-type.deutschebahn.planned-path.label = Geplante Route +channel-type.deutschebahn.planned-path.description = Gibt die geplante Route des Zuges an, dabei werden die Stationen mit | getrennt aufgelistet. F�r Ank�nfte besteht der Pfad aus den Halten, die vor der aktuellen Station kamen, das erste Element ist der Startbahnhof. F�r Abfahrten werden die Stationen aufgelistet, die nach der aktuellen Station kommen. Das letzte Element ist der Zielbahnhof. +channel-type.deutschebahn.changed-path.label = Ge�ndert Route +channel-type.deutschebahn.changed-path.description = Gibt die ge�nderte Route des Zuges an, dabei werden die Stationen mit | getrennt aufgelistet. Ist nicht gesetzt, falls keine �nderungen vorliegen. +channel-type.deutschebahn.planned-platform.label = Geplantes Gleis +channel-type.deutschebahn.planned-platform.description = Gibt das geplante Gleis an, auf dem der Zug ankommt/abf�hrt. +channel-type.deutschebahn.changed-platform.label = Ge�ndertes Gleis +channel-type.deutschebahn.changed-platform.description = Gibt das ge�ndert Gleis an, auf dem der Zug ankommt/abf�hrt. Ist nicht gesetzt, falls keine �nderungen vorliegen. +channel-type.deutschebahn.planned-time.label = Geplante Zeit +channel-type.deutschebahn.planned-time.description = Gibt die geplante Zeit f�r die Ankunft/Abfahrt des Zuges an. +channel-type.deutschebahn.changed-time.label = Ge�nderte Zeit +channel-type.deutschebahn.changed-time.description = Gibt die ge�nder Zeit f�r die Ankunft/Abfahrt des Zuges an. Ist nicht gesetzt, falls keine �nderungen vorliegen. +channel-type.deutschebahn.planned-status.label = Geplanter Status +channel-type.deutschebahn.planned-status.description = Gibt den Stauts des Fahrplaneintrags an. +channel-type.deutschebahn.changed-status.label = Ge�nderter Status +channel-type.deutschebahn.changed-status.description = Gibt den ge�nderten Status des Fahrplaneintrags an. Ist nicht gesetzt, falls keine �nderungen vorliegen. +channel-type.deutschebahn.cancellation-time.label = Stornierungs-Zeitpunkt +channel-type.deutschebahn.cancellation-time.description = Gibt den Zeitpunkt an, an dem der Halt storniert wurde. +channel-type.deutschebahn.line.label = Linie +channel-type.deutschebahn.line.description = Gibt die Linie des Zuges an. +channel-type.deutschebahn.messages.label = Meldungen +channel-type.deutschebahn.messages.description = Textmeldungen, die f�r diese Ankunft/Abfahrt des Zuges vorliegen. Mehrere Meldungen werden mit einem Strich getrennt ausgegeben. +channel-type.deutschebahn.hidden.label = Versteckt +channel-type.deutschebahn.hidden.description = Gibt an, ob die Ankunft/Abfahrt im Fahrplan nicht angezeigt werden soll, da ein Ein-/Aussteigen nicht m�glich ist. +channel-type.deutschebahn.wings.label = Wing +channel-type.deutschebahn.wings.description = Gibt eine Folge | separierten "Trip-IDs"an. +channel-type.deutschebahn.transition.label = �bergang +channel-type.deutschebahn.transition.description = Gibt bei Z�gen, die zusmmengef�hrt oder getrennt werden die Trip-ID des vorherigen oder nachfolgenden Zuges an. +channel-type.deutschebahn.planned-distant-endpoint.label = Geplanter entfernter Endpunkt +channel-type.deutschebahn.planned-distant-endpoint.description = Gibt den geplanten entfernten Endpunkt des Zuges an. +channel-type.deutschebahn.changed-distant-endpoint.label = Ge�nderter entfernter Endpunkt +channel-type.deutschebahn.changed-distant-endpoint.description = Gibt den ge�nderten entfernten Endpunkt des Zuges an. Ist nicht gesetzt, falls keine �nderungen vorliegen. +channel-type.deutschebahn.distant-change.label = Ge�nderter Zielbahnhof +channel-type.deutschebahn.distant-change.description = Gibt den ge�nderten Zielbahnhof des Zuges an. +channel-type.deutschebahn.planned-final-station.label = Geplanter Start-/Zielbahnhof +channel-type.deutschebahn.planned-final-station.description = Gibt den geplanten Startbahnhof (f�r Ank�nfte) bzw. Zielbahnhof (f�r Abfahrten) an. +channel-type.deutschebahn.planned-intermediate-stations.label = Geplante Halte +channel-type.deutschebahn.planned-intermediate-stations.description = Gibt die geplanten Halte des Zuges auf dem Weg zum aktuellen Bahnhof an (f�r Ank�nfte) bzw. die folgenden Halte (f�r Abfahrten). +channel-type.deutschebahn.changed-final-station.label = Ge�nderter Start-/Zielbahnhof +channel-type.deutschebahn.changed-final-station.description = Gibt den ge�nderten Startbahnhof (f�r Ank�nfte) bzw. Zielbahnhof (f�r Abfahrten) an. Ist nicht gesetzt, falls keine �nderungen vorliegen. +channel-type.deutschebahn.changed-intermediate-stations.label = Ge�nderte Halte +channel-type.deutschebahn.changed-intermediate-stations.description = Gibt die ge�nderten Halte des Zuges auf dem Weg zum aktuellen Bahnhof an (f�r Ank�nfte) bzw. die folgenden Halte (f�r Abfahrten). Ist nicht gesetzt, falls keine �nderungen vorliegen. diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.deutschebahn/src/main/resources/OH-INF/thing/thing-types.xml new file mode 100644 index 0000000000000..d85a7c028ebac --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,342 @@ + + + + + + + Connection to the timetable API of Deutsche Bahn. Provides timetable data that can be displayed using the + train things. + + + + + Access Token from Deutsche Bahn developer portal for accessing the webservice api. + + + + evaNo of the station, for which the timetable should be requested. see + https://data.deutschebahn.com/dataset.tags.EVA-Nr..html + + + true + departures + + Selects the trains that will be be displayed in this timetable. If not set only departures will be + provided. + + + + + + + + + + + + + + + Displays informations about an train within the given timetable at one station. + + + + Contains all informations about the trip of the train. + + + + + Contains all informations about the arrival of the train at the station. + Channels may be empty, if the + trains starts at this station. + + + + + Contains all informations about the departure of the train at the station. + Channels may be empty, if the + trains ends at this station. + + + + + + + Selects the position of the train in the timetable. + + + + + + + Contains all informations about the trip of the train. + + + + + + + + + + + + Contains all attributes for an event (arrival / departure) of an train at the station. + + + + + + + + + + + + + + + + + + + + + + + + + + + + String + + Provides the category of the trip, e.g. "ICE" or "RE". + + + + + String + + Provides the trip/train number, e.g. "4523". + + + + + String + + Provides the filter flags. + + + + + String + + Provides the type of the trip. + + + + + + + + String + + Provides the owner of the train. A unique short-form and only intended to map a trip to specific evu + (EisenbahnVerkehrsUnternehmen). + + + + + + String + + Provides the planned platform of a train. + + + + + String + + Provides the changed platform of a train. + + + + + DateTime + + Provides the planned time of a train. + + + + + DateTime + + Provides the changed time of a train. + + + + + String + + Provides the planned status of a train. + + + + + + + + + + + String + + Provides the changed status of a train. + + + + + + + + + + + String + + The line indicator. + + + + + String + + Messages for this train. Contains all translated codes from the messages of the selected train stop. + Multiple messages will be separated with an single dash. + + + + + + DateTime + + Time when the cancellation of this stop was created. + + + + + String + + Provides the planned path of a train. + For arrival, the path indicates the stations that come before the + current station. The first element then is the trip’s + start station. For departure, the path indicates the stations + that come after the current station. The last ele-ment + in the path then is the trip’s destination station. Note that + the current station is never included in the path + (neither for arrival nor for departure). + + + + + String + + Provides the planned path of a train. + For arrival, the path indicates the stations that come before the + current station. The first element then is the trip’s + start station. For departure, the path indicates the stations + that come after the current station. The last ele-ment + in the path then is the trip’s destination station. Note that + the current station is never included in the path + (neither for arrival nor for departure). + + + + + Switch + + On if the event should not be shown, because travellers are not supposed to enter or exit the train + at + this stop. + + + + + String + + A sequence of trip id separated by the pipe symbols (“|”). + + + + + String + + Trip id of the next or previous train of a shared train. At the start stop this references the previous + trip, at the last stop it references the next trip. + + + + + String + + Planned distant endpoint. + + + + + String + + Changed distant endpoint. + + + + + Number + + distant change + + + + + + String + + Planned final station of the train. For arrivals the starting station is returned, for departures the + target station is returned. + + + + String + + Returns the planned stations this train came from (for arrivals) or the stations this train will go to + (for departures). Stations will be separated by single dash. + + + + + String + + Changed final station of the train. For arrivals the starting station is returned, for departures the + target station is returned. + + + + + String + + Returns the changed stations this train came from (for arrivals) or the stations this train will go to + (for departures). Stations will be separated by single dash. + + + + diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/resources/xsd/Timetables_REST.xsd b/bundles/org.openhab.binding.deutschebahn/src/main/resources/xsd/Timetables_REST.xsd new file mode 100644 index 0000000000000..c0091341a79be --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/main/resources/xsd/Timetables_REST.xsd @@ -0,0 +1,441 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTimetableHandlerTest.java b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTimetableHandlerTest.java new file mode 100644 index 0000000000000..5209ad9d283e9 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTimetableHandlerTest.java @@ -0,0 +1,187 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.GregorianCalendar; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.deutschebahn.internal.timetable.TimeproviderStub; +import org.openhab.binding.deutschebahn.internal.timetable.TimetablesV1ApiFactory; +import org.openhab.binding.deutschebahn.internal.timetable.TimetablesV1ApiStub; +import org.openhab.binding.deutschebahn.internal.timetable.TimetablesV1Impl.HttpCallable; +import org.openhab.binding.deutschebahn.internal.timetable.TimetablesV1ImplTestHelper; +import org.openhab.binding.deutschebahn.internal.timetable.dto.Event; +import org.openhab.binding.deutschebahn.internal.timetable.dto.Timetable; +import org.openhab.binding.deutschebahn.internal.timetable.dto.TimetableStop; +import org.openhab.core.config.core.Configuration; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Channel; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.ThingHandlerCallback; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; + +/** + * Tests for {@link DeutscheBahnTimetableHandler}. + * + * @author Sönke Küper - initial contribution. + */ +@NonNullByDefault +public class DeutscheBahnTimetableHandlerTest implements TimetablesV1ImplTestHelper { + + private static Configuration createConfig() { + final Configuration config = new Configuration(); + config.put("accessToken", "letMeIn"); + config.put("evaNo", "8000226"); + config.put("trainFilter", "all"); + return config; + } + + private static Bridge mockBridge() { + final Bridge bridge = mock(Bridge.class); + when(bridge.getUID()).thenReturn(new ThingUID(DeutscheBahnBindingConstants.TIMETABLE_TYPE, "timetable")); + when(bridge.getConfiguration()).thenReturn(createConfig()); + + final List things = new ArrayList<>(); + things.add(DeutscheBahnTrainHandlerTest.mockThing(1)); + things.add(DeutscheBahnTrainHandlerTest.mockThing(2)); + things.add(DeutscheBahnTrainHandlerTest.mockThing(3)); + when(things.get(0).getHandler()).thenReturn(mock(DeutscheBahnTrainHandler.class)); + when(things.get(1).getHandler()).thenReturn(mock(DeutscheBahnTrainHandler.class)); + when(things.get(2).getHandler()).thenReturn(mock(DeutscheBahnTrainHandler.class)); + + when(bridge.getThings()).thenReturn(things); + + return bridge; + } + + private DeutscheBahnTimetableHandler createAndInitHandler(final ThingHandlerCallback callback, final Bridge bridge) + throws Exception { + return createAndInitHandler(callback, bridge, createApiWithTestdata().getApiFactory()); + } + + private DeutscheBahnTimetableHandler createAndInitHandler( // + final ThingHandlerCallback callback, // + final Bridge bridge, // + final TimetablesV1ApiFactory apiFactory) throws Exception { // + final TimeproviderStub timeProvider = new TimeproviderStub(); + timeProvider.time = new GregorianCalendar(2021, Calendar.AUGUST, 16, 9, 30); + + final DeutscheBahnTimetableHandler handler = new DeutscheBahnTimetableHandler(bridge, apiFactory, timeProvider); + handler.setCallback(callback); + handler.initialize(); + return handler; + } + + @Test + public void testUpdateChannels() throws Exception { + final Bridge bridge = mockBridge(); + final ThingHandlerCallback callback = mock(ThingHandlerCallback.class); + + final DeutscheBahnTimetableHandler handler = createAndInitHandler(callback, bridge); + + try { + verify(callback).statusUpdated(eq(bridge), argThat(arg -> arg.getStatus().equals(ThingStatus.UNKNOWN))); + verify(callback, timeout(1000)).statusUpdated(eq(bridge), + argThat(arg -> arg.getStatus().equals(ThingStatus.ONLINE))); + + verifyThingUpdated(bridge, 0, "-5296516961807204721-2108160906-5"); + verifyThingUpdated(bridge, 1, "-8364795265993682073-2108160911-6"); + verifyThingUpdated(bridge, 2, "-2949440726131702047-2108160858-10"); + } finally { + handler.dispose(); + } + } + + private void verifyThingUpdated(final Bridge bridge, int offset, String stopId) { + final Thing train = bridge.getThings().get(offset); + final DeutscheBahnTrainHandler childHandler = (DeutscheBahnTrainHandler) train.getHandler(); + verify(childHandler, timeout(1000)) + .updateChannels(argThat((TimetableStop stop) -> stop.getId().equals(stopId))); + } + + @Test + public void testUpdateTrainsToUndefinedIfNoDataWasProvided() throws Exception { + final Bridge bridge = mockBridge(); + final ThingHandlerCallback callback = mock(ThingHandlerCallback.class); + + final TimetablesV1ApiStub stubWithError = TimetablesV1ApiStub.createWithException(); + + final DeutscheBahnTimetableHandler handler = createAndInitHandler(callback, bridge, + (String authToken, HttpCallable httpCallable) -> stubWithError); + + try { + verify(callback).statusUpdated(eq(bridge), argThat(arg -> arg.getStatus().equals(ThingStatus.UNKNOWN))); + verify(callback, timeout(1000)).statusUpdated(eq(bridge), + argThat(arg -> arg.getStatus().equals(ThingStatus.OFFLINE))); + + verifyChannelsUpdatedToUndef(bridge, 0, callback, UnDefType.UNDEF); + verifyChannelsUpdatedToUndef(bridge, 1, callback, UnDefType.UNDEF); + verifyChannelsUpdatedToUndef(bridge, 2, callback, UnDefType.UNDEF); + + } finally { + handler.dispose(); + } + } + + private static void verifyChannelsUpdatedToUndef(Bridge bridge, int offset, ThingHandlerCallback callback, + State expectedState) { + final Thing thing = bridge.getThings().get(offset); + for (Channel channel : thing.getChannels()) { + verify(callback).stateUpdated(eq(channel.getUID()), eq(expectedState)); + } + } + + @Test + public void testUpdateTrainsToUndefinedIfNotEnoughDataWasProvided() throws Exception { + final Bridge bridge = mockBridge(); + final ThingHandlerCallback callback = mock(ThingHandlerCallback.class); + + // Bridge contains 3 trains, but Timetable contains only 1 items, so two trains has to be updated to undef + // value. + final Timetable timetable = new Timetable(); + TimetableStop stop01 = new TimetableStop(); + stop01.setId("stop01id"); + Event dp = new Event(); + dp.setPt("2108161000"); + stop01.setDp(dp); + timetable.getS().add(stop01); + + final TimetablesV1ApiStub stubWithData = TimetablesV1ApiStub.createWithResult(timetable); + + final DeutscheBahnTimetableHandler handler = createAndInitHandler(callback, bridge, + (String authToken, HttpCallable httpCallable) -> stubWithData); + + try { + verify(callback).statusUpdated(eq(bridge), argThat(arg -> arg.getStatus().equals(ThingStatus.UNKNOWN))); + verify(callback, timeout(1000)).statusUpdated(eq(bridge), + argThat(arg -> arg.getStatus().equals(ThingStatus.ONLINE))); + + verifyThingUpdated(bridge, 0, stop01.getId()); + verifyChannelsUpdatedToUndef(bridge, 1, callback, UnDefType.UNDEF); + verifyChannelsUpdatedToUndef(bridge, 2, callback, UnDefType.UNDEF); + + } finally { + handler.dispose(); + } + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTrainHandlerTest.java b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTrainHandlerTest.java new file mode 100644 index 0000000000000..627e53d3f5f3d --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTrainHandlerTest.java @@ -0,0 +1,225 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.Date; +import java.util.GregorianCalendar; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.openhab.binding.deutschebahn.internal.timetable.dto.Event; +import org.openhab.binding.deutschebahn.internal.timetable.dto.TimetableStop; +import org.openhab.binding.deutschebahn.internal.timetable.dto.TripLabel; +import org.openhab.core.config.core.Configuration; +import org.openhab.core.library.types.DateTimeType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.thing.Channel; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.ThingHandlerCallback; +import org.openhab.core.thing.internal.BridgeImpl; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; + +/** + * Tests for {@link DeutscheBahnTrainHandler}. + * + * @author Sönke Küper - initial contribution. + */ +@NonNullByDefault +public class DeutscheBahnTrainHandlerTest { + + private static final String SAMPLE_PATH = "Bielefeld Hbf|Herford|Löhne(Westf)|Bad Oeynhausen|Porta Westfalica|Minden(Westf)|Bückeburg|Stadthagen|Haste|Wunstorf|Hannover Hbf|Lehrte"; + + private static Configuration createConfig(int position) { + final Configuration config = new Configuration(); + config.put("position", String.valueOf(position)); + return config; + } + + static Thing mockThing(int id) { + final Thing thing = mock(Thing.class); + when(thing.getUID()).thenReturn(new ThingUID(DeutscheBahnBindingConstants.TRAIN_TYPE, "train-" + id)); + when(thing.getThingTypeUID()).thenReturn(DeutscheBahnBindingConstants.TRAIN_TYPE); + when(thing.getConfiguration()).thenReturn(createConfig(id)); + ThingUID bridgeId = new ThingUID(DeutscheBahnBindingConstants.TIMETABLE_TYPE, "timetable"); + when(thing.getBridgeUID()).thenReturn(bridgeId); + + final Channel tripLabelCategory = mockChannel(thing.getUID(), "trip#category"); + + final Channel arrivalPlannedTime = mockChannel(thing.getUID(), "arrival#planned-time"); + final Channel arrivalLine = mockChannel(thing.getUID(), "arrival#line"); + final Channel arrivalChangedTime = mockChannel(thing.getUID(), "arrival#changed-time"); + + final Channel departurePlannedTime = mockChannel(thing.getUID(), "departure#planned-time"); + final Channel departurePlannedPlatform = mockChannel(thing.getUID(), "departure#planned-platform"); + final Channel departureTargetStation = mockChannel(thing.getUID(), "departure#planned-final-station"); + + when(thing.getChannelsOfGroup("trip")).thenReturn(Arrays.asList(tripLabelCategory)); + when(thing.getChannelsOfGroup("arrival")) + .thenReturn(Arrays.asList(arrivalPlannedTime, arrivalLine, arrivalChangedTime)); + when(thing.getChannelsOfGroup("departure")) + .thenReturn(Arrays.asList(departurePlannedTime, departurePlannedPlatform, departureTargetStation)); + when(thing.getChannels()).thenReturn(Arrays.asList( // + tripLabelCategory, // + arrivalPlannedTime, arrivalLine, arrivalChangedTime, // + departurePlannedTime, departurePlannedPlatform, departureTargetStation)); + + return thing; + } + + private static Channel mockChannel(final ThingUID thingId, final String channelId) { + final Channel channel = Mockito.mock(Channel.class); + when(channel.getUID()).thenReturn(new ChannelUID(thingId, channelId)); + return channel; + } + + private static DeutscheBahnTrainHandler createAndInitHandler(final ThingHandlerCallback callback, + final Thing thing) { + final DeutscheBahnTrainHandler handler = new DeutscheBahnTrainHandler(thing); + handler.setCallback(callback); + handler.initialize(); + return handler; + } + + private static State getDateTime(final Date day) { + final ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(day.toInstant(), ZoneId.systemDefault()); + return new DateTimeType(zonedDateTime); + } + + @Test + public void testUpdateChannels() { + final Thing thing = mockThing(1); + final ThingHandlerCallback callback = mock(ThingHandlerCallback.class); + ThingUID bridgeId = new ThingUID(DeutscheBahnBindingConstants.TIMETABLE_TYPE, "timetable"); + when(callback.getBridge(bridgeId)) + .thenReturn(new BridgeImpl(DeutscheBahnBindingConstants.TIMETABLE_TYPE, bridgeId)); + final DeutscheBahnTrainHandler handler = createAndInitHandler(callback, thing); + + try { + verify(callback).statusUpdated(eq(thing), argThat(arg -> arg.getStatus().equals(ThingStatus.UNKNOWN))); + verify(callback, timeout(1000)).statusUpdated(eq(thing), + argThat(arg -> arg.getStatus().equals(ThingStatus.ONLINE))); + + // Provide data that will update the channels + TimetableStop stop = new TimetableStop(); + + TripLabel label = new TripLabel(); + label.setC("WFB"); + stop.setTl(label); + + Event arrival = new Event(); + arrival.setPt("2108161434"); + arrival.setL("RE60"); + stop.setAr(arrival); + Event departure = new Event(); + departure.setPt("2108161435"); + departure.setPp("2"); + departure.setPpth(SAMPLE_PATH); + stop.setDp(departure); + + handler.updateChannels(stop); + + final Date arrivalTime = new GregorianCalendar(2021, 7, 16, 14, 34).getTime(); + final Date departureTime = new GregorianCalendar(2021, 7, 16, 14, 35).getTime(); + + verify(callback, timeout(1000)).stateUpdated(new ChannelUID(thing.getUID(), "trip#category"), + new StringType("WFB")); + verify(callback, timeout(1000)).stateUpdated(new ChannelUID(thing.getUID(), "arrival#planned-time"), + getDateTime(arrivalTime)); + verify(callback, timeout(1000)).stateUpdated(new ChannelUID(thing.getUID(), "arrival#line"), + new StringType("RE60")); + verify(callback, timeout(1000)).stateUpdated(new ChannelUID(thing.getUID(), "arrival#changed-time"), + UnDefType.NULL); + verify(callback, timeout(1000)).stateUpdated(new ChannelUID(thing.getUID(), "departure#planned-time"), + getDateTime(departureTime)); + verify(callback, timeout(1000)).stateUpdated(new ChannelUID(thing.getUID(), "departure#planned-platform"), + new StringType("2")); + verify(callback, timeout(1000)).stateUpdated( + new ChannelUID(thing.getUID(), "departure#planned-final-station"), new StringType("Lehrte")); + } finally { + handler.dispose(); + } + } + + @Test + public void testUpdateChannelsWithEventNotPresent() { + final Thing thing = mockThing(1); + final ThingHandlerCallback callback = mock(ThingHandlerCallback.class); + ThingUID bridgeId = new ThingUID(DeutscheBahnBindingConstants.TIMETABLE_TYPE, "timetable"); + when(callback.getBridge(bridgeId)) + .thenReturn(new BridgeImpl(DeutscheBahnBindingConstants.TIMETABLE_TYPE, bridgeId)); + final DeutscheBahnTrainHandler handler = createAndInitHandler(callback, thing); + + try { + verify(callback).statusUpdated(eq(thing), argThat(arg -> arg.getStatus().equals(ThingStatus.UNKNOWN))); + verify(callback, timeout(1000)).statusUpdated(eq(thing), + argThat(arg -> arg.getStatus().equals(ThingStatus.ONLINE))); + + // Provide data that will update the channels + TimetableStop stop = new TimetableStop(); + + Event arrival = new Event(); + arrival.setPt("2108161434"); + arrival.setL("RE60"); + stop.setAr(arrival); + + handler.updateChannels(stop); + + final Date arrivalTime = new GregorianCalendar(2021, 7, 16, 14, 34).getTime(); + + verify(callback, timeout(1000)).stateUpdated(new ChannelUID(thing.getUID(), "trip#category"), + UnDefType.UNDEF); + + verify(callback, timeout(1000)).stateUpdated(new ChannelUID(thing.getUID(), "arrival#planned-time"), + getDateTime(arrivalTime)); + verify(callback, timeout(1000)).stateUpdated(new ChannelUID(thing.getUID(), "arrival#line"), + new StringType("RE60")); + verify(callback, timeout(1000)).stateUpdated(new ChannelUID(thing.getUID(), "arrival#changed-time"), + UnDefType.NULL); + + verify(callback, timeout(1000)).stateUpdated(new ChannelUID(thing.getUID(), "departure#planned-time"), + UnDefType.UNDEF); + verify(callback, timeout(1000)).stateUpdated(new ChannelUID(thing.getUID(), "departure#planned-platform"), + UnDefType.UNDEF); + verify(callback, timeout(1000)) + .stateUpdated(new ChannelUID(thing.getUID(), "departure#planned-final-station"), UnDefType.UNDEF); + } finally { + handler.dispose(); + } + } + + @Test + public void testWithoutBridgeStateUpdatesToOffline() { + final Thing thing = mockThing(1); + final ThingHandlerCallback callback = mock(ThingHandlerCallback.class); + final DeutscheBahnTrainHandler handler = createAndInitHandler(callback, thing); + + try { + verify(callback).statusUpdated(eq(thing), argThat(arg -> arg.getStatus().equals(ThingStatus.UNKNOWN))); + verify(callback, timeout(1000)).statusUpdated(eq(thing), + argThat(arg -> arg.getStatus().equals(ThingStatus.OFFLINE))); + } finally { + handler.dispose(); + } + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/EventAttributeTest.java b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/EventAttributeTest.java new file mode 100644 index 0000000000000..1f11a0891b56c --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/EventAttributeTest.java @@ -0,0 +1,282 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.GregorianCalendar; +import java.util.List; +import java.util.function.Consumer; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.junit.jupiter.api.Test; +import org.openhab.binding.deutschebahn.internal.timetable.dto.Event; +import org.openhab.binding.deutschebahn.internal.timetable.dto.EventStatus; +import org.openhab.binding.deutschebahn.internal.timetable.dto.Message; +import org.openhab.core.library.types.DateTimeType; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.types.State; + +/** + * Tests Mapping from {@link Event} attribute values to openhab state values. + * + * @author Sönke Küper - initial contribution. + */ +@NonNullByDefault +@SuppressWarnings("unchecked") +public class EventAttributeTest { + + private static final String SAMPLE_PATH = "Bielefeld Hbf|Herford|Löhne(Westf)|Bad Oeynhausen|Porta Westfalica|Minden(Westf)|Bückeburg|Stadthagen|Haste|Wunstorf|Hannover Hbf|Lehrte"; + + private void doTestEventAttribute( // + String channelName, // + @Nullable String expectedChannelName, // + Consumer setValue, // + VALUE_TYPE expectedValue, // + @Nullable STATE_TYPE expectedState, // + EventType eventType, // + boolean performSetterTest) { // + final EventAttribute attribute = (EventAttribute) EventAttribute + .getByChannelName(channelName, eventType); + assertThat(attribute, is(not(nullValue()))); + assertThat(attribute.getChannelTypeName(), is(expectedChannelName == null ? channelName : expectedChannelName)); + assertThat(attribute.getValue(new Event()), is(nullValue())); + assertThat(attribute.getState(new Event()), is(nullValue())); + + // Create an event and set the attribute value. + final Event eventWithValueSet = new Event(); + setValue.accept(eventWithValueSet); + + // then try get value and state. + assertThat(attribute.getValue(eventWithValueSet), is(expectedValue)); + assertThat(attribute.getState(eventWithValueSet), is(expectedState)); + + // Try set Value in new Event + final Event copyTarget = new Event(); + attribute.setValue(copyTarget, expectedValue); + if (performSetterTest) { + assertThat(attribute.getValue(copyTarget), is(expectedValue)); + } + } + + @Test + public void testGetNonExistingChannel() { + assertThat(EventAttribute.getByChannelName("unkownChannel", EventType.ARRIVAL), is(nullValue())); + } + + @Test + public void testPlannedPath() { + doTestEventAttribute("planned-path", null, (Event e) -> e.setPpth(SAMPLE_PATH), SAMPLE_PATH, + new StringType(SAMPLE_PATH), EventType.DEPARTURE, true); + } + + @Test + public void testChangedPath() { + doTestEventAttribute("changed-path", null, (Event e) -> e.setCpth(SAMPLE_PATH), SAMPLE_PATH, + new StringType(SAMPLE_PATH), EventType.DEPARTURE, true); + } + + @Test + public void testPlannedPlatform() { + String platform = "2"; + doTestEventAttribute("planned-platform", null, (Event e) -> e.setPp(platform), platform, + new StringType(platform), EventType.DEPARTURE, true); + } + + @Test + public void testChangedPlatform() { + String platform = "2"; + doTestEventAttribute("changed-platform", null, (Event e) -> e.setCp(platform), platform, + new StringType(platform), EventType.DEPARTURE, true); + } + + @Test + public void testWings() { + String wings = "-906407760000782942-1403311431"; + doTestEventAttribute("wings", null, (Event e) -> e.setWings(wings), wings, new StringType(wings), + EventType.DEPARTURE, true); + } + + @Test + public void testTransition() { + String transition = "2016448009055686515-1403311438-1"; + doTestEventAttribute("transition", null, (Event e) -> e.setTra(transition), transition, + new StringType(transition), EventType.DEPARTURE, true); + } + + @Test + public void testPlannedDistantEndpoint() { + String endpoint = "Hannover Hbf"; + doTestEventAttribute("planned-distant-endpoint", null, (Event e) -> e.setPde(endpoint), endpoint, + new StringType(endpoint), EventType.DEPARTURE, true); + } + + @Test + public void testChangedDistantEndpoint() { + String endpoint = "Hannover Hbf"; + doTestEventAttribute("changed-distant-endpoint", null, (Event e) -> e.setCde(endpoint), endpoint, + new StringType(endpoint), EventType.DEPARTURE, true); + } + + @Test + public void testLine() { + String line = "RE60"; + doTestEventAttribute("line", null, (Event e) -> e.setL(line), line, new StringType(line), EventType.DEPARTURE, + true); + } + + @Test + public void testPlannedTime() { + String time = "2109111825"; + GregorianCalendar expectedValue = new GregorianCalendar(2021, 8, 11, 18, 25, 0); + DateTimeType expectedState = new DateTimeType( + ZonedDateTime.ofInstant(expectedValue.toInstant(), ZoneId.systemDefault())); + doTestEventAttribute("planned-time", null, (Event e) -> e.setPt(time), expectedValue.getTime(), expectedState, + EventType.DEPARTURE, true); + } + + @Test + public void testChangedTime() { + String time = "2109111825"; + GregorianCalendar expectedValue = new GregorianCalendar(2021, 8, 11, 18, 25, 0); + DateTimeType expectedState = new DateTimeType( + ZonedDateTime.ofInstant(expectedValue.toInstant(), ZoneId.systemDefault())); + doTestEventAttribute("changed-time", null, (Event e) -> e.setCt(time), expectedValue.getTime(), expectedState, + EventType.DEPARTURE, true); + } + + @Test + public void testCancellationTime() { + String time = "2109111825"; + GregorianCalendar expectedValue = new GregorianCalendar(2021, 8, 11, 18, 25, 0); + DateTimeType expectedState = new DateTimeType( + ZonedDateTime.ofInstant(expectedValue.toInstant(), ZoneId.systemDefault())); + doTestEventAttribute("cancellation-time", null, (Event e) -> e.setClt(time), expectedValue.getTime(), + expectedState, EventType.DEPARTURE, true); + } + + @Test + public void testPlannedStatus() { + EventStatus expectedValue = EventStatus.A; + doTestEventAttribute("planned-status", null, (Event e) -> e.setPs(expectedValue), expectedValue, + new StringType(expectedValue.name().toLowerCase()), EventType.DEPARTURE, true); + } + + @Test + public void testChangedStatus() { + EventStatus expectedValue = EventStatus.C; + doTestEventAttribute("changed-status", null, (Event e) -> e.setCs(expectedValue), expectedValue, + new StringType(expectedValue.name().toLowerCase()), EventType.DEPARTURE, true); + } + + @Test + public void testHidden() { + doTestEventAttribute("hidden", null, (Event e) -> e.setHi(0), 0, OnOffType.OFF, EventType.DEPARTURE, true); + doTestEventAttribute("hidden", null, (Event e) -> e.setHi(1), 1, OnOffType.ON, EventType.DEPARTURE, true); + } + + @Test + public void testDistantChange() { + doTestEventAttribute("distant-change", null, (Event e) -> e.setDc(42), 42, new DecimalType(42), + EventType.DEPARTURE, true); + } + + @Test + public void testPlannedFinalStation() { + doTestEventAttribute("planned-final-station", "planned-target-station", (Event e) -> e.setPpth(SAMPLE_PATH), + "Lehrte", new StringType("Lehrte"), EventType.DEPARTURE, false); + doTestEventAttribute("planned-final-station", "planned-start-station", (Event e) -> e.setPpth(SAMPLE_PATH), + "Bielefeld Hbf", new StringType("Bielefeld Hbf"), EventType.ARRIVAL, false); + } + + @Test + public void testChangedFinalStation() { + doTestEventAttribute("changed-final-station", "changed-target-station", (Event e) -> e.setCpth(SAMPLE_PATH), + "Lehrte", new StringType("Lehrte"), EventType.DEPARTURE, false); + doTestEventAttribute("changed-final-station", "changed-start-station", (Event e) -> e.setCpth(SAMPLE_PATH), + "Bielefeld Hbf", new StringType("Bielefeld Hbf"), EventType.ARRIVAL, false); + } + + @Test + public void testPlannedIntermediateStations() { + String expectedFollowing = "Bielefeld Hbf - Herford - Löhne(Westf) - Bad Oeynhausen - Porta Westfalica - Minden(Westf) - Bückeburg - Stadthagen - Haste - Wunstorf - Hannover Hbf"; + doTestEventAttribute("planned-intermediate-stations", "planned-following-stations", + (Event e) -> e.setPpth(SAMPLE_PATH), expectedFollowing, new StringType(expectedFollowing), + EventType.DEPARTURE, false); + String expectedPrevious = "Herford - Löhne(Westf) - Bad Oeynhausen - Porta Westfalica - Minden(Westf) - Bückeburg - Stadthagen - Haste - Wunstorf - Hannover Hbf - Lehrte"; + doTestEventAttribute("planned-intermediate-stations", "planned-previous-stations", + (Event e) -> e.setPpth(SAMPLE_PATH), expectedPrevious, new StringType(expectedPrevious), + EventType.ARRIVAL, false); + } + + @Test + public void testChangedIntermediateStations() { + String expectedFollowing = "Bielefeld Hbf - Herford - Löhne(Westf) - Bad Oeynhausen - Porta Westfalica - Minden(Westf) - Bückeburg - Stadthagen - Haste - Wunstorf - Hannover Hbf"; + doTestEventAttribute("changed-intermediate-stations", "changed-following-stations", + (Event e) -> e.setCpth(SAMPLE_PATH), expectedFollowing, new StringType(expectedFollowing), + EventType.DEPARTURE, false); + String expectedPrevious = "Herford - Löhne(Westf) - Bad Oeynhausen - Porta Westfalica - Minden(Westf) - Bückeburg - Stadthagen - Haste - Wunstorf - Hannover Hbf - Lehrte"; + doTestEventAttribute("changed-intermediate-stations", "changed-previous-stations", + (Event e) -> e.setCpth(SAMPLE_PATH), expectedPrevious, new StringType(expectedPrevious), + EventType.ARRIVAL, false); + } + + @Test + public void testMessages() { + String expectedOneMessage = "Verzögerungen im Betriebsablauf"; + List messages = new ArrayList<>(); + Message m1 = new Message(); + m1.setC(99); + messages.add(m1); + doTestEventAttribute("messages", null, (Event e) -> e.getM().addAll(messages), messages, + new StringType(expectedOneMessage), EventType.DEPARTURE, true); + + String expectedTwoMessages = "Verzögerungen im Betriebsablauf - keine Qualitätsmängel"; + Message m2 = new Message(); + m2.setC(88); + messages.add(m2); + doTestEventAttribute("messages", null, (Event e) -> e.getM().addAll(messages), messages, + new StringType(expectedTwoMessages), EventType.DEPARTURE, true); + } + + @Test + public void testFilterDuplicateMessages() { + String expectedOneMessage = "andere Reihenfolge der Wagen - technische Störung am Zug - Zug verkehrt richtig gereiht"; + List messages = new ArrayList<>(); + Message m1 = new Message(); + m1.setC(80); + messages.add(m1); + Message m2 = new Message(); + m2.setC(80); + messages.add(m2); + Message m3 = new Message(); + m3.setC(36); + messages.add(m3); + Message m4 = new Message(); + m4.setC(80); + messages.add(m4); + Message m5 = new Message(); + m5.setC(84); + messages.add(m5); + + doTestEventAttribute("messages", null, (Event e) -> e.getM().addAll(messages), messages, + new StringType(expectedOneMessage), EventType.DEPARTURE, true); + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/TripLabelAttributeTest.java b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/TripLabelAttributeTest.java new file mode 100644 index 0000000000000..191378a57b3bf --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/TripLabelAttributeTest.java @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +import java.util.function.Consumer; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.junit.jupiter.api.Test; +import org.openhab.binding.deutschebahn.internal.timetable.dto.TripLabel; +import org.openhab.binding.deutschebahn.internal.timetable.dto.TripType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.types.State; + +/** + * Tests Mapping from {@link TripLabel} attribute values to openhab state values. + * + * @author Sönke Küper - initial contribution. + */ +@NonNullByDefault +@SuppressWarnings("unchecked") +public class TripLabelAttributeTest { + + private void doTestTripAttribute( // + String channelName, // + @Nullable String expectedChannelName, // + Consumer setValue, // + VALUE_TYPE expectedValue, // + @Nullable STATE_TYPE expectedState, // + boolean performSetterTest) { // + final TripLabelAttribute attribute = (TripLabelAttribute) TripLabelAttribute + .getByChannelName(channelName); + assertThat(attribute, is(not(nullValue()))); + assertThat(attribute.getChannelTypeName(), is(expectedChannelName == null ? channelName : expectedChannelName)); + assertThat(attribute.getValue(new TripLabel()), is(nullValue())); + assertThat(attribute.getState(new TripLabel()), is(nullValue())); + + // Create an trip label and set the attribute value. + final TripLabel labelWithValueSet = new TripLabel(); + setValue.accept(labelWithValueSet); + + // then try get value and state. + assertThat(attribute.getValue(labelWithValueSet), is(expectedValue)); + assertThat(attribute.getState(labelWithValueSet), is(expectedState)); + + // Try set Value in new Event + final TripLabel copyTarget = new TripLabel(); + attribute.setValue(copyTarget, expectedValue); + if (performSetterTest) { + assertThat(attribute.getValue(copyTarget), is(expectedValue)); + } + } + + @Test + public void testGetNonExistingChannel() { + assertThat(TripLabelAttribute.getByChannelName("unkownChannel"), is(nullValue())); + } + + @Test + public void testCategory() { + final String category = "ICE"; + doTestTripAttribute("category", null, (TripLabel e) -> e.setC(category), category, new StringType(category), + true); + } + + @Test + public void testNumber() { + final String number = "4567"; + doTestTripAttribute("number", null, (TripLabel e) -> e.setN(number), number, new StringType(number), true); + } + + @Test + public void testOwner() { + final String owner = "W3"; + doTestTripAttribute("owner", null, (TripLabel e) -> e.setO(owner), owner, new StringType(owner), true); + } + + @Test + public void testFilterFlages() { + final String filter = "a"; + doTestTripAttribute("filter-flags", null, (TripLabel e) -> e.setF(filter), filter, new StringType(filter), + true); + } + + @Test + public void testTripType() { + final TripType type = TripType.E; + doTestTripAttribute("trip-type", null, (TripLabel e) -> e.setT(type), type, new StringType("e"), true); + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimeproviderStub.java b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimeproviderStub.java new file mode 100644 index 0000000000000..0bf7072e48d56 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimeproviderStub.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal.timetable; + +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.function.Supplier; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Stub time provider. + * + * @author Sönke Küper - Initial contribution. + */ +@NonNullByDefault +public final class TimeproviderStub implements Supplier { + + public GregorianCalendar time = new GregorianCalendar(); + + @Override + public Date get() { + return this.time.getTime(); + } + + public void moveAhead(int seconds) { + this.time.set(Calendar.SECOND, time.get(Calendar.SECOND) + seconds); + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetableLoaderTest.java b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetableLoaderTest.java new file mode 100644 index 0000000000000..6a25c8cf51b2b --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetableLoaderTest.java @@ -0,0 +1,229 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal.timetable; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Calendar; +import java.util.GregorianCalendar; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.deutschebahn.internal.EventType; +import org.openhab.binding.deutschebahn.internal.TimetableStopFilter; +import org.openhab.binding.deutschebahn.internal.timetable.dto.TimetableStop; + +/** + * Tests for the {@link TimetableLoader}. + * + * @author Sönke Küper - initial contribution + */ +@NonNullByDefault +public class TimetableLoaderTest implements TimetablesV1ImplTestHelper { + + @Test + public void testLoadRequiredStopCount() throws Exception { + final TimetablesApiTestModule timeTableTestModule = this.createApiWithTestdata(); + final TimeproviderStub timeProvider = new TimeproviderStub(); + final TimetableLoader loader = new TimetableLoader(timeTableTestModule.getApi(), TimetableStopFilter.ALL, + EventType.DEPARTURE, timeProvider, EVA_LEHRTE, 20); + + timeProvider.time = new GregorianCalendar(2021, Calendar.AUGUST, 16, 9, 30); + + final List stops = loader.getTimetableStops(); + assertThat(timeTableTestModule.getRequestedPlanUrls(), + contains("https://api.deutschebahn.com/timetables/v1/plan/8000226/210816/09", + "https://api.deutschebahn.com/timetables/v1/plan/8000226/210816/10", + "https://api.deutschebahn.com/timetables/v1/plan/8000226/210816/11")); + assertThat(timeTableTestModule.getRequestedFullChangesUrls(), + contains("https://api.deutschebahn.com/timetables/v1/fchg/8000226")); + assertThat(timeTableTestModule.getRequestedRecentChangesUrls(), empty()); + + assertThat(stops, hasSize(21)); + assertEquals("-5296516961807204721-2108160906-5", stops.get(0).getId()); + assertEquals("-3222259045572671319-2108161155-1", stops.get(20).getId()); + + // when requesting again no further call to plan is made, because required stops are available. + final List stops02 = loader.getTimetableStops(); + assertThat(stops02, hasSize(21)); + assertThat(timeTableTestModule.getRequestedPlanUrls(), hasSize(3)); + assertThat(timeTableTestModule.getRequestedFullChangesUrls(), hasSize(1)); + assertThat(timeTableTestModule.getRequestedRecentChangesUrls(), empty()); + } + + @Test + public void testLoadNewDataIfRequired() throws Exception { + final TimetablesApiTestModule timeTableTestModule = this.createApiWithTestdata(); + final TimeproviderStub timeProvider = new TimeproviderStub(); + final TimetableLoader loader = new TimetableLoader(timeTableTestModule.getApi(), TimetableStopFilter.ALL, + EventType.DEPARTURE, timeProvider, EVA_LEHRTE, 8); + + timeProvider.time = new GregorianCalendar(2021, Calendar.AUGUST, 16, 9, 0); + + final List stops = loader.getTimetableStops(); + assertThat(timeTableTestModule.getRequestedPlanUrls(), + contains("https://api.deutschebahn.com/timetables/v1/plan/8000226/210816/09")); + assertThat(timeTableTestModule.getRequestedFullChangesUrls(), + contains("https://api.deutschebahn.com/timetables/v1/fchg/8000226")); + assertThat(timeTableTestModule.getRequestedRecentChangesUrls(), empty()); + + assertThat(stops, hasSize(8)); + assertEquals("1763676552526687479-2108160847-6", stops.get(0).getId()); + assertEquals("8681599812964340829-2108160955-1", stops.get(7).getId()); + + // Move clock ahead for 30 minutes, so that some of the fetched data is in past and new plan data must be + // requested + timeProvider.moveAhead(30 * 60); + + final List stops02 = loader.getTimetableStops(); + assertThat(stops02, hasSize(13)); + assertThat(timeTableTestModule.getRequestedPlanUrls(), + contains("https://api.deutschebahn.com/timetables/v1/plan/8000226/210816/09", + "https://api.deutschebahn.com/timetables/v1/plan/8000226/210816/10")); + assertThat(timeTableTestModule.getRequestedFullChangesUrls(), + contains("https://api.deutschebahn.com/timetables/v1/fchg/8000226", + "https://api.deutschebahn.com/timetables/v1/fchg/8000226")); + assertThat(timeTableTestModule.getRequestedRecentChangesUrls(), empty()); + + assertEquals("-5296516961807204721-2108160906-5", stops02.get(0).getId()); + assertEquals("-3376513334056532423-2108161055-1", stops02.get(12).getId()); + } + + @Test + public void testRequestUpdates() throws Exception { + final TimetablesApiTestModule timeTableTestModule = this.createApiWithTestdata(); + final TimeproviderStub timeProvider = new TimeproviderStub(); + final TimetableLoader loader = new TimetableLoader(timeTableTestModule.getApi(), TimetableStopFilter.ALL, + EventType.DEPARTURE, timeProvider, EVA_LEHRTE, 1); + + timeProvider.time = new GregorianCalendar(2021, Calendar.AUGUST, 16, 9, 30); + + // First call - plan and full changes are requested. + loader.getTimetableStops(); + assertThat(timeTableTestModule.getRequestedPlanUrls(), + contains("https://api.deutschebahn.com/timetables/v1/plan/8000226/210816/09")); + assertThat(timeTableTestModule.getRequestedFullChangesUrls(), + contains("https://api.deutschebahn.com/timetables/v1/fchg/8000226")); + assertThat(timeTableTestModule.getRequestedRecentChangesUrls(), empty()); + + // Changes are updated only every 30 seconds, so move clock ahead 20 seconds, no request is made + timeProvider.moveAhead(20); + loader.getTimetableStops(); + + assertThat(timeTableTestModule.getRequestedPlanUrls(), + contains("https://api.deutschebahn.com/timetables/v1/plan/8000226/210816/09")); + assertThat(timeTableTestModule.getRequestedFullChangesUrls(), + contains("https://api.deutschebahn.com/timetables/v1/fchg/8000226")); + assertThat(timeTableTestModule.getRequestedRecentChangesUrls(), empty()); + + // Move ahead 10 seconds, so recent changes are fetched + timeProvider.moveAhead(10); + loader.getTimetableStops(); + assertThat(timeTableTestModule.getRequestedPlanUrls(), + contains("https://api.deutschebahn.com/timetables/v1/plan/8000226/210816/09")); + assertThat(timeTableTestModule.getRequestedFullChangesUrls(), + contains("https://api.deutschebahn.com/timetables/v1/fchg/8000226")); + assertThat(timeTableTestModule.getRequestedRecentChangesUrls(), + contains("https://api.deutschebahn.com/timetables/v1/rchg/8000226")); + + // Move again ahead 30 seconds, recent changes are fetched again + timeProvider.moveAhead(30); + loader.getTimetableStops(); + assertThat(timeTableTestModule.getRequestedPlanUrls(), + contains("https://api.deutschebahn.com/timetables/v1/plan/8000226/210816/09")); + assertThat(timeTableTestModule.getRequestedFullChangesUrls(), + contains("https://api.deutschebahn.com/timetables/v1/fchg/8000226")); + assertThat(timeTableTestModule.getRequestedRecentChangesUrls(), + contains("https://api.deutschebahn.com/timetables/v1/rchg/8000226", + "https://api.deutschebahn.com/timetables/v1/rchg/8000226")); + + // If recent change were not updated last 120 seconds the full changes must be requested + timeProvider.moveAhead(120); + loader.getTimetableStops(); + assertThat(timeTableTestModule.getRequestedPlanUrls(), + contains("https://api.deutschebahn.com/timetables/v1/plan/8000226/210816/09")); + assertThat(timeTableTestModule.getRequestedFullChangesUrls(), + contains("https://api.deutschebahn.com/timetables/v1/fchg/8000226", + "https://api.deutschebahn.com/timetables/v1/fchg/8000226")); + assertThat(timeTableTestModule.getRequestedRecentChangesUrls(), + contains("https://api.deutschebahn.com/timetables/v1/rchg/8000226", + "https://api.deutschebahn.com/timetables/v1/rchg/8000226")); + } + + @Test + public void testReturnOnlyArrivals() throws Exception { + final TimetablesApiTestModule timeTableTestModule = this.createApiWithTestdata(); + final TimeproviderStub timeProvider = new TimeproviderStub(); + final TimetableLoader loader = new TimetableLoader(timeTableTestModule.getApi(), TimetableStopFilter.ARRIVALS, + EventType.ARRIVAL, timeProvider, EVA_LEHRTE, 20); + + // Simulate that only one url is available + timeTableTestModule.addAvailableUrl("https://api.deutschebahn.com/timetables/v1/plan/8000226/210816/09"); + + timeProvider.time = new GregorianCalendar(2021, Calendar.AUGUST, 16, 9, 0); + + final List stops = loader.getTimetableStops(); + + // File contains 8 stops, but 2 are only departures + assertThat(stops, hasSize(6)); + assertEquals("1763676552526687479-2108160847-6", stops.get(0).getId()); + assertEquals("-735649762452915464-2108160912-6", stops.get(5).getId()); + } + + @Test + public void testReturnOnlyDepartures() throws Exception { + final TimetablesApiTestModule timeTableTestModule = this.createApiWithTestdata(); + final TimeproviderStub timeProvider = new TimeproviderStub(); + final TimetableLoader loader = new TimetableLoader(timeTableTestModule.getApi(), TimetableStopFilter.DEPARTURES, + EventType.DEPARTURE, timeProvider, EVA_LEHRTE, 20); + + // Simulate that only one url is available + timeTableTestModule.addAvailableUrl("https://api.deutschebahn.com/timetables/v1/plan/8000226/210816/09"); + + timeProvider.time = new GregorianCalendar(2021, Calendar.AUGUST, 16, 9, 0); + + final List stops = loader.getTimetableStops(); + + // File contains 8 stops, but 2 are only arrivals + assertThat(stops, hasSize(6)); + assertEquals("-94442819435724762-2108160916-1", stops.get(0).getId()); + assertEquals("8681599812964340829-2108160955-1", stops.get(5).getId()); + } + + @Test + public void testRemoveEntryOnlyIfChangedTimeIsInPast() throws Exception { + final TimetablesApiTestModule timeTableTestModule = this.createApiWithTestdata(); + final TimeproviderStub timeProvider = new TimeproviderStub(); + final TimetableLoader loader = new TimetableLoader(timeTableTestModule.getApi(), TimetableStopFilter.DEPARTURES, + EventType.DEPARTURE, timeProvider, EVA_LEHRTE, 1); + + timeProvider.time = new GregorianCalendar(2021, Calendar.AUGUST, 16, 9, 35); + + final List stops = loader.getTimetableStops(); + assertThat(timeTableTestModule.getRequestedPlanUrls(), + contains("https://api.deutschebahn.com/timetables/v1/plan/8000226/210816/09")); + assertThat(timeTableTestModule.getRequestedFullChangesUrls(), + contains("https://api.deutschebahn.com/timetables/v1/fchg/8000226")); + assertThat(timeTableTestModule.getRequestedRecentChangesUrls(), empty()); + + // Stop -5296516961807204721-2108160906-5 has its planned time at 9:34, but its included because its changed + // time is 9:42 + assertThat(stops, hasSize(4)); + assertEquals("-5296516961807204721-2108160906-5", stops.get(0).getId()); + assertEquals("2108160942", stops.get(0).getDp().getCt()); + assertEquals("8681599812964340829-2108160955-1", stops.get(3).getId()); + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetableStubHttpCallable.java b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetableStubHttpCallable.java new file mode 100644 index 0000000000000..44aab0cfbe9f0 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetableStubHttpCallable.java @@ -0,0 +1,151 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal.timetable; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Properties; +import java.util.Set; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.deutschebahn.internal.timetable.TimetablesV1Impl.HttpCallable; + +/** + * Stub Implementation for {@link HttpCallable}, that provides Data for the selected station, date and hour from file + * system. + * + * @author Sönke Küper - initial contribution. + */ +@NonNullByDefault +public class TimetableStubHttpCallable implements HttpCallable { + + private static final Pattern PLAN_URL_PATTERN = Pattern + .compile("https://api.deutschebahn.com/timetables/v1/plan/(\\d+)/(\\d+)/(\\d+)"); + private static final Pattern FULL_CHANGES_URL_PATTERN = Pattern + .compile("https://api.deutschebahn.com/timetables/v1/fchg/(\\d+)"); + private static final Pattern RECENT_CHANGES_URL_PATTERN = Pattern + .compile("https://api.deutschebahn.com/timetables/v1/rchg/(\\d+)"); + + private final File testdataDir; + private final List requestedPlanUrls; + private final List requestedFullChangesUrls; + private final List requestedRecentChangesUrls; + + // Allows simulation of available data. + // if not set all available files will be served. + private @Nullable Set availableUrls = null; + + public TimetableStubHttpCallable(File testdataDir) { + this.testdataDir = testdataDir; + this.requestedPlanUrls = new ArrayList<>(); + this.requestedFullChangesUrls = new ArrayList(); + this.requestedRecentChangesUrls = new ArrayList(); + } + + public void addAvailableUrl(String url) { + if (this.availableUrls == null) { + availableUrls = new HashSet<>(); + } + this.availableUrls.add(url); + } + + @Override + public String executeUrl( // + String httpMethod, // + String url, // + Properties httpHeaders, // + @Nullable InputStream content, // + @Nullable String contentType, // + int timeout) throws IOException { + final Matcher planMatcher = PLAN_URL_PATTERN.matcher(url); + if (planMatcher.matches()) { + requestedPlanUrls.add(url); + return processRequest(url, planMatcher, this::getPlanData); + } + + final Matcher fullChangesMatcher = FULL_CHANGES_URL_PATTERN.matcher(url); + if (fullChangesMatcher.matches()) { + requestedFullChangesUrls.add(url); + return processRequest(url, fullChangesMatcher, this::getFullChanges); + } + + final Matcher recentChangesMatcher = RECENT_CHANGES_URL_PATTERN.matcher(url); + if (recentChangesMatcher.matches()) { + requestedRecentChangesUrls.add(url); + return processRequest(url, recentChangesMatcher, this::getRecentChanges); + } + return ""; + } + + private String processRequest(String url, Matcher matcher, Function responseSupplier) { + if (availableUrls != null && !availableUrls.contains(url)) { + return ""; + } else { + return responseSupplier.apply(matcher); + } + } + + private String getPlanData(final Matcher planMatcher) { + final String evaNo = planMatcher.group(1); + final String day = planMatcher.group(2); + final String hour = planMatcher.group(3); + + final File responseFile = new File(this.testdataDir, "plan/" + evaNo + "/" + day + "/" + hour + ".xml"); + return serveFileContentIfExists(responseFile); + } + + private String serveFileContentIfExists(File responseFile) { + if (!responseFile.exists()) { + return ""; + } + + try { + return Files.readString(responseFile.toPath()); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + private String getRecentChanges(Matcher recentChangesMatcher) { + final String evaNo = recentChangesMatcher.group(1); + File responseFile = new File(this.testdataDir, "rchg/" + evaNo + ".xml"); + return serveFileContentIfExists(responseFile); + } + + private String getFullChanges(Matcher fullChangesMatcher) { + final String evaNo = fullChangesMatcher.group(1); + File responseFile = new File(this.testdataDir, "fchg/" + evaNo + ".xml"); + return serveFileContentIfExists(responseFile); + } + + public List getRequestedPlanUrls() { + return requestedPlanUrls; + } + + public List getRequestedFullChangesUrls() { + return requestedFullChangesUrls; + } + + public List getRequestedRecentChangesUrls() { + return requestedRecentChangesUrls; + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesApiTestModule.java b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesApiTestModule.java new file mode 100644 index 0000000000000..5c90d602d502b --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesApiTestModule.java @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal.timetable; + +import java.net.URISyntaxException; +import java.util.List; + +import javax.xml.bind.JAXBException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.deutschebahn.internal.timetable.TimetablesV1Impl.HttpCallable; +import org.xml.sax.SAXException; + +/** + * Testmodule that contains the {@link TimetablesV1Api} and {@link TimetableStubHttpCallable}. + * Used in tests to check which http calls have been made. + * + * @author Sönke Küper - Initial contribution. + */ +@NonNullByDefault +public final class TimetablesApiTestModule { + + private final TimetablesV1Api api; + private final TimetableStubHttpCallable httpStub; + + public TimetablesApiTestModule(TimetablesV1Api api, TimetableStubHttpCallable httpStub) { + this.api = api; + this.httpStub = httpStub; + } + + public TimetablesV1Api getApi() { + return api; + } + + public void addAvailableUrl(String url) { + this.httpStub.addAvailableUrl(url); + } + + public List getRequestedPlanUrls() { + return httpStub.getRequestedPlanUrls(); + } + + public List getRequestedFullChangesUrls() { + return httpStub.getRequestedFullChangesUrls(); + } + + public List getRequestedRecentChangesUrls() { + return httpStub.getRequestedRecentChangesUrls(); + } + + public TimetablesV1ApiFactory getApiFactory() { + return new TimetablesV1ApiFactory() { + + @Override + public TimetablesV1Api create(String authToken, HttpCallable httpCallable) + throws JAXBException, SAXException, URISyntaxException { + return api; + } + }; + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1ApiStub.java b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1ApiStub.java new file mode 100644 index 0000000000000..3b14cdfdc5824 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1ApiStub.java @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal.timetable; + +import java.io.IOException; +import java.util.Date; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.deutschebahn.internal.timetable.dto.Timetable; + +/** + * Stub Implementation of {@link TimetablesV1Api}, that may return an preconfigured Timetable or + * throws an {@link IOException} if not data has been set. + * + * @author Sönke Küper - initial contribution + */ +@NonNullByDefault +public final class TimetablesV1ApiStub implements TimetablesV1Api { + + @Nullable + private final Timetable result; + + private TimetablesV1ApiStub(@Nullable Timetable result) { + this.result = result; + } + + /** + * Creates an new {@link TimetablesV1ApiStub}, that returns the given result. + */ + public static TimetablesV1ApiStub createWithResult(Timetable timetable) { + return new TimetablesV1ApiStub(timetable); + } + + /** + * Creates an new {@link TimetablesV1ApiStub} that throws an Exception. + */ + public static TimetablesV1ApiStub createWithException() { + return new TimetablesV1ApiStub(null); + } + + @Override + public Timetable getPlan(String evaNo, Date time) throws IOException { + final Timetable currentResult = this.result; + if (currentResult == null) { + throw new IOException("No timetable data is available"); + } else { + return currentResult; + } + } + + @Override + public Timetable getFullChanges(String evaNo) throws IOException { + return new Timetable(); + } + + @Override + public Timetable getRecentChanges(String evaNo) throws IOException { + return new Timetable(); + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1ImplTest.java b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1ImplTest.java new file mode 100644 index 0000000000000..9e5ae39276040 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1ImplTest.java @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal.timetable; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.deutschebahn.internal.timetable.dto.Timetable; + +/** + * Tests for {@link TimetablesV1Impl} + * + * @author Sönke Küper - Initial contribution. + */ +@NonNullByDefault +public class TimetablesV1ImplTest implements TimetablesV1ImplTestHelper { + + @Test + public void testGetDataForLehrte() throws Exception { + TimetablesV1Api timeTableApi = createApiWithTestdata().getApi(); + + Date time = new GregorianCalendar(2021, Calendar.AUGUST, 16, 9, 22).getTime(); + + Timetable timeTable = timeTableApi.getPlan(EVA_LEHRTE, time); + assertNotNull(timeTable); + assertEquals(8, timeTable.getS().size()); + } + + @Test + public void testGetNonExistingData() throws Exception { + TimetablesV1Api timeTableApi = createApiWithTestdata().getApi(); + + Date time = new GregorianCalendar(2021, Calendar.AUGUST, 16, 9, 22).getTime(); + + Timetable timeTable = timeTableApi.getPlan("ABCDEF", time); + assertNotNull(timeTable); + assertEquals(0, timeTable.getS().size()); + } + + @Test + public void testGetDataForHannoverHBF() throws Exception { + TimetablesV1Api timeTableApi = createApiWithTestdata().getApi(); + + Date time = new GregorianCalendar(2021, Calendar.OCTOBER, 14, 11, 00).getTime(); + + Timetable timeTable = timeTableApi.getPlan(EVA_HANNOVER_HBF, time); + assertNotNull(timeTable); + assertEquals(50, timeTable.getS().size()); + + Timetable changes = timeTableApi.getFullChanges(EVA_HANNOVER_HBF); + assertNotNull(changes); + assertEquals(730, changes.getS().size()); + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1ImplTestHelper.java b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1ImplTestHelper.java new file mode 100644 index 0000000000000..2a92393279619 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/timetable/TimetablesV1ImplTestHelper.java @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.deutschebahn.internal.timetable; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; +import java.net.URL; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Helper interface for jUnit Tests to provide an {@link TimetablesApiTestModule}. + * + * @author Sönke Küper - initial contribution. + */ +@NonNullByDefault +public interface TimetablesV1ImplTestHelper { + + public static final String EVA_LEHRTE = "8000226"; + public static final String EVA_HANNOVER_HBF = "8000152"; + public static final String AUTH_TOKEN = "354c8161cd7fb0936c840240280c131e"; + + /** + * Creates an {@link TimetablesApiTestModule} that uses http response data from file system. + */ + public default TimetablesApiTestModule createApiWithTestdata() throws Exception { + final URL timetablesData = getClass().getResource("/timetablesData"); + assertNotNull(timetablesData); + final File testDataDir = new File(timetablesData.toURI()); + final TimetableStubHttpCallable httpStub = new TimetableStubHttpCallable(testDataDir); + final TimetablesV1Impl timeTableApi = new TimetablesV1Impl(AUTH_TOKEN, httpStub); + return new TimetablesApiTestModule(timeTableApi, httpStub); + } +} diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/fchg/8000152.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/fchg/8000152.xml new file mode 100644 index 0000000000000..b95ec6d5eb10c --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/fchg/8000152.xml @@ -0,0 +1,5401 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/fchg/8000226.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/fchg/8000226.xml new file mode 100644 index 0000000000000..b4783dbe27c03 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/fchg/8000226.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000152/211014/11.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000152/211014/11.xml new file mode 100644 index 0000000000000..52bb29f7e56dd --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000152/211014/11.xml @@ -0,0 +1,282 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/07.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/07.xml new file mode 100644 index 0000000000000..4f00d86594ccf --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/07.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/08.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/08.xml new file mode 100644 index 0000000000000..c2413f0139472 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/08.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/09.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/09.xml new file mode 100644 index 0000000000000..0613399d0f9f6 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/09.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/10.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/10.xml new file mode 100644 index 0000000000000..e2d7fe24c89a8 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/10.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/11.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/11.xml new file mode 100644 index 0000000000000..494e57b898e8b --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/11.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/12.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/12.xml new file mode 100644 index 0000000000000..f1b3ee40f6bd7 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/12.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/13.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/13.xml new file mode 100644 index 0000000000000..e4212807bf6d3 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/13.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/14.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/14.xml new file mode 100644 index 0000000000000..682e6e2413f1c --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/14.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/15.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/15.xml new file mode 100644 index 0000000000000..f65a2880434b8 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/15.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/16.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/16.xml new file mode 100644 index 0000000000000..68e29297f1407 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/16.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/17.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/17.xml new file mode 100644 index 0000000000000..6fb571efa3533 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/17.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/18.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/18.xml new file mode 100644 index 0000000000000..0f143706b5c1d --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/18.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/19.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/19.xml new file mode 100644 index 0000000000000..ce466a72562bc --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/19.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/20.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/20.xml new file mode 100644 index 0000000000000..1ba8e2629e637 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/20.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/21.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/21.xml new file mode 100644 index 0000000000000..5714e711df55f --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/21.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/22.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/22.xml new file mode 100644 index 0000000000000..63fc2808f2ae0 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/22.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/23.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/23.xml new file mode 100644 index 0000000000000..d30ab2d780e48 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210816/23.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210817/00.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210817/00.xml new file mode 100644 index 0000000000000..6acd438713d8e --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210817/00.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210817/01.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210817/01.xml new file mode 100644 index 0000000000000..5a5adda84e890 --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210817/01.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210817/02.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210817/02.xml new file mode 100644 index 0000000000000..5f341233944ce --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210817/02.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210817/03.xml b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210817/03.xml new file mode 100644 index 0000000000000..5f341233944ce --- /dev/null +++ b/bundles/org.openhab.binding.deutschebahn/src/test/resources/timetablesData/plan/8000226/210817/03.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/bundles/pom.xml b/bundles/pom.xml index 695088f236523..6ab04faa208be 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -96,6 +96,7 @@ org.openhab.binding.dbquery org.openhab.binding.deconz org.openhab.binding.denonmarantz + org.openhab.binding.deutschebahn org.openhab.binding.digiplex org.openhab.binding.digitalstrom org.openhab.binding.dlinksmarthome