[SNCF] A binding to get French railways arrivals and departures (#11607)

* SNCF : new binding

Signed-off-by: clinique <gael@lhopital.org>
This commit is contained in:
Gaël L'hopital
2021-12-04 18:33:50 +01:00
committed by GitHub
parent 83f5f01267
commit cb0c4bbcb4
30 changed files with 1285 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.sncf-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-binding-sncf" description="SNCF Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.sncf/${project.version}</bundle>
</feature>
</features>

View File

@@ -0,0 +1,54 @@
/**
* 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.sncf.internal;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;
/**
* The {@link SncfBindingConstants} class defines common constants, which are
* used across the whole binding.
*
* @author Gaël L'hopital - Initial contribution
*/
@NonNullByDefault
public class SncfBindingConstants {
public static final String BINDING_ID = "sncf";
// Station properties
public static final String STOP_POINT_ID = "stopPointId";
public static final String DISTANCE = "Distance";
public static final String LOCATION = "Location";
public static final String TIMEZONE = "Timezone";
// List of Channel groups
public static final String GROUP_ARRIVAL = "arrivals";
public static final String GROUP_DEPARTURE = "departures";
// List of Channel id's
public static final String DIRECTION = "direction";
public static final String LINE_NAME = "lineName";
public static final String NAME = "name";
public static final String NETWORK = "network";
public static final String TIMESTAMP = "timestamp";
// List of Thing Type UIDs
public static final ThingTypeUID APIBRIDGE_THING_TYPE = new ThingTypeUID(BINDING_ID, "api");
public static final ThingTypeUID STATION_THING_TYPE = new ThingTypeUID(BINDING_ID, "station");
// List of all adressable things
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(APIBRIDGE_THING_TYPE, STATION_THING_TYPE);
}

View File

@@ -0,0 +1,38 @@
/**
* 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.sncf.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Exception for errors when using the SNCF API
*
* @author Gaël L'hopital - Initial contribution
*/
@NonNullByDefault
public class SncfException extends Exception {
private static final long serialVersionUID = -6215621577081394328L;
public SncfException(String label) {
super(label);
}
public SncfException(Throwable e) {
super(e);
}
public SncfException(@Nullable String message, @Nullable Throwable e) {
super(message, e);
}
}

View File

@@ -0,0 +1,78 @@
/**
* 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.sncf.internal;
import static org.openhab.binding.sncf.internal.SncfBindingConstants.*;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.sncf.internal.handler.SncfBridgeHandler;
import org.openhab.binding.sncf.internal.handler.StationHandler;
import org.openhab.core.i18n.LocationProvider;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.Bridge;
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.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.FieldNamingPolicy;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
/**
* The {@link SncfHandlerFactory} is responsible for creating things and thing
* handlers.
*
* @author Gaël L'hopital - Initial contribution
*/
@NonNullByDefault
@Component(configurationPid = "binding.sncf", service = ThingHandlerFactory.class)
public class SncfHandlerFactory extends BaseThingHandlerFactory {
private final Logger logger = LoggerFactory.getLogger(SncfHandlerFactory.class);
private final LocationProvider locationProvider;
private final HttpClient httpClient;
private final Gson gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.create();
@Activate
public SncfHandlerFactory(@Reference LocationProvider locationProvider,
final @Reference HttpClientFactory httpClientFactory) {
this.locationProvider = locationProvider;
this.httpClient = httpClientFactory.getCommonHttpClient();
}
@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 (APIBRIDGE_THING_TYPE.equals(thingTypeUID)) {
return new SncfBridgeHandler((Bridge) thing, gson, locationProvider, httpClient);
} else if (STATION_THING_TYPE.equals(thingTypeUID)) {
return new StationHandler(thing, locationProvider);
}
logger.warn("ThingHandler not found for {}", thing.getThingTypeUID());
return null;
}
}

View File

