[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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1285 additions and 0 deletions

View File

@ -279,6 +279,7 @@
/bundles/org.openhab.binding.smartmeter/ @msteigenberger
/bundles/org.openhab.binding.smartthings/ @BobRak
/bundles/org.openhab.binding.smhi/ @pacive
/bundles/org.openhab.binding.sncf/ @clinique
/bundles/org.openhab.binding.snmp/ @openhab/add-ons-maintainers
/bundles/org.openhab.binding.solaredge/ @alexf2015
/bundles/org.openhab.binding.solarlog/ @johannrichard

View File

@ -1386,6 +1386,11 @@
<artifactId>org.openhab.binding.smhi</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.sncf</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.snmp</artifactId>

View File

@ -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

View File

@ -0,0 +1,87 @@
# SNCF Binding
The SNCF binding provides real-time data(*) for each train, bus, tramway... station in France.
This is based on live API provided by DIGITALSNCF.
Get your API key on [DIGITALSNCF web site](https://www.digital.sncf.com/startup/api/token-developpeur)
Note : SNCF Api is based on the open [API Navitia](https://doc.navitia.io/#getting-started).
This binding uses a very small subset of it, restricted to its primary purpose.
(*) According to DIGITALSNCF Transilien may only be available for schedule, maybe not real-time.
## Supported Things
Bridge: The binding supports a bridge to connect to the [DIGITALSNCF service](https://www.digital.sncf.com/startup/api/token developpeur).
A bridge uses the thing ID "api".
Station: Represents a given bus, train station.
Of course, you can add as many stations as needed.
## Discovery
This binding takes care of auto discovery. This method is strongly recommended as it is the only way to get proper station ID depending upon transportation type.
To enable auto-discovery, your location system setting must be defined.
Once done, at first launch, discovery will search every station in a radius of 2000 m around the system, extending it by step of 500 m until it finds a first set of results.
Every following manual successive launch will extend this radius by 500 m, increasing the number of stations discovered.
## Binding Configuration
The binding has no configuration options, all configuration is done at Thing level.
## Bridge Configuration
The bridge configuration only holds the api key :
| Parameter | Description |
|-----------|----------------------------------------------------------------|
| apiID | API ID provided by the DIGITALSNCF service. Mandatory. |
## Thing Configuration
The 'Station' thing has only one configuration parameter:
| Parameter | Description |
|-------------|--------------------------------------------------------------|
| stopPointId | Identifier of the station in the DIGITALSNCF network. |
The thing will auto-update depending on the timestamp of the earliest event detected to trigger (arrival or departure).
## Channels
The Station thing holds two groups of channels (arrivals and departures) containing these channels:
| Channel ID | Item Type | Description |
|-----------------------|-----------|--------------------------------------------------|
| direction | String | The direction of the route |
| lineName | String | Commercial name of the line |
| name | String | Name of the line |
| network | String | Name of the network ruling the line |
| timestamp | DateTime | Timestamp of the event (departure, arrival) |
## Full Example
sncf.things:
```
Bridge sncf:api:8901d44a68 "Bridge" [apiID="xxx-yyy-zzz"] {
station MyHouse "Krakow"[stopPointId="stop_point:SNCF:87561951:Bus"]
}
```
sncf.items:
```
String Arrival_Direction { channel="sncf:station:8901d44a68:87381475_RapidTransit:arrivals#direction" }
String Arrival_Line { channel="sncf:station:8901d44a68:87381475_RapidTransit:arrivals#lineName" }
DateTime Arrival_Time { channel="sncf:station:8901d44a68:87381475_RapidTransit:arrivals#timestamp" }
String Departure_Direction { channel="sncf:station:8901d44a68:87381475_RapidTransit:departures#direction" }
String Departure_Line { channel="sncf:station:8901d44a68:87381475_RapidTransit:departures#lineName" }
DateTime Departure_Time { channel="sncf:station:8901d44a68:87381475_RapidTransit:departures#timestamp" }
```

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>3.2.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.sncf</artifactId>
<name>openHAB Add-ons :: Bundles :: SNCF Binding</name>
</project>

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>

View File

@ -311,6 +311,7 @@
<module>org.openhab.binding.smartmeter</module>
<module>org.openhab.binding.smhi</module>
<module>org.openhab.binding.smartthings</module>
<module>org.openhab.binding.sncf</module>
<module>org.openhab.binding.snmp</module>
<module>org.openhab.binding.solaredge</module>
<module>org.openhab.binding.solarlog</module>