]> git.basschouten.com Git - openhab-addons.git/blob
9b924401b54f700fe058e9c54ffb79c34985f88a
[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.publictransportswitzerland.internal.stationboard;
14
15 import static org.openhab.binding.publictransportswitzerland.internal.PublicTransportSwitzerlandBindingConstants.*;
16
17 import java.io.IOException;
18 import java.net.URLEncoder;
19 import java.nio.charset.StandardCharsets;
20 import java.text.SimpleDateFormat;
21 import java.util.ArrayList;
22 import java.util.Arrays;
23 import java.util.Date;
24 import java.util.List;
25 import java.util.Map;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
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.core.cache.ExpiringCache;
33 import org.openhab.core.io.net.http.HttpUtil;
34 import org.openhab.core.library.types.StringType;
35 import org.openhab.core.thing.Channel;
36 import org.openhab.core.thing.ChannelGroupUID;
37 import org.openhab.core.thing.ChannelUID;
38 import org.openhab.core.thing.Thing;
39 import org.openhab.core.thing.ThingStatus;
40 import org.openhab.core.thing.ThingStatusDetail;
41 import org.openhab.core.thing.binding.BaseThingHandler;
42 import org.openhab.core.thing.binding.builder.ChannelBuilder;
43 import org.openhab.core.thing.binding.builder.ThingBuilder;
44 import org.openhab.core.thing.type.ChannelTypeUID;
45 import org.openhab.core.types.Command;
46 import org.openhab.core.types.RefreshType;
47 import org.openhab.core.types.UnDefType;
48 import org.slf4j.Logger;
49 import org.slf4j.LoggerFactory;
50
51 import com.google.gson.JsonArray;
52 import com.google.gson.JsonElement;
53 import com.google.gson.JsonObject;
54 import com.google.gson.JsonParser;
55
56 /**
57  * The {@link PublicTransportSwitzerlandStationboardHandler} is responsible for handling commands, which are
58  * sent to one of the channels.
59  *
60  * @author Jeremy Stucki - Initial contribution
61  */
62 @NonNullByDefault
63 public class PublicTransportSwitzerlandStationboardHandler extends BaseThingHandler {
64
65     // Limit the API response to the necessary fields
66     private static final String FIELD_FILTERS = createFilterForFields("stationboard/to", "stationboard/category",
67             "stationboard/number", "stationboard/stop/departureTimestamp", "stationboard/stop/delay",
68             "stationboard/stop/platform");
69
70     private static final String TSV_CHANNEL = "tsv";
71
72     private final ChannelGroupUID dynamicChannelGroupUID = new ChannelGroupUID(getThing().getUID(), "departures");
73
74     private final Logger logger = LoggerFactory.getLogger(PublicTransportSwitzerlandStationboardHandler.class);
75
76     private final SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm");
77
78     private @Nullable ScheduledFuture<?> updateChannelsJob;
79     private @Nullable ExpiringCache<@Nullable JsonElement> cache;
80     private @Nullable PublicTransportSwitzerlandStationboardConfiguration configuration;
81
82     public PublicTransportSwitzerlandStationboardHandler(Thing thing) {
83         super(thing);
84     }
85
86     @Override
87     public void handleCommand(ChannelUID channelUID, Command command) {
88         if (command instanceof RefreshType) {
89             updateChannels();
90         }
91     }
92
93     @Override
94     public void initialize() {
95         // Together with the 10 second timeout, this should be less than a minute
96         cache = new ExpiringCache<>(45_000, this::updateData);
97
98         PublicTransportSwitzerlandStationboardConfiguration configuration = getConfigAs(
99                 PublicTransportSwitzerlandStationboardConfiguration.class);
100         this.configuration = configuration;
101
102         String configurationError = findConfigurationError(configuration);
103         if (configurationError != null) {
104             stopChannelUpdate();
105             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configurationError);
106         } else {
107             updateStatus(ThingStatus.UNKNOWN);
108             startChannelUpdate();
109         }
110     }
111
112     @Override
113     public void dispose() {
114         stopChannelUpdate();
115     }
116
117     @Override
118     public void handleConfigurationUpdate(Map<String, Object> configurationParameters) {
119         super.handleConfigurationUpdate(configurationParameters);
120
121         PublicTransportSwitzerlandStationboardConfiguration configuration = getConfigAs(
122                 PublicTransportSwitzerlandStationboardConfiguration.class);
123         this.configuration = configuration;
124
125         ScheduledFuture<?> updateJob = updateChannelsJob;
126
127         String configurationError = findConfigurationError(configuration);
128         if (configurationError != null) {
129             stopChannelUpdate();
130             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configurationError);
131         } else if (updateJob == null || updateJob.isCancelled()) {
132             startChannelUpdate();
133         }
134     }
135
136     private @Nullable String findConfigurationError(PublicTransportSwitzerlandStationboardConfiguration configuration) {
137         String station = configuration.station;
138         if (station == null || station.isEmpty()) {
139             return "The station is not set";
140         }
141
142         return null;
143     }
144
145     private void startChannelUpdate() {
146         updateChannelsJob = scheduler.scheduleWithFixedDelay(this::updateChannels, 0, 60, TimeUnit.SECONDS);
147     }
148
149     private void stopChannelUpdate() {
150         ScheduledFuture<?> updateJob = updateChannelsJob;
151
152         if (updateJob != null) {
153             updateJob.cancel(true);
154         }
155     }
156
157     public @Nullable JsonElement updateData() {
158         PublicTransportSwitzerlandStationboardConfiguration config = configuration;
159         if (config == null) {
160             logger.warn("Unable to access configuration");
161             return null;
162         }
163
164         String station = config.station;
165         if (station == null) {
166             logger.warn("Station is null");
167             return null;
168         }
169
170         try {
171             String escapedStation = URLEncoder.encode(station, StandardCharsets.UTF_8.name());
172             String requestUrl = BASE_URL + "stationboard?station=" + escapedStation + FIELD_FILTERS;
173
174             String response = HttpUtil.executeUrl("GET", requestUrl, 10_000);
175             logger.debug("Got response from API: {}", response);
176
177             return JsonParser.parseString(response);
178         } catch (IOException e) {
179             logger.warn("Unable to fetch stationboard data: {}", e.getMessage());
180             return null;
181         }
182     }
183
184     private static String createFilterForFields(String... fields) {
185         return Arrays.stream(fields).map((field) -> "&fields[]=" + field).collect(Collectors.joining());
186     }
187
188     private void updateChannels() {
189         ExpiringCache<@Nullable JsonElement> expiringCache = cache;
190
191         if (expiringCache == null) {
192             logger.warn("Cache is null");
193             return;
194         }
195
196         JsonElement jsonObject = expiringCache.getValue();
197
198         if (jsonObject == null) {
199             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
200
201             updateState(TSV_CHANNEL, UnDefType.UNDEF);
202
203             for (Channel channel : getThing().getChannelsOfGroup(dynamicChannelGroupUID.getId())) {
204                 updateState(channel.getUID(), UnDefType.UNDEF);
205             }
206
207             return;
208         }
209
210         updateStatus(ThingStatus.ONLINE);
211
212         JsonArray stationboard = jsonObject.getAsJsonObject().get("stationboard").getAsJsonArray();
213
214         createDynamicChannels(stationboard.size());
215         setUnusedDynamicChannelsToUndef(stationboard.size());
216
217         List<String> tsvRows = new ArrayList<>();
218
219         for (int i = 0; i < stationboard.size(); i++) {
220             JsonElement jsonElement = stationboard.get(i);
221
222             JsonObject departureObject = jsonElement.getAsJsonObject();
223             JsonElement stopElement = departureObject.get("stop");
224
225             if (stopElement == null) {
226                 logger.warn("Skipping stationboard item. Stop element is missing from departure object");
227                 continue;
228             }
229
230             JsonObject stopObject = stopElement.getAsJsonObject();
231
232             JsonElement categoryElement = departureObject.get("category");
233             JsonElement numberElement = departureObject.get("number");
234             JsonElement destinationElement = departureObject.get("to");
235             JsonElement departureTimeElement = stopObject.get("departureTimestamp");
236
237             if (categoryElement == null || numberElement == null || destinationElement == null
238                     || departureTimeElement == null) {
239                 logger.warn("Skipping stationboard item."
240                         + "One of the following is null: category: {}, number: {}, destination: {}, departureTime: {}",
241                         categoryElement, numberElement, destinationElement, departureTimeElement);
242                 continue;
243             }
244
245             String category = categoryElement.getAsString();
246             String number = numberElement.getAsString();
247             String destination = destinationElement.getAsString();
248             Long departureTime = departureTimeElement.getAsLong();
249
250             String identifier = createIdentifier(category, number);
251
252             String delay = getStringValueOrNull(departureObject.get("delay"));
253             String track = getStringValueOrNull(stopObject.get("platform"));
254
255             updateState(getChannelUIDForPosition(i),
256                     new StringType(formatDeparture(identifier, departureTime, destination, track, delay)));
257             tsvRows.add(String.join("\t", identifier, departureTimeElement.toString(), destination, track, delay));
258         }
259
260         updateState(TSV_CHANNEL, new StringType(String.join("\n", tsvRows)));
261     }
262
263     private @Nullable String getStringValueOrNull(@Nullable JsonElement jsonElement) {
264         if (jsonElement == null || jsonElement.isJsonNull()) {
265             return null;
266         }
267
268         String stringValue = jsonElement.getAsString();
269
270         if (stringValue.isEmpty()) {
271             return null;
272         }
273
274         return stringValue;
275     }
276
277     private String formatDeparture(String identifier, Long departureTimestamp, String destination,
278             @Nullable String track, @Nullable String delay) {
279         Date departureDate = new Date(departureTimestamp * 1000);
280         String formattedDate = timeFormat.format(departureDate);
281
282         String result = String.format("%s - %s %s", formattedDate, identifier, destination);
283
284         if (track != null) {
285             result += " - Pl. " + track;
286         }
287
288         if (delay != null) {
289             result += String.format(" (%s' late)", delay);
290         }
291
292         return result;
293     }
294
295     private String createIdentifier(String category, String number) {
296         // Only show the number for buses
297         if ("B".equals(category)) {
298             return number;
299         }
300
301         // Some weird quirk with the API
302         if (number.startsWith(category)) {
303             return category;
304         }
305
306         return category + number;
307     }
308
309     private void createDynamicChannels(int numberOfChannels) {
310         List<Channel> existingChannels = getThing().getChannelsOfGroup(dynamicChannelGroupUID.getId());
311
312         ThingBuilder thingBuilder = editThing();
313
314         for (int i = existingChannels.size(); i < numberOfChannels; i++) {
315             Channel channel = ChannelBuilder.create(getChannelUIDForPosition(i), "String")
316                     .withLabel("Departure " + (i + 1))
317                     .withType(new ChannelTypeUID("publictransportswitzerland", "departure")).build();
318             thingBuilder.withChannel(channel);
319         }
320
321         updateThing(thingBuilder.build());
322     }
323
324     private void setUnusedDynamicChannelsToUndef(int amountOfUsedChannels) {
325         getThing().getChannelsOfGroup(dynamicChannelGroupUID.getId()).stream().skip(amountOfUsedChannels)
326                 .forEach(channel -> updateState(channel.getUID(), UnDefType.UNDEF));
327     }
328
329     private ChannelUID getChannelUIDForPosition(int position) {
330         return new ChannelUID(dynamicChannelGroupUID, String.valueOf(position + 1));
331     }
332 }