]> git.basschouten.com Git - openhab-addons.git/blob
96d1cf38639dc84f360d8c104b998a5846a4cbfe
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.deutschebahn.internal.timetable;
14
15 import java.io.IOException;
16 import java.time.temporal.ChronoUnit;
17 import java.util.ArrayList;
18 import java.util.Calendar;
19 import java.util.Collections;
20 import java.util.Date;
21 import java.util.GregorianCalendar;
22 import java.util.HashMap;
23 import java.util.Iterator;
24 import java.util.List;
25 import java.util.Map;
26 import java.util.Map.Entry;
27 import java.util.function.Supplier;
28 import java.util.stream.Collectors;
29
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.openhab.binding.deutschebahn.internal.EventAttribute;
33 import org.openhab.binding.deutschebahn.internal.EventType;
34 import org.openhab.binding.deutschebahn.internal.TimetableStopFilter;
35 import org.openhab.binding.deutschebahn.internal.timetable.dto.Event;
36 import org.openhab.binding.deutschebahn.internal.timetable.dto.Timetable;
37 import org.openhab.binding.deutschebahn.internal.timetable.dto.TimetableStop;
38 import org.openhab.core.library.types.DateTimeType;
39
40 /**
41  * Helper for loading the required amount of {@link TimetableStop} via an {@link TimetablesV1Api}.
42  * This consists of a series of calls.
43  *
44  * @author Sönke Küper - initial contribution
45  */
46 @NonNullByDefault
47 public final class TimetableLoader {
48
49     // The api provides at most 18 hours in advance.
50     private static final int MAX_ADVANCE_HOUR = 18;
51
52     // The recent changes only contains all changes done within the last 2 minutes.
53     private static final int MAX_RECENT_CHANGE_UPDATE = 120;
54
55     // The min. request interval for recent changes is 30 seconds.
56     private static final int MIN_RECENT_CHANGE_INTERVAL = 30;
57
58     // Cache containing the TimetableStops per ID
59     private final Map<String, TimetableStop> cachedStopsPerId;
60     private final Map<String, TimetableStop> cachedChanges;
61
62     private final TimetablesV1Api api;
63     private final TimetableStopFilter stopFilter;
64     private final TimetableStopComparator comparator;
65     private final Supplier<Date> currentTimeProvider;
66     private int stopCount;
67
68     private final String evaNo;
69
70     @Nullable
71     private Date lastRequestedPlan;
72     @Nullable
73     private Date lastRequestedChanges;
74
75     /**
76      * Creates an new {@link TimetableLoader}.
77      *
78      * @param api {@link TimetablesV1Api} to use.
79      * @param stopFilter Filter for selection of loaded {@link TimetableStop}.
80      * @param requestedStopCount Count of stops to be loaded on each call.
81      * @param currentTimeProvider {@link Supplier} for the current time.
82      */
83     public TimetableLoader(final TimetablesV1Api api, final TimetableStopFilter stopFilter, final EventType eventToSort,
84             final Supplier<Date> currentTimeProvider, final String evaNo, final int requestedStopCount) {
85         this.api = api;
86         this.stopFilter = stopFilter;
87         this.currentTimeProvider = currentTimeProvider;
88         this.evaNo = evaNo;
89         this.stopCount = requestedStopCount;
90         this.comparator = new TimetableStopComparator(eventToSort);
91         this.cachedStopsPerId = new HashMap<>();
92         this.cachedChanges = new HashMap<>();
93         this.lastRequestedChanges = null;
94         this.lastRequestedPlan = null;
95     }
96
97     /**
98      * Sets the count of needed {@link TimetableStop} that is required at each call of {@link #getTimetableStops()}.
99      */
100     public void setStopCount(int stopCount) {
101         this.stopCount = stopCount;
102     }
103
104     /**
105      * Updates the cache with current data from plan and changes and returns the {@link TimetableStop}.
106      */
107     public List<TimetableStop> getTimetableStops() throws IOException {
108         this.updateCache();
109         final List<TimetableStop> result = new ArrayList<>(this.cachedStopsPerId.values());
110         Collections.sort(result, this.comparator);
111         return result;
112     }
113
114     /**
115      * Updates the cached {@link TimetableStop} to ensure that the requested amount of stops is available.
116      */
117     private void updateCache() throws IOException {
118         final Date currentTime = this.currentTimeProvider.get();
119
120         // First update the changes. This will merge them into the existing plan data
121         // or cache them, if no corresponding stop is available.
122         this.updateChanges(currentTime);
123
124         // Remove all stops that are in the past
125         this.removeOldStops(currentTime);
126
127         // Finally fill up plan until required amount of data is available.
128         this.updatePlan(currentTime);
129     }
130
131     /**
132      * Removes all stops from the cache with planned and changed time after the current time.
133      */
134     private void removeOldStops(final Date currentTime) {
135         final Iterator<Entry<String, TimetableStop>> it = this.cachedStopsPerId.entrySet().iterator();
136         while (it.hasNext()) {
137             final Entry<String, TimetableStop> currentEntry = it.next();
138             final TimetableStop stop = currentEntry.getValue();
139
140             // Remove entry if planned and changed time are in the past
141             if (isInPast(stop, currentTime)) {
142                 it.remove();
143             }
144         }
145     }
146
147     /**
148      * Returns <code>true</code> if the planned and changed time from arrival and departure are in the past.
149      */
150     private static boolean isInPast(TimetableStop stop, Date currentTime) {
151         return isBefore(EventAttribute.PT, stop.getAr(), currentTime) //
152                 && isBefore(EventAttribute.CT, stop.getAr(), currentTime) //
153                 && isBefore(EventAttribute.PT, stop.getDp(), currentTime) //
154                 && isBefore(EventAttribute.PT, stop.getDp(), currentTime);
155     }
156
157     /**
158      * Checks if the value of the given {@link EventAttribute} is either <code>null</code> or before
159      * the given compareTime.
160      * If the {@link Event} is <code>null</code> it will return <code>true</code>.
161      */
162     private static boolean isBefore( //
163             final EventAttribute<Date, DateTimeType> attribute, //
164             final @Nullable Event event, //
165             final Date toCompare) {
166         if (event == null) {
167             return true;
168         }
169         final Date value = attribute.getValue(event);
170         if (value == null) {
171             return true;
172         } else {
173             return value.before(toCompare);
174         }
175     }
176
177     /**
178      * Checks if enough plan entries are available and loads them from the backing {@link TimetablesV1Api} if required.
179      */
180     private void updatePlan(final Date currentTime) throws IOException {
181         // If enough stops are available in cache do nothing.
182         if (this.cachedStopsPerId.size() >= this.stopCount) {
183             return;
184         }
185
186         // start requesting at last request time.
187         final GregorianCalendar requestTime = new GregorianCalendar();
188         if (this.lastRequestedPlan != null) {
189             requestTime.setTime(this.lastRequestedPlan);
190             requestTime.set(Calendar.HOUR_OF_DAY, requestTime.get(Calendar.HOUR_OF_DAY) + 1);
191         } else {
192             requestTime.setTime(currentTime);
193         }
194
195         // Determine the max. time for which an plan is available
196         final GregorianCalendar maxRequestTime = new GregorianCalendar();
197         maxRequestTime.setTime(currentTime);
198         maxRequestTime.set(Calendar.HOUR_OF_DAY, maxRequestTime.get(Calendar.HOUR_OF_DAY) + MAX_ADVANCE_HOUR);
199
200         // load until required amount of stops is present or no more data is available.
201         while ((this.cachedStopsPerId.size() < this.stopCount) && requestTime.before(maxRequestTime)) {
202             final Timetable timetable = this.api.getPlan(this.evaNo, requestTime.getTime());
203             this.lastRequestedPlan = requestTime.getTime();
204
205             // Filter only stops that are selected by given filter
206             final List<TimetableStop> stops = timetable //
207                     .getS() //
208                     .stream() //
209                     .filter(this.stopFilter) //
210                     .collect(Collectors.toList());
211
212             // Merge the loaded stops with the cached changes and put them into the plan cache.
213             this.processLoadedPlan(stops, currentTime);
214
215             // Move request time one hour ahead.
216             requestTime.set(Calendar.HOUR_OF_DAY, requestTime.get(Calendar.HOUR_OF_DAY) + 1);
217         }
218     }
219
220     /**
221      * Merges the loaded plan stops with the previously cached changes.
222      * The result will be cached as plan data, if not in the past.
223      */
224     private void processLoadedPlan(List<TimetableStop> stops, Date currentTime) {
225         for (final TimetableStop stop : stops) {
226
227             // Check if an change for the stop was cached and apply it
228             final TimetableStop change = this.cachedChanges.remove(stop.getId());
229             if (change != null) {
230                 TimetableStopMerger.merge(stop, change);
231             }
232
233             // Check if stop is in past after applying changes and put
234             // into cached plan if not.
235             if (!isInPast(stop, currentTime)) {
236                 this.cachedStopsPerId.put(stop.getId(), stop);
237             }
238         }
239     }
240
241     /**
242      * Loads the changes from the api and merges them into the cached plan entries.
243      */
244     private void updateChanges(final Date currentTime) throws IOException {
245         final List<TimetableStop> changes = this.loadChanges(currentTime);
246         this.processChanges(changes);
247     }
248
249     /**
250      * Merges the given {@link TimetableStop} into the cached plan.
251      * If no stop in the plan for the change exist it will be put into the changes cache.
252      */
253     private void processChanges(final List<TimetableStop> changes) {
254         for (final TimetableStop change : changes) {
255
256             final TimetableStop existingEntry = this.cachedStopsPerId.get(change.getId());
257             if (existingEntry != null) {
258                 TimetableStopMerger.merge(existingEntry, change);
259             } else {
260                 this.cachedChanges.put(change.getId(), change);
261             }
262         }
263     }
264
265     /**
266      * Loads the full or recent changes depending on last request time.
267      */
268     private List<TimetableStop> loadChanges(final Date currentTime) throws IOException {
269         boolean fullChanges = false;
270         final long secondsSinceLastUpdate = this.getSecondsSinceLastRequestedChanges(currentTime);
271
272         // The recent changes are updated every 30 seconds, so if last update is less than 30 seconds do nothing.
273         if (secondsSinceLastUpdate < MIN_RECENT_CHANGE_INTERVAL) {
274             return Collections.emptyList();
275         }
276
277         // The recent changes are only available for 120 seconds, so if last update is older perform an full update.
278         if (secondsSinceLastUpdate >= MAX_RECENT_CHANGE_UPDATE) {
279             fullChanges = true;
280         }
281
282         Timetable changes;
283         if (fullChanges) {
284             changes = this.api.getFullChanges(this.evaNo);
285         } else {
286             changes = this.api.getRecentChanges(this.evaNo);
287         }
288         this.lastRequestedChanges = currentTime;
289         return changes.getS();
290     }
291
292     @SuppressWarnings("null")
293     private long getSecondsSinceLastRequestedChanges(final Date currentTime) {
294         if (this.lastRequestedChanges == null) {
295             return Long.MAX_VALUE;
296         } else {
297             return ChronoUnit.SECONDS.between(this.lastRequestedChanges.toInstant(), currentTime.toInstant());
298         }
299     }
300 }