@@ -0,0 +1,115 @@
/**
* 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.sncf.internal.discovery;
import static org.openhab.binding.sncf.internal.SncfBindingConstants.*;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.sncf.internal.SncfException;
import org.openhab.binding.sncf.internal.dto.PlaceNearby;
import org.openhab.binding.sncf.internal.handler.SncfBridgeHandler;
import org.openhab.core.config.discovery.AbstractDiscoveryService;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.i18n.LocationProvider;
import org.openhab.core.library.types.PointType;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerService;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link SncfDiscoveryService} searches for available
* station discoverable through API
*
* @author Gaël L'hopital - Initial contribution
*/
@Component(service = ThingHandlerService.class)
@NonNullByDefault
public class SncfDiscoveryService extends AbstractDiscoveryService implements ThingHandlerService {
private static final int SEARCH_TIME = 7;
private final Logger logger = LoggerFactory.getLogger(SncfDiscoveryService.class);
private @Nullable LocationProvider locationProvider;
private @Nullable SncfBridgeHandler bridgeHandler;
private int searchRange = 1500;
@Activate
public SncfDiscoveryService() {
super(SUPPORTED_THING_TYPES_UIDS, SEARCH_TIME, false);
}
@Override
public void deactivate() {
super.deactivate();
}
@Override
public void startScan() {
SncfBridgeHandler handler = bridgeHandler;
LocationProvider provider = locationProvider;
if (provider != null && handler != null) {
PointType location = provider.getLocation();
if (location != null) {
ThingUID bridgeUID = handler.getThing().getUID();
searchRange += 500;
try {
List<PlaceNearby> places = handler.discoverNearby(location, searchRange);
if (places != null && !places.isEmpty()) {
places.forEach(place -> {
// stop_point:SNCF:87386573:Bus
List<String> idElts = new LinkedList<String>(Arrays.asList(place.id.split(":")));
idElts.remove(0);
idElts.remove(0);
thingDiscovered(DiscoveryResultBuilder
.create(new ThingUID(STATION_THING_TYPE, bridgeUID, String.join("_", idElts)))
.withLabel(String.format("%s (%s)", place.stopPoint.name, idElts.get(1))
.replace("-", "_"))
.withBridge(bridgeUID).withRepresentationProperty(STOP_POINT_ID)
.withProperty(STOP_POINT_ID, place.id).build());
});
} else {
logger.info("No station found in a perimeter of {} m, extending search", searchRange);
startScan();
}
} catch (SncfException e) {
logger.warn("Error calling SNCF Api : {}", e.getMessage());
}
} else {
logger.info("Please set a system location to enable station discovery");
}
}
}
@Override
public void setThingHandler(ThingHandler handler) {
if (handler instanceof SncfBridgeHandler) {
this.bridgeHandler = (SncfBridgeHandler) handler;
this.locationProvider = ((SncfBridgeHandler) handler).getLocationProvider();
}
}
@Override
public @Nullable ThingHandler getThingHandler() {
return bridgeHandler;
}
}

View File

@@ -0,0 +1,23 @@
/**
* 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.sncf.internal.dto;
/**
* The {@link Coord} class holds latitude and longitude of a point
*
* @author Gaël L'hopital - Initial contribution
*/
public class Coord {
public String lat;
public String lon;
}

View File

@@ -0,0 +1,23 @@
/**
* 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.sncf.internal.dto;
/**
* The {@link NavitiaObject} base class for API objects
*
* @author Gaël L'hopital - Initial contribution
*/
public class NavitiaObject {
public String id;
public String name;
}

View File

@@ -0,0 +1,24 @@
/**
* 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.sncf.internal.dto;
/**
* The {@link Passage} holds data regarding a transportation
* information passing at a given station
*
* @author Gaël L'hopital - Initial contribution
*/
public class Passage {
public VJDisplayInformation displayInformations;
public StopDateTime stopDateTime;
}

View File

@@ -0,0 +1,30 @@
/**
* 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.sncf.internal.dto;
import java.util.List;
import org.eclipse.jdt.annotation.Nullable;
import com.google.gson.annotations.SerializedName;
/**
* The {@link Passages} is responsible for storing
* list of arrivals or departures depending upon called API
*
* @author Gaël L'hopital - Initial contribution
*/
public class Passages extends SncfAnswer {
@SerializedName(value = "departures", alternate = "arrivals")
public @Nullable List<Passage> passages;
}

