]> git.basschouten.com Git - openhab-addons.git/commitdiff
[deutschebahn] Implemented filters for trains in timetable (#11745)
authorSönke Küper <soenkekueper@outlook.de>
Sun, 12 Dec 2021 18:32:58 +0000 (19:32 +0100)
committerGitHub <noreply@github.com>
Sun, 12 Dec 2021 18:32:58 +0000 (19:32 +0100)
* Implemented filters within timetable.

Signed-off-by: Sönke Küper <soenkekueper@gmx.de>
* Added position information for filtertokens, to allow detailled failure information

Signed-off-by: Sönke Küper <soenkekueper@gmx.de>
* Added documentation for non matching values.

Signed-off-by: Sönke Küper <soenkekueper@gmx.de>
* Applied review remarks.

Signed-off-by: Sönke Küper <soenkekueper@gmx.de>
Co-authored-by: Sönke Küper <soenkekueper@gmx.de>
31 files changed:
bundles/org.openhab.binding.deutschebahn/README.md
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/AbstractDtoAttributeSelector.java
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/AttributeSelection.java
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTimetableConfiguration.java
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/DeutscheBahnTimetableHandler.java
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/EventAttribute.java
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/EventAttributeSelection.java
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/TimetableStopFilter.java
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/TripLabelAttribute.java
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/AndOperator.java [new file with mode: 0644]
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/AndPredicate.java [new file with mode: 0644]
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/BracketCloseToken.java [new file with mode: 0644]
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/BracketOpenToken.java [new file with mode: 0644]
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/ChannelNameEquals.java [new file with mode: 0644]
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/FilterParser.java [new file with mode: 0644]
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/FilterParserException.java [new file with mode: 0644]
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/FilterScanner.java [new file with mode: 0644]
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/FilterScannerException.java [new file with mode: 0644]
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/FilterToken.java [new file with mode: 0644]
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/FilterTokenVisitor.java [new file with mode: 0644]
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/OperatorToken.java [new file with mode: 0644]
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/OrOperator.java [new file with mode: 0644]
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/OrPredicate.java [new file with mode: 0644]
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/TimetableStopByStringEventAttributeFilter.java [new file with mode: 0644]
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/TimetableStopPredicate.java [new file with mode: 0644]
bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/timetable/TimetableLoader.java
bundles/org.openhab.binding.deutschebahn/src/main/resources/OH-INF/thing/thing-types.xml
bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/EventAttributeTest.java
bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/filter/FilterParserTest.java [new file with mode: 0644]
bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/filter/FilterScannerTest.java [new file with mode: 0644]
bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/filter/TimetableByStringEventAttributeFilterTest.java [new file with mode: 0644]

index 23184d523fea2f6f934139a1d73181f9f4efdc36..9a2aec47e50934954017d05959bea34356f5d869 100644 (file)
@@ -40,7 +40,27 @@ In addition you can configure if only arrivals, only departures or all trains sh
 | `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. |
+| `additionalFilter` | | No | Specifies additional filters for trains, that should be displayed within the timetable. |
 
+** Additional filter **
+If you only want to display certain trains within your timetable, you can specify an additional filter. This will be evaluated when loading trains,
+and only trains that matches the given filter will be contained within the timetable.
+
+To specify an advanced filter you can
+
+- specify a filter for the value of a given channel. Therefore you must specify the channel name (with channel group) and specify a compare value like this: 
+`departure#line="RE60"` this will select all trains with line RE60
+- use regular expressions for expected channel values, for example:  `departure#line="RE.*"`, this will match all lines starting with "RE".
+- combine multiple statements as "and" conjunction by using `&`. If used, both parts must match, for example: `departure#line="RE60" & trip#category="WFB"`
+- combine multiple statements as "or" disjunction by using `|`. If used, one of the parts must match, for example: `departure#line="RE60" | departure#line="RE60"`
+- use brackets to build more complex queries like `trip#category="RE" AND (departure#line="17" OR departure#line="57")`
+
+If a channel has multiple values, like the channels `arrival#planned-path` and `departure#planned-path` have a list of stations,
+only one of the values must match the given expression. So you can specify a filter like `departure#planned-path="Hannover Hbf"`
+to easily display only trains that will go to Hannover Hbf.
+
+If the filtered value is not present for a train, for example if you filter a departure attribute but train ends at the selected station,
+the filter will not match.
 
 ### Configuring the trains
 
index b5c6db1040b759cdc8e9879bd91aa9402b36eac3..827008f156f6023e2a1ddedbf223128ac3a3de83 100644 (file)
@@ -12,6 +12,7 @@
  */
 package org.openhab.binding.deutschebahn.internal;
 
+import java.util.List;
 import java.util.function.BiConsumer;
 import java.util.function.Function;
 
@@ -37,6 +38,7 @@ public abstract class AbstractDtoAttributeSelector<DTO_TYPE extends JaxbEntity,
     private final Function<VALUE_TYPE, @Nullable STATE_TYPE> getState;
     private final String channelTypeName;
     private final Class<STATE_TYPE> stateType;
+    private final Function<VALUE_TYPE, List<String>> valueToList;
 
     /**
      * Creates an new {@link EventAttribute}.
@@ -49,11 +51,13 @@ public abstract class AbstractDtoAttributeSelector<DTO_TYPE extends JaxbEntity,
             final Function<DTO_TYPE, @Nullable VALUE_TYPE> getter, //
             final BiConsumer<DTO_TYPE, VALUE_TYPE> setter, //
             final Function<VALUE_TYPE, @Nullable STATE_TYPE> getState, //
+            final Function<VALUE_TYPE, List<String>> valueToList, //
             final Class<STATE_TYPE> stateType) {
         this.channelTypeName = channelTypeName;
         this.getter = getter;
         this.setter = setter;
         this.getState = getState;
+        this.valueToList = valueToList;
         this.stateType = stateType;
     }
 
@@ -92,6 +96,14 @@ public abstract class AbstractDtoAttributeSelector<DTO_TYPE extends JaxbEntity,
         return this.getter.apply(object);
     }
 
+    /**
+     * Returns a list of values as string list.
+     * Returns empty list if value is not present, singleton list if attribute is not single-valued.
+     */
+    public final List<String> getStringValues(DTO_TYPE object) {
+        return this.valueToList.apply(getValue(object));
+    }
+
     /**
      * Sets the value for the selected attribute in the given DTO object
      */
index 6c0d7670669493c936cf7cd6c5ea77e020900612..54226218025d0e8187bb6c039c13db09193df67c 100644 (file)
@@ -12,6 +12,8 @@
  */
 package org.openhab.binding.deutschebahn.internal;
 
+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;
@@ -25,9 +27,21 @@ import org.openhab.core.types.State;
 @NonNullByDefault
 public interface AttributeSelection {
 
+    /**
+     * Returns the value for this attribute.
+     */
+    @Nullable
+    public abstract Object getValue(TimetableStop stop);
+
     /**
      * Returns the {@link State} that should be set for the channels'value for this attribute.
      */
     @Nullable
     public abstract State getState(TimetableStop stop);
+
+    /**
+     * Returns a list of values as string list.
+     * Returns empty list if value is not present, singleton list if attribute is not single-valued.
+     */
+    public abstract List<String> getStringValues(TimetableStop t);
 }
index ee93c69650e1bda686b779b3de6c091f551d95a7..f04d0a09a940ea9ee2883f8dc70602035bfb3d10 100644 (file)
  */
 package org.openhab.binding.deutschebahn.internal;
 
+import java.util.List;
+
 import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.deutschebahn.internal.filter.FilterParser;
+import org.openhab.binding.deutschebahn.internal.filter.FilterParserException;
+import org.openhab.binding.deutschebahn.internal.filter.FilterScanner;
+import org.openhab.binding.deutschebahn.internal.filter.FilterScannerException;
+import org.openhab.binding.deutschebahn.internal.filter.FilterToken;
+import org.openhab.binding.deutschebahn.internal.filter.TimetableStopPredicate;
 
 /**
  * The {@link DeutscheBahnTimetableConfiguration} for the Timetable bridge-type.
@@ -37,10 +46,28 @@ public class DeutscheBahnTimetableConfiguration {
      */
     public String trainFilter = "";
 
+    /**
+     * Specifies additional filters for trains to be displayed within the timetable.
+     */
+    public String additionalFilter = "";
+
     /**
      * Returns the {@link TimetableStopFilter}.
      */
-    public TimetableStopFilter getTimetableStopFilter() {
+    public TimetableStopFilter getTrainFilterFilter() {
         return TimetableStopFilter.valueOf(this.trainFilter.toUpperCase());
     }
+
+    /**
+     * Returns the additional configured {@link TimetableStopPredicate} or <code>null</code> if not specified.
+     */
+    public @Nullable TimetableStopPredicate getAdditionalFilter() throws FilterScannerException, FilterParserException {
+        if (additionalFilter.isBlank()) {
+            return null;
+        } else {
+            final FilterScanner scanner = new FilterScanner();
+            final List<FilterToken> filterTokens = scanner.processInput(additionalFilter);
+            return FilterParser.parse(filterTokens);
+        }
+    }
 }
index 616493a9991575587bde9a076c07b38f816a01eb..6daaca73205d5af29487b8d7e0874063eb8f737a 100644 (file)
@@ -30,6 +30,10 @@ import javax.xml.bind.JAXBException;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.deutschebahn.internal.filter.AndPredicate;
+import org.openhab.binding.deutschebahn.internal.filter.FilterParserException;
+import org.openhab.binding.deutschebahn.internal.filter.FilterScannerException;
+import org.openhab.binding.deutschebahn.internal.filter.TimetableStopPredicate;
 import org.openhab.binding.deutschebahn.internal.timetable.TimetableLoader;
 import org.openhab.binding.deutschebahn.internal.timetable.TimetablesV1Api;
 import org.openhab.binding.deutschebahn.internal.timetable.TimetablesV1ApiFactory;
@@ -151,14 +155,22 @@ public class DeutscheBahnTimetableHandler extends BaseBridgeHandler {
         try {
             final TimetablesV1Api api = this.timetablesV1ApiFactory.create(config.accessToken, HttpUtil::executeUrl);
 
-            final TimetableStopFilter stopFilter = config.getTimetableStopFilter();
+            final TimetableStopFilter stopFilter = config.getTrainFilterFilter();
+            final TimetableStopPredicate additionalFilter = config.getAdditionalFilter();
+
+            final TimetableStopPredicate combinedFilter;
+            if (additionalFilter == null) {
+                combinedFilter = stopFilter;
+            } else {
+                combinedFilter = new AndPredicate(stopFilter, additionalFilter);
+            }
 
             final EventType eventSelection = stopFilter == TimetableStopFilter.ARRIVALS ? EventType.ARRIVAL
                     : EventType.ARRIVAL;
 
             this.loader = new TimetableLoader( //
                     api, //
-                    stopFilter, //
+                    combinedFilter, //
                     eventSelection, //
                     currentTimeProvider, //
                     config.evaNo, //
@@ -170,6 +182,8 @@ public class DeutscheBahnTimetableHandler extends BaseBridgeHandler {
                 this.updateChannels();
                 this.restartJob();
             });
+        } catch (FilterScannerException | FilterParserException e) {
+            this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
         } catch (JAXBException | SAXException | URISyntaxException e) {
             this.logger.error("Error initializing api", e);
             this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
index 26ad3e5a098ca6f4189ffbddf8b18b9e650fbdd8..d67cd6bd65d045a889ef015c14e3b4f19b8131bc 100644 (file)
@@ -17,6 +17,7 @@ import java.text.SimpleDateFormat;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.Date;
 import java.util.List;
 import java.util.function.BiConsumer;
@@ -55,145 +56,155 @@ public final class EventAttribute<VALUE_TYPE, STATE_TYPE extends State>
      * Planned Path.
      */
     public static final EventAttribute<String, StringType> PPTH = new EventAttribute<>("planned-path", Event::getPpth,
-            Event::setPpth, StringType::new, StringType.class);
+            Event::setPpth, StringType::new, EventAttribute::splitOnPipeToList, StringType.class);
 
     /**
      * Changed Path.
      */
     public static final EventAttribute<String, StringType> CPTH = new EventAttribute<>("changed-path", Event::getCpth,
-            Event::setCpth, StringType::new, StringType.class);
+            Event::setCpth, StringType::new, EventAttribute::splitOnPipeToList, StringType.class);
     /**
      * Planned platform.
      */
     public static final EventAttribute<String, StringType> PP = new EventAttribute<>("planned-platform", Event::getPp,
-            Event::setPp, StringType::new, StringType.class);
+            Event::setPp, StringType::new, EventAttribute::singletonList, StringType.class);
     /**
      * Changed platform.
      */
     public static final EventAttribute<String, StringType> CP = new EventAttribute<>("changed-platform", Event::getCp,
-            Event::setCp, StringType::new, StringType.class);
+            Event::setCp, StringType::new, EventAttribute::singletonList, StringType.class);
     /**
      * Planned time.
      */
     public static final EventAttribute<Date, DateTimeType> PT = new EventAttribute<>("planned-time",
-            getDate(Event::getPt), setDate(Event::setPt), EventAttribute::createDateTimeType, DateTimeType.class);
+            getDate(Event::getPt), setDate(Event::setPt), EventAttribute::createDateTimeType,
+            EventAttribute::mapDateToStringList, DateTimeType.class);
     /**
      * Changed time.
      */
     public static final EventAttribute<Date, DateTimeType> CT = new EventAttribute<>("changed-time",
-            getDate(Event::getCt), setDate(Event::setCt), EventAttribute::createDateTimeType, DateTimeType.class);
+            getDate(Event::getCt), setDate(Event::setCt), EventAttribute::createDateTimeType,
+            EventAttribute::mapDateToStringList, DateTimeType.class);
     /**
      * Planned status.
      */
     public static final EventAttribute<EventStatus, StringType> PS = new EventAttribute<>("planned-status",
-            Event::getPs, Event::setPs, EventAttribute::fromEventStatus, StringType.class);
+            Event::getPs, Event::setPs, EventAttribute::fromEventStatus, EventAttribute::listFromEventStatus,
+            StringType.class);
     /**
      * Changed status.
      */
     public static final EventAttribute<EventStatus, StringType> CS = new EventAttribute<>("changed-status",
-            Event::getCs, Event::setCs, EventAttribute::fromEventStatus, StringType.class);
+            Event::getCs, Event::setCs, EventAttribute::fromEventStatus, EventAttribute::listFromEventStatus,
+            StringType.class);
     /**
      * Hidden.
      */
     public static final EventAttribute<Integer, OnOffType> HI = new EventAttribute<>("hidden", Event::getHi,
-            Event::setHi, EventAttribute::parseHidden, OnOffType.class);
+            Event::setHi, EventAttribute::parseHidden, EventAttribute::mapIntegerToStringList, OnOffType.class);
     /**
      * Cancellation time.
      */
     public static final EventAttribute<Date, DateTimeType> CLT = new EventAttribute<>("cancellation-time",
-            getDate(Event::getClt), setDate(Event::setClt), EventAttribute::createDateTimeType, DateTimeType.class);
+            getDate(Event::getClt), setDate(Event::setClt), EventAttribute::createDateTimeType,
+            EventAttribute::mapDateToStringList, DateTimeType.class);
     /**
      * Wing.
      */
     public static final EventAttribute<String, StringType> WINGS = new EventAttribute<>("wings", Event::getWings,
-            Event::setWings, StringType::new, StringType.class);
+            Event::setWings, StringType::new, EventAttribute::splitOnPipeToList, StringType.class);
     /**
      * Transition.
      */
     public static final EventAttribute<String, StringType> TRA = new EventAttribute<>("transition", Event::getTra,
-            Event::setTra, StringType::new, StringType.class);
+            Event::setTra, StringType::new, EventAttribute::singletonList, StringType.class);
     /**
      * Planned distant endpoint.
      */
     public static final EventAttribute<String, StringType> PDE = new EventAttribute<>("planned-distant-endpoint",
-            Event::getPde, Event::setPde, StringType::new, StringType.class);
+            Event::getPde, Event::setPde, StringType::new, EventAttribute::singletonList, StringType.class);
     /**
      * Changed distant endpoint.
      */
     public static final EventAttribute<String, StringType> CDE = new EventAttribute<>("changed-distant-endpoint",
-            Event::getCde, Event::setCde, StringType::new, StringType.class);
+            Event::getCde, Event::setCde, StringType::new, EventAttribute::singletonList, StringType.class);
     /**
      * Distant change.
      */
     public static final EventAttribute<Integer, DecimalType> DC = new EventAttribute<>("distant-change", Event::getDc,
-            Event::setDc, DecimalType::new, DecimalType.class);
+            Event::setDc, DecimalType::new, EventAttribute::mapIntegerToStringList, DecimalType.class);
     /**
      * Line.
      */
     public static final EventAttribute<String, StringType> L = new EventAttribute<>("line", Event::getL, Event::setL,
-            StringType::new, StringType.class);
+            StringType::new, EventAttribute::singletonList, StringType.class);
 
     /**
      * Messages.
      */
     public static final EventAttribute<List<Message>, StringType> MESSAGES = new EventAttribute<>("messages",
-            EventAttribute.getMessages(), EventAttribute::setMessages, EventAttribute::mapMessages, StringType.class);
+            EventAttribute.getMessages(), EventAttribute::setMessages, EventAttribute::mapMessages,
+            EventAttribute::mapMessagesToList, StringType.class);
 
     /**
      * Planned Start station.
      */
     public static final EventAttribute<String, StringType> PLANNED_START_STATION = new EventAttribute<>(
             "planned-start-station", EventAttribute.getSingleStationFromPath(Event::getPpth, true),
-            EventAttribute.voidSetter(), StringType::new, StringType.class);
+            EventAttribute.voidSetter(), StringType::new, EventAttribute::singletonList, StringType.class);
 
     /**
      * Planned Previous stations.
      */
