2 * Copyright (c) 2010-2021 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.deutschebahn.internal.timetable;
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;
26 import java.util.Map.Entry;
27 import java.util.function.Supplier;
28 import java.util.stream.Collectors;
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;
41 * Helper for loading the required amount of {@link TimetableStop} via an {@link TimetablesV1Api}.
42 * This consists of a series of calls.
44 * @author Sönke Küper - initial contribution
47 public final class TimetableLoader {
49 // The api provides at most 18 hours in advance.
50 private static final int MAX_ADVANCE_HOUR = 18;
52 // The recent changes only contains all changes done within the last 2 minutes.
53 private static final int MAX_RECENT_CHANGE_UPDATE = 120;
55 // The min. request interval for recent changes is 30 seconds.
56 private static final int MIN_RECENT_CHANGE_INTERVAL = 30;
58 // Cache containing the TimetableStops per ID
59 private final Map<String, TimetableStop> cachedStopsPerId;
60 private final Map<String, TimetableStop> cachedChanges;
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;
68 private final String evaNo;
71 private Date lastRequestedPlan;
73 private Date lastRequestedChanges;
76 * Creates an new {@link TimetableLoader}.
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.
83 public TimetableLoader(final TimetablesV1Api api, final TimetableStopFilter stopFilter, final EventType eventToSort,
84 final Supplier<Date> currentTimeProvider, final String evaNo, final int requestedStopCount) {
86 this.stopFilter = stopFilter;
87 this.currentTimeProvider = currentTimeProvider;
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;
98 * Sets the count of needed {@link TimetableStop} that is required at each call of {@link #getTimetableStops()}.
100 public void setStopCount(int stopCount) {
101 this.stopCount = stopCount;
105 * Updates the cache with current data from plan and changes and returns the {@link TimetableStop}.
107 public List<TimetableStop> getTimetableStops() throws IOException {
109 final List<TimetableStop> result = new ArrayList<>(this.cachedStopsPerId.values());
110 Collections.sort(result, this.comparator);
115 * Updates the cached {@link TimetableStop} to ensure that the requested amount of stops is available.
117 private void updateCache() throws IOException {
118 final Date currentTime = this.currentTimeProvider.get();
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);
124 // Remove all stops that are in the past
125 this.removeOldStops(currentTime);
127 // Finally fill up plan until required amount of data is available.
128 this.updatePlan(currentTime);
132 * Removes all stops from the cache with planned and changed time after the current time.
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();
140 // Remove entry if planned and changed time are in the past
141 if (isInPast(stop, currentTime)) {
148 * Returns <code>true</code> if the planned and changed time from arrival and departure are in the past.
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);
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>.
162 private static boolean isBefore( //
163 final EventAttribute<Date, DateTimeType> attribute, //
164 final @Nullable Event event, //
165 final Date toCompare) {
169 final Date value = attribute.getValue(event);
173 return value.before(toCompare);
178 * Checks if enough plan entries are available and loads them from the backing {@link TimetablesV1Api} if required.
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) {
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);
192 requestTime.setTime(currentTime);
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);
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();
205 // Filter only stops that are selected by given filter
206 final List<TimetableStop> stops = timetable //
209 .filter(this.stopFilter) //
210 .collect(Collectors.toList());
212 // Merge the loaded stops with the cached changes and put them into the plan cache.
213 this.processLoadedPlan(stops, currentTime);
215 // Move request time one hour ahead.
216 requestTime.set(Calendar.HOUR_OF_DAY, requestTime.get(Calendar.HOUR_OF_DAY) + 1);
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.
224 private void processLoadedPlan(List<TimetableStop> stops, Date currentTime) {
225 for (final TimetableStop stop : stops) {
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);
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);
242 * Loads the changes from the api and merges them into the cached plan entries.
244 private void updateChanges(final Date currentTime) throws IOException {
245 final List<TimetableStop> changes = this.loadChanges(currentTime);
246 this.processChanges(changes);
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.
253 private void processChanges(final List<TimetableStop> changes) {
254 for (final TimetableStop change : changes) {
256 final TimetableStop existingEntry = this.cachedStopsPerId.get(change.getId());
257 if (existingEntry != null) {
258 TimetableStopMerger.merge(existingEntry, change);
260 this.cachedChanges.put(change.getId(), change);
266 * Loads the full or recent changes depending on last request time.
268 private List<TimetableStop> loadChanges(final Date currentTime) throws IOException {
269 boolean fullChanges = false;
270 final long secondsSinceLastUpdate = this.getSecondsSinceLastRequestedChanges(currentTime);
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();
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) {
284 changes = this.api.getFullChanges(this.evaNo);
286 changes = this.api.getRecentChanges(this.evaNo);
288 this.lastRequestedChanges = currentTime;
289 return changes.getS();
292 @SuppressWarnings("null")
293 private long getSecondsSinceLastRequestedChanges(final Date currentTime) {
294 if (this.lastRequestedChanges == null) {
295 return Long.MAX_VALUE;
297 return ChronoUnit.SECONDS.between(this.lastRequestedChanges.toInstant(), currentTime.toInstant());