]> git.basschouten.com Git - openhab-addons.git/commitdiff
[SNCF] A binding to get French railways arrivals and departures (#11607)
authorGaël L'hopital <gael@lhopital.org>
Sat, 4 Dec 2021 17:33:50 +0000 (18:33 +0100)
committerGitHub <noreply@github.com>
Sat, 4 Dec 2021 17:33:50 +0000 (18:33 +0100)
* SNCF : new binding

Signed-off-by: clinique <gael@lhopital.org>
30 files changed:
CODEOWNERS
bom/openhab-addons/pom.xml
bundles/org.openhab.binding.sncf/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.sncf/README.md [new file with mode: 0644]
bundles/org.openhab.binding.sncf/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/SncfBindingConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/SncfException.java [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/SncfHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/discovery/SncfDiscoveryService.java [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/Coord.java [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/NavitiaObject.java [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/Passage.java [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/Passages.java [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/PlaceNearby.java [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/PlacesNearby.java [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/SncfAnswer.java [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/StopArea.java [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/StopDateTime.java [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/StopPoint.java [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/StopPoints.java [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/VJDisplayInformation.java [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/handler/SncfBridgeHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/handler/StationHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/resources/OH-INF/binding/binding.xml [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/resources/OH-INF/config/config.xml [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/resources/OH-INF/i18n/sncf.properties [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/resources/OH-INF/thing/bridge.xml [new file with mode: 0644]
bundles/org.openhab.binding.sncf/src/main/resources/OH-INF/thing/station.xml [new file with mode: 0644]
bundles/pom.xml

index 3b49605401ad7bdf649dbf53a5b8e8f6f3be760d..0bb108fa734bb15bcf8910551b244d5a1ecf7e1f 100644 (file)
 /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
index 4fe676379221e39ff08ed7c0dd456827f65e455d..c354225909cd116c113630e4d858b3417ee8d66e 100644 (file)
       <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>
diff --git a/bundles/org.openhab.binding.sncf/NOTICE b/bundles/org.openhab.binding.sncf/NOTICE
new file mode 100644 (file)
index 0000000..38d625e
--- /dev/null
@@ -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.sncf/README.md b/bundles/org.openhab.binding.sncf/README.md
new file mode 100644 (file)
index 0000000..0c6ad36
--- /dev/null
@@ -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" }
+
+```
+
diff --git a/bundles/org.openhab.binding.sncf/pom.xml b/bundles/org.openhab.binding.sncf/pom.xml
new file mode 100644 (file)
index 0000000..aefe541
--- /dev/null
@@ -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>
diff --git a/bundles/org.openhab.binding.sncf/src/main/feature/feature.xml b/bundles/org.openhab.binding.sncf/src/main/feature/feature.xml
new file mode 100644 (file)
index 0000000..7acc033
--- /dev/null
@@ -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>
diff --git a/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/SncfBindingConstants.java b/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/SncfBindingConstants.java
new file mode 100644 (file)
index 0000000..8f68576
--- /dev/null
@@ -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);
+}
diff --git a/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/SncfException.java b/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/SncfException.java
new file mode 100644 (file)
index 0000000..de12a40
--- /dev/null
@@ -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);
+    }
+}
diff --git a/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/SncfHandlerFactory.java b/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/SncfHandlerFactory.java
new file mode 100644 (file)
index 0000000..131e5d9
--- /dev/null
@@ -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;
+    }
+}
diff --git a/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/discovery/SncfDiscoveryService.java b/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/discovery/SncfDiscoveryService.java
new file mode 100644 (file)
index 0000000..73f96c6
--- /dev/null
@@ -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;
+    }
+}
diff --git a/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/Coord.java b/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/Coord.java
new file mode 100644 (file)
index 0000000..7441c5c
--- /dev/null
@@ -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;
+}
diff --git a/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/NavitiaObject.java b/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/NavitiaObject.java
new file mode 100644 (file)
index 0000000..56b514a
--- /dev/null
@@ -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;
+}
diff --git a/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/Passage.java b/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/Passage.java
new file mode 100644 (file)
index 0000000..0a9fbe6
--- /dev/null
@@ -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;
+}
diff --git a/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/Passages.java b/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/Passages.java
new file mode 100644 (file)
index 0000000..f4988bf
--- /dev/null
@@ -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;
+}
diff --git a/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/PlaceNearby.java b/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/PlaceNearby.java
new file mode 100644 (file)
index 0000000..a293135
--- /dev/null
@@ -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;
+}
diff --git a/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/PlacesNearby.java b/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/PlacesNearby.java
new file mode 100644 (file)
index 0000000..9141872
--- /dev/null
@@ -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;
+}
diff --git a/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/SncfAnswer.java b/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/SncfAnswer.java
new file mode 100644 (file)
index 0000000..b572f84
--- /dev/null
@@ -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;
+}
diff --git a/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/StopArea.java b/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/StopArea.java
new file mode 100644 (file)
index 0000000..706874a
--- /dev/null
@@ -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;
+}
diff --git a/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/StopDateTime.java b/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/StopDateTime.java
new file mode 100644 (file)
index 0000000..0bcce5c
--- /dev/null
@@ -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;
+}
diff --git a/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/StopPoint.java b/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/StopPoint.java
new file mode 100644 (file)
index 0000000..e372dc1
--- /dev/null
@@ -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;
+}
diff --git a/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/StopPoints.java b/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/StopPoints.java
new file mode 100644 (file)
index 0000000..578d33e
--- /dev/null
@@ -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;
+}
diff --git a/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/VJDisplayInformation.java b/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/dto/VJDisplayInformation.java
new file mode 100644 (file)
index 0000000..7e182f6
--- /dev/null
@@ -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;
+}
diff --git a/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/handler/SncfBridgeHandler.java b/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/handler/SncfBridgeHandler.java
new file mode 100644 (file)
index 0000000..0ef7fb0
--- /dev/null
@@ -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);
+    }
+}
diff --git a/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/handler/StationHandler.java b/bundles/org.openhab.binding.sncf/src/main/java/org/openhab/binding/sncf/internal/handler/StationHandler.java
new file mode 100644 (file)
index 0000000..8551992
--- /dev/null
@@ -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;
+    }
+}
diff --git a/bundles/org.openhab.binding.sncf/src/main/resources/OH-INF/binding/binding.xml b/bundles/org.openhab.binding.sncf/src/main/resources/OH-INF/binding/binding.xml
new file mode 100644 (file)
index 0000000..e756b39
--- /dev/null
@@ -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>
diff --git a/bundles/org.openhab.binding.sncf/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.binding.sncf/src/main/resources/OH-INF/config/config.xml
new file mode 100644 (file)
index 0000000..d96cf36
--- /dev/null
@@ -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>
diff --git a/bundles/org.openhab.binding.sncf/src/main/resources/OH-INF/i18n/sncf.properties b/bundles/org.openhab.binding.sncf/src/main/resources/OH-INF/i18n/sncf.properties
new file mode 100644 (file)
index 0000000..1b02bcf
--- /dev/null
@@ -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.
diff --git a/bundles/org.openhab.binding.sncf/src/main/resources/OH-INF/thing/bridge.xml b/bundles/org.openhab.binding.sncf/src/main/resources/OH-INF/thing/bridge.xml
new file mode 100644 (file)
index 0000000..ab99f53
--- /dev/null
@@ -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>
diff --git a/bundles/org.openhab.binding.sncf/src/main/resources/OH-INF/thing/station.xml b/bundles/org.openhab.binding.sncf/src/main/resources/OH-INF/thing/station.xml
new file mode 100644 (file)
index 0000000..495b8c1
--- /dev/null
@@ -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>
index e2dcde088fb957de9077a980f0318968b8d15dc5..711297dbcd8b3ab8dba12a2b952784ffc6b0e159 100644 (file)
     <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>