-    public static final EventAttribute<String, StringType> PLANNED_PREVIOUS_STATIONS = new EventAttribute<>(
+    public static final EventAttribute<List<String>, StringType> PLANNED_PREVIOUS_STATIONS = new EventAttribute<>(
             "planned-previous-stations", EventAttribute.getIntermediateStationsFromPath(Event::getPpth, true),
-            EventAttribute.voidSetter(), StringType::new, StringType.class);
+            EventAttribute.voidSetter(), EventAttribute::fromStringList, EventAttribute::nullToEmptyList,
+            StringType.class);
 
     /**
      * Planned Target station.
      */
     public static final EventAttribute<String, StringType> PLANNED_TARGET_STATION = new EventAttribute<>(
             "planned-target-station", EventAttribute.getSingleStationFromPath(Event::getPpth, false),
-            EventAttribute.voidSetter(), StringType::new, StringType.class);
+            EventAttribute.voidSetter(), StringType::new, EventAttribute::singletonList, StringType.class);
 
     /**
      * Planned Following stations.
      */
-    public static final EventAttribute<String, StringType> PLANNED_FOLLOWING_STATIONS = new EventAttribute<>(
+    public static final EventAttribute<List<String>, StringType> PLANNED_FOLLOWING_STATIONS = new EventAttribute<>(
             "planned-following-stations", EventAttribute.getIntermediateStationsFromPath(Event::getPpth, false),
-            EventAttribute.voidSetter(), StringType::new, StringType.class);
+            EventAttribute.voidSetter(), EventAttribute::fromStringList, EventAttribute::nullToEmptyList,
+            StringType.class);
 
     /**
      * Changed Start station.
      */
     public static final EventAttribute<String, StringType> CHANGED_START_STATION = new EventAttribute<>(
             "changed-start-station", EventAttribute.getSingleStationFromPath(Event::getCpth, true),
-            EventAttribute.voidSetter(), StringType::new, StringType.class);
+            EventAttribute.voidSetter(), StringType::new, EventAttribute::singletonList, StringType.class);
 
     /**
      * Changed Previous stations.
      */
-    public static final EventAttribute<String, StringType> CHANGED_PREVIOUS_STATIONS = new EventAttribute<>(
+    public static final EventAttribute<List<String>, StringType> CHANGED_PREVIOUS_STATIONS = new EventAttribute<>(
             "changed-previous-stations", EventAttribute.getIntermediateStationsFromPath(Event::getCpth, true),
-            EventAttribute.voidSetter(), StringType::new, StringType.class);
+            EventAttribute.voidSetter(), EventAttribute::fromStringList, EventAttribute::nullToEmptyList,
+            StringType.class);
 
     /**
      * Changed Target station.
      */
     public static final EventAttribute<String, StringType> CHANGED_TARGET_STATION = new EventAttribute<>(
             "changed-target-station", EventAttribute.getSingleStationFromPath(Event::getCpth, false),
-            EventAttribute.voidSetter(), StringType::new, StringType.class);
+            EventAttribute.voidSetter(), StringType::new, EventAttribute::singletonList, StringType.class);
 
     /**
      * Changed Following stations.
      */
