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.publictransportswitzerland.internal.stationboard;
15 import static org.openhab.binding.publictransportswitzerland.internal.PublicTransportSwitzerlandBindingConstants.*;
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;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
28 import java.util.stream.Collectors;
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;
51 import com.google.gson.JsonArray;
52 import com.google.gson.JsonElement;
53 import com.google.gson.JsonObject;
54 import com.google.gson.JsonParser;
57 * The {@link PublicTransportSwitzerlandStationboardHandler} is responsible for handling commands, which are
58 * sent to one of the channels.
60 * @author Jeremy Stucki - Initial contribution
63 public class PublicTransportSwitzerlandStationboardHandler extends BaseThingHandler {
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");
70 private static final String TSV_CHANNEL = "tsv";
72 private final ChannelGroupUID dynamicChannelGroupUID = new ChannelGroupUID(getThing().getUID(), "departures");
74 private final Logger logger = LoggerFactory.getLogger(PublicTransportSwitzerlandStationboardHandler.class);
76 private final SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm");
78 private @Nullable ScheduledFuture<?> updateChannelsJob;
79 private @Nullable ExpiringCache<@Nullable JsonElement> cache;
80 private @Nullable PublicTransportSwitzerlandStationboardConfiguration configuration;
82 public PublicTransportSwitzerlandStationboardHandler(Thing thing) {
87 public void handleCommand(ChannelUID channelUID, Command command) {
88 if (command instanceof RefreshType) {
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);
98 PublicTransportSwitzerlandStationboardConfiguration configuration = getConfigAs(
99 PublicTransportSwitzerlandStationboardConfiguration.class);
100 this.configuration = configuration;
102 String configurationError = findConfigurationError(configuration);
103 if (configurationError != null) {
105 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configurationError);
107 updateStatus(ThingStatus.UNKNOWN);
108 startChannelUpdate();
113 public void dispose() {
118 public void handleConfigurationUpdate(Map<String, Object> configurationParameters) {
119 super.handleConfigurationUpdate(configurationParameters);
121 PublicTransportSwitzerlandStationboardConfiguration configuration = getConfigAs(
122 PublicTransportSwitzerlandStationboardConfiguration.class);
123 this.configuration = configuration;
125 ScheduledFuture<?> updateJob = updateChannelsJob;
127 String configurationError = findConfigurationError(configuration);
128 if (configurationError != null) {
130 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configurationError);
131 } else if (updateJob == null || updateJob.isCancelled()) {
132 startChannelUpdate();
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";
145 private void startChannelUpdate() {
146 updateChannelsJob = scheduler.scheduleWithFixedDelay(this::updateChannels, 0, 60, TimeUnit.SECONDS);
149 private void stopChannelUpdate() {
150 ScheduledFuture<?> updateJob = updateChannelsJob;
152 if (updateJob != null) {
153 updateJob.cancel(true);
157 public @Nullable JsonElement updateData() {
158 PublicTransportSwitzerlandStationboardConfiguration config = configuration;
159 if (config == null) {
160 logger.warn("Unable to access configuration");
164 String station = config.station;
165 if (station == null) {
166 logger.warn("Station is null");
171 String escapedStation = URLEncoder.encode(station, StandardCharsets.UTF_8.name());
172 String requestUrl = BASE_URL + "stationboard?station=" + escapedStation + FIELD_FILTERS;
174 String response = HttpUtil.executeUrl("GET", requestUrl, 10_000);
175 logger.debug("Got response from API: {}", response);
177 return JsonParser.parseString(response);
178 } catch (IOException e) {
179 logger.warn("Unable to fetch stationboard data: {}", e.getMessage());
184 private static String createFilterForFields(String... fields) {
185 return Arrays.stream(fields).map((field) -> "&fields[]=" + field).collect(Collectors.joining());
188 private void updateChannels() {
189 ExpiringCache<@Nullable JsonElement> expiringCache = cache;
191 if (expiringCache == null) {
192 logger.warn("Cache is null");
196 JsonElement jsonObject = expiringCache.getValue();
198 if (jsonObject == null) {
199 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
201 updateState(TSV_CHANNEL, UnDefType.UNDEF);
203 for (Channel channel : getThing().getChannelsOfGroup(dynamicChannelGroupUID.getId())) {
204 updateState(channel.getUID(), UnDefType.UNDEF);
210 updateStatus(ThingStatus.ONLINE);
212 JsonArray stationboard = jsonObject.getAsJsonObject().get("stationboard").getAsJsonArray();
214 createDynamicChannels(stationboard.size());
215 setUnusedDynamicChannelsToUndef(stationboard.size());
217 List<String> tsvRows = new ArrayList<>();
219 for (int i = 0; i < stationboard.size(); i++) {
220 JsonElement jsonElement = stationboard.get(i);
222 JsonObject departureObject = jsonElement.getAsJsonObject();
223 JsonElement stopElement = departureObject.get("stop");
225 if (stopElement == null) {
226 logger.warn("Skipping stationboard item. Stop element is missing from departure object");
230 JsonObject stopObject = stopElement.getAsJsonObject();
232 JsonElement categoryElement = departureObject.get("category");
233 JsonElement numberElement = departureObject.get("number");
234 JsonElement destinationElement = departureObject.get("to");
235 JsonElement departureTimeElement = stopObject.get("departureTimestamp");
237 if (categoryElement == null || numberElement == null || destinationElement == null
238 || departureTimeElement == null) {
240 Skipping stationboard item.\
241 One of the following is null: category: {}, number: {}, destination: {}, departureTime: {}\
242 """, categoryElement, numberElement, destinationElement, departureTimeElement);
246 String category = categoryElement.getAsString();
247 String number = numberElement.getAsString();
248 String destination = destinationElement.getAsString();
249 Long departureTime = departureTimeElement.getAsLong();
251 String identifier = createIdentifier(category, number);
253 String delay = getStringValueOrNull(departureObject.get("delay"));
254 String track = getStringValueOrNull(stopObject.get("platform"));
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));
261 updateState(TSV_CHANNEL, new StringType(String.join("\n", tsvRows)));
264 private @Nullable String getStringValueOrNull(@Nullable JsonElement jsonElement) {
265 if (jsonElement == null || jsonElement.isJsonNull()) {
269 String stringValue = jsonElement.getAsString();
271 if (stringValue.isEmpty()) {
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);
283 String result = String.format("%s - %s %s", formattedDate, identifier, destination);
286 result += " - Pl. " + track;
290 result += String.format(" (%s' late)", delay);
296 private String createIdentifier(String category, String number) {
297 // Only show the number for buses
298 if ("B".equals(category)) {
302 // Some weird quirk with the API
303 if (number.startsWith(category)) {
307 return category + number;
310 private void createDynamicChannels(int numberOfChannels) {
311 List<Channel> existingChannels = getThing().getChannelsOfGroup(dynamicChannelGroupUID.getId());
313 ThingBuilder thingBuilder = editThing();
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);
322 updateThing(thingBuilder.build());
325 private void setUnusedDynamicChannelsToUndef(int amountOfUsedChannels) {
326 getThing().getChannelsOfGroup(dynamicChannelGroupUID.getId()).stream().skip(amountOfUsedChannels)
327 .forEach(channel -> updateState(channel.getUID(), UnDefType.UNDEF));
330 private ChannelUID getChannelUIDForPosition(int position) {
331 return new ChannelUID(dynamicChannelGroupUID, String.valueOf(position + 1));