]> git.basschouten.com Git - openhab-addons.git/blob
c0eda20e74843931807346bc205cb79b206592e8
[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("""
240                         Skipping stationboard item.\
241                         One of the following is null: category: {}, number: {}, destination: {}, departureTime: {}\
242                         """, categoryElement, numberElement, destinationElement, departureTimeElement);
243                 continue;
244             }
245
246             String category = categoryElement.getAsString();
247             String number = numberElement.getAsString();
248             String destination = destinationElement.getAsString();
249             Long departureTime = departureTimeElement.getAsLong();
250
251             String identifier = createIdentifier(category, number);
252
253             String delay = getStringValueOrNull(departureObject.get("delay"));
254             String track = getStringValueOrNull(stopObject.get("platform"));
255
256             updateState(getChannelUIDForPosition(i),
257                     new StringType(formatDeparture(identifier, departureTime, destination, track, delay)));
258             tsvRows.add(String.join("\t", identifier, departureTimeElement.toString(), destination, track, delay));
259         }
260
261         updateState(TSV_CHANNEL, new StringType(String.join("\n", tsvRows)));
262     }
263
264     private @Nullable String getStringValueOrNull(@Nullable JsonElement jsonElement) {
265         if (jsonElement == null || jsonElement.isJsonNull()) {
266             return null;
267         }
268
269         String stringValue = jsonElement.getAsString();
270
271         if (stringValue.isEmpty()) {
272             return null;
273         }
274
275         return stringValue;
276     }
277
278     private String formatDeparture(String identifier, Long departureTimestamp, String destination,
279             @Nullable String track, @Nullable String delay) {
280         Date departureDate = new Date(departureTimestamp * 1000);
281         String formattedDate = timeFormat.format(departureDate);
282
283         String result = String.format("%s - %s %s", formattedDate, identifier, destination);
284
285         if (track != null) {
286             result += " - Pl. " + track;
287         }
288
289         if (delay != null) {
290             result += String.format(" (%s' late)", delay);
291         }
292
293         return result;
294     }
295
296     private String createIdentifier(String category, String number) {
297         // Only show the number for buses
298         if ("B".equals(category)) {
299             return number;
300         }
301
302         // Some weird quirk with the API
303         if (number.startsWith(category)) {
304             return category;
305         }
306
307         return category + number;
308     }
309
310     private void createDynamicChannels(int numberOfChannels) {
311         List<Channel> existingChannels = getThing().getChannelsOfGroup(dynamicChannelGroupUID.getId());
312
313         ThingBuilder thingBuilder = editThing();
314
315         for (int i = existingChannels.size(); i < numberOfChannels; i++) {
316             Channel channel = ChannelBuilder.create(getChannelUIDForPosition(i), "String")
317                     .withLabel("Departure " + (i + 1))
318                     .withType(new ChannelTypeUID("publictransportswitzerland", "departure")).build();
319             thingBuilder.withChannel(channel);
320         }
321
322         updateThing(thingBuilder.build());
323     }
324
325     private void setUnusedDynamicChannelsToUndef(int amountOfUsedChannels) {
326         getThing().getChannelsOfGroup(dynamicChannelGroupUID.getId()).stream().skip(amountOfUsedChannels)
327                 .forEach(channel -> updateState(channel.getUID(), UnDefType.UNDEF));
328     }
329
330     private ChannelUID getChannelUIDForPosition(int position) {
331         return new ChannelUID(dynamicChannelGroupUID, String.valueOf(position + 1));
332     }
333 }