View File

@@ -0,0 +1,22 @@
/**
* 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.sncf.internal.dto;
/**
* The {@link PlaceNearby} holds data returned by the API call
*
* @author Gaël L'hopital - Initial contribution
*/
public class PlaceNearby extends NavitiaObject {
public StopPoint stopPoint;
}

View File

@@ -0,0 +1,24 @@
/**
* 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.sncf.internal.dto;
import java.util.List;
/**
* The {@link PlacesNearby} holds a list or nearby places.
*
* @author Gaël L'hopital - Initial contribution
*/
public class PlacesNearby extends SncfAnswer {
public List<PlaceNearby> placesNearby;
}

View File

@@ -0,0 +1,23 @@
/**
* 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.sncf.internal.dto;
/**
* The {@link SncfAnswer} is the base class for all Sncf API requests
*
* @author Gaël L'hopital - Initial contribution
*/
public abstract class SncfAnswer {
public Error error;
public String message;
}

View File

@@ -0,0 +1,23 @@
/**
* 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.sncf.internal.dto;
/**
* The {@link StopArea} class holds informations for a Stop Area
* (usually a train station)
*
* @author Gaël L'hopital - Initial contribution
*/
public class StopArea extends NavitiaObject {
public String timezone;
}

View File

@@ -0,0 +1,23 @@
/**
* 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.sncf.internal.dto;
/**
* The {@link StopDateTime} class holds informations for a transportation stop
*
* @author Gaël L'hopital - Initial contribution
*/
public class StopDateTime {
public String arrivalDateTime;
public String departureDateTime;
}

View File

@@ -0,0 +1,23 @@
/**
* 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.sncf.internal.dto;
/**
* The {@link StopPoint} class holds informations for a train station
*
* @author Gaël L'hopital - Initial contribution
*/
public class StopPoint extends NavitiaObject {
public StopArea stopArea;
public Coord coord;
}

View File

@@ -0,0 +1,26 @@
/**
* 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.sncf.internal.dto;
import java.util.List;
import org.eclipse.jdt.annotation.Nullable;
/**
* The {@link StopPoints} holds a list of Stop Points.
*
* @author Gaël L'hopital - Initial contribution
*/
public class StopPoints extends SncfAnswer {
public @Nullable List<StopPoint> stopPoints;
}

View File

@@ -0,0 +1,27 @@
/**
* 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.sncf.internal.dto;
/**
* The {@link VJDisplayInformation} class holds informations displayed
* to traveller regarding a stop in the station
*
* @author Gaël L'hopital - Initial contribution
*/
public class VJDisplayInformation {
public String code;
public String network;
public String name;
public String commercialMode;
public String direction;
}

View File

