diff --git a/CODEOWNERS b/CODEOWNERS index c09e2b9f1..a3f827da5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -245,6 +245,7 @@ /bundles/org.openhab.binding.plugwiseha/ @lsiepel /bundles/org.openhab.binding.powermax/ @lolodomo /bundles/org.openhab.binding.proteusecometer/ @2chilled +/bundles/org.openhab.binding.publictransportswitzerland/ @jeremystucki /bundles/org.openhab.binding.pulseaudio/ @peuter /bundles/org.openhab.binding.pushbullet/ @hakan42 /bundles/org.openhab.binding.pushover/ @cweitkamp diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index 8e2a9ade6..2a71e5b30 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -1216,6 +1216,11 @@ org.openhab.binding.proteusecometer ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.publictransportswitzerland + ${project.version} + org.openhab.addons.bundles org.openhab.binding.pulseaudio diff --git a/bundles/org.openhab.binding.publictransportswitzerland/NOTICE b/bundles/org.openhab.binding.publictransportswitzerland/NOTICE new file mode 100644 index 000000000..38d625e34 --- /dev/null +++ b/bundles/org.openhab.binding.publictransportswitzerland/NOTICE @@ -0,0 +1,13 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons diff --git a/bundles/org.openhab.binding.publictransportswitzerland/README.md b/bundles/org.openhab.binding.publictransportswitzerland/README.md new file mode 100644 index 000000000..e8b601cf4 --- /dev/null +++ b/bundles/org.openhab.binding.publictransportswitzerland/README.md @@ -0,0 +1,43 @@ +# Public Transport Switzerland Binding + +Connects to the "Swiss public transport API" to provide real-time public transport information. [Link to the API](https://transport.opendata.ch/) + +For example, here is a station board in HABPanel. (Download [here](https://github.com/StefanieJaeger/HABPanel-departure-board)) + +![Departure board in HABPanel](doc/departure_board_habpanel.png) + +## Supported Things + +### Stationboard + +Upcoming departures for a single station. This is what you would usually see displayed at the train station. + +#### Channels + +| channel | type | description | +|----------------|--------|----------------------------------------------------------------------------------------------| +| departures#n | String | A dynamic channel for each upcoming departure | +| tsv (advanced) | String | A tsv which contains the fields:
`identifier, departureTime, destination, track, delay` | + +#### UI based Configuration + +`station` is the station name for which to display departures. +The name has to be one that is used by the swiss federal railways. +Please consult their [website](https://sbb.ch/en). + +#### Textual configuration + +##### Thing +``` +Thing publictransportswitzerland:stationboard:zurich [ station="Zürich HB" ] +``` + +##### Items +``` +String Next_Departure "Next Departure" { channel="publictransportswitzerland:stationboard:zurich:departures#1" } +String Upcoming_Departures_TSV "Upcoming_Departures_TSV" { channel="publictransportswitzerland:stationboard:zurich:tsv" } +``` + +## Discovery + +This binding does not support auto-discovery. diff --git a/bundles/org.openhab.binding.publictransportswitzerland/doc/departure_board_habpanel.png b/bundles/org.openhab.binding.publictransportswitzerland/doc/departure_board_habpanel.png new file mode 100644 index 000000000..233a3b9bd Binary files /dev/null and b/bundles/org.openhab.binding.publictransportswitzerland/doc/departure_board_habpanel.png differ diff --git a/bundles/org.openhab.binding.publictransportswitzerland/pom.xml b/bundles/org.openhab.binding.publictransportswitzerland/pom.xml new file mode 100644 index 000000000..7392a0d35 --- /dev/null +++ b/bundles/org.openhab.binding.publictransportswitzerland/pom.xml @@ -0,0 +1,17 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 3.3.0-SNAPSHOT + + + org.openhab.binding.publictransportswitzerland + + openHAB Add-ons :: Bundles :: PublicTransportSwitzerland Binding + + diff --git a/bundles/org.openhab.binding.publictransportswitzerland/src/main/feature/feature.xml b/bundles/org.openhab.binding.publictransportswitzerland/src/main/feature/feature.xml new file mode 100644 index 000000000..38b894ebc --- /dev/null +++ b/bundles/org.openhab.binding.publictransportswitzerland/src/main/feature/feature.xml @@ -0,0 +1,9 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.publictransportswitzerland/${project.version} + + diff --git a/bundles/org.openhab.binding.publictransportswitzerland/src/main/java/org/openhab/binding/publictransportswitzerland/internal/PublicTransportSwitzerlandBindingConstants.java b/bundles/org.openhab.binding.publictransportswitzerland/src/main/java/org/openhab/binding/publictransportswitzerland/internal/PublicTransportSwitzerlandBindingConstants.java new file mode 100644 index 000000000..a66660465 --- /dev/null +++ b/bundles/org.openhab.binding.publictransportswitzerland/src/main/java/org/openhab/binding/publictransportswitzerland/internal/PublicTransportSwitzerlandBindingConstants.java @@ -0,0 +1,33 @@ +/** + * 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; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link PublicTransportSwitzerlandBindingConstants} class defines common constants, which are + * used across the whole binding. + * + * @author Jeremy Stucki - Initial contribution + */ +@NonNullByDefault +public class PublicTransportSwitzerlandBindingConstants { + + private static final String BINDING_ID = "publictransportswitzerland"; + + // List of all Thing Type UIDs + public static final ThingTypeUID THING_TYPE_STATIONBOARD = new ThingTypeUID(BINDING_ID, "stationboard"); + + public static final String BASE_URL = "https://transport.opendata.ch/v1/"; +} diff --git a/bundles/org.openhab.binding.publictransportswitzerland/src/main/java/org/openhab/binding/publictransportswitzerland/internal/PublicTransportSwitzerlandHandlerFactory.java b/bundles/org.openhab.binding.publictransportswitzerland/src/main/java/org/openhab/binding/publictransportswitzerland/internal/PublicTransportSwitzerlandHandlerFactory.java new file mode 100644 index 000000000..b6978827e --- /dev/null +++ b/bundles/org.openhab.binding.publictransportswitzerland/src/main/java/org/openhab/binding/publictransportswitzerland/internal/PublicTransportSwitzerlandHandlerFactory.java @@ -0,0 +1,57 @@ +/** + * 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; + +import static org.openhab.binding.publictransportswitzerland.internal.PublicTransportSwitzerlandBindingConstants.*; + +import java.util.Collections; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.publictransportswitzerland.internal.stationboard.PublicTransportSwitzerlandStationboardHandler; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.binding.BaseThingHandlerFactory; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.osgi.service.component.annotations.Component; + +/** + * The {@link PublicTransportSwitzerlandHandlerFactory} is responsible for creating things and thing + * handlers. + * + * @author Jeremy Stucki - Initial contribution + */ +@NonNullByDefault +@Component(configurationPid = "binding.publictransportswitzerland", service = ThingHandlerFactory.class) +public class PublicTransportSwitzerlandHandlerFactory extends BaseThingHandlerFactory { + + private static final Set SUPPORTED_THING_TYPES_UIDS = Collections.singleton(THING_TYPE_STATIONBOARD); + + @Override + public boolean supportsThingType(ThingTypeUID thingTypeUID) { + return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); + } + + @Override + protected @Nullable ThingHandler createHandler(Thing thing) { + ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + + if (THING_TYPE_STATIONBOARD.equals(thingTypeUID)) { + return new PublicTransportSwitzerlandStationboardHandler(thing); + } + + return null; + } +} diff --git a/bundles/org.openhab.binding.publictransportswitzerland/src/main/java/org/openhab/binding/publictransportswitzerland/internal/stationboard/PublicTransportSwitzerlandStationboardConfiguration.java b/bundles/org.openhab.binding.publictransportswitzerland/src/main/java/org/openhab/binding/publictransportswitzerland/internal/stationboard/PublicTransportSwitzerlandStationboardConfiguration.java new file mode 100644 index 000000000..565c0f694 --- /dev/null +++ b/bundles/org.openhab.binding.publictransportswitzerland/src/main/java/org/openhab/binding/publictransportswitzerland/internal/stationboard/PublicTransportSwitzerlandStationboardConfiguration.java @@ -0,0 +1,31 @@ +/** + * 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 org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The {@link PublicTransportSwitzerlandStationboardConfiguration} class contains fields mapping thing configuration + * parameters. + * + * @author Jeremy Stucki - Initial contribution + */ +@NonNullByDefault +public class PublicTransportSwitzerlandStationboardConfiguration { + + /** + * The station name + */ + public @Nullable String station; +} diff --git a/bundles/org.openhab.binding.publictransportswitzerland/src/main/java/org/openhab/binding/publictransportswitzerland/internal/stationboard/PublicTransportSwitzerlandStationboardHandler.java b/bundles/org.openhab.binding.publictransportswitzerland/src/main/java/org/openhab/binding/publictransportswitzerland/internal/stationboard/PublicTransportSwitzerlandStationboardHandler.java new file mode 100644 index 000000000..37c95d42f --- /dev/null +++ b/bundles/org.openhab.binding.publictransportswitzerland/src/main/java/org/openhab/binding/publictransportswitzerland/internal/stationboard/PublicTransportSwitzerlandStationboardHandler.java @@ -0,0 +1,332 @@ +/** + * 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 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 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 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)); + } +} diff --git a/bundles/org.openhab.binding.publictransportswitzerland/src/main/resources/OH-INF/binding/binding.xml b/bundles/org.openhab.binding.publictransportswitzerland/src/main/resources/OH-INF/binding/binding.xml new file mode 100644 index 000000000..ab321cce0 --- /dev/null +++ b/bundles/org.openhab.binding.publictransportswitzerland/src/main/resources/OH-INF/binding/binding.xml @@ -0,0 +1,9 @@ + + + + Public Transport Switzerland Binding + Connects to the "Swiss public transport API" to provide real-time public transport information. + + diff --git a/bundles/org.openhab.binding.publictransportswitzerland/src/main/resources/OH-INF/thing/stationboard.xml b/bundles/org.openhab.binding.publictransportswitzerland/src/main/resources/OH-INF/thing/stationboard.xml new file mode 100644 index 000000000..d2b417d3a --- /dev/null +++ b/bundles/org.openhab.binding.publictransportswitzerland/src/main/resources/OH-INF/thing/stationboard.xml @@ -0,0 +1,33 @@ + + + + + + Upcoming departures for a single station. + + + + + + + The name of the station + + + + + + String + + + + + String + + A single departure + + + + diff --git a/bundles/pom.xml b/bundles/pom.xml index 62cc68f53..a633b89ee 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -277,6 +277,7 @@ org.openhab.binding.plugwiseha org.openhab.binding.powermax org.openhab.binding.proteusecometer + org.openhab.binding.publictransportswitzerland org.openhab.binding.pulseaudio org.openhab.binding.pushbullet org.openhab.binding.pushover