--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.publictransportswitzerland.internal.stationboard;
+
+import static org.openhab.binding.publictransportswitzerland.internal.PublicTransportSwitzerlandBindingConstants.*;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.cache.ExpiringCache;
+import org.openhab.core.io.net.http.HttpUtil;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.ChannelGroupUID;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.thing.binding.builder.ChannelBuilder;
+import org.openhab.core.thing.binding.builder.ThingBuilder;
+import org.openhab.core.thing.type.ChannelTypeUID;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+/**
+ * The {@link PublicTransportSwitzerlandStationboardHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author Jeremy Stucki - Initial contribution
+ */
+@NonNullByDefault
+public class PublicTransportSwitzerlandStationboardHandler extends BaseThingHandler {
+
+ // Limit the API response to the necessary fields
+ private static final String FIELD_FILTERS = createFilterForFields("stationboard/to", "stationboard/category",
+ "stationboard/number", "stationboard/stop/departureTimestamp", "stationboard/stop/delay",
+ "stationboard/stop/platform");
+
+ private static final String TSV_CHANNEL = "tsv";
+
+ private final ChannelGroupUID dynamicChannelGroupUID = new ChannelGroupUID(getThing().getUID(), "departures");
+
+ private final Logger logger = LoggerFactory.getLogger(PublicTransportSwitzerlandStationboardHandler.class);
+
+ private final SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm");
+
+ private @Nullable ScheduledFuture<?> updateChannelsJob;
+ private @Nullable ExpiringCache<@Nullable JsonElement> cache;
+ private @Nullable PublicTransportSwitzerlandStationboardConfiguration configuration;
+
+ public PublicTransportSwitzerlandStationboardHandler(Thing thing) {
+ super(thing);
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ if (command instanceof RefreshType) {
+ updateChannels();
+ }
+ }
+
+ @Override
+ public void initialize() {
+ // Together with the 10 second timeout, this should be less than a minute
+ cache = new ExpiringCache<>(45_000, this::updateData);
+
+ PublicTransportSwitzerlandStationboardConfiguration configuration = getConfigAs(
+ PublicTransportSwitzerlandStationboardConfiguration.class);
+ this.configuration = configuration;
+
+ String configurationError = findConfigurationError(configuration);
+ if (configurationError != null) {
+ stopChannelUpdate();
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configurationError);
+ } else {
+ updateStatus(ThingStatus.UNKNOWN);
+ startChannelUpdate();
+ }
+ }
+
+ @Override
+ public void dispose() {
+ stopChannelUpdate();
+ }
+
+ @Override
+ public void handleConfigurationUpdate(Map<String, Object> configurationParameters) {
+ super.handleConfigurationUpdate(configurationParameters);
+
+ PublicTransportSwitzerlandStationboardConfiguration configuration = getConfigAs(
+ PublicTransportSwitzerlandStationboardConfiguration.class);
+ this.configuration = configuration;
+
+ ScheduledFuture<?> updateJob = updateChannelsJob;
+
+ String configurationError = findConfigurationError(configuration);
+ if (configurationError != null) {
+ stopChannelUpdate();
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configurationError);
+ } else if (updateJob == null || updateJob.isCancelled()) {
+ startChannelUpdate();
+ }
+ }
+
+ private @Nullable String findConfigurationError(PublicTransportSwitzerlandStationboardConfiguration configuration) {
+ String station = configuration.station;
+ if (station == null || station.isEmpty()) {
+ return "The station is not set";
+ }
+
+ return null;
+ }
+
+ private void startChannelUpdate() {
+ updateChannelsJob = scheduler.scheduleWithFixedDelay(this::updateChannels, 0, 60, TimeUnit.SECONDS);
+ }
+
+ private void stopChannelUpdate() {
+ ScheduledFuture<?> updateJob = updateChannelsJob;
+
+ if (updateJob != null) {
+ updateJob.cancel(true);
+ }
+ }
+
+ public @Nullable JsonElement updateData() {
+ PublicTransportSwitzerlandStationboardConfiguration config = configuration;
+ if (config == null) {
+ logger.warn("Unable to access configuration");
+ return null;
+ }
+
+ String station = config.station;
+ if (station == null) {
+ logger.warn("Station is null");
+ return null;
+ }
+
+ try {
+ String escapedStation = URLEncoder.encode(station, StandardCharsets.UTF_8.name());
+ String requestUrl = BASE_URL + "stationboard?station=" + escapedStation + FIELD_FILTERS;
+
+ String response = HttpUtil.executeUrl("GET", requestUrl, 10_000);
+ logger.debug("Got response from API: {}", response);
+
+ return JsonParser.parseString(response);
+ } catch (IOException e) {
+ logger.warn("Unable to fetch stationboard data: {}", e.getMessage());
+ return null;
+ }
+ }
+
+ private static String createFilterForFields(String... fields) {
+ return Arrays.stream(fields).map((field) -> "&fields[]=" + field).collect(Collectors.joining());
+ }
+
+ private void updateChannels() {
+ ExpiringCache<@Nullable JsonElement> expiringCache = cache;
+
+ if (expiringCache == null) {
+ logger.warn("Cache is null");
+ return;
+ }
+
+ JsonElement jsonObject = expiringCache.getValue();
+
+ if (jsonObject == null) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
+
+ updateState(TSV_CHANNEL, UnDefType.UNDEF);
+
+ for (Channel channel : getThing().getChannelsOfGroup(dynamicChannelGroupUID.getId())) {
+ updateState(channel.getUID(), UnDefType.UNDEF);
+ }
+
+ return;
+ }
+
+ updateStatus(ThingStatus.ONLINE);
+
+ JsonArray stationboard = jsonObject.getAsJsonObject().get("stationboard").getAsJsonArray();
+
+ createDynamicChannels(stationboard.size());
+ setUnusedDynamicChannelsToUndef(stationboard.size());
+
+ List<String> tsvRows = new ArrayList<>();
+
+ for (int i = 0; i < stationboard.size(); i++) {
+ JsonElement jsonElement = stationboard.get(i);
+
+ JsonObject departureObject = jsonElement.getAsJsonObject();
+ JsonElement stopElement = departureObject.get("stop");
+
+ if (stopElement == null) {
+ logger.warn("Skipping stationboard item. Stop element is missing from departure object");
+ continue;
+ }
+
+ JsonObject stopObject = stopElement.getAsJsonObject();
+
+ JsonElement categoryElement = departureObject.get("category");
+ JsonElement numberElement = departureObject.get("number");
+ JsonElement destinationElement = departureObject.get("to");
+ JsonElement departureTimeElement = stopObject.get("departureTimestamp");
+
+ if (categoryElement == null || numberElement == null || destinationElement == null
+ || departureTimeElement == null) {
+ logger.warn("Skipping stationboard item."
+ + "One of the following is null: category: {}, number: {}, destination: {}, departureTime: {}",
+ categoryElement, numberElement, destinationElement, departureTimeElement);
+ continue;
+ }
+
+ String category = categoryElement.getAsString();
+ String number = numberElement.getAsString();
+ String destination = destinationElement.getAsString();
+ Long departureTime = departureTimeElement.getAsLong();
+
+ String identifier = createIdentifier(category, number);
+
+ String delay = getStringValueOrNull(departureObject.get("delay"));
+ String track = getStringValueOrNull(stopObject.get("platform"));
+
+ updateState(getChannelUIDForPosition(i),
+ new StringType(formatDeparture(identifier, departureTime, destination, track, delay)));
+ tsvRows.add(String.join("\t", identifier, departureTimeElement.toString(), destination, track, delay));
+ }
+
+ updateState(TSV_CHANNEL, new StringType(String.join("\n", tsvRows)));
+ }
+
+ private @Nullable String getStringValueOrNull(@Nullable JsonElement jsonElement) {
+ if (jsonElement == null || jsonElement.isJsonNull()) {
+ return null;
+ }
+
+ String stringValue = jsonElement.getAsString();
+
+ if (stringValue.isEmpty()) {
+ return null;
+ }
+
+ return stringValue;
+ }
+
+ private String formatDeparture(String identifier, Long departureTimestamp, String destination,
+ @Nullable String track, @Nullable String delay) {
+ Date departureDate = new Date(departureTimestamp * 1000);
+ String formattedDate = timeFormat.format(departureDate);
+
+ String result = String.format("%s - %s %s", formattedDate, identifier, destination);
+
+ if (track != null) {
+ result += " - Pl. " + track;
+ }
+
+ if (delay != null) {
+ result += String.format(" (%s' late)", delay);
+ }
+
+ return result;
+ }
+
+ private String createIdentifier(String category, String number) {
+ // Only show the number for buses
+ if ("B".equals(category)) {
+ return number;
+ }
+
+ // Some weird quirk with the API
+ if (number.startsWith(category)) {
+ return category;
+ }
+
+ return category + number;
+ }
+
+ private void createDynamicChannels(int numberOfChannels) {
+ List<Channel> existingChannels = getThing().getChannelsOfGroup(dynamicChannelGroupUID.getId());
+
+ ThingBuilder thingBuilder = editThing();
+
+ for (int i = existingChannels.size(); i < numberOfChannels; i++) {
+ Channel channel = ChannelBuilder.create(getChannelUIDForPosition(i), "String")
+ .withLabel("Departure " + (i + 1))
+ .withType(new ChannelTypeUID("publictransportswitzerland", "departure")).build();
+ thingBuilder.withChannel(channel);
+ }
+
+ updateThing(thingBuilder.build());
+ }
+
+ private void setUnusedDynamicChannelsToUndef(int amountOfUsedChannels) {
+ getThing().getChannelsOfGroup(dynamicChannelGroupUID.getId()).stream().skip(amountOfUsedChannels)
+ .forEach(channel -> updateState(channel.getUID(), UnDefType.UNDEF));
+ }
+
+ private ChannelUID getChannelUIDForPosition(int position) {
+ return new ChannelUID(dynamicChannelGroupUID, String.valueOf(position + 1));
+ }
+}