@@ -0,0 +1,171 @@
/**
* 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.sncf.internal.handler;
import static org.eclipse.jetty.http.HttpMethod.GET;
import static org.eclipse.jetty.http.HttpStatus.OK_200;
import java.time.Duration;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.http.HttpHeader;
import org.openhab.binding.sncf.internal.SncfException;
import org.openhab.binding.sncf.internal.discovery.SncfDiscoveryService;
import org.openhab.binding.sncf.internal.dto.Passage;
import org.openhab.binding.sncf.internal.dto.Passages;
import org.openhab.binding.sncf.internal.dto.PlaceNearby;
import org.openhab.binding.sncf.internal.dto.PlacesNearby;
import org.openhab.binding.sncf.internal.dto.SncfAnswer;
import org.openhab.binding.sncf.internal.dto.StopPoint;
import org.openhab.binding.sncf.internal.dto.StopPoints;
import org.openhab.core.cache.ExpiringCacheMap;
import org.openhab.core.i18n.LocationProvider;
import org.openhab.core.library.types.PointType;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseBridgeHandler;
import org.openhab.core.thing.binding.ThingHandlerService;
import org.openhab.core.types.Command;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;
/**
* The {@link SncfBridgeHandler} is handles connection and communication toward
* SNCF API
*
* @author Gaël L'hopital - Initial contribution
*/
@NonNullByDefault
public class SncfBridgeHandler extends BaseBridgeHandler {
public static final String JSON_CONTENT_TYPE = "application/json";
public static final String SERVICE_URL = "https://api.sncf.com/v1/coverage/sncf/";
private final Logger logger = LoggerFactory.getLogger(SncfBridgeHandler.class);
private final LocationProvider locationProvider;
private final ExpiringCacheMap<String, @Nullable String> cache = new ExpiringCacheMap<>(Duration.ofMinutes(1));
private final HttpClient httpClient;
private final Gson gson;
private @NonNullByDefault({}) String apiId;
public SncfBridgeHandler(Bridge bridge, Gson gson, LocationProvider locationProvider, HttpClient httpClient) {
super(bridge);
this.locationProvider = locationProvider;
this.httpClient = httpClient;
this.gson = gson;
}
@Override
public void initialize() {
logger.debug("Initializing SNCF API bridge handler.");
apiId = (String) getConfig().get("apiID");
if (apiId != null && !apiId.isBlank()) {
updateStatus(ThingStatus.ONLINE);
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/null-or-empty-api-key");
}
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
logger.debug("SNCF API Bridge is read-only and does not handle commands");
}
private <T extends SncfAnswer> T getResponseFromCache(String url, Class<T> objectClass) throws SncfException {
String answer = cache.putIfAbsentAndGet(url, () -> getResponse(url));
try {
if (answer != null) {
@Nullable
T response = gson.fromJson(answer, objectClass);
if (response == null) {
throw new SncfException("Unable to deserialize API answer");
}
if (response.message != null) {
throw new SncfException(response.message);
}
return response;
} else {
throw new SncfException(String.format("Unable to get api answer for url : %s", url));
}
} catch (JsonSyntaxException e) {
throw new SncfException(e);
}
}
private @Nullable String getResponse(String url) {
try {
logger.debug("SNCF Api request: url = '{}'", url);
ContentResponse contentResponse = httpClient.newRequest(url).method(GET).timeout(10, TimeUnit.SECONDS)
.header(HttpHeader.AUTHORIZATION, apiId).send();
int httpStatus = contentResponse.getStatus();
String content = contentResponse.getContentAsString();
logger.debug("SNCF Api response: status = {}, content = '{}'", httpStatus, content);
if (httpStatus == OK_200) {
return content;
}
logger.debug("SNCF Api server responded with status code {}: {}", httpStatus, content);
} catch (TimeoutException | ExecutionException e) {
logger.debug("Execution occured : {}", e.getMessage(), e);
} catch (InterruptedException e) {
logger.debug("Execution interrupted : {}", e.getMessage(), e);
Thread.currentThread().interrupt();
}
return null;
}
public @Nullable List<PlaceNearby> discoverNearby(PointType location, int distance) throws SncfException {
String url = String.format(Locale.US, "%scoord/%.5f;%.5f/places_nearby?distance=%d&type[]=stop_point&count=100",
SERVICE_URL, location.getLongitude().floatValue(), location.getLatitude().floatValue(), distance);
PlacesNearby places = getResponseFromCache(url, PlacesNearby.class);
return places.placesNearby;
}
public Optional<StopPoint> stopPointDetail(String stopPointId) throws SncfException {
String url = String.format("%sstop_points/%s", SERVICE_URL, stopPointId);
List<StopPoint> points = getResponseFromCache(url, StopPoints.class).stopPoints;
return points != null && !points.isEmpty() ? Optional.ofNullable(points.get(0)) : Optional.empty();
}
public Optional<Passage> getNextPassage(String stopPointId, String expected) throws SncfException {
String url = String.format("%sstop_points/%s/%s?disable_geojson=true&count=1", SERVICE_URL, stopPointId,
expected);
List<Passage> passages = getResponseFromCache(url, Passages.class).passages;
return passages != null && !passages.isEmpty() ? Optional.ofNullable(passages.get(0)) : Optional.empty();
}
public LocationProvider getLocationProvider() {
return locationProvider;
}
@Override
public Collection<Class<? extends ThingHandlerService>> getServices() {
return Set.of(SncfDiscoveryService.class);
}
}

