]> git.basschouten.com Git - openhab-addons.git/blob
c5c4ecef1a7946d21c0a1284003168e4704d4e00
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.filter.TimetableStopPredicate;
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 a {@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 TimetableStopPredicate stopPredicate;
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 a new {@link TimetableLoader}.
77      *
78      * @param api {@link TimetablesV1Api} to use.
79      * @param stopPredicate 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 TimetableStopPredicate stopPredicate,
84             final EventType eventToSort, final Supplier<Date> currentTimeProvider, final String evaNo,
85             final int requestedStopCount) {
86         this.api = api;
87         this.stopPredicate = stopPredicate;
88         this.currentTimeProvider = currentTimeProvider;
89         this.evaNo = evaNo;
90         this.stopCount = requestedStopCount;
91         this.comparator = new TimetableStopComparator(eventToSort);
92         this.cachedStopsPerId = new HashMap<>();
93         this.cachedChanges = new HashMap<>();
94         this.lastRequestedChanges = null;
95         this.lastRequestedPlan = null;
96     }
97
98     /**
99      * Sets the count of needed {@link TimetableStop} that is required at each call of {@link #getTimetableStops()}.
100      */
101     public void setStopCount(int stopCount) {
102         this.stopCount = stopCount;
103     }
104
105     /**
106      * Updates the cache with current data from plan and changes and returns the {@link TimetableStop}.
107      */
108     public List<TimetableStop> getTimetableStops() throws IOException {
109         this.updateCache();
110         final List<TimetableStop> result = new ArrayList<>(this.cachedStopsPerId.values());
111         Collections.sort(result, this.comparator);
112         return result;
113     }
114
115     /**
116      * Updates the cached {@link TimetableStop} to ensure that the requested amount of stops is available.
117      */
118     private void updateCache() throws IOException {
119         final Date currentTime = this.currentTimeProvider.get();
120
121         // First update the changes. This will merge them into the existing plan data
122         // or cache them, if no corresponding stop is available.
123         this.updateChanges(currentTime);
124
125         // Remove all stops that are in the past
126         this.removeOldStops(currentTime);
127
128         // Finally fill up plan until required amount of data is available.
129         this.updatePlan(currentTime);
130     }
131
132     /**
133      * Removes all stops from the cache with planned and changed time after the current time.
134      */
135     private void removeOldStops(final Date currentTime) {
136         final Iterator<Entry<String, TimetableStop>> it = this.cachedStopsPerId.entrySet().iterator();
137         while (it.hasNext()) {
138             final Entry<String, TimetableStop> currentEntry = it.next();
139             final TimetableStop stop = currentEntry.getValue();
140
141             // Remove entry if planned and changed time are in the past
142             if (isInPast(stop, currentTime)) {
143                 it.remove();
144             }
145         }
146     }
147
148     /**
149      * Returns <code>true</code> if the planned and changed time from arrival and departure are in the past.
150      */
151     private static boolean isInPast(TimetableStop stop, Date currentTime) {
152         return isBefore(EventAttribute.PT, stop.getAr(), currentTime) //
153                 && isBefore(EventAttribute.CT, stop.getAr(), currentTime) //
154                 && isBefore(EventAttribute.PT, stop.getDp(), currentTime) //
155                 && isBefore(EventAttribute.PT, stop.getDp(), currentTime);
156     }
157
158     /**
159      * Checks if the value of the given {@link EventAttribute} is either <code>null</code> or before
160      * the given compareTime.
161      * If the {@link Event} is <code>null</code> it will return <code>true</code>.
162      */
163     private static boolean isBefore( //
164             final EventAttribute<Date, DateTimeType> attribute, //
165             final @Nullable Event event, //
166             final Date toCompare) {
167         if (event == null) {
168             return true;
169         }
170         final Date value = attribute.getValue(event);
171         if (value == null) {
172             return true;
173         } else {
174             return value.before(toCompare);
175         }
176     }
177
178     /**
179      * Checks if enough plan entries are available and loads them from the backing {@link TimetablesV1Api} if required.
180      */
181     private void updatePlan(final Date currentTime) throws IOException {
182         // If enough stops are available in cache do nothing.
183         if (this.cachedStopsPerId.size() >= this.stopCount) {
184             return;
185         }
186
187         // start requesting at last request time.
188         final GregorianCalendar requestTime = new GregorianCalendar();
189         if (this.lastRequestedPlan != null) {
190             requestTime.setTime(this.lastRequestedPlan);
191             requestTime.set(Calendar.HOUR_OF_DAY, requestTime.get(Calendar.HOUR_OF_DAY) + 1);
192         } else {
193             requestTime.setTime(currentTime);
194         }
195
196         // Determine the max. time for which a plan is available
197         final GregorianCalendar maxRequestTime = new GregorianCalendar();
198         maxRequestTime.setTime(currentTime);
199         maxRequestTime.set(Calendar.HOUR_OF_DAY, maxRequestTime.get(Calendar.HOUR_OF_DAY) + MAX_ADVANCE_HOUR);
200
201         // load until required amount of stops is present or no more data is available.
202         while ((this.cachedStopsPerId.size() < this.stopCount) && requestTime.before(maxRequestTime)) {
203             final Timetable timetable = this.api.getPlan(this.evaNo, requestTime.getTime());
204             this.lastRequestedPlan = requestTime.getTime();
205
206             // Filter only stops that are selected by given filter
207             final List<TimetableStop> stops = timetable //
208                     .getS() //
209                     .stream() //
210                     .filter(this.stopPredicate) //
211                     .collect(Collectors.toList());
212
213             // Merge the loaded stops with the cached changes and put them into the plan cache.
214             this.processLoadedPlan(stops, currentTime);
215
216             // Move request time one hour ahead.
217             requestTime.set(Calendar.HOUR_OF_DAY, requestTime.get(Calendar.HOUR_OF_DAY) + 1);
218         }
219     }
220
221     /**
222      * Merges the loaded plan stops with the previously cached changes.
223      * The result will be cached as plan data, if not in the past.
224      */
225     private void processLoadedPlan(List<TimetableStop> stops, Date currentTime) {
226         for (final TimetableStop stop : stops) {
227
228             // Check if a change for the stop was cached and apply it
229             final TimetableStop change = this.cachedChanges.remove(stop.getId());
230             if (change != null) {
231                 TimetableStopMerger.merge(stop, change);
232             }
233
234             // Check if stop is in past after applying changes and put
235             // into cached plan if not.
236             if (!isInPast(stop, currentTime)) {
237                 this.cachedStopsPerId.put(stop.getId(), stop);
238             }
239         }
240     }
241
242     /**
243      * Loads the changes from the api and merges them into the cached plan entries.
244      */
245     private void updateChanges(final Date currentTime) throws IOException {
246         final List<TimetableStop> changes = this.loadChanges(currentTime);
247         this.processChanges(changes);
248     }
249
250     /**
251      * Merges the given {@link TimetableStop} into the cached plan.
252      * If no stop in the plan for the change exist it will be put into the changes cache.
253      */
254     private void processChanges(final List<TimetableStop> changes) {
255         for (final TimetableStop change : changes) {
256
257             final TimetableStop existingEntry = this.cachedStopsPerId.get(change.getId());
258             if (existingEntry != null) {
259                 TimetableStopMerger.merge(existingEntry, change);
260             } else {
261                 this.cachedChanges.put(change.getId(), change);
262             }
263         }
264     }
265
266     /**
267      * Loads the full or recent changes depending on last request time.
268      */
269     private List<TimetableStop> loadChanges(final Date currentTime) throws IOException {
270         boolean fullChanges = false;
271         final long secondsSinceLastUpdate = this.getSecondsSinceLastRequestedChanges(currentTime);
272
273         // The recent changes are updated every 30 seconds, so if last update is less than 30 seconds do nothing.
274         if (secondsSinceLastUpdate < MIN_RECENT_CHANGE_INTERVAL) {
275             return Collections.emptyList();
276         }
277
278         // The recent changes are only available for 120 seconds, so if last update is older perform a full update.
279         if (secondsSinceLastUpdate >= MAX_RECENT_CHANGE_UPDATE) {
280             fullChanges = true;
281         }
282
283         Timetable changes;
284         if (fullChanges) {
285             changes = this.api.getFullChanges(this.evaNo);
286         } else {
287             changes = this.api.getRecentChanges(this.evaNo);
288         }
289         this.lastRequestedChanges = currentTime;
290         return changes.getS();
291     }
292
293     @SuppressWarnings("null")
294     private long getSecondsSinceLastRequestedChanges(final Date currentTime) {
295         if (this.lastRequestedChanges == null) {
296             return Long.MAX_VALUE;
297         } else {
298             return ChronoUnit.SECONDS.between(this.lastRequestedChanges.toInstant(), currentTime.toInstant());
299         }
300     }
301 }