-    public static final EventAttribute<String, StringType> CHANGED_FOLLOWING_STATIONS = new EventAttribute<>(
+    public static final EventAttribute<List<String>, StringType> CHANGED_FOLLOWING_STATIONS = new EventAttribute<>(
             "changed-following-stations", EventAttribute.getIntermediateStationsFromPath(Event::getCpth, false),
-            EventAttribute.voidSetter(), StringType::new, StringType.class);
+            EventAttribute.voidSetter(), EventAttribute::fromStringList, EventAttribute::nullToEmptyList,
+            StringType.class);
 
     /**
      * List containing all known {@link EventAttribute}.
@@ -214,14 +225,38 @@ public final class EventAttribute<VALUE_TYPE, STATE_TYPE extends State>
             final Function<Event, @Nullable VALUE_TYPE> getter, //
             final BiConsumer<Event, VALUE_TYPE> setter, //
             final Function<VALUE_TYPE, @Nullable STATE_TYPE> getState, //
+            final Function<VALUE_TYPE, List<String>> valueToList, //
             final Class<STATE_TYPE> stateType) {
-        super(channelTypeName, getter, setter, getState, stateType);
+        super(channelTypeName, getter, setter, getState, valueToList, stateType);
     }
 
     private static StringType fromEventStatus(final EventStatus value) {
         return new StringType(value.value());
     }
 
+    private static List<String> listFromEventStatus(final @Nullable EventStatus value) {
+        if (value == null) {
+            return Collections.emptyList();
+        } else {
+            return Collections.singletonList(value.value());
+        }
+    }
+
+    private static StringType fromStringList(final List<String> value) {
+        return new StringType(value.stream().collect(Collectors.joining(" - ")));
+    }
+
+    private static List<String> nullToEmptyList(@Nullable final List<String> value) {
+        return value == null ? Collections.emptyList() : value;
+    }
+
+    /**
+     * Returns a list containing only the given value or empty list if value is <code>null</code>.
+     */
+    private static List<String> singletonList(@Nullable String value) {
+        return value == null ? Collections.emptyList() : Collections.singletonList(value);
+    }
+
     private static OnOffType parseHidden(@Nullable Integer value) {
         return OnOffType.from(value != null && value == 1);
     }
@@ -291,6 +326,24 @@ public final class EventAttribute<VALUE_TYPE, STATE_TYPE extends State>
         }
     }
 
+    /**
+     * Maps the status codes from the messages into string list.
+     */
+    private static List<String> mapMessagesToList(final @Nullable List<Message> messages) {
+        if (messages == null || messages.isEmpty()) {
+            return Collections.emptyList();
+        } else {
+            return messages //
+                    .stream()//
+                    .filter((Message message) -> message.getC() != null) //
+                    .map(Message::getC) //
+                    .distinct() //
+                    .map(MessageCodes::getMessage) //
+                    .filter((String messageText) -> !messageText.isEmpty()) //
+                    .collect(Collectors.toList());
+        }
+    }
+
     private static Function<Event, @Nullable List<Message>> getMessages() {
         return new Function<Event, @Nullable List<Message>>() {
 
@@ -305,6 +358,22 @@ public final class EventAttribute<VALUE_TYPE, STATE_TYPE extends State>
         };
     }
 
+    private static List<String> mapIntegerToStringList(@Nullable Integer value) {
+        if (value == null) {
+            return Collections.emptyList();
+        } else {
+            return Collections.singletonList(String.valueOf(value));
+        }
+    }
+
+    private static List<String> mapDateToStringList(@Nullable Date value) {
+        if (value == null) {
+            return Collections.emptyList();
+        } else {
+            return Collections.singletonList(DATETIME_FORMAT.format(value));
+        }
+    }
+
     /**
      * Returns an single station from an path value (i.e. pipe separated value of stations).
      * 
@@ -337,7 +406,7 @@ public final class EventAttribute<VALUE_TYPE, STATE_TYPE extends State>
      * @param removeFirst if <code>true</code> the first value will be removed, <code>false</code> will remove the last
      *            value.
      */
-    private static Function<Event, @Nullable String> getIntermediateStationsFromPath(
+    private static Function<Event, @Nullable List<String>> getIntermediateStationsFromPath(
             final Function<Event, @Nullable String> getPath, boolean removeFirst) {
         return (final Event event) -> {
             final String path = getPath.apply(event);
@@ -351,7 +420,7 @@ public final class EventAttribute<VALUE_TYPE, STATE_TYPE extends State>
             } else {
                 stations = stations.limit(stationValues.length - 1);
             }
-            return stations.collect(Collectors.joining(" - "));
+            return stations.collect(Collectors.toList());
         };
     }
 
@@ -372,6 +441,10 @@ public final class EventAttribute<VALUE_TYPE, STATE_TYPE extends State>
         return path.split("\\|");
     }
 
+    private static List<String> splitOnPipeToList(final String value) {
+        return Arrays.asList(value.split("\\|"));
+    }
+
     /**
      * Returns an {@link EventAttribute} for the given channel-type and {@link EventType}.
      */
index 51224949f9a100fe2b1f271a79b647d5cc1b486c..ee681ec3b65a3859725bc62a07e669ddbd31075a 100644 (file)
  */
 package org.openhab.binding.deutschebahn.internal;
 
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
 import org.openhab.binding.deutschebahn.internal.timetable.dto.Event;
@@ -49,4 +53,38 @@ public final class EventAttributeSelection implements AttributeSelection {
             return this.eventAttribute.getState(event);
         }
     }
+
+    @Override
+    public @Nullable Object getValue(TimetableStop stop) {
+        final Event event = eventType.getEvent(stop);
+        if (event == null) {
+            return UnDefType.UNDEF;
+        } else {
+            return this.eventAttribute.getValue(event);
+        }
+    }
+
+    @Override
+    public List<String> getStringValues(TimetableStop stop) {
+        final Event event = eventType.getEvent(stop);
+        if (event == null) {
+            return Collections.emptyList();
+        } else {
+            return this.eventAttribute.getStringValues(event);
+        }
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(eventAttribute, eventType);
+    }
+
+    @Override
+    public boolean equals(@Nullable Object obj) {
+        if (!(obj instanceof EventAttributeSelection)) {
+            return false;
+        }
+        final EventAttributeSelection other = (EventAttributeSelection) obj;
+        return Objects.equals(eventAttribute, other.eventAttribute) && eventType == other.eventType;
+    }
 }
index e0256f42453e93222d51db0d393226d0aa80217e..192b4b39e356ca8f8d077f72de07b7abc0979afd 100644 (file)
@@ -12,9 +12,8 @@
  */
 package org.openhab.binding.deutschebahn.internal;
 
-import java.util.function.Predicate;
-
 import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.deutschebahn.internal.filter.TimetableStopPredicate;
 import org.openhab.binding.deutschebahn.internal.timetable.dto.TimetableStop;
 
 /**
@@ -23,7 +22,7 @@ import org.openhab.binding.deutschebahn.internal.timetable.dto.TimetableStop;
  * @author Sönke Küper - initial contribution.
  */
 @NonNullByDefault
-public enum TimetableStopFilter implements Predicate<TimetableStop> {
+public enum TimetableStopFilter implements TimetableStopPredicate {
 
     /**
      * Selects all entries.
index 2acbaeaab5e40d3cc73a5c07aba3395bf25d12e6..3b0750bd0a8a2bd566d83d6b55ee2228cb838086 100644 (file)
@@ -12,6 +12,8 @@
  */
 package org.openhab.binding.deutschebahn.internal;
 
+import java.util.Collections;
+import java.util.List;
 import java.util.function.BiConsumer;
 import java.util.function.Function;
 
@@ -44,29 +46,30 @@ public final class TripLabelAttribute<VALUE_TYPE, STATE_TYPE extends State> exte
      * Trip category.
      */
     public static final TripLabelAttribute<String, StringType> C = new TripLabelAttribute<>("category", TripLabel::getC,
-            TripLabel::setC, StringType::new, StringType.class);
+            TripLabel::setC, StringType::new, TripLabelAttribute::singletonList, StringType.class);
 
     /**
      * Number.
      */
     public static final TripLabelAttribute<String, StringType> N = new TripLabelAttribute<>("number", TripLabel::getN,
-            TripLabel::setN, StringType::new, StringType.class);
+            TripLabel::setN, StringType::new, TripLabelAttribute::singletonList, StringType.class);
 
     /**
      * Filter flags.
      */
     public static final TripLabelAttribute<String, StringType> F = new TripLabelAttribute<>("filter-flags",
-            TripLabel::getF, TripLabel::setF, StringType::new, StringType.class);
+            TripLabel::getF, TripLabel::setF, StringType::new, TripLabelAttribute::singletonList, StringType.class);
     /**
      * Trip Type.
      */
     public static final TripLabelAttribute<TripType, StringType> T = new TripLabelAttribute<>("trip-type",
-            TripLabel::getT, TripLabel::setT, TripLabelAttribute::fromTripType, StringType.class);
+            TripLabel::getT, TripLabel::setT, TripLabelAttribute::fromTripType, TripLabelAttribute::listFromTripType,
+            StringType.class);
     /**
      * Owner.
      */
     public static final TripLabelAttribute<String, StringType> O = new TripLabelAttribute<>("owner", TripLabel::getO,
-            TripLabel::setO, StringType::new, StringType.class);
+            TripLabel::setO, StringType::new, TripLabelAttribute::singletonList, StringType.class);
 
     /**
      * Creates an new {@link TripLabelAttribute}.
@@ -79,8 +82,9 @@ public final class TripLabelAttribute<VALUE_TYPE, STATE_TYPE extends State> exte
             final Function<TripLabel, @Nullable VALUE_TYPE> getter, //
             final BiConsumer<TripLabel, VALUE_TYPE> setter, //
             final Function<VALUE_TYPE, @Nullable STATE_TYPE> getState, //
+            final Function<VALUE_TYPE, List<String>> valueToList, //
             final Class<STATE_TYPE> stateType) {
-        super(channelTypeName, getter, setter, getState, stateType);
+        super(channelTypeName, getter, setter, getState, valueToList, stateType);
     }
 
     @Nullable
@@ -92,10 +96,41 @@ public final class TripLabelAttribute<VALUE_TYPE, STATE_TYPE extends State> exte
         return super.getState(stop.getTl());
     }
 
+    @Override
+    public @Nullable Object getValue(TimetableStop stop) {
+        if (stop.getTl() == null) {
+            return UnDefType.UNDEF;
+        }
+        return super.getValue(stop.getTl());
+    }
+
+    @Override
+    public List<String> getStringValues(TimetableStop stop) {
+        if (stop.getTl() == null) {
+            return Collections.emptyList();
+        }
+        return this.getStringValues(stop.getTl());
+    }
+
     private static StringType fromTripType(final TripType value) {
         return new StringType(value.value());
     }
 
+    private static List<String> listFromTripType(@Nullable final TripType value) {
+        if (value == null) {
+            return Collections.emptyList();
+        } else {
+            return Collections.singletonList(value.value());
+        }
+    }
+
+    /**
+     * Returns a list containing only the given value or empty list if value is <code>null</code>.
+     */
+    private static List<String> singletonList(@Nullable String value) {
+        return value == null ? Collections.emptyList() : Collections.singletonList(value);
+    }
+
     /**
      * Returns an {@link TripLabelAttribute} for the given channel-name.
      */
diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/AndOperator.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/AndOperator.java
new file mode 100644 (file)
index 0000000..eb5a5b0
--- /dev/null
@@ -0,0 +1,41 @@
+/**
+ * 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.filter;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * A token representing an conjunction.
+ * 
+ * @author Sönke Küper - Initial contribution.
+ */
+@NonNullByDefault
+public final class AndOperator extends OperatorToken {
+
+    /**
+     * Creates new {@link AndOperator}.
+     */
+    public AndOperator(int position) {
+        super(position);
+    }
+
+    @Override
+    public String toString() {
+        return "&";
+    }
+
+    @Override
+    public <R> R accept(FilterTokenVisitor<R> visitor) throws FilterParserException {
+        return visitor.handle(this);
+    }
+}
diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/AndPredicate.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/AndPredicate.java
new file mode 100644 (file)
index 0000000..a2162a1
--- /dev/null
@@ -0,0 +1,55 @@
+/**
+ * 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.filter;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.deutschebahn.internal.timetable.dto.TimetableStop;
+
+/**
+ * And conjunction for {@link TimetableStopPredicate}.
+ * 
+ * @author Sönke Küper - initial contribution
+ */
+@NonNullByDefault
+public final class AndPredicate implements TimetableStopPredicate {
+
+    private final TimetableStopPredicate first;
+    private final TimetableStopPredicate second;
+
+    /**
+     * Creates an new {@link AndPredicate}.
+     */
+    public AndPredicate(TimetableStopPredicate first, TimetableStopPredicate second) {
+        this.first = first;
+        this.second = second;
+    }
+
+    @Override
+    public boolean test(TimetableStop t) {
+        return first.test(t) && second.test(t);
+    }
+
+    /**
+     * Returns first argument.
+     */
+    TimetableStopPredicate getFirst() {
+        return first;
+    }
+
+    /**
+     * Returns second argument.
+     */
+    TimetableStopPredicate getSecond() {
+        return second;
+    }
+}
diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/BracketCloseToken.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/BracketCloseToken.java
new file mode 100644 (file)
index 0000000..f056569
--- /dev/null
@@ -0,0 +1,41 @@
+/**
+ * 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.filter;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * A token representing an closing bracket.
+ * 
+ * @author Sönke Küper - Initial contribution.
+ */
+@NonNullByDefault
+public final class BracketCloseToken extends OperatorToken {
+
+    /**
+     * Creates new {@link BracketCloseToken}.
+     */
+    public BracketCloseToken(int position) {
+        super(position);
+    }
+
+    @Override
+    public String toString() {
+        return ")";
+    }
+
+    @Override
+    public <R> R accept(FilterTokenVisitor<R> visitor) throws FilterParserException {
+        return visitor.handle(this);
+    }
+}
diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/BracketOpenToken.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/BracketOpenToken.java
new file mode 100644 (file)
index 0000000..03d00cd
--- /dev/null
@@ -0,0 +1,41 @@
+/**
+ * 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.filter;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * A token representing an opening bracket.
+ * 
+ * @author Sönke Küper - Initial contribution.
+ */
+@NonNullByDefault
+public final class BracketOpenToken extends OperatorToken {
+
+    /**
+     * Creates new {@link BracketOpenToken}.
+     */
+    public BracketOpenToken(int position) {
+        super(position);
+    }
+
+    @Override
+    public String toString() {
+        return "(";
+    }
+
+    @Override
+    public <R> R accept(FilterTokenVisitor<R> visitor) throws FilterParserException {
+        return visitor.handle(this);
+    }
+}
diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/ChannelNameEquals.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/ChannelNameEquals.java
new file mode 100644 (file)
index 0000000..3431331
--- /dev/null
@@ -0,0 +1,114 @@
+/**
+ * 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.filter;
+
+import java.util.regex.Pattern;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.deutschebahn.internal.AttributeSelection;
+import org.openhab.binding.deutschebahn.internal.EventAttribute;
+import org.openhab.binding.deutschebahn.internal.EventAttributeSelection;
+import org.openhab.binding.deutschebahn.internal.EventType;
+import org.openhab.binding.deutschebahn.internal.TripLabelAttribute;
+
+/**
+ * Token representing an attribute filter.
+ * 
+ * @author Sönke Küper - initial contribution.
+ */
+@NonNullByDefault
+public final class ChannelNameEquals extends FilterToken {
+
+    private final String channelName;
+    private final Pattern filterValue;
+    private String channelGroup;
+
+    /**
+     * Creates an new {@link ChannelNameEquals}.
+     */
+    public ChannelNameEquals(int position, String channelGroup, String channelName, Pattern filterPattern) {
+        super(position);
+        this.channelGroup = channelGroup;
+        this.channelName = channelName;
+        this.filterValue = filterPattern;
+    }
+
+    /**
+     * Returns the channel group.
+     */
+    public String getChannelGroup() {
+        return channelGroup;
+    }
+
+    /**
+     * Returns the channel name.
+     */
+    public String getChannelName() {
+        return channelName;
+    }
+
+    /**
+     * Returns the filter value.
+     */
+    public Pattern getFilterValue() {
+        return filterValue;
+    }
+
+    @Override
+    public String toString() {
+        return this.channelGroup + "#" + channelName + "=\"" + this.filterValue.toString() + "\"";
+    }
+
+    @Override
+    public <R> R accept(FilterTokenVisitor<R> visitor) throws FilterParserException {
+        return visitor.handle(this);
+    }
+
+    /**
+     * Maps this into an {@link TimetableStopByStringEventAttributeFilter}.
+     */
+    public TimetableStopByStringEventAttributeFilter mapToPredicate() throws FilterParserException {
+        return new TimetableStopByStringEventAttributeFilter(mapAttributeSelection(), filterValue);
+    }
+
+    private AttributeSelection mapAttributeSelection() throws FilterParserException {
+        switch (this.channelGroup) {
+            case "trip":
+                final TripLabelAttribute<?, ?> tripAttribute = TripLabelAttribute.getByChannelName(this.channelName);
+                if (tripAttribute == null) {
+                    throw new FilterParserException("Invalid trip channel: " + channelName);
+                }
+                return tripAttribute;
+
+            case "departure":
+                final EventType eventTypeDeparture = EventType.DEPARTURE;
+                final EventAttribute<?, ?> departureAttribute = EventAttribute.getByChannelName(this.channelName,
+                        eventTypeDeparture);
+                if (departureAttribute == null) {
+                    throw new FilterParserException("Invalid departure channel: " + channelName);
+                }
+                return new EventAttributeSelection(eventTypeDeparture, departureAttribute);
+
+            case "arrival":
+                final EventType eventTypeArrival = EventType.ARRIVAL;
+                final EventAttribute<?, ?> arrivalAttribute = EventAttribute.getByChannelName(this.channelName,
+                        eventTypeArrival);
+                if (arrivalAttribute == null) {
+                    throw new FilterParserException("Invalid arrival channel: " + channelName);
+                }
+                return new EventAttributeSelection(eventTypeArrival, arrivalAttribute);
+            default:
+                throw new FilterParserException("Unknown channel group: " + channelGroup);
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/FilterParser.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/FilterParser.java
new file mode 100644 (file)
index 0000000..e030997
--- /dev/null
@@ -0,0 +1,299 @@
+/**
+ * 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.filter;
+
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * Parses an {@link FilterToken}-Sequence into a {@link TimetableStopPredicate}.
+ * 
+ * @author Sönke Küper - Initial contribution.
+ */
+@NonNullByDefault
+public final class FilterParser {
+
+    /**
+     * Parser's state.
+     */
+    private abstract static class State implements FilterTokenVisitor<State> {
+
+        @Nullable
+        private State previousState;
+
+        public State(@Nullable State previousState) {
+            this.previousState = previousState;
+        }
+
+        private final State handle(FilterToken token) throws FilterParserException {
+            return token.accept(this);
+        }
+
+        protected abstract State handleChildResult(TimetableStopPredicate predicate) throws FilterParserException;
+
+        @Override
+        public final State handle(ChannelNameEquals channelEquals) throws FilterParserException {
+            final TimetableStopByStringEventAttributeFilter predicate = channelEquals.mapToPredicate();
+            return this.handleChildResult(predicate);
+        }
+
+        protected final State publishResultToPrevious(TimetableStopPredicate predicate) throws FilterParserException {
+            return this.getPreviousState().handleChildResult(predicate);
+        }
+
+        protected State getPreviousState() throws FilterParserException {
+            final State previousStateValue = this.previousState;
+            if (previousStateValue == null) {
+                throw new FilterParserException("Invalid filter");
+            } else {
+                return previousStateValue;
+            }
+        }
+
+        /**
+         * Returns the result.
+         */
+        public abstract TimetableStopPredicate getResult() throws FilterParserException;
+    }
+
+    /**
+     * Initial state for the parser.
+     */
+    private static final class InitialState extends State {
+
+        @Nullable
+        private TimetableStopPredicate result;
+
+        public InitialState() {
+            super(null);
+        }
+
+        @Override
+        public State handle(OrOperator operator) throws FilterParserException {
+            final TimetableStopPredicate currentResult = this.result;
+            this.result = null;
+            if (currentResult == null) {
+                throw new FilterParserException(
+                        "Invalid filter: first argument missing for '|' at " + operator.getPosition());
+            }
+            return new OrState(this, currentResult);
+        }
+
+        @Override
+        public State handle(AndOperator operator) throws FilterParserException {
+            final TimetableStopPredicate currentResult = this.result;
+            this.result = null;
+            if (currentResult == null) {
+                throw new FilterParserException(
+                        "Invalid filter: first argument missing for '&' at " + operator.getPosition());
+            }
+            return new AndState(this, currentResult);
+        }
+
+        @Override
+        public State handle(BracketOpenToken token) throws FilterParserException {
+            this.result = null;
+            return new SubQueryState(this);
+        }
+
+        @Override
+        public State handle(BracketCloseToken token) throws FilterParserException {
+            throw new FilterParserException("Unexpected token " + token.toString() + " at " + token.getPosition());
+        }
+
+        @Override
+        protected State handleChildResult(TimetableStopPredicate predicate) throws FilterParserException {
+            if (this.result == null) {
+                this.result = predicate;
+                return this;
+            } else {
+                throw new FilterParserException("Invalid filter: Operator for multiple filters missing.");
+            }
+        }
+
+        @Override
+        public TimetableStopPredicate getResult() throws FilterParserException {
+            final TimetableStopPredicate currentResult = this.result;
+            if (currentResult != null) {
+                return currentResult;
+            }
+            throw new FilterParserException("Invalid filter.");
+        }
+    }
+
+    /**
+     * State while parsing an conjunction.
+     */
+    private static final class AndState extends State {
+
+        private final TimetableStopPredicate first;
+
+        public AndState(State previousState, final TimetableStopPredicate first) {
+            super(previousState);
+            this.first = first;
+        }
+
+        @Override
+        public State handle(OrOperator operator) throws FilterParserException {
+            throw new FilterParserException("Invalid second argument for '&' operator " + operator.toString() + " at "
+                    + operator.getPosition());
+        }
+
+        @Override
+        public State handle(AndOperator operator) throws FilterParserException {
+            throw new FilterParserException("Invalid second argument for '&' operator " + operator.toString() + " at "
+                    + operator.getPosition());
+        }
+
+        @Override
+        public State handle(BracketOpenToken token) throws FilterParserException {
+            return new SubQueryState(this);
+        }
+
+        @Override
+        public State handle(BracketCloseToken token) throws FilterParserException {
+            throw new FilterParserException(
+                    "Invalid second argument for '&' operator " + token.toString() + " at " + token.getPosition());
+        }
+
+        @Override
+        protected State handleChildResult(TimetableStopPredicate predicate) throws FilterParserException {
+            return this.publishResultToPrevious(new AndPredicate(first, predicate));
+        }
+
+        @Override
+        public TimetableStopPredicate getResult() throws FilterParserException {
+            throw new FilterParserException("Invalid filter");
+        }
+    }
+
+    /**
+     * State while parsing an disjunction.
+     */
+    private static final class OrState extends State {
+
+        private final TimetableStopPredicate first;
+
+        public OrState(State previousState, final TimetableStopPredicate first) {
+            super(previousState);
+            this.first = first;
+        }
+
+        @Override
+        public State handle(OrOperator operator) throws FilterParserException {
+            throw new FilterParserException("Invalid second argument for '|' operator " + operator.toString() + " at "
+                    + operator.getPosition());
+        }
+
+        @Override
+        public State handle(AndOperator operator) throws FilterParserException {
+            throw new FilterParserException("Invalid second argument for '|' operator " + operator.toString() + " at "
+                    + operator.getPosition());
+        }
+
+        @Override
+        public State handle(BracketOpenToken token) throws FilterParserException {
+            return new SubQueryState(this);
+        }
+
+        @Override
+        public State handle(BracketCloseToken token) throws FilterParserException {
+            throw new FilterParserException(
+                    "Invalid second argument for '|' operator " + token.toString() + " at " + token.getPosition());
+        }
+
+        @Override
+        protected State handleChildResult(TimetableStopPredicate second) throws FilterParserException {
+            return this.publishResultToPrevious(new OrPredicate(first, second));
+        }
+
+        @Override
+        public TimetableStopPredicate getResult() throws FilterParserException {
+            throw new FilterParserException("Invalid filter");
+        }
+    }
+
+    /**
+     * State while parsing an Subquery.
+     */
+    private static final class SubQueryState extends State {
+
+        @Nullable
+        private TimetableStopPredicate currentResult;
+
+        public SubQueryState(State previousState) {
+            super(previousState);
+        }
+
+        @Override
+        public State handle(OrOperator operator) throws FilterParserException {
+            TimetableStopPredicate result = this.currentResult;
+            if (result == null) {
+                throw new FilterParserException(
+                        "Operator '|' at " + operator.getPosition() + " must not be first element in subquery.");
+            }
+            return new OrState(this, result);
+        }
+
+        @Override
+        public State handle(AndOperator operator) throws FilterParserException {
+            TimetableStopPredicate result = this.currentResult;
+            if (result == null) {
+                throw new FilterParserException(
+                        "Operator '&' at" + operator.getPosition() + " must not be first element in subquery.");
+            }
+            return new AndState(this, result);
+        }
+
+        @Override
+        public State handle(BracketOpenToken token) throws FilterParserException {
+            return new SubQueryState(this);
+        }
+
+        @Override
+        public State handle(BracketCloseToken token) throws FilterParserException {
+            TimetableStopPredicate result = this.currentResult;
+            if (result == null) {
+                throw new FilterParserException("Subquery must not be empty at " + token.getPosition());
+            }
+            return publishResultToPrevious(result);
+        }
+
+        @Override
+        protected State handleChildResult(TimetableStopPredicate predicate) {
+            this.currentResult = predicate;
+            return this;
+        }
+
+        @Override
+        public TimetableStopPredicate getResult() throws FilterParserException {
+            throw new FilterParserException("Invalid filter");
+        }
+    }
+
+    private FilterParser() {
+    }
+
+    /**
+     * Parses the given {@link FilterToken} into an {@link TimetableStopPredicate}.
+     */
+    public static TimetableStopPredicate parse(final List<FilterToken> tokens) throws FilterParserException {
+        State state = new InitialState();
+        for (FilterToken token : tokens) {
+            state = state.handle(token);
+        }
+        return state.getResult();
+    }
+}
diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/FilterParserException.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/FilterParserException.java
new file mode 100644 (file)
index 0000000..8efb6f4
--- /dev/null
@@ -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.filter;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Exception showing problems during parsing a filter expression.
+ * 
+ * @author Sönke Küper - initial contribution.
+ */
+@NonNullByDefault
+public final class FilterParserException extends Exception {
+
+    private static final long serialVersionUID = 3104578924298682889L;
+
+    /**
+     * Creates an new {@link FilterParserException}.
+     */
+    public FilterParserException(String message) {
+        super(message);
+    }
+}
diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/FilterScanner.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/FilterScanner.java
new file mode 100644 (file)
index 0000000..8282807
--- /dev/null
@@ -0,0 +1,239 @@
+/**
+ * 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.filter;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Scanner for filter expression.
+ * 
+ * @author Sönke Küper - Initial contribution.
+ */
+@NonNullByDefault
+public final class FilterScanner {
+
+    private static final Set<Character> OP_CHARS = new HashSet<>(Arrays.asList('&', '|', '!', '(', ')'));
+    private static final Pattern CHANNEL_NAME = Pattern.compile("(trip|arrival|departure)#(\\S+)");
+
+    /**
+     * State of the scanner.
+     */
+    private interface State {
+
+        /**
+         * Handles the next read character.
+         * 
+         * @return Returns the next scanner state.
+         */
+        public abstract State handle(int position, char currentChar) throws FilterScannerException;
+
+        /**
+         * Called when no more input is available.
+         */
+        public abstract void finish(int position) throws FilterScannerException;
+    }
+
+    /**
+     * Initial state of the scanner.
+     */
+    private final class InitialState implements State {
+
+        @Override
+        public State handle(int position, char currentChar) throws FilterScannerException {
+            // Skip white spaces
+            if (Character.isWhitespace(currentChar)) {
+                return this;
+            }
+
+            switch (currentChar) {
+                // Handle all operator tokens
+                case '&':
+                    result.add(new AndOperator(position));
+                    return this;
+                case '|':
+                    result.add(new OrOperator(position));
+                    return this;
+                case '(':
+                    result.add(new BracketOpenToken(position));
+                    return this;
+                case ')':
+                    result.add(new BracketCloseToken(position));
+                    return this;
+                default:
+                    final ChannelNameState channelNameState = new ChannelNameState();
+                    return channelNameState.handle(position, currentChar);
+            }
+        }
+
+        @Override
+        public void finish(int position) {
+        }
+    }
+
+    /**
+     * State scanning an channel name until the equals-sign.
+     */
+    private final class ChannelNameState implements State {
+
+        private final StringBuilder channelName = new StringBuilder();
+        private int startPosition = -1;
+
+        @Override
+        public State handle(int position, final char currentChar) throws FilterScannerException {
+            // Skip white spaces at front
+            if (Character.isWhitespace(currentChar) && channelName.toString().isEmpty()) {
+                return this;
+            }
+
+            if (Character.isWhitespace(currentChar)) {
+                throw new FilterScannerException(position, "Channel name must not contain whitespace.");
+            }
+
+            if (currentChar == '=') {
+                final String channelNameValue = this.channelName.toString();
+                if (channelNameValue.isEmpty()) {
+                    throw new FilterScannerException(position, "Channel name must not be empty.");
+                }
+
+                final Matcher matcher = CHANNEL_NAME.matcher(channelNameValue);
+                if (!matcher.matches()) {
+                    throw new FilterScannerException(position, "Invalid channel name: " + channelNameValue);
+                }
+
+                return new ExpectQuotesState(startPosition, matcher.group(1), matcher.group(2));
+            }
+
+            if (OP_CHARS.contains(currentChar)) {
+                throw new FilterScannerException(position, "Channel name must not contain operation char.");
+            }
+
+            this.channelName.append(currentChar);
+            if (startPosition == -1) {
+                startPosition = position;
+            }
+            return this;
+        }
+
+        @Override
+        public void finish(int position) throws FilterScannerException {
+            throw new FilterScannerException(position, "Filter value is missing.");
+        }
+    }
+
+    /**
+     * State after channel name, wiating for quotes.
+     */
+    private final class ExpectQuotesState implements State {
+
+        private final int startPosition;
+        private final String channelName;
+        private String channelGroup;
+
+        /**
+         * Creates an new {@link ExpectQuotesState}.
+         */
+        public ExpectQuotesState(int startPosition, final String channelGroup, String channelName) {
+            this.startPosition = startPosition;
+            this.channelGroup = channelGroup;
+            this.channelName = channelName;
+        }
+
+        @Override
+        public State handle(int position, char currentChar) throws FilterScannerException {
+            if (currentChar != '"') {
+                throw new FilterScannerException(position, "Filter value must start with quotes");
+            }
+            return new FilterValueState(startPosition, channelGroup, channelName);
+        }
+
+        @Override
+        public void finish(int position) throws FilterScannerException {
+            throw new FilterScannerException(position, "Filter value is missing.");
+        }
+    }
+
+    /**
+     * State scanning the filter value until next quotes.
+     */
+    private final class FilterValueState implements State {
+
+        private final int startPosition;
+        private final String channelGroup;
+        private final String channelName;
+        private final StringBuilder filterValue;
+
+        /**
+         * Creates an new {@link FilterValueState}.
+         */
+        public FilterValueState(int startPosition, String channelGroup, String channelName) {
+            this.startPosition = startPosition;
+            this.channelGroup = channelGroup;
+            this.channelName = channelName;
+            this.filterValue = new StringBuilder();
+        }
+
+        @Override
+        public State handle(int position, char currentChar) throws FilterScannerException {
+            if (currentChar == '"') {
+                finish(position);
+                return new InitialState();
+            }
+            filterValue.append(currentChar);
+            return this;
+        }
+
+        @Override
+        public void finish(int position) throws FilterScannerException {
+            String filterPattern = this.filterValue.toString();
+            try {
+                result.add(new ChannelNameEquals(startPosition, this.channelGroup, this.channelName,
+                        Pattern.compile(filterPattern)));
+            } catch (PatternSyntaxException e) {
+                throw new FilterScannerException(position, "Filter pattern is invalid: " + filterPattern, e);
+            }
+        }
+    }
+
+    private List<FilterToken> result;
+
+    /**
+     * Creates an new {@link FilterScanner}.
+     */
+    public FilterScanner() {
+        this.result = new ArrayList<>();
+    }
+
+    /**
+     * Scans the given filter expression and returns the result sequence of {@link FilterToken}.
+     */
+    public List<FilterToken> processInput(String value) throws FilterScannerException {
+        State state = new InitialState();
+        for (int pos = 0; pos < value.length(); pos++) {
+            char currentChar = value.charAt(pos);
+            state = state.handle(pos + 1, currentChar);
+        }
+
+        state.finish(value.length());
+
+        return this.result;
+    }
+}
diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/FilterScannerException.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/FilterScannerException.java
new file mode 100644 (file)
index 0000000..abb64f6
--- /dev/null
@@ -0,0 +1,42 @@
+/**
+ * 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.filter;
+
+import java.util.regex.PatternSyntaxException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Exception for errors within the filter scanner.
+ * 
+ * @author Sönke Küper - initial contribution
+ */
+@NonNullByDefault
+public final class FilterScannerException extends Exception {
+
+    private static final long serialVersionUID = -7319023069454747511L;
+
+    /**
+     * Creates an exception with given position and message.
+     */
+    FilterScannerException(int position, String message) {
+        super("Scanner failed at positon: " + position + ": " + message);
+    }
+
+    /**
+     * Creates an exception with given position, message and cause.
+     */
+    FilterScannerException(int position, String message, PatternSyntaxException e) {
+        super("Scanner failed at positon: " + position + ": " + message, e);
+    }
+}
diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/FilterToken.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/FilterToken.java
new file mode 100644 (file)
index 0000000..03f9e03
--- /dev/null
@@ -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.filter;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * A token representing a part of an filter expression.
+ * 
+ * @author Sönke Küper - Initial contribution.
+ */
+@NonNullByDefault
+public abstract class FilterToken {
+
+    private final int position;
+
+    /**
+     * Creates an new {@link FilterToken}.
+     */
+    public FilterToken(int position) {
+        this.position = position;
+    }
+
+    /**
+     * Returns the start position of the token.
+     */
+    public final int getPosition() {
+        return position;
+    }
+
+    /**
+     * Accept for {@link FilterTokenVisitor}.
+     */
+    public abstract <R> R accept(FilterTokenVisitor<R> visitor) throws FilterParserException;
+}
diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/FilterTokenVisitor.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/FilterTokenVisitor.java
new file mode 100644 (file)
index 0000000..79c2808
--- /dev/null
@@ -0,0 +1,51 @@
+/**
+ * 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.filter;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Visitor for {@link FilterToken}.
+ * 
+ * @author Sönke Küper - Initial Contribution.
+ *
+ * @param <R> Return type.
+ */
+@NonNullByDefault
+public interface FilterTokenVisitor<R> {
+
+    /**
+     * Handles {@link ChannelNameEquals}.
+     */
+    public abstract R handle(ChannelNameEquals equals) throws FilterParserException;
+
+    /**
+     * Handles {@link OrOperator}.
+     */
+    public abstract R handle(OrOperator operator) throws FilterParserException;
+
+    /**
+     * Handles {@link AndOperator}.
+     */
+    public abstract R handle(AndOperator operator) throws FilterParserException;
+
+    /**
+     * Handles {@link BracketOpenToken}.
+     */
+    public abstract R handle(BracketOpenToken token) throws FilterParserException;
+
+    /**
+     * Handles {@link BracketCloseToken}.
+     */
+    public abstract R handle(BracketCloseToken token) throws FilterParserException;
+}
diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/OperatorToken.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/OperatorToken.java
new file mode 100644 (file)
index 0000000..4c4d867
--- /dev/null
@@ -0,0 +1,31 @@
+/**
+ * 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.filter;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Abstraction for all operators.
+ * 
+ * @author Sönke Küper - initial contribution.
+ */
+@NonNullByDefault
+public abstract class OperatorToken extends FilterToken {
+
+    /**
+     * Creates an new {@link OperatorToken}.
+     */
+    public OperatorToken(int position) {
+        super(position);
+    }
+}
diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/OrOperator.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/OrOperator.java
new file mode 100644 (file)
index 0000000..14a18be
--- /dev/null
@@ -0,0 +1,41 @@
+/**
+ * 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.filter;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * A token representing an disjunction.
+ * 
+ * @author Sönke Küper - Initial contribution.
+ */
+@NonNullByDefault
+public final class OrOperator extends OperatorToken {
+
+    /**
+     * Creates new {@link OrOperator}.
+     */
+    public OrOperator(int position) {
+        super(position);
+    }
+
+    @Override
+    public String toString() {
+        return "|";
+    }
+
+    @Override
+    public <R> R accept(FilterTokenVisitor<R> visitor) throws FilterParserException {
+        return visitor.handle(this);
+    }
+}
diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/OrPredicate.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/OrPredicate.java
new file mode 100644 (file)
index 0000000..5224a96
--- /dev/null
@@ -0,0 +1,55 @@
+/**
+ * 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.filter;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.deutschebahn.internal.timetable.dto.TimetableStop;
+
+/**
+ * Disjunction for {@link TimetableStopPredicate}.
+ * 
+ * @author Sönke Küper - initial contribution
+ */
+@NonNullByDefault
+final class OrPredicate implements TimetableStopPredicate {
+
+    private final TimetableStopPredicate first;
+    private final TimetableStopPredicate second;
+
+    /**
+     * Creates an new {@link OrPredicate}.
+     */
+    public OrPredicate(TimetableStopPredicate first, TimetableStopPredicate second) {
+        this.first = first;
+        this.second = second;
+    }
+
+    @Override
+    public boolean test(TimetableStop t) {
+        return first.test(t) || second.test(t);
+    }
+
+    /**
+     * Returns first argument.
+     */
+    TimetableStopPredicate getFirst() {
+        return first;
+    }
+
+    /**
+     * Returns second argument.
+     */
+    TimetableStopPredicate getSecond() {
+        return second;
+    }
+}
diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/TimetableStopByStringEventAttributeFilter.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/TimetableStopByStringEventAttributeFilter.java
new file mode 100644 (file)
index 0000000..b3ab27f
--- /dev/null
@@ -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.filter;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.deutschebahn.internal.AttributeSelection;
+import org.openhab.binding.deutschebahn.internal.timetable.dto.TimetableStop;
+
+/**
+ * Abstract predicate that filters timetable stops by an selected attribute of an {@link TimetableStop}.
+ * 
+ * If value has multiple values (for example stations on the planned-path) the predicate will return <code>true</code>,
+ * if at least one value matches the given filter.
+ * 
+ * @author Sönke Küper - initial contribution
+ */
+@NonNullByDefault
+public final class TimetableStopByStringEventAttributeFilter implements TimetableStopPredicate {
+
+    private final AttributeSelection attributeSelection;
+    private final Pattern filter;
+
+    /**
+     * Creates an new {@link TimetableStopByStringEventAttributeFilter}.
+     */
+    TimetableStopByStringEventAttributeFilter(final AttributeSelection attributeSelection, final Pattern filter) {
+        this.attributeSelection = attributeSelection;
+        this.filter = filter;
+    }
+
+    @Override
+    public boolean test(TimetableStop t) {
+        final List<String> values = attributeSelection.getStringValues(t);
+
+        for (String actualValue : values) {
+            if (filter.matcher(actualValue).matches()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Returns the {@link AttributeSelection}.
+     */
+    final AttributeSelection getAttributeSelection() {
+        return attributeSelection;
+    }
+
+    /**
+     * Returns the filter pattern.
+     */
+    final Pattern getFilter() {
+        return filter;
+    }
+}
diff --git a/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/TimetableStopPredicate.java b/bundles/org.openhab.binding.deutschebahn/src/main/java/org/openhab/binding/deutschebahn/internal/filter/TimetableStopPredicate.java
new file mode 100644 (file)
index 0000000..c90a411
--- /dev/null
@@ -0,0 +1,28 @@
+/**
+ * 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.filter;
+
+import java.util.function.Predicate;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.deutschebahn.internal.timetable.dto.TimetableStop;
+
+/**
+ * Predicate to match an TimetableStop
+ * 
+ * @author Sönke Küper - initial contribution.
+ */
+@NonNullByDefault
+public interface TimetableStopPredicate extends Predicate<TimetableStop> {
+
+}
index 96d1cf38639dc84f360d8c104b998a5846a4cbfe..9efa6b3132a15ef732ac9af18754b6fd1b31601d 100644 (file)
@@ -31,7 +31,7 @@ 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.filter.TimetableStopPredicate;
 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;
@@ -60,7 +60,7 @@ public final class TimetableLoader {
     private final Map<String, TimetableStop> cachedChanges;
 
     private final TimetablesV1Api api;
-    private final TimetableStopFilter stopFilter;
+    private final TimetableStopPredicate stopPredicate;
     private final TimetableStopComparator comparator;
     private final Supplier<Date> currentTimeProvider;
     private int stopCount;
@@ -76,14 +76,15 @@ public final class TimetableLoader {
      * Creates an new {@link TimetableLoader}.
      *
      * @param api {@link TimetablesV1Api} to use.
-     * @param stopFilter Filter for selection of loaded {@link TimetableStop}.
+     * @param stopPredicate 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<Date> currentTimeProvider, final String evaNo, final int requestedStopCount) {
+    public TimetableLoader(final TimetablesV1Api api, final TimetableStopPredicate stopPredicate,
+            final EventType eventToSort, final Supplier<Date> currentTimeProvider, final String evaNo,
+            final int requestedStopCount) {
         this.api = api;
-        this.stopFilter = stopFilter;
+        this.stopPredicate = stopPredicate;
         this.currentTimeProvider = currentTimeProvider;
         this.evaNo = evaNo;
         this.stopCount = requestedStopCount;
@@ -206,7 +207,7 @@ public final class TimetableLoader {
             final List<TimetableStop> stops = timetable //
                     .getS() //
                     .stream() //
-                    .filter(this.stopFilter) //
+                    .filter(this.stopPredicate) //
                     .collect(Collectors.toList());
 
             // Merge the loaded stops with the cached changes and put them into the plan cache.
index d85a7c028ebac317425a0946a8ec40aa787d8ef1..18eebbe775a25aa873ecd929f78b794dd12cc5f6 100644 (file)
                                        <option value="departures">Departures</option>
                                </options>
                        </parameter>
+                       <parameter name="additionalFilter" type="text" required="false">
+                               <advanced>true</advanced>
+                               <label>Additional Filter</label>
+                               <description>Specifies additional filters for trains, that should be displayed within the timetable.</description>
+                       </parameter>
                </config-description>
        </bridge-type>
 
index 1f11a0891b56cd166d24e4aa87a94a1d26526a30..fa5929f4a202e4c79a066344c9ebfd3eb7d2d8ea 100644 (file)
@@ -18,6 +18,7 @@ import static org.hamcrest.Matchers.*;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.GregorianCalendar;
 import java.util.List;
 import java.util.function.Consumer;
@@ -218,24 +219,32 @@ public class EventAttributeTest {
     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);
+                (Event e) -> e.setPpth(SAMPLE_PATH),
+                Arrays.asList("Bielefeld Hbf", "Herford", "Löhne(Westf)", "Bad Oeynhausen", "Porta Westfalica",
+                        "Minden(Westf)", "Bückeburg", "Stadthagen", "Haste", "Wunstorf", "Hannover Hbf"),
+                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);
+                (Event e) -> e.setPpth(SAMPLE_PATH),
+                Arrays.asList("Herford", "Löhne(Westf)", "Bad Oeynhausen", "Porta Westfalica", "Minden(Westf)",
+                        "Bückeburg", "Stadthagen", "Haste", "Wunstorf", "Hannover Hbf", "Lehrte"),
+                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);
+                (Event e) -> e.setCpth(SAMPLE_PATH),
+                Arrays.asList("Bielefeld Hbf", "Herford", "Löhne(Westf)", "Bad Oeynhausen", "Porta Westfalica",
+                        "Minden(Westf)", "Bückeburg", "Stadthagen", "Haste", "Wunstorf", "Hannover Hbf"),
+                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);
+                (Event e) -> e.setCpth(SAMPLE_PATH),
+                Arrays.asList("Herford", "Löhne(Westf)", "Bad Oeynhausen", "Porta Westfalica", "Minden(Westf)",
+                        "Bückeburg", "Stadthagen", "Haste", "Wunstorf", "Hannover Hbf", "Lehrte"),
+                new StringType(expectedPrevious), EventType.ARRIVAL, false);
     }
 
     @Test
diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/filter/FilterParserTest.java b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/filter/FilterParserTest.java
new file mode 100644 (file)
index 0000000..813cc81
--- /dev/null
@@ -0,0 +1,284 @@
+/**
+ * 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.filter;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.*;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.deutschebahn.internal.AttributeSelection;
+import org.openhab.binding.deutschebahn.internal.EventAttribute;
+import org.openhab.binding.deutschebahn.internal.EventAttributeSelection;
+import org.openhab.binding.deutschebahn.internal.EventType;
+import org.openhab.binding.deutschebahn.internal.TripLabelAttribute;
+
+/**
+ * Tests for {@link FilterParser}
+ *
+ * @author Sönke Küper - Initial contribution.
+ */
+@NonNullByDefault
+public class FilterParserTest {
+
+    private static final class FilterTokenSequenceBuilder {
+
+        private final List<FilterToken> tokens = new ArrayList<>();
+        private int position = 0;
+
+        private int getPos() {
+            this.position++;
+            return this.position;
+        }
+
+        public List<FilterToken> build() {
+            return this.tokens;
+        }
+
+        public FilterTokenSequenceBuilder and() {
+            this.tokens.add(new AndOperator(getPos()));
+            return this;
+        }
+
+        public FilterTokenSequenceBuilder or() {
+            this.tokens.add(new OrOperator(getPos()));
+            return this;
+        }
+
+        public FilterTokenSequenceBuilder bracketOpen() {
+            this.tokens.add(new BracketOpenToken(getPos()));
+            return this;
+        }
+
+        public FilterTokenSequenceBuilder bracketClose() {
+            this.tokens.add(new BracketCloseToken(getPos()));
+            return this;
+        }
+
+        public ChannelNameEquals channelFilter(String channelGroup, String channelName, String pattern) {
+            ChannelNameEquals channelNameEquals = new ChannelNameEquals(getPos(), channelGroup, channelName,
+                    Pattern.compile(pattern));
+            this.tokens.add(channelNameEquals);
+            return channelNameEquals;
+        }
+
+        public FilterTokenSequenceBuilder channelFilter(ChannelNameEquals equals) {
+            this.tokens.add(equals);
+            return this;
+        }
+    }
+
+    private static FilterTokenSequenceBuilder builder() {
+        return new FilterTokenSequenceBuilder();
+    }
+
+    private static void checkAttributeFilter(TimetableStopPredicate predicate, ChannelNameEquals channelEquals,
+            EventType eventType, EventAttribute<?, ?> eventAttribute) {
+        checkAttributeFilter(predicate, channelEquals, new EventAttributeSelection(eventType, eventAttribute));
+    }
+
+    private static void checkAttributeFilter(TimetableStopPredicate predicate, ChannelNameEquals channelEquals,
+            AttributeSelection attributeSelection) {
+        assertThat(predicate, is(instanceOf(TimetableStopByStringEventAttributeFilter.class)));
+        TimetableStopByStringEventAttributeFilter attributeFilter = (TimetableStopByStringEventAttributeFilter) predicate;
+        assertThat(attributeFilter.getFilter(), is(channelEquals.getFilterValue()));
+        assertThat(attributeFilter.getAttributeSelection(), is(attributeSelection));
+    }
+
+    private static OrPredicate assertOr(TimetableStopPredicate predicate) {
+        assertThat(predicate, is(instanceOf(OrPredicate.class)));
+        return (OrPredicate) predicate;
+    }
+
+    private static AndPredicate assertAnd(TimetableStopPredicate predicate) {
+        assertThat(predicate, is(instanceOf(AndPredicate.class)));
+        return (AndPredicate) predicate;
+    }
+
+    @Test
+    public void testParseSimple() throws FilterParserException {
+        final List<FilterToken> input = new ArrayList<>();
+        ChannelNameEquals channelEquals = new ChannelNameEquals(1, "trip", "number", Pattern.compile("20"));
+        input.add(channelEquals);
+        final TimetableStopPredicate result = FilterParser.parse(input);
+        checkAttributeFilter(result, channelEquals, TripLabelAttribute.N);
+    }
+
+    @Test
+    public void testParseAnd() throws FilterParserException {
+        final FilterTokenSequenceBuilder b = builder();
+        final ChannelNameEquals channelEquals01 = b.channelFilter("trip", "number", "20");
+        b.and();
+        final ChannelNameEquals channelEquals02 = b.channelFilter("trip", "number", "30");
+        final TimetableStopPredicate result = FilterParser.parse(b.build());
+        final AndPredicate andPredicate = assertAnd(result);
+
+        checkAttributeFilter(andPredicate.getFirst(), channelEquals01, TripLabelAttribute.N);
+        checkAttributeFilter(andPredicate.getSecond(), channelEquals02, TripLabelAttribute.N);
+    }
+
+    @Test
+    public void testParseOr() throws FilterParserException {
+        final FilterTokenSequenceBuilder b = builder();
+        final ChannelNameEquals channelEquals01 = b.channelFilter("trip", "number", "20");
+        b.or();
+        final ChannelNameEquals channelEquals02 = b.channelFilter("trip", "number", "30");
+        final TimetableStopPredicate result = FilterParser.parse(b.build());
+        final OrPredicate orPredicate = assertOr(result);
+
+        checkAttributeFilter(orPredicate.getFirst(), channelEquals01, TripLabelAttribute.N);
+        checkAttributeFilter(orPredicate.getSecond(), channelEquals02, TripLabelAttribute.N);
+    }
+
+    @Test
+    public void testParseWithBrackets() throws FilterParserException {
+        final FilterTokenSequenceBuilder b = new FilterTokenSequenceBuilder();
+        final ChannelNameEquals channelEquals01 = b.channelFilter("trip", "number", "20");
+        b.and();
+        b.bracketOpen();
+        final ChannelNameEquals channelEquals02 = b.channelFilter("departure", "line", "RE10");
+        b.or();
+        final ChannelNameEquals channelEquals03 = b.channelFilter("departure", "line", "RE20");
+        b.bracketClose();
+        final List<FilterToken> input = b.build();
+
+        final TimetableStopPredicate result = FilterParser.parse(input);
+        final AndPredicate andPredicate = assertAnd(result);
+
+        checkAttributeFilter(andPredicate.getFirst(), channelEquals01, TripLabelAttribute.N);
+        final OrPredicate orPredicate = assertOr(andPredicate.getSecond());
+
+        checkAttributeFilter(orPredicate.getFirst(), channelEquals02, EventType.DEPARTURE, EventAttribute.L);
+        checkAttributeFilter(orPredicate.getSecond(), channelEquals03, EventType.DEPARTURE, EventAttribute.L);
+    }
+
+    @Test
+    public void testParseWithMultipleBrackets() throws FilterParserException {
+        final FilterTokenSequenceBuilder b = builder();
+        b.bracketOpen();
+        b.bracketOpen();
+        final ChannelNameEquals channelEquals01 = b.channelFilter("trip", "number", "20");
+        b.and();
+        final ChannelNameEquals channelEquals02 = b.channelFilter("departure", "line", "RE22");
+        b.bracketClose();
+        b.or();
+        b.bracketOpen();
+        final ChannelNameEquals channelEquals03 = b.channelFilter("trip", "number", "30");
+        b.and();
+        final ChannelNameEquals channelEquals04 = b.channelFilter("departure", "line", "RE33");
+        b.bracketClose();
+        b.bracketClose();
+
+        final List<FilterToken> input = b.build();
+
+        final TimetableStopPredicate result = FilterParser.parse(input);
+        final OrPredicate orPredicate = assertOr(result);
+
+        final AndPredicate firstAnd = assertAnd(orPredicate.getFirst());
+        checkAttributeFilter(firstAnd.getFirst(), channelEquals01, TripLabelAttribute.N);
+        checkAttributeFilter(firstAnd.getSecond(), channelEquals02, EventType.DEPARTURE, EventAttribute.L);
+
+        final AndPredicate secondAnd = assertAnd(orPredicate.getSecond());
+        checkAttributeFilter(secondAnd.getFirst(), channelEquals03, TripLabelAttribute.N);
+        checkAttributeFilter(secondAnd.getSecond(), channelEquals04, EventType.DEPARTURE, EventAttribute.L);
+    }
+
+    @Test
+    public void testParseErrors() {
+        final ChannelNameEquals channelEquals = new ChannelNameEquals(1, "trip", "number", Pattern.compile("20"));
+        try {
+            FilterParser.parse(Collections.emptyList());
+            fail();
+        } catch (FilterParserException e) {
+        }
+
+        try {
+            FilterParser.parse(builder().and().build());
+            fail();
+        } catch (FilterParserException e) {
+        }
+
+        try {
+            FilterParser.parse(builder().or().build());
+            fail();
+        } catch (FilterParserException e) {
+        }
+        try {
+            FilterParser.parse(builder().bracketOpen().build());
+            fail();
+        } catch (FilterParserException e) {
+        }
+        try {
+            FilterParser.parse(builder().bracketClose().build());
+            fail();
+        } catch (FilterParserException e) {
+        }
+        try {
+            FilterParser.parse(builder().bracketOpen().bracketClose().build());
+            fail();
+        } catch (FilterParserException e) {
+        }
+        try {
+            FilterParser.parse(builder().bracketOpen().and().build());
+            fail();
+        } catch (FilterParserException e) {
+        }
+        try {
+            FilterParser.parse(builder().bracketOpen().and().build());
+            fail();
+        } catch (FilterParserException e) {
+        }
+        try {
+            FilterParser.parse(builder().channelFilter(channelEquals).and().bracketOpen().build());
+            fail();
+        } catch (FilterParserException e) {
+        }
+        try {
+            FilterParser.parse(builder().channelFilter(channelEquals).and().bracketClose().build());
+            fail();
+        } catch (FilterParserException e) {
+        }
+        try {
+            FilterParser.parse(builder().channelFilter(channelEquals).or().bracketOpen().build());
+            fail();
+        } catch (FilterParserException e) {
+        }
+        try {
+            FilterParser.parse(builder().channelFilter(channelEquals).or().bracketClose().build());
+            fail();
+        } catch (FilterParserException e) {
+        }
+        try {
+            FilterParser.parse(builder().channelFilter(channelEquals).and().build());
+            fail();
+        } catch (FilterParserException e) {
+        }
+        try {
+            FilterParser.parse(builder().channelFilter(channelEquals).or().build());
+            fail();
+        } catch (FilterParserException e) {
+        }
+        try {
+            FilterParser.parse(Arrays.asList(channelEquals, channelEquals));
+            fail();
+        } catch (FilterParserException e) {
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/filter/FilterScannerTest.java b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/filter/FilterScannerTest.java
new file mode 100644 (file)
index 0000000..68fc3fb
--- /dev/null
@@ -0,0 +1,114 @@
+/**
+ * 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.filter;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.*;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link FilterScanner}
+ * 
+ * @author Sönke Küper - Initial contribution.
+ */
+@NonNullByDefault
+public class FilterScannerTest {
+
+    private static void assertAttributeEquals(FilterToken token, String expectedChannelGroup,
+            String expectedChannelName, String expectedFilter, int expectedPosition) {
+        assertThat(token, is(instanceOf(ChannelNameEquals.class)));
+        ChannelNameEquals actual = (ChannelNameEquals) token;
+        assertThat(actual.getChannelGroup(), is(expectedChannelGroup));
+        assertThat(actual.getChannelName(), is(expectedChannelName));
+        assertThat(actual.getFilterValue().toString(), is(expectedFilter));
+        assertThat(actual.getPosition(), is(expectedPosition));
+    }
+
+    private static void assertOperator(FilterToken token, OperatorToken expected) {
+        assertThat(token.getClass(), is(expected.getClass()));
+        assertThat(token.getPosition(), is(expected.getPosition()));
+    }
+
+    private static List<FilterToken> processInput(String input, int expectedCount) throws FilterScannerException {
+        final List<FilterToken> tokens = new FilterScanner().processInput(input);
+        assertThat(tokens, hasSize(expectedCount));
+        return tokens;
+    }
+
+    @Test
+    public void testSimpleAttributEquals() throws FilterScannerException {
+        String input = "trip#number=\"20\"";
+        List<FilterToken> tokens = processInput(input, 1);
+        assertAttributeEquals(tokens.get(0), "trip", "number", "20", 1);
+    }
+
+    @Test
+    public void testAttributeEqualsWithWhitespace() throws FilterScannerException {
+        String input = "departure#planned-path=\"Hannover Hbf\"";
+        List<FilterToken> tokens = processInput(input, 1);
+        assertAttributeEquals(tokens.get(0), "departure", "planned-path", "Hannover Hbf", 1);
+    }
+
+    @Test
+    public void testInvalidAttributEquals() {
+        try {
+            new FilterScanner().processInput("trip#number=20");
+            fail();
+        } catch (FilterScannerException e) {
+        }
+
+        try {
+            new FilterScanner().processInput("trip#number");
+            fail();
+        } catch (FilterScannerException e) {
+        }
+
+        try {
+            new FilterScanner().processInput("trip#number=");
+            fail();
+        } catch (FilterScannerException e) {
+        }
+
+        try {
+            new FilterScanner().processInput("=abc");
+            fail();
+        } catch (FilterScannerException e) {
+        }
+
+        try {
+            new FilterScanner().processInput("train#number=\"abc\"");
+            fail();
+        } catch (FilterScannerException e) {
+        }
+    }
+
+    @Test
+    public void testComplexExample() throws FilterScannerException {
+        String input = "trip#category=\"RE\" & (departure#line=\"17\" | departure#line=\"57\") & departure#planned-path=\"Cologne\"";
+        List<FilterToken> tokens = processInput(input, 9);
+        assertAttributeEquals(tokens.get(0), "trip", "category", "RE", 1);
+        assertOperator(tokens.get(1), new AndOperator(20));
+        assertOperator(tokens.get(2), new BracketOpenToken(22));
+        assertAttributeEquals(tokens.get(3), "departure", "line", "17", 23);
+        assertOperator(tokens.get(4), new OrOperator(43));
+        assertAttributeEquals(tokens.get(5), "departure", "line", "57", 45);
+        assertOperator(tokens.get(6), new BracketCloseToken(64));
+        assertOperator(tokens.get(7), new AndOperator(66));
+        assertAttributeEquals(tokens.get(8), "departure", "planned-path", "Cologne", 68);
+    }
+}
diff --git a/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/filter/TimetableByStringEventAttributeFilterTest.java b/bundles/org.openhab.binding.deutschebahn/src/test/java/org/openhab/binding/deutschebahn/internal/filter/TimetableByStringEventAttributeFilterTest.java
new file mode 100644 (file)
index 0000000..3a799be
--- /dev/null
@@ -0,0 +1,111 @@
+/**
+ * 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.filter;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.regex.Pattern;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.deutschebahn.internal.EventAttribute;
+import org.openhab.binding.deutschebahn.internal.EventAttributeSelection;
+import org.openhab.binding.deutschebahn.internal.EventType;
+import org.openhab.binding.deutschebahn.internal.TripLabelAttribute;
+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;
+
+/**
+ * Tests for {@link TimetableStopByStringEventAttributeFilter}
+ * 
+ * @author Sönke Küper - Initial contribution.
+ */
+@NonNullByDefault
+public final class TimetableByStringEventAttributeFilterTest {
+
+    @Test
+    public void testFilterTripLabelAttribute() {
+        final TimetableStopByStringEventAttributeFilter filter = new TimetableStopByStringEventAttributeFilter(
+                TripLabelAttribute.C, Pattern.compile("IC.*"));
+        final TimetableStop stop = new TimetableStop();
+
+        // TripLabel is not set -> does not match
+        assertFalse(filter.test(stop));
+
+        final TripLabel label = new TripLabel();
+        stop.setTl(label);
+
+        // Attribute is not set -> does not match
+        assertFalse(filter.test(stop));
+
+        // Set attribute -> matches depending on value
+        label.setC("RE");
+        assertFalse(filter.test(stop));
+        label.setC("ICE");
+        assertTrue(filter.test(stop));
+        label.setC("IC");
+        assertTrue(filter.test(stop));
+    }
+
+    @Test
+    public void testFilterEventAttribute() {
+        final EventAttributeSelection eventAttribute = new EventAttributeSelection(EventType.DEPARTURE,
+                EventAttribute.L);
+        final TimetableStopByStringEventAttributeFilter filter = new TimetableStopByStringEventAttributeFilter(
+                eventAttribute, Pattern.compile("RE.*"));
+        final TimetableStop stop = new TimetableStop();
+
+        // Event is not set -> does not match
+        assertFalse(filter.test(stop));
+
+        Event event = new Event();
+        stop.setDp(event);
+
+        // Attribute is not set -> does not match
+        assertFalse(filter.test(stop));
+
+        // Set attribute -> matches depending on value
+        event.setL("S5");
+        assertFalse(filter.test(stop));
+        event.setL("5");
+        assertFalse(filter.test(stop));
+        event.setL("RE60");
+        assertTrue(filter.test(stop));
+
+        // Set wrong event
+        stop.setAr(event);
+        stop.setDp(null);
+        assertFalse(filter.test(stop));
+    }
+
+    @Test
+    public void testFilterEventAttributeList() {
+        final EventAttributeSelection eventAttribute = new EventAttributeSelection(EventType.DEPARTURE,
+                EventAttribute.PPTH);
+        final TimetableStopByStringEventAttributeFilter filter = new TimetableStopByStringEventAttributeFilter(
+                eventAttribute, Pattern.compile("Hannover.*"));
+        final TimetableStop stop = new TimetableStop();
+        Event event = new Event();
+        stop.setDp(event);
+
+        event.setPpth("Hannover Hbf|Hannover-Kleefeld|Hannover Karl-Wiechert-Allee|Hannover Anderten-Misburg|Ahlten");
+        assertTrue(filter.test(stop));
+        event.setPpth(
+                "Ahlten|Hannover Hbf|Hannover-Kleefeld|Hannover Karl-Wiechert-Allee|Hannover Anderten-Misburg|Ahlten");
+        assertTrue(filter.test(stop));
+        event.setPpth(
+                "Wolfsburg Hbf|Fallersleben|Calberlah|Gifhorn|Leiferde(b Gifhorn)|Meinersen|Dedenhausen|Dollbergen|Immensen-Arpke");
+        assertFalse(filter.test(stop));
+    }
+}