View File

@@ -0,0 +1,259 @@
/**
* 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.sncf.internal.handler;
import static org.openhab.binding.sncf.internal.SncfBindingConstants.*;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.sncf.internal.SncfException;
import org.openhab.binding.sncf.internal.dto.Passage;
import org.openhab.core.i18n.LocationProvider;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.PointType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.library.unit.SIUnits;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Channel;
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.ThingStatusInfo;
import org.openhab.core.thing.binding.BaseThingHandler;
import org.openhab.core.thing.binding.BridgeHandler;
import org.openhab.core.thing.binding.builder.ThingBuilder;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link StationHandler} is responsible for handling commands, which are sent
* to one of the channels.
*
* @author Gaël L'hopital - Initial contribution
*/
@NonNullByDefault
public class StationHandler extends BaseThingHandler {
private static final DateTimeFormatter NAVITIA_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmssZ");
private final Logger logger = LoggerFactory.getLogger(StationHandler.class);
private final LocationProvider locationProvider;
private @Nullable ScheduledFuture<?> refreshJob;
private @NonNullByDefault({}) String stationId;
private @NonNullByDefault({}) String zoneOffset;
public StationHandler(Thing thing, LocationProvider locationProvider) {
super(thing);
this.locationProvider = locationProvider;
}
@Override
public void initialize() {
logger.trace("Initializing the Station handler for {}", getThing().getUID());
stationId = (String) getConfig().get("stopPointId");
if (stationId == null || stationId.isBlank()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/null-or-empty-station-id");
return;
}
if (thing.getProperties().isEmpty() && !discoverAttributes(stationId)) {
return;
}
String timezone = thing.getProperties().get(TIMEZONE);
if (timezone == null || timezone.isBlank()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/null-or-empty-timezone");
return;
}
zoneOffset = ZoneId.of(timezone).getRules().getOffset(Instant.now()).getId().replace(":", "");
scheduleRefresh(ZonedDateTime.now().plusSeconds(2));
updateStatus(ThingStatus.ONLINE);
}
@Override
public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
super.bridgeStatusChanged(bridgeStatusInfo);
if (thing.getStatus() == ThingStatus.ONLINE) {
initialize();
}
}
private boolean discoverAttributes(String localStation) {
SncfBridgeHandler bridgeHandler = getBridgeHandler();
if (bridgeHandler != null) {
Map<String, String> properties = new HashMap<>();
try {
bridgeHandler.stopPointDetail(localStation).ifPresent(stopPoint -> {
String stationLoc = String.format("%s,%s", stopPoint.coord.lat, stopPoint.coord.lon);
properties.put(LOCATION, stationLoc);
properties.put(TIMEZONE, stopPoint.stopArea.timezone);
PointType serverLoc = locationProvider.getLocation();
if (serverLoc != null) {
PointType stationLocation = new PointType(stationLoc);
double distance = serverLoc.distanceFrom(stationLocation).doubleValue();
properties.put(DISTANCE, new QuantityType<>(distance, SIUnits.METRE).toString());
}
});
ThingBuilder thingBuilder = editThing();
thingBuilder.withProperties(properties);
updateThing(thingBuilder.build());
return true;
} catch (SncfException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
}
}
return false;
}
private void scheduleRefresh(@Nullable ZonedDateTime when) {
// Ensure we'll try to refresh in one minute if no valid timestamp is provided
long wishedDelay = ZonedDateTime.now().until(when != null ? when : ZonedDateTime.now().plusMinutes(1),
ChronoUnit.SECONDS);
wishedDelay = wishedDelay < 0 ? 60 : wishedDelay;
logger.debug("wishedDelay is {} seconds", wishedDelay);
ScheduledFuture<?> job = refreshJob;
if (job != null) {
long existingDelay = job.getDelay(TimeUnit.SECONDS);
logger.debug("existingDelay is {} seconds", existingDelay);
if (existingDelay < wishedDelay && existingDelay > 0) {
logger.debug("Do nothing, existingDelay earlier than wishedDelay");
return;
}
freeRefreshJob();
}
logger.debug("Scheduling update in {} seconds.", wishedDelay);
refreshJob = scheduler.schedule(() -> updateThing(), wishedDelay, TimeUnit.SECONDS);
}
private void updateThing() {
SncfBridgeHandler bridgeHandler = getBridgeHandler();
if (bridgeHandler != null) {
scheduler.submit(() -> {
updatePassage(bridgeHandler, GROUP_ARRIVAL);
updatePassage(bridgeHandler, GROUP_DEPARTURE);
});
}
}
private void updatePassage(SncfBridgeHandler bridgeHandler, String direction) {
try {
bridgeHandler.getNextPassage(stationId, direction).ifPresentOrElse(passage -> {
getThing().getChannels().stream().map(Channel::getUID)
.filter(channelUID -> isLinked(channelUID) && direction.equals(channelUID.getGroupId()))
.forEach(channelUID -> {
State state = getValue(channelUID.getIdWithoutGroup(), passage, direction);
updateState(channelUID, state);
});
ZonedDateTime eventTime = getEventTimestamp(passage, direction);
if (eventTime != null) {
scheduleRefresh(eventTime.plusSeconds(10));
}
}, () -> {
logger.debug("No {} available", direction);
scheduleRefresh(ZonedDateTime.now().plusMinutes(5));
});
updateStatus(ThingStatus.ONLINE);
} catch (SncfException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
freeRefreshJob();
}
}
private State getValue(String channelId, Passage passage, String direction) {
switch (channelId) {
case DIRECTION:
return fromNullableString(passage.displayInformations.direction);
case LINE_NAME:
return fromNullableString(String.format("%s %s", passage.displayInformations.commercialMode,
passage.displayInformations.code));
case NAME:
return fromNullableString(passage.displayInformations.name);
case NETWORK:
return fromNullableString(passage.displayInformations.network);
case TIMESTAMP:
return fromNullableTime(passage, direction);
}
return UnDefType.NULL;
}
private State fromNullableString(@Nullable String aValue) {
return aValue != null ? StringType.valueOf(aValue) : UnDefType.NULL;
}
private @Nullable ZonedDateTime getEventTimestamp(Passage passage, String direction) {
String eventTime = direction.equals(GROUP_ARRIVAL) ? passage.stopDateTime.arrivalDateTime
: passage.stopDateTime.departureDateTime;
return eventTime != null ? ZonedDateTime.parse(eventTime + zoneOffset, NAVITIA_DATE_FORMAT) : null;
}
private State fromNullableTime(Passage passage, String direction) {
ZonedDateTime timestamp = getEventTimestamp(passage, direction);
return timestamp != null ? new DateTimeType(timestamp) : UnDefType.NULL;
}
private void freeRefreshJob() {
ScheduledFuture<?> job = refreshJob;
if (job != null) {
job.cancel(true);
this.refreshJob = null;
}
}
@Override
public void dispose() {
freeRefreshJob();
super.dispose();
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (command instanceof RefreshType) {
updateThing();
}
}
private @Nullable SncfBridgeHandler getBridgeHandler() {
Bridge bridge = getBridge();
if (bridge != null) {
BridgeHandler handler = bridge.getHandler();
if (handler != null) {
if (handler.getThing().getStatus() == ThingStatus.ONLINE) {
return (SncfBridgeHandler) handler;
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
return null;
}
}
}
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED);
return null;
}
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<binding:binding id="sncf" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:binding="https://openhab.org/schemas/binding/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/binding/v1.0.0 https://openhab.org/schemas/binding-1.0.0.xsd">
<name>SNCF Binding</name>
<description>Retrieves French railway informations</description>
</binding:binding>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<config-description:config-descriptions
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0
https://openhab.org/schemas/config-description-1.0.0.xsd">
<config-description uri="thing-type:sncf:api">
<parameter name="apiID" type="text" required="true">
<label>API ID</label>
<context>password</context>
</parameter>
</config-description>
</config-description:config-descriptions>

