2 * Copyright (c) 2010-2023 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.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;
41 * Helper for loading the required amount of {@link TimetableStop} via a {@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 TimetableStopPredicate stopPredicate;
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 a new {@link TimetableLoader}.
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.
83 public TimetableLoader(final TimetablesV1Api api, final TimetableStopPredicate stopPredicate,
84 final EventType eventToSort, final Supplier<Date> currentTimeProvider, final String evaNo,
85 final int requestedStopCount) {
87 this.stopPredicate = stopPredicate;
88 this.currentTimeProvider = currentTimeProvider;
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;
99 * Sets the count of needed {@link TimetableStop} that is required at each call of {@link #getTimetableStops()}.
101 public void setStopCount(int stopCount) {
102 this.stopCount = stopCount;
106 * Updates the cache with current data from plan and changes and returns the {@link TimetableStop}.
108 public List<TimetableStop> getTimetableStops() throws IOException {
110 final List<TimetableStop> result = new ArrayList<>(this.cachedStopsPerId.values());
111 Collections.sort(result, this.comparator);
116 * Updates the cached {@link TimetableStop} to ensure that the requested amount of stops is available.
118 private void updateCache() throws IOException {
119 final Date currentTime = this.currentTimeProvider.get();
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);
125 // Remove all stops that are in the past
126 this.removeOldStops(currentTime);
128 // Finally fill up plan until required amount of data is available.
129 this.updatePlan(currentTime);
133 * Removes all stops from the cache with planned and changed time after the current time.
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();
141 // Remove entry if planned and changed time are in the past
142 if (isInPast(stop, currentTime)) {
149 * Returns <code>true</code> if the planned and changed time from arrival and departure are in the past.
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);
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>.
163 private static boolean isBefore( //
164 final EventAttribute<Date, DateTimeType> attribute, //
165 final @Nullable Event event, //
166 final Date toCompare) {
170 final Date value = attribute.getValue(event);
174 return value.before(toCompare);
179 * Checks if enough plan entries are available and loads them from the backing {@link TimetablesV1Api} if required.
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) {
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);
193 requestTime.setTime(currentTime);
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);
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();
206 // Filter only stops that are selected by given filter
207 final List<TimetableStop> stops = timetable //
210 .filter(this.stopPredicate) //
211 .collect(Collectors.toList());
213 // Merge the loaded stops with the cached changes and put them into the plan cache.
214 this.processLoadedPlan(stops, currentTime);
216 // Move request time one hour ahead.
217 requestTime.set(Calendar.HOUR_OF_DAY, requestTime.get(Calendar.HOUR_OF_DAY) + 1);
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.
225 private void processLoadedPlan(List<TimetableStop> stops, Date currentTime) {
226 for (final TimetableStop stop : stops) {
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);
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);
243 * Loads the changes from the api and merges them into the cached plan entries.
245 private void updateChanges(final Date currentTime) throws IOException {
246 final List<TimetableStop> changes = this.loadChanges(currentTime);
247 this.processChanges(changes);
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.
254 private void processChanges(final List<TimetableStop> changes) {
255 for (final TimetableStop change : changes) {
257 final TimetableStop existingEntry = this.cachedStopsPerId.get(change.getId());
258 if (existingEntry != null) {
259 TimetableStopMerger.merge(existingEntry, change);
261 this.cachedChanges.put(change.getId(), change);
267 * Loads the full or recent changes depending on last request time.
269 private List<TimetableStop> loadChanges(final Date currentTime) throws IOException {
270 boolean fullChanges = false;
271 final long secondsSinceLastUpdate = this.getSecondsSinceLastRequestedChanges(currentTime);
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();
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) {
285 changes = this.api.getFullChanges(this.evaNo);
287 changes = this.api.getRecentChanges(this.evaNo);
289 this.lastRequestedChanges = currentTime;
290 return changes.getS();
293 @SuppressWarnings("null")
294 private long getSecondsSinceLastRequestedChanges(final Date currentTime) {
295 if (this.lastRequestedChanges == null) {
296 return Long.MAX_VALUE;
298 return ChronoUnit.SECONDS.between(this.lastRequestedChanges.toInstant(), currentTime.toInstant());