View File

@@ -0,0 +1,36 @@
binding.sncf.name = SNCF Binding
binding.sncf.description = Retrieves French railway informations
config.thing-type.sncf.api.apiID.label = API ID
config.thing-type.sncf.api.apiID.description = Your SNCF API ID
thing-type.sncf.api.label = SNCF API
thing-type.sncf.api.description = This bridge is the gateway to SNCF API.
thing-type.sncf.station.label = Station
thing-type.sncf.station.description = Represents a station hosting some transportation mode.
thing-type.sncf.station.group.arrivals.label = Next Arrival
thing-type.sncf.station.group.arrivals.description = Informations regarding next arrival at the station.
thing-type.sncf.station.group.departures.label = Next Departure
thing-type.sncf.station.group.departures.description = Informations regarding next departure from the station.
thing-type.config.sncf.station.stopPointId.label = Stop Point ID
thing-type.config.sncf.station.stopPointId.description = The stop point ID of the station as defined by DIGITALSNCF.
channel-type.sncf.direction.label = Direction
channel-type.sncf.direction.description = The direction of this route.
channel-type.sncf.lineName.label = Line
channel-type.sncf.lineName.description = Name of the line (network + line number/letter)
channel-type.sncf.name.label = Name
channel-type.sncf.name.description = Name of the line.
channel-type.sncf.network.label = Network
channel-type.sncf.network.description = Name of the transportation network.
channel-type.sncf.timestamp.label = Timestamp
channel-type.sncf.timestamp.description = Timestamp of the future event.
# Error messages
null-or-empty-api-key = Null or empty API ID
error-invalid-apikey = Invalid API ID
null-or-empty-station-id = Null or empty Station ID
null-or-empty-timezone = Timezone is empty. It should have been set at first initialization.

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="sncf"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<bridge-type id="api">
<label>SNCF API</label>
<config-description-ref uri="thing-type:sncf:api"/>
</bridge-type>
</thing:thing-descriptions>

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="sncf"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<thing-type id="station">
<supported-bridge-type-refs>
<bridge-type-ref id="api"/>
</supported-bridge-type-refs>
<label>Station</label>
<channel-groups>
<channel-group id="arrivals" typeId="passage">
<label>Next Arrival</label>
</channel-group>
<channel-group id="departures" typeId="passage">
<label>Next Departure</label>
</channel-group>
</channel-groups>
<representation-property>stopPointId</representation-property>
<config-description>
<parameter name="stopPointId" type="text" required="true">
<label>Station ID</label>
</parameter>
</config-description>
</thing-type>
<channel-group-type id="passage">
<label>Other</label>
<channels>
<channel id="direction" typeId="direction"/>
<channel id="lineName" typeId="lineName"/>
<channel id="name" typeId="name"/>
<channel id="network" typeId="network"/>
<channel id="timestamp" typeId="timestamp"/>
</channels>
</channel-group-type>
<channel-type id="direction">
<item-type>String</item-type>
<label>Direction</label>
<state readOnly="true"></state>
</channel-type>
<channel-type id="lineName">
<item-type>String</item-type>
<label>Line</label>
<state readOnly="true"></state>
</channel-type>
<channel-type id="name" advanced="true">
<item-type>String</item-type>
<label>Name</label>
<state readOnly="true"></state>
</channel-type>
<channel-type id="network" advanced="true">
<item-type>String</item-type>
<label>Network</label>
<state readOnly="true"></state>
</channel-type>
<channel-type id="timestamp">
<item-type>DateTime</item-type>
<label>Timestamp</label>
<category>time</category>
<state readOnly="true" pattern="%1$tH:%1$tM:%1$tS"/>
</channel-type>
</thing:thing-descriptions>