Explorar o código

Created a FieldClimate module

Lukas Cerny %!s(int64=6) %!d(string=hai) anos
pai
achega
9d59b63f2b
Modificáronse 33 ficheiros con 1966 adicións e 81 borrados
  1. 39 0
      config/fieldclimateSenslog1.yaml
  2. 1 1
      connector-common/src/main/java/cz/senslog/connector/http/ContentType.java
  3. 23 3
      connector-common/src/main/java/cz/senslog/connector/http/HttpClient.java
  4. 4 0
      connector-common/src/main/java/cz/senslog/connector/http/HttpHeader.java
  5. 23 1
      connector-common/src/main/java/cz/senslog/connector/json/BasicJson.java
  6. 5 0
      connector-common/src/main/java/cz/senslog/connector/json/Formatter.java
  7. 1 0
      connector-fetch-azure/src/main/java/cz/senslog/connector/fetch/azure/AzureFetcher.java
  8. 21 2
      connector-fetch-fieldclimate/src/main/java/cz/senslog/connector/fetch/fieldclimate/ConnectorFetchFieldClimateProvider.java
  9. 44 0
      connector-fetch-fieldclimate/src/main/java/cz/senslog/connector/fetch/fieldclimate/FieldClimateConfig.java
  10. 249 5
      connector-fetch-fieldclimate/src/main/java/cz/senslog/connector/fetch/fieldclimate/FieldClimateFetcher.java
  11. 32 0
      connector-fetch-fieldclimate/src/main/java/cz/senslog/connector/fetch/fieldclimate/StationTimeRange.java
  12. 33 0
      connector-fetch-fieldclimate/src/main/java/cz/senslog/connector/fetch/fieldclimate/auth/AuthConfig.java
  13. 43 0
      connector-fetch-fieldclimate/src/main/java/cz/senslog/connector/fetch/fieldclimate/auth/AuthenticationService.java
  14. 485 0
      connector-fetch-fieldclimate/src/test/java/cz/senslog/connector/fetch/fieldclimate/FieldClimateFetcherTest.java
  15. 2 1
      connector-model/src/main/java/cz/senslog/connector/model/converter/AzureModelSenslogV1ModelConverter.java
  16. 147 0
      connector-model/src/main/java/cz/senslog/connector/model/converter/FieldClimateModelSenslogV1ModelConverter.java
  17. 1 1
      connector-model/src/main/java/cz/senslog/connector/model/converter/ModelConverterProvider.java
  18. 9 1
      connector-model/src/main/java/cz/senslog/connector/model/fieldclimate/FieldClimateModel.java
  19. 73 0
      connector-model/src/main/java/cz/senslog/connector/model/fieldclimate/SensorDataInfo.java
  20. 106 0
      connector-model/src/main/java/cz/senslog/connector/model/fieldclimate/SensorType.java
  21. 44 0
      connector-model/src/main/java/cz/senslog/connector/model/fieldclimate/StationData.java
  22. 35 0
      connector-model/src/main/java/cz/senslog/connector/model/fieldclimate/StationInfo.java
  23. 1 30
      connector-model/src/main/java/cz/senslog/connector/model/v1/Observation.java
  24. 73 0
      connector-model/src/main/java/cz/senslog/connector/model/v1/Position.java
  25. 36 0
      connector-model/src/main/java/cz/senslog/connector/model/v1/Record.java
  26. 3 3
      connector-model/src/main/java/cz/senslog/connector/model/v1/SenslogV1Model.java
  27. 4 2
      connector-model/src/test/java/cz/senslog/connector/model/converter/AzureModelSenslogV1ModelConverterTest.java
  28. 338 0
      connector-model/src/test/java/cz/senslog/connector/model/converter/FieldClimateModelSenslogV1ModelConverterTest.java
  29. 80 21
      connector-push-rest-senslog-v1/src/main/java/cz/senslog/connector/push/rest/senslog/v1/SenslogV1Pusher.java
  30. 3 2
      connector-push-rest-senslog-v1/src/test/java/cz/senslog/connector/push/rest/senslog/v1/SenslogV1PusherTest.java
  31. 5 5
      docker-compose.yaml
  32. 1 1
      docker/Dockerfile
  33. 2 2
      pom.xml

+ 39 - 0
config/fieldclimateSenslog1.yaml

@@ -0,0 +1,39 @@
+settings:
+  - SenslogV1:
+      name: "Senslog V1"
+      provider: "cz.senslog.connector.push.rest.senslog.v1.SenslogV1ConnectorPushProvider"
+      host:
+        domain: "http://localhost:9080"
+        path: "/FeederServlet"
+
+  - Fieldclimate:
+      name: "FieldClimate: Pessl Instruments"
+      provider: "cz.senslog.connector.fetch.fieldclimate.ConnectorFetchFieldClimateProvider"
+      startDate: 2019-03-07T00:00:00.000
+
+      <<: &apiDomain
+        domain: "https://api.fieldclimate.com/v1"
+
+      authentication:
+        publicKey: "3737ed4fe98fae975e54991216ed473c8d7db48662deff19"
+        privateKey: "ed2e4abacdaad1d542eeabcec4ee4f6c8fbf3b8bb167b84b"
+
+      stationsHost:
+        <<: *apiDomain
+        path: "/user/stations"
+
+      stationDataHost:
+        <<: *apiDomain
+        path: "/data/normal/{station_id}/raw/from/{from}/to/{to}"
+
+      stationTimeRangeHost:
+        <<: *apiDomain
+        path: "/data/{station_id}"
+
+
+connectors:
+    - FieldclimateToV1:
+        fetcher: "Fieldclimate"
+        pusher: "SenslogV1"
+        period: 3000
+        initDelay: 5

+ 1 - 1
connector-common/src/main/java/cz/senslog/connector/http/ContentType.java

@@ -2,7 +2,7 @@ package cz.senslog.connector.http;
 
 public class ContentType {
 
-    public static final String APPLICATION_JSON = "application/json; charset=utf-8";
+    public static final String APPLICATION_JSON = "application/json";
 
     public static final String TEXT_PLAIN = "text/plain";
 }

+ 23 - 3
connector-common/src/main/java/cz/senslog/connector/http/HttpClient.java

@@ -7,8 +7,11 @@ import org.apache.http.HttpMessage;
 import org.apache.http.client.methods.HttpGet;
 import org.apache.http.client.methods.HttpPost;
 import org.apache.http.client.methods.HttpRequestBase;
+import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
+import org.apache.http.conn.ssl.TrustStrategy;
 import org.apache.http.entity.StringEntity;
 import org.apache.http.impl.client.HttpClientBuilder;
+import org.apache.http.ssl.SSLContextBuilder;
 import org.apache.http.util.EntityUtils;
 
 import java.io.BufferedReader;
@@ -17,6 +20,9 @@ import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.net.URI;
 import java.net.URISyntaxException;
+import java.security.KeyManagementException;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -41,14 +47,28 @@ public class HttpClient {
      * @return new instance of {@code HttpClient}.
      */
     public static HttpClient newHttpClient() {
-        return new HttpClient();
+        return new HttpClient(HttpClientBuilder.create());
+    }
+
+    public static HttpClient newHttpSSLClient() {
+        try {
+            SSLContextBuilder builder = new SSLContextBuilder();
+            builder.loadTrustMaterial(null, (TrustStrategy) (chain, authType) -> true);
+
+            SSLConnectionSocketFactory sslSF = new SSLConnectionSocketFactory(builder.build(),
+                    SSLConnectionSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);
+            return new HttpClient(HttpClientBuilder.create().setSSLSocketFactory(sslSF));
+
+        } catch (NoSuchAlgorithmException | KeyStoreException | KeyManagementException e) {
+            return null;
+        }
     }
 
     /**
      * Private constructors sets http client.
      */
-    private HttpClient() {
-        this.client = HttpClientBuilder.create().build();
+    private HttpClient(HttpClientBuilder httpClientBuilder) {
+        this.client = httpClientBuilder.build();
     }
 
     /**

+ 4 - 0
connector-common/src/main/java/cz/senslog/connector/http/HttpHeader.java

@@ -3,4 +3,8 @@ package cz.senslog.connector.http;
 public class HttpHeader {
 
     public static final String AUTHORIZATION = "Authorization";
+
+    public static final String DATE = "Date";
+
+    public static final String ACCEPT = "Accept";
 }

+ 23 - 1
connector-common/src/main/java/cz/senslog/connector/json/BasicJson.java

@@ -30,7 +30,7 @@ import static java.time.format.DateTimeFormatter.ISO_DATE_TIME;
  * @version 1.0
  * @since 1.0
  */
-public final class BasicJson {
+public class BasicJson {
 
     /** Instance of json converter. */
     private static Gson gson = new GsonBuilder()
@@ -129,6 +129,28 @@ public final class BasicJson {
         }
     }
 
+    /*
+    public static <T, F> String objectToJson(T object, Formatter<?>... formatters) {
+
+        GsonBuilder gsonBuilder = new GsonBuilder();
+        for (Formatter<?> formatter : formatters) {
+
+            // TODO does not work -> get parametrized type of the formatter class
+            ParameterizedType converterTypes = (ParameterizedType) formatter.getClass().getGenericInterfaces()[0];
+            Class formatterClass = (Class) converterTypes.getActualTypeArguments()[0];
+
+            gsonBuilder.registerTypeAdapter(LocalDateTime.class, (JsonSerializer<?>) (f, type, jsonSerializationContext) ->
+                    new JsonPrimitive(formatter.format(f)));
+        }
+
+        try {
+            return gsonBuilder.create().toJson(object);
+        } catch (JsonSyntaxException e) {
+            throw new SyntaxException(e.getMessage());
+        }
+    }
+    */
+
     /**
      * Checks if input string is in json format.
      * @param json - input json.

+ 5 - 0
connector-common/src/main/java/cz/senslog/connector/json/Formatter.java

@@ -0,0 +1,5 @@
+package cz.senslog.connector.json;
+
+public interface Formatter<T> {
+    String format(T object);
+}

+ 1 - 0
connector-fetch-azure/src/main/java/cz/senslog/connector/fetch/azure/AzureFetcher.java

@@ -189,6 +189,7 @@ public class AzureFetcher implements ConnectorFetcher<AzureModel> {
                     .GET().build();
 
             logger.info("Sending the http request.");
+            logger.info(request);
             HttpResponse response = httpClient.send(request);
             logger.info("Received a response with a status: {}.", response.getStatus());
 

+ 21 - 2
connector-fetch-fieldclimate/src/main/java/cz/senslog/connector/fetch/fieldclimate/ConnectorFetchFieldClimateProvider.java

@@ -3,17 +3,36 @@ package cz.senslog.connector.fetch.fieldclimate;
 import cz.senslog.connector.config.model.DefaultConfig;
 import cz.senslog.connector.fetch.api.ConnectorFetchProvider;
 import cz.senslog.connector.fetch.api.ConnectorFetcher;
+import cz.senslog.connector.fetch.fieldclimate.auth.AuthConfig;
+import cz.senslog.connector.fetch.fieldclimate.auth.AuthenticationService;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 
+import static cz.senslog.connector.http.HttpClient.newHttpSSLClient;
+
 
 public final class ConnectorFetchFieldClimateProvider implements ConnectorFetchProvider {
 
     private static Logger logger = LogManager.getLogger(ConnectorFetchFieldClimateProvider.class);
 
     @Override
-    public ConnectorFetcher createFetcher(DefaultConfig config) {
+    public ConnectorFetcher createFetcher(DefaultConfig defaultConfig) {
         logger.info("Initialization a new fetch provider {}.", ConnectorFetchFieldClimateProvider.class);
-        return new FieldClimateFetcher();
+
+        logger.debug("Creating a new configuration.");
+        FieldClimateConfig config = new FieldClimateConfig(defaultConfig);
+        logger.info("Configuration for {} was created successfully.", ConnectorFetcher.class);
+
+        logger.debug("Getting a configuration for authentication.");
+        AuthConfig authConfig = config.getAuthentication();
+
+        logger.info("Initialization a new Azure authentication service.");
+        AuthenticationService authService = new AuthenticationService(authConfig);
+
+        logger.debug("Creating a new instance of {}.", FieldClimateFetcher.class);
+        FieldClimateFetcher fetch = new FieldClimateFetcher(config, authService, newHttpSSLClient());
+        logger.info("Fetcher for {} was created successfully.", FieldClimateFetcher.class);
+
+        return fetch;
     }
 }

+ 44 - 0
connector-fetch-fieldclimate/src/main/java/cz/senslog/connector/fetch/fieldclimate/FieldClimateConfig.java

@@ -0,0 +1,44 @@
+package cz.senslog.connector.fetch.fieldclimate;
+
+import cz.senslog.connector.config.model.DefaultConfig;
+import cz.senslog.connector.config.model.HostConfig;
+import cz.senslog.connector.fetch.fieldclimate.auth.AuthConfig;
+
+import java.time.LocalDateTime;
+
+public class FieldClimateConfig {
+
+    private final LocalDateTime startDate;
+    private final HostConfig stationsHost;
+    private final HostConfig stationDataHost;
+    private final HostConfig stationTimeRangeHost;
+    private final AuthConfig authentication;
+
+    public FieldClimateConfig(DefaultConfig config) {
+        this.startDate = config.getLocalDateTimeProperty("startDate");
+        this.stationsHost = new HostConfig(config.getPropertyConfig("stationsHost"));
+        this.stationDataHost = new HostConfig(config.getPropertyConfig("stationDataHost"));
+        this.stationTimeRangeHost = new HostConfig(config.getPropertyConfig("stationTimeRangeHost"));
+        this.authentication = new AuthConfig(config.getPropertyConfig("authentication"));
+    }
+
+    public LocalDateTime getStartDate() {
+        return startDate;
+    }
+
+    public HostConfig getStationsHost() {
+        return stationsHost;
+    }
+
+    public HostConfig getStationDataHost() {
+        return stationDataHost;
+    }
+
+    public HostConfig getStationTimeRangeHost() {
+        return stationTimeRangeHost;
+    }
+
+    public AuthConfig getAuthentication() {
+        return authentication;
+    }
+}

+ 249 - 5
connector-fetch-fieldclimate/src/main/java/cz/senslog/connector/fetch/fieldclimate/FieldClimateFetcher.java

@@ -1,21 +1,265 @@
 package cz.senslog.connector.fetch.fieldclimate;
 
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.reflect.TypeToken;
+import cz.senslog.connector.config.model.HostConfig;
+import cz.senslog.connector.exception.SyntaxException;
 import cz.senslog.connector.fetch.api.ConnectorFetcher;
+import cz.senslog.connector.fetch.fieldclimate.auth.AuthenticationService;
+import cz.senslog.connector.http.HttpClient;
+import cz.senslog.connector.http.HttpRequest;
+import cz.senslog.connector.http.HttpResponse;
+import cz.senslog.connector.http.URLBuilder;
 import cz.senslog.connector.model.fieldclimate.FieldClimateModel;
+import cz.senslog.connector.model.fieldclimate.StationData;
+import cz.senslog.connector.model.fieldclimate.StationInfo;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
 
+import java.lang.reflect.Type;
 import java.time.LocalDateTime;
+import java.time.format.DateTimeParseException;
+import java.util.*;
 
-import static java.time.LocalDateTime.MIN;
+import static cz.senslog.connector.http.ContentType.APPLICATION_JSON;
+import static cz.senslog.connector.http.HttpHeader.*;
+import static cz.senslog.connector.http.HttpMethod.GET;
+import static cz.senslog.connector.json.BasicJson.jsonToObject;
+import static cz.senslog.connector.util.StringUtils.isBlank;
+import static java.lang.String.format;
+import static java.time.ZoneOffset.UTC;
+import static java.time.format.DateTimeFormatter.ISO_DATE_TIME;
+import static java.time.format.DateTimeFormatter.ofPattern;
+import static java.util.Collections.emptyList;
+import static org.apache.http.client.utils.DateUtils.formatDate;
 
 public class FieldClimateFetcher implements ConnectorFetcher<FieldClimateModel> {
 
+    private static Logger logger = LogManager.getLogger(FieldClimateFetcher.class);
+
+
+    private final AuthenticationService authService;
+    private final FieldClimateConfig config;
+    private final HttpClient httpClient;
+
+    private List<StationInfo> stationInfos;
+    private Map<String, LocalDateTime> stationTimeRanges;
+
+
+    public FieldClimateFetcher(FieldClimateConfig config, AuthenticationService authService, HttpClient httpClient) {
+        this.authService = authService;
+        this.config = config;
+        this.httpClient = httpClient;
+    }
+
     @Override
-    public void init() {}
+    public void init() throws Exception {
+
+        String requestDate = formatDate(new Date());
+        HostConfig stationsHost = config.getStationsHost();
+        String seed = "GET" + stationsHost.getPath() + requestDate;
+
+        HttpRequest request = HttpRequest.newBuilder().GET()
+                .url(URLBuilder.newBuilder(stationsHost.getDomain(), stationsHost.getPath()).build())
+                .header(ACCEPT, APPLICATION_JSON)
+                .header(AUTHORIZATION, authService.getAccessToken(seed))
+                .header(DATE, requestDate)
+                .build();
+
+        HttpResponse response = httpClient.send(request);
+
+        if (response.isError()) {
+            throw logger.throwing(new Exception(format(
+                    "Can not get information about the stations. %s", response.getBody()
+            )));
+        }
+
+        List<StationInfo> stations;
+        try {
+            logger.debug("Parsing body of the response to the class {}.", StationInfo.class);
+            Type sensorInfoListType = new TypeToken<Collection<StationInfo>>() {}.getType();
+            stations = jsonToObject(response.getBody(), sensorInfoListType);
+        } catch (SyntaxException e) {
+            throw logger.throwing(new Exception(format(
+                    "JSON response can not be parsed to the list of stations. %s", e.getMessage()
+            )));
+        }
+
+        if (stations.isEmpty()) {
+            throw logger.throwing(new Exception("Received empty list of stations."));
+        }
+
+        stationInfos = stations;
+        logger.info("{} stations were loaded.", stationInfos.size());
+        logger.info(stationInfos.toString());
+
+        stationTimeRanges = new HashMap<>(stations.size());
+        for (StationInfo station : stations) {
+            stationTimeRanges.put(station.getName().getOriginal(), config.getStartDate());
+        }
+    }
 
-    int days = 5;
     @Override
     public FieldClimateModel fetch() {
-        LocalDateTime max = MIN.plusDays(days--);
-        return new FieldClimateModel(MIN, max);
+
+        List<StationData> stationDataList = new ArrayList<>(stationInfos.size());
+        int totalFetched = 0;
+
+        LocalDateTime globalFrom = LocalDateTime.MAX;
+        LocalDateTime globalTo = LocalDateTime.MIN;
+
+        for (StationInfo station : stationInfos) {
+
+            String stationId = station.getName().getOriginal();
+            logger.info("Fetching data for the station {}.", stationId);
+
+            if (isBlank(stationId)) {
+                logger.warn("The station ID is empty, data can not be fetched."); continue;
+            }
+
+            logger.debug("Getting a start date time for station {}.", stationId);
+            LocalDateTime localFrom = stationTimeRanges.get(stationId);
+            LocalDateTime localTo = localFrom.plusHours(5); // default interval (5h around 600 observations)
+
+            StationTimeRange timeRange = getTimeRangeForStation(stationId);
+            if (timeRange == null) break;
+
+            if (localFrom.isBefore(timeRange.getMin_date())) {
+                localFrom = timeRange.getMin_date();
+            }
+
+            if (localTo.isAfter(timeRange.getMax_date())) {
+                localTo = timeRange.getMax_date();
+            }
+            logger.info("New data for the station {} are going to be fetched from {} to {}.",
+                    stationId, localFrom, localTo);
+
+            HostConfig stationDataHost = config.getStationDataHost();
+            logger.info("Creating a http request to {}.", stationDataHost);
+            String path = stationDataHost.getPath()
+                    .replace("{station_id}", stationId)
+                    .replace("{from}", String.valueOf(localFrom.toEpochSecond(UTC)))
+                    .replace("{to}", String.valueOf(localTo.toEpochSecond(UTC)));
+
+            String requestDate = formatDate(new Date());
+            String authToken = authService.getAccessToken(GET.name() + path + requestDate);
+            HttpRequest request = HttpRequest.newBuilder().GET()
+                    .url(URLBuilder.newBuilder(stationDataHost.getDomain(), path).build())
+                    .header(ACCEPT, APPLICATION_JSON)
+                    .header(DATE, requestDate)
+                    .header(AUTHORIZATION, authToken)
+                    .build();
+
+            logger.info("Sending the http request: {}", request);
+            HttpResponse response = httpClient.send(request);
+            logger.info("Received a response with a status: {}.", response.getStatus());
+
+            if (response.isError()) {
+                logger.error("Can not get data from the station {}. Error {} {}",
+                        stationId, response.getStatus(), response.getBody());
+                continue;
+            }
+
+            StationData stationData;
+            try {
+                logger.debug("Parsing body of the response to the class {}.", StationData.class);
+                stationData = jsonToObject(response.getBody(), StationData.class);
+                logger.info("Received {} records for the sensor {}.", stationData.getData().size(), stationId);
+            } catch (SyntaxException e) {
+                logger.catching(e); continue;
+            }
+
+            stationData.setId(stationId);
+
+            LocalDateTime latestTimestamp = LocalDateTime.MIN;
+            for (Map<String, String> sensorDataMap : stationData.getData()) {
+                String strDate = sensorDataMap.getOrDefault("date", "");
+                LocalDateTime timestamp = LocalDateTime.parse(strDate, ofPattern("yyyy-MM-dd HH:mm:ss"));
+                sensorDataMap.put("date", timestamp.format(ISO_DATE_TIME));
+
+                if (latestTimestamp.isBefore(timestamp)) {
+                    latestTimestamp = timestamp;
+                }
+            }
+
+            localTo = latestTimestamp.isAfter(localTo) ? latestTimestamp : localTo;
+
+            stationTimeRanges.put(stationId, localTo);
+
+            if (globalFrom.isAfter(localFrom)) {
+                globalFrom = localFrom;
+            }
+
+            if (globalTo.isBefore(localTo)) {
+                globalTo = localTo;
+            }
+
+            stationDataList.add(stationData);
+
+            int records = stationData.getSensors().size();
+            totalFetched += records;
+
+            logger.info("Fetched {} records for the station {}.", records, stationId);
+        }
+
+        FieldClimateModel model;
+        if (totalFetched != 0) {
+            String fromStr = globalFrom.format(ISO_DATE_TIME);
+            String toStr = globalTo.format(ISO_DATE_TIME);
+            logger.info("Fetched {} records from {} to {}.", totalFetched, fromStr, toStr);
+
+            model = new FieldClimateModel(stationDataList, globalFrom, globalTo);
+
+        } else {
+            logger.warn("No data was fetched.");
+
+            LocalDateTime date = config.getStartDate();
+            model = new FieldClimateModel(emptyList(), date, date);
+        }
+
+        return model;
+    }
+
+    private StationTimeRange getTimeRangeForStation(String stationId) {
+        logger.debug("Getting an actual time range for the station {}.", stationId);
+        String requestDate = formatDate(new Date());
+        HostConfig timeRangeHost = config.getStationTimeRangeHost();
+        String path = timeRangeHost.getPath().replace("{station_id}", stationId);
+        String authToken =  authService.getAccessToken(GET.name() + path + requestDate);
+
+        HttpRequest request = HttpRequest.newBuilder().GET()
+                .url(URLBuilder.newBuilder(timeRangeHost.getDomain(), path).build())
+                .header(ACCEPT, APPLICATION_JSON)
+                .header(DATE, requestDate)
+                .header(AUTHORIZATION, authToken)
+                .build();
+
+        logger.info("Sending the http request: {}", request);
+        HttpResponse response = httpClient.send(request);
+        logger.info("Received a response with a status: {}.", response.getStatus());
+
+        if (response.isError()) {
+            logger.error("Can not get data of the sensor {}. Error {}: {}",
+                    stationId, response.getStatus(), response.getBody());
+            return null;
+        }
+
+        StationTimeRange timeRange = null;
+        try {
+            logger.debug("Creating a new json converter via class {}.", Gson.class);
+            Gson gson = new GsonBuilder()
+                    .registerTypeAdapter(LocalDateTime.class, (JsonDeserializer<LocalDateTime>) (jsonElement, type, jsonDeserializationContext) ->
+                            LocalDateTime.parse(jsonElement.getAsString(), ofPattern("yyyy-MM-dd HH:mm:ss")))
+                    .create();
+
+            logger.debug("Parsing body of the json response to the class {}.", StationTimeRange.class);
+            timeRange = gson.fromJson(response.getBody(), StationTimeRange.class);
+        } catch (SyntaxException | DateTimeParseException e) {
+            logger.catching(e);
+        }
+
+        return timeRange;
     }
 }

+ 32 - 0
connector-fetch-fieldclimate/src/main/java/cz/senslog/connector/fetch/fieldclimate/StationTimeRange.java

@@ -0,0 +1,32 @@
+package cz.senslog.connector.fetch.fieldclimate;
+
+import java.time.LocalDateTime;
+
+import static cz.senslog.connector.json.BasicJson.objectToJson;
+
+public class StationTimeRange {
+
+    private LocalDateTime min_date;
+    private LocalDateTime max_date;
+
+    public LocalDateTime getMin_date() {
+        return min_date;
+    }
+
+    public void setMin_date(LocalDateTime min_date) {
+        this.min_date = min_date;
+    }
+
+    public LocalDateTime getMax_date() {
+        return max_date;
+    }
+
+    public void setMax_date(LocalDateTime max_date) {
+        this.max_date = max_date;
+    }
+
+    @Override
+    public String toString() {
+        return objectToJson(this);
+    }
+}

+ 33 - 0
connector-fetch-fieldclimate/src/main/java/cz/senslog/connector/fetch/fieldclimate/auth/AuthConfig.java

@@ -0,0 +1,33 @@
+package cz.senslog.connector.fetch.fieldclimate.auth;
+
+import cz.senslog.connector.config.model.PropertyConfig;
+
+/**
+ * The class {@code AuthConfig} represents a configuration class for the {@link AuthenticationService}.
+ *
+ * @author Lukas Cerny
+ * @version 1.0
+ * @since 1.0
+ */
+public class AuthConfig {
+
+    private final String publicKey;
+    private final String privateKey;
+
+    /**
+     * Constructor sets class attributes from input configuration class {@link PropertyConfig}.
+     * @param config - configuration
+     */
+    public AuthConfig(PropertyConfig config) {
+        this.publicKey = config.getStringProperty("publicKey");
+        this.privateKey = config.getStringProperty("privateKey");
+    }
+
+    public String getPublicKey() {
+        return publicKey;
+    }
+
+    public String getPrivateKey() {
+        return privateKey;
+    }
+}

+ 43 - 0
connector-fetch-fieldclimate/src/main/java/cz/senslog/connector/fetch/fieldclimate/auth/AuthenticationService.java

@@ -0,0 +1,43 @@
+package cz.senslog.connector.fetch.fieldclimate.auth;
+
+import org.apache.commons.codec.binary.Hex;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.io.UnsupportedEncodingException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+
+public class AuthenticationService {
+
+    private final AuthConfig authConfig;
+
+    public AuthenticationService(AuthConfig authConfig) {
+        this.authConfig = authConfig;
+    }
+
+
+    public String getAccessToken(String seed) {
+
+        String publicKey = authConfig.getPublicKey();
+        String privateKey = authConfig.getPrivateKey();
+        String contentToSign = seed + publicKey;
+
+        String signature = generateHmacSHA256Signature(contentToSign, privateKey);
+        return "hmac " + publicKey + ":" + signature;
+    }
+
+    private static String generateHmacSHA256Signature(String data, String key){
+
+        try
+        {
+            SecretKeySpec secretKey = new SecretKeySpec(key.getBytes("UTF-8"), "HmacSHA256");
+            Mac mac = Mac.getInstance("HmacSHA256");
+            mac.init(secretKey);
+            return Hex.encodeHexString(mac.doFinal(data.getBytes("UTF-8")));
+
+        } catch (UnsupportedEncodingException | NoSuchAlgorithmException | InvalidKeyException e) {
+            return "";
+        }
+    }
+}

+ 485 - 0
connector-fetch-fieldclimate/src/test/java/cz/senslog/connector/fetch/fieldclimate/FieldClimateFetcherTest.java

@@ -0,0 +1,485 @@
+package cz.senslog.connector.fetch.fieldclimate;
+
+import cz.senslog.connector.config.model.DefaultConfig;
+import cz.senslog.connector.fetch.fieldclimate.auth.AuthenticationService;
+import cz.senslog.connector.http.HttpClient;
+import cz.senslog.connector.http.HttpRequest;
+import cz.senslog.connector.http.HttpResponse;
+import cz.senslog.connector.model.fieldclimate.FieldClimateModel;
+import cz.senslog.connector.model.fieldclimate.SensorDataInfo;
+import cz.senslog.connector.model.fieldclimate.StationData;
+import cz.senslog.connector.model.fieldclimate.StationInfo;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.mockito.stubbing.Answer;
+
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static cz.senslog.connector.http.HttpCode.OK;
+import static cz.senslog.connector.http.HttpCode.SERVER_ERROR;
+import static java.lang.String.format;
+import static java.time.format.DateTimeFormatter.ofPattern;
+import static java.util.Collections.emptyList;
+import static java.util.Collections.singletonList;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class FieldClimateFetcherTest {
+
+    private static FieldClimateConfig config;
+    private static AuthenticationService authService;
+    private static LocalDateTime startDate = LocalDateTime.of(2019, 9, 22, 0, 0,0);
+
+    @BeforeAll
+    static void init() {
+        DefaultConfig fcDConfig = new DefaultConfig("fcConfig", null);
+        fcDConfig.setProperty("stationsHost", new HashMap<String, String>(){{
+            put("domain", "https://api.fieldclimate.com/v1");
+            put("path", "/user/stations");
+        }});
+
+        fcDConfig.setProperty("stationDataHost", new HashMap<String, String>(){{
+            put("domain", "https://api.fieldclimate.com/v1");
+            put("path", "/data/normal/{station_id}/raw/from/{from}/to/{to}");
+        }});
+
+        fcDConfig.setProperty("stationTimeRangeHost", new HashMap<String, String>(){{
+            put("domain", "https://api.fieldclimate.com/v1");
+            put("path", "/data/{station_id}");
+        }});
+
+        fcDConfig.setProperty("authentication", new HashMap<String, String>(){{
+            put("publicKey", "3737ed4fe98fae975e54991216ed473c8d7db48662deff19");
+            put("privateKey", "ed2e4abacdaad1d542eeabcec4ee4f6c8fbf3b8bb167b84b");
+        }});
+
+        fcDConfig.setProperty("startDate", startDate);
+
+        config = new FieldClimateConfig(fcDConfig);
+        authService = new AuthenticationService(config.getAuthentication());
+    }
+
+    @Test
+    void init_GetStations() throws Exception {
+
+        HttpClient httpClient = mock(HttpClient.class);
+        when(httpClient.send(any(HttpRequest.class))).thenAnswer((Answer<HttpResponse>) invocationOnMock -> {
+            HttpRequest request = invocationOnMock.getArgument(0, HttpRequest.class);
+            String path = request.getUrl().getPath();
+            if (path.contains("/user/stations")) {
+                StationInfo stationInfo = new StationInfo();
+                StationInfo.Name stationName = new StationInfo.Name();
+                stationName.setOriginal("original_name");
+                stationInfo.setName(stationName);
+                String stationsJson = singletonList(stationInfo).toString();
+                return HttpResponse.newBuilder()
+                        .status(OK).body(stationsJson).build();
+            }
+            return HttpResponse.newBuilder()
+                    .status(SERVER_ERROR).build();
+        });
+        FieldClimateFetcher fetcher = new FieldClimateFetcher(config, authService, httpClient);
+
+        fetcher.init();
+
+    }
+
+    @Test
+    void init_StationsRequestError_Exception() {
+
+        HttpClient httpClient = mock(HttpClient.class);
+        when(httpClient.send(any(HttpRequest.class))).thenAnswer((Answer<HttpResponse>) invocationOnMock ->
+            HttpResponse.newBuilder().status(SERVER_ERROR).build()
+        );
+        FieldClimateFetcher fetcher = new FieldClimateFetcher(config, authService, httpClient);
+
+        Assertions.assertThrows(Exception.class, fetcher::init);
+    }
+
+    @Test
+    void init_NoneStations_Exception() {
+
+        HttpClient httpClient = mock(HttpClient.class);
+        when(httpClient.send(any(HttpRequest.class))).thenAnswer((Answer<HttpResponse>) invocationOnMock -> {
+            HttpRequest request = invocationOnMock.getArgument(0, HttpRequest.class);
+            String path = request.getUrl().getPath();
+            if (path.contains("/user/stations")) {
+                return HttpResponse.newBuilder()
+                        .status(OK).body(emptyList().toString()).build();
+            }
+
+            return HttpResponse.newBuilder().status(SERVER_ERROR).build();
+        });
+        FieldClimateFetcher fetcher = new FieldClimateFetcher(config, authService, httpClient);
+
+        Assertions.assertThrows(Exception.class, fetcher::init);
+    }
+
+    @Test
+    void fetch_EmptyStationName_EmptyModel() throws Exception {
+        HttpClient httpClient = mock(HttpClient.class);
+        when(httpClient.send(any(HttpRequest.class))).thenAnswer((Answer<HttpResponse>) invocationOnMock -> {
+            HttpRequest request = invocationOnMock.getArgument(0, HttpRequest.class);
+            String path = request.getUrl().getPath();
+            if (path.contains("/user/stations")) {
+                StationInfo stationInfo = new StationInfo();
+                StationInfo.Name name = new StationInfo.Name();
+                name.setOriginal("");
+                stationInfo.setName(name);
+                List<StationInfo> stations = singletonList(stationInfo);
+                return HttpResponse.newBuilder()
+                        .status(OK).body(stations.toString()).build();
+            }
+            return HttpResponse.newBuilder()
+                    .status(SERVER_ERROR).build();
+        });
+        FieldClimateFetcher fetcher = new FieldClimateFetcher(config, authService, httpClient);
+        fetcher.init();
+
+        FieldClimateModel model = fetcher.fetch();
+
+        assertTrue(model.getStations().isEmpty());
+        assertEquals(startDate, model.getFrom());
+        assertEquals(startDate, model.getTo());
+    }
+
+    @Test
+    void fetch_StationTimeRangeRequestError_EmptyModel() throws Exception {
+        String stationName = "original_name";
+        HttpClient httpClient = mock(HttpClient.class);
+        when(httpClient.send(any(HttpRequest.class))).thenAnswer((Answer<HttpResponse>) invocationOnMock -> {
+            HttpRequest request = invocationOnMock.getArgument(0, HttpRequest.class);
+            String path = request.getUrl().getPath();
+            if (path.contains("/user/stations")) {
+                StationInfo stationInfo = new StationInfo();
+                StationInfo.Name name = new StationInfo.Name();
+                name.setOriginal(stationName);
+                stationInfo.setName(name);
+                List<StationInfo> stations = singletonList(stationInfo);
+                return HttpResponse.newBuilder()
+                        .status(OK).body(stations.toString()).build();
+            }
+            if (path.contains("/data/"+stationName)) {
+                return HttpResponse.newBuilder()
+                        .status(SERVER_ERROR).build();
+            }
+            return HttpResponse.newBuilder()
+                    .status(SERVER_ERROR).build();
+        });
+        FieldClimateFetcher fetcher = new FieldClimateFetcher(config, authService, httpClient);
+        fetcher.init();
+
+        FieldClimateModel model = fetcher.fetch();
+
+        assertTrue(model.getStations().isEmpty());
+        assertEquals(startDate, model.getFrom());
+        assertEquals(startDate, model.getTo());
+    }
+
+    @Test
+    void fetch_StationTimeRangeBadData_EmptyModel() throws Exception {
+        String stationName = "original_name";
+        HttpClient httpClient = mock(HttpClient.class);
+        when(httpClient.send(any(HttpRequest.class))).thenAnswer((Answer<HttpResponse>) invocationOnMock -> {
+            HttpRequest request = invocationOnMock.getArgument(0, HttpRequest.class);
+            String path = request.getUrl().getPath();
+            if (path.contains("/user/stations")) {
+                StationInfo stationInfo = new StationInfo();
+                StationInfo.Name name = new StationInfo.Name();
+                name.setOriginal(stationName);
+                stationInfo.setName(name);
+                List<StationInfo> stations = singletonList(stationInfo);
+                return HttpResponse.newBuilder()
+                        .status(OK).body(stations.toString()).build();
+            }
+            if (path.contains("/data/"+stationName)) {
+                String json = String.format("{\"min_date\":\"%s\",\"max_date\":\"%s\"}", "unknown", "unknown");
+                return HttpResponse.newBuilder().status(OK).body(json).build();
+            }
+            return HttpResponse.newBuilder()
+                    .status(SERVER_ERROR).build();
+        });
+        FieldClimateFetcher fetcher = new FieldClimateFetcher(config, authService, httpClient);
+        fetcher.init();
+
+        FieldClimateModel model = fetcher.fetch();
+
+        assertTrue(model.getStations().isEmpty());
+        assertEquals(startDate, model.getFrom());
+        assertEquals(startDate, model.getTo());
+    }
+
+    @Test
+    void fetch_StationDataErrorRequest_EmptyModel() throws Exception {
+        String stationName = "original_name";
+        HttpClient httpClient = mock(HttpClient.class);
+        when(httpClient.send(any(HttpRequest.class))).thenAnswer((Answer<HttpResponse>) invocationOnMock -> {
+            HttpRequest request = invocationOnMock.getArgument(0, HttpRequest.class);
+            String path = request.getUrl().getPath();
+            if (path.contains("/user/stations")) {
+                StationInfo stationInfo = new StationInfo();
+                StationInfo.Name name = new StationInfo.Name();
+                name.setOriginal(stationName);
+                stationInfo.setName(name);
+                List<StationInfo> stations = singletonList(stationInfo);
+                return HttpResponse.newBuilder()
+                        .status(OK).body(stations.toString()).build();
+            }
+            if (path.contains("/data/"+stationName)) {
+                String json = String.format("{\"min_date\":\"%s\",\"max_date\":\"%s\"}",
+                        startDate.minusDays(1).format(ofPattern("yyyy-MM-dd HH:mm:ss")),
+                        startDate.plusDays(1).format(ofPattern("yyyy-MM-dd HH:mm:ss"))
+                );
+                return HttpResponse.newBuilder().status(OK).body(json).build();
+            }
+            if (path.contains("/data/normal/"+stationName)) {
+                return HttpResponse.newBuilder()
+                        .status(SERVER_ERROR).build();
+            }
+            return HttpResponse.newBuilder()
+                    .status(SERVER_ERROR).build();
+        });
+        FieldClimateFetcher fetcher = new FieldClimateFetcher(config, authService, httpClient);
+        fetcher.init();
+
+        FieldClimateModel model = fetcher.fetch();
+
+        assertTrue(model.getStations().isEmpty());
+        assertEquals(startDate, model.getFrom());
+        assertEquals(startDate, model.getTo());
+    }
+
+    @Test
+    void fetch_StationDataBadJson_EmptyModel() throws Exception {
+        String stationName = "original_name";
+        HttpClient httpClient = mock(HttpClient.class);
+        when(httpClient.send(any(HttpRequest.class))).thenAnswer((Answer<HttpResponse>) invocationOnMock -> {
+            HttpRequest request = invocationOnMock.getArgument(0, HttpRequest.class);
+            String path = request.getUrl().getPath();
+            if (path.contains("/user/stations")) {
+                StationInfo stationInfo = new StationInfo();
+                StationInfo.Name name = new StationInfo.Name();
+                name.setOriginal(stationName);
+                stationInfo.setName(name);
+                List<StationInfo> stations = singletonList(stationInfo);
+                return HttpResponse.newBuilder()
+                        .status(OK).body(stations.toString()).build();
+            }
+            if (path.contains("/data/"+stationName)) {
+                String json = String.format("{\"min_date\":\"%s\",\"max_date\":\"%s\"}",
+                        startDate.minusDays(1).format(ofPattern("yyyy-MM-dd HH:mm:ss")),
+                        startDate.plusDays(1).format(ofPattern("yyyy-MM-dd HH:mm:ss"))
+                );
+                return HttpResponse.newBuilder().status(OK).body(json).build();
+            }
+            if (path.contains("/data/normal/"+stationName)) {
+                return HttpResponse.newBuilder()
+                        .status(OK).body("unknown").build();
+            }
+            return HttpResponse.newBuilder()
+                    .status(SERVER_ERROR).build();
+        });
+        FieldClimateFetcher fetcher = new FieldClimateFetcher(config, authService, httpClient);
+        fetcher.init();
+
+        FieldClimateModel model = fetcher.fetch();
+
+        assertTrue(model.getStations().isEmpty());
+        assertEquals(startDate, model.getFrom());
+        assertEquals(startDate, model.getTo());
+    }
+
+    @Test
+    void fetch_Valid_Model() throws Exception {
+        String stationName = "original_name";
+        HttpClient httpClient = mock(HttpClient.class);
+        when(httpClient.send(any(HttpRequest.class))).thenAnswer((Answer<HttpResponse>) invocationOnMock -> {
+            HttpRequest request = invocationOnMock.getArgument(0, HttpRequest.class);
+            String path = request.getUrl().getPath();
+            if (path.contains("/user/stations")) {
+                StationInfo stationInfo = new StationInfo();
+                StationInfo.Name name = new StationInfo.Name();
+                name.setOriginal(stationName);
+                stationInfo.setName(name);
+                List<StationInfo> stations = singletonList(stationInfo);
+                return HttpResponse.newBuilder()
+                        .status(OK).body(stations.toString()).build();
+            }
+            if (path.contains("/data/"+stationName)) {
+                String json = String.format("{\"min_date\":\"%s\",\"max_date\":\"%s\"}",
+                        startDate.plusHours(1).format(ofPattern("yyyy-MM-dd HH:mm:ss")),
+                        startDate.plusHours(2).format(ofPattern("yyyy-MM-dd HH:mm:ss"))
+                );
+                return HttpResponse.newBuilder().status(OK).body(json).build();
+            }
+            if (path.contains("/data/normal/"+stationName)) {
+                StationData data = new StationData();
+                data.setId(stationName);
+                SensorDataInfo dataInfo = new SensorDataInfo();
+                dataInfo.setName("sensorName");
+                dataInfo.setCh(1L);
+                dataInfo.setCode(2L);
+                dataInfo.setMac("mac");
+                dataInfo.setSerial("serial");
+                Map<String, Integer> aggrMap = new HashMap<>();
+                aggrMap.put("avg", 1);
+                dataInfo.setAggr(aggrMap);
+                Map<String, String> dataMap = new HashMap<>();
+                String sensorDataHash = format("%s_%s_%s_%s_%s",
+                        dataInfo.getCh(), dataInfo.getMac(), dataInfo.getSerial(), dataInfo.getCode(), "avg");
+                dataMap.put(sensorDataHash, "100");
+                dataMap.put("date", startDate.plusHours(1).plusMinutes(5).format(ofPattern("yyyy-MM-dd HH:mm:ss")));
+                data.setSensors(singletonList(dataInfo));
+                data.setData(singletonList(dataMap));
+                String json = data.toString();
+                return HttpResponse.newBuilder()
+                        .status(OK).body(json).build();
+            }
+            return HttpResponse.newBuilder()
+                    .status(SERVER_ERROR).build();
+        });
+        FieldClimateFetcher fetcher = new FieldClimateFetcher(config, authService, httpClient);
+        fetcher.init();
+
+        FieldClimateModel model = fetcher.fetch();
+
+        assertEquals(startDate.plusHours(1), model.getFrom());
+        assertEquals(startDate.plusHours(2), model.getTo());
+
+        assertEquals(1, model.getStations().size());
+
+        StationData stationData = model.getStations().get(0);
+        assertEquals(stationName, stationData.getId());
+
+        assertEquals(1, stationData.getSensors().size());
+        assertEquals(1, stationData.getData().size());
+
+        SensorDataInfo dataInfo = stationData.getSensors().get(0);
+        Map<String, String> data = stationData.getData().get(0);
+        assertEquals("sensorName", dataInfo.getName());
+        String sensorDataHash = format("%s_%s_%s_%s_%s",
+                dataInfo.getCh(), dataInfo.getMac(), dataInfo.getSerial(), dataInfo.getCode(),
+                dataInfo.getAggr().entrySet().iterator().next().getKey());
+        assertEquals(100, Integer.valueOf(data.get(sensorDataHash)));
+    }
+
+
+
+/*
+    @Test
+    void fetch() throws Exception {
+
+        FieldClimateFetcher fetcher = new FieldClimateFetcher(config, authService, HttpClient.newHttpSSLClient());
+
+        fetcher.init();
+
+        fetcher.fetch();
+        fetcher.fetch();
+
+
+        System.out.println(new FieldClimateModelSenslogV1ModelConverter().convert(fetcher.fetch()).getObservations());
+    }
+*/
+    /*
+    private void saveToCSV(FieldClimateModel model) throws IOException {
+
+        String [] header = new String[] {
+                "name", "ch", "code", "serial", "mac", "timestamp"
+        };
+
+        String [] valueType = new String[] {
+                "avg", "min", "last", "max", "sum", "time"
+        };
+        
+        String delimiter = ";";
+
+        for (StationData station : model.getStations()) {
+
+            StringBuilder stationSheet = new StringBuilder();
+
+            // header
+            stationSheet.append(String.join(delimiter, header));
+            stationSheet.append(delimiter);
+            stationSheet.append(String.join(delimiter, valueType));
+            stationSheet.append("\n");
+
+            for (Map<String, String> stationData : station.getData()) {
+                String timestamp = stationData.getOrDefault("date", "");
+
+                Set<String> done = new HashSet<>();
+                for (Map.Entry<String, String> dataEntry : stationData.entrySet()) {
+                    String key = dataEntry.getKey();
+                    String value = dataEntry.getValue();
+
+                    if (key.contains("_")) {
+                        String [] cmp = key.split("_");
+
+                        String ch = cmp[0];
+                        String mac = cmp[1];
+                        String serial = cmp[2];
+                        String code = cmp[3];
+                        String aggr = cmp[4];
+
+                        SensorDataInfo sensor = findSensor(station.getSensors(), Integer.valueOf(ch), mac, serial, Integer.valueOf(code));
+
+                        String sensorHash = format("%s_%s_%s_%s", sensor.getCh(), sensor.getMac(), sensor.getSerial(), sensor.getCode());
+
+                        if (done.contains(sensorHash)) continue;
+
+                        StringBuilder sensorBuilder = new StringBuilder();
+                        sensorBuilder.append(sensor.getName()).append(delimiter);
+                        sensorBuilder.append(sensor.getCh()).append(delimiter);
+                        sensorBuilder.append(sensor.getCode()).append(delimiter);
+                        sensorBuilder.append(sensor.getSerial()).append(delimiter);
+                        sensorBuilder.append(sensor.getMac());
+                        String sensorLine = sensorBuilder.toString();
+
+                        StringBuilder valueBuilder = new StringBuilder();
+                        for (String type : valueType) {
+
+                            String sensorDataHash = format("%s_%s", sensorHash, type);
+
+                            if (stationData.containsKey(sensorDataHash)) {
+                                valueBuilder.append(stationData.get(sensorDataHash));
+                            }
+                            valueBuilder.append(delimiter);
+                        }
+                        String valuesLine = valueBuilder.toString();
+
+
+                        stationSheet.append(sensorLine).append(delimiter)
+                                .append(timestamp).append(delimiter)
+                                .append(valuesLine).append("\n");
+
+                        done.add(sensorHash);
+                    }
+                }
+            }
+
+            Path filePath = Paths.get("sensor_" + station.getStationId()+ ".csv");
+            Files.write(filePath, Collections.singleton(stationSheet.toString()));
+        }
+    }
+
+    private SensorDataInfo findSensor(List<SensorDataInfo> sensors, Integer ch, String mac, String serial, Integer code) {
+        for (SensorDataInfo sensor : sensors) {
+            if (sensor.getCode().equals(code) &&
+                    sensor.getCh().equals(ch) &&
+                    sensor.getMac().equals(mac) &&
+                    sensor.getSerial().equals(serial)
+            ) {
+                return sensor;
+            }
+        }
+
+        return null;
+    }
+    */
+}

+ 2 - 1
connector-model/src/main/java/cz/senslog/connector/model/converter/AzureModelSenslogV1ModelConverter.java

@@ -6,6 +6,7 @@ import cz.senslog.connector.model.azure.SensorData;
 import cz.senslog.connector.model.azure.SensorInfo;
 import cz.senslog.connector.model.azure.SensorType;
 import cz.senslog.connector.model.v1.Observation;
+import cz.senslog.connector.model.v1.Record;
 import cz.senslog.connector.model.v1.SenslogV1Model;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
@@ -60,7 +61,7 @@ public final class AzureModelSenslogV1ModelConverter implements Converter<AzureM
         logger.info("Received {} sensors and {} records to convert.", sensorInfos.size(), records);
 
         logger.debug("Creating an empty list of observation with init size {}.", size);
-        List<Observation> observations = new ArrayList<>(size);
+        List<Record> observations = new ArrayList<>(size);
 
         for (SensorInfo sensorInfo : sensorInfos) {
             logger.debug("Converting sensor with Eui {}.", sensorInfo.getEui());

+ 147 - 0
connector-model/src/main/java/cz/senslog/connector/model/converter/FieldClimateModelSenslogV1ModelConverter.java

@@ -0,0 +1,147 @@
+package cz.senslog.connector.model.converter;
+
+import cz.senslog.connector.model.api.Converter;
+import cz.senslog.connector.model.fieldclimate.FieldClimateModel;
+import cz.senslog.connector.model.fieldclimate.SensorDataInfo;
+import cz.senslog.connector.model.fieldclimate.SensorType;
+import cz.senslog.connector.model.fieldclimate.StationData;
+import cz.senslog.connector.model.v1.Observation;
+import cz.senslog.connector.model.v1.Position;
+import cz.senslog.connector.model.v1.Record;
+import cz.senslog.connector.model.v1.SenslogV1Model;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.time.LocalDateTime;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static cz.senslog.connector.model.fieldclimate.SensorType.Group.POSITION;
+import static cz.senslog.connector.model.fieldclimate.SensorType.countOfGroup;
+import static java.lang.String.format;
+import static java.time.ZoneOffset.UTC;
+import static java.time.format.DateTimeFormatter.ISO_DATE_TIME;
+
+public class FieldClimateModelSenslogV1ModelConverter implements Converter<FieldClimateModel, SenslogV1Model> {
+
+    private static Logger logger = LogManager.getLogger(FieldClimateModelSenslogV1ModelConverter.class);
+
+    @Override
+    public SenslogV1Model convert(FieldClimateModel model) {
+
+        List<StationData> stations = model.getStations();
+
+        int size = 0;
+        for (StationData sensor : stations) {
+            for (Map<String, String> sensorData : sensor.getData()) {
+                size += sensorData.size();
+            }
+        }
+
+        List<Record> observations = new ArrayList<>(size);
+        for (StationData station : stations) {
+
+            int locationMapInitSize = station.getData().size() * countOfGroup(POSITION);
+            Map<ZonedDateTime, Position> locationData = new HashMap<>(locationMapInitSize);
+
+            Long unitId = Long.parseLong(station.getId(), 16);
+
+            for (SensorDataInfo sensor : station.getSensors()) {
+
+                SensorType sensorType = SensorType.of(sensor.getCode());
+
+                if (sensorType == null) {
+                    logger.warn("Sensor type is not registered: {}", sensor); continue;
+                }
+
+                Map<String, Integer> aggregateInfos = sensor.getAggr();
+
+                if (aggregateInfos.size() != 1) {
+                    Integer avgActive = aggregateInfos.getOrDefault("avg", 0);
+                    if (avgActive == 1) {
+                        aggregateInfos.clear();
+                        aggregateInfos.put("avg", 1);
+                    } else {
+                        Map.Entry<String, Integer> entry = null;
+                        for (Map.Entry<String, Integer> aggrEntry : aggregateInfos.entrySet()) {
+                            if (aggrEntry.getValue() == 1) {
+                                entry = aggrEntry;
+                                break;
+                            }
+                        }
+                        aggregateInfos.clear();
+                        if (entry != null) {
+                            aggregateInfos.put(entry.getKey(), entry.getValue());
+                        }
+                    }
+                }
+
+                for (Map.Entry<String, Integer> aggrEntry : aggregateInfos.entrySet()) {
+
+                    String aggrType = aggrEntry.getKey();
+                    String sensorDataHash = format("%s_%s_%s_%s_%s",
+                            sensor.getCh(), sensor.getMac(), sensor.getSerial(), sensor.getCode(), aggrType);
+
+                    for (Map<String, String> dataMap : station.getData()) {
+
+                        if (!dataMap.containsKey(sensorDataHash)) {
+                            logger.warn("For the sensor {}({}) at the station {} does not exist aggregate '{}' value.",
+                                    sensor.getName(), sensor.getCode(), station.getId(), aggrType);
+                            continue;
+                        }
+
+                        Float value = Float.valueOf(dataMap.get(sensorDataHash));
+                        String strDate = dataMap.getOrDefault("date", "");
+                        ZonedDateTime time = LocalDateTime.parse(strDate, ISO_DATE_TIME).atZone(UTC);
+
+                        if (sensorType.isGrouped()) {
+                            switch (sensorType.getGroup()) {
+                                case POSITION: {
+                                    Position position = locationData.getOrDefault(time, null);
+                                    if (position == null) {
+                                        position = new Position();
+                                        position.setUnitId(unitId);
+                                        position.setTime(time);
+                                        locationData.put(time, position);
+                                    }
+
+                                    switch (sensorType) {
+                                        case LATITUDE:  position.setLatitude(value);                        break;
+                                        case LONGITUDE: position.setLongitude(value);                       break;
+                                        case ALTITUDE:  position.setAltitude(value);                        break;
+                                        case HDOP:      position.setDilutionOfPrecision(value.intValue());  break;
+                                    }
+                                } break;
+                                case MULTICHANNEL: {
+                                    Long sensorId = sensorType.getId() + sensor.getCh();
+                                    Observation observation = new Observation();
+                                    observation.setTime(time);
+                                    observation.setUnitId(unitId);
+                                    observation.setSensorId(sensorId);
+                                    observation.setValue(value);
+
+                                    observations.add(observation);
+                                } break;
+                            }
+                        } else {
+                            Observation observation = new Observation();
+                            observation.setTime(time);
+                            observation.setUnitId(unitId);
+                            observation.setSensorId(sensorType.getId());
+                            observation.setValue(value);
+
+                            observations.add(observation);
+                        }
+                    }
+                }
+            }
+
+            observations.addAll(locationData.values());
+        }
+
+        return new SenslogV1Model(observations, model.getFrom(), model.getTo());
+    }
+}

+ 1 - 1
connector-model/src/main/java/cz/senslog/connector/model/converter/ModelConverterProvider.java

@@ -19,6 +19,6 @@ public class ModelConverterProvider extends ConverterProvider {
     protected void config() {
         register(AzureModelSenslogV1ModelConverter.class);
         register(AzureModelSenslogV2ModelConverter.class);
-        register(FieldClimateModelSenslogV2ModelConverter.class);
+        register(FieldClimateModelSenslogV1ModelConverter.class);
     }
 }

+ 9 - 1
connector-model/src/main/java/cz/senslog/connector/model/fieldclimate/FieldClimateModel.java

@@ -3,10 +3,18 @@ package cz.senslog.connector.model.fieldclimate;
 import cz.senslog.connector.model.api.AbstractModel;
 
 import java.time.LocalDateTime;
+import java.util.List;
 
 public class FieldClimateModel extends AbstractModel {
 
-    public FieldClimateModel(LocalDateTime from, LocalDateTime to) {
+    private final List<StationData> stations;
+
+    public FieldClimateModel(List<StationData> stations, LocalDateTime from, LocalDateTime to) {
         super(from, to);
+        this.stations = stations;
+    }
+
+    public List<StationData> getStations() {
+        return stations;
     }
 }

+ 73 - 0
connector-model/src/main/java/cz/senslog/connector/model/fieldclimate/SensorDataInfo.java

@@ -0,0 +1,73 @@
+package cz.senslog.connector.model.fieldclimate;
+
+import java.util.Map;
+
+import static cz.senslog.connector.json.BasicJson.objectToJson;
+
+public class SensorDataInfo {
+
+    private String name;
+
+    private Long ch;
+
+    private Long code;
+
+    private String serial;
+
+    private String mac;
+
+    private Map<String, Integer> aggr;
+
+    public String getName() {
+        return name;
+    }
+
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    public Long getCh() {
+        return ch;
+    }
+
+    public void setCh(Long ch) {
+        this.ch = ch;
+    }
+
+    public Long getCode() {
+        return code;
+    }
+
+    public void setCode(Long code) {
+        this.code = code;
+    }
+
+    public String getSerial() {
+        return serial;
+    }
+
+    public void setSerial(String serial) {
+        this.serial = serial;
+    }
+
+    public String getMac() {
+        return mac;
+    }
+
+    public void setMac(String mac) {
+        this.mac = mac;
+    }
+
+    public Map<String, Integer> getAggr() {
+        return aggr;
+    }
+
+    public void setAggr(Map<String, Integer> aggr) {
+        this.aggr = aggr;
+    }
+
+    @Override
+    public String toString() {
+        return objectToJson(this);
+    }
+}

+ 106 - 0
connector-model/src/main/java/cz/senslog/connector/model/fieldclimate/SensorType.java

@@ -0,0 +1,106 @@
+package cz.senslog.connector.model.fieldclimate;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * The enum {@code SensorType} represents type of sensor
+ * which each physical quantity is represents by identifier.
+ *
+ * @author Lukas Cerny
+ * @version 1.0
+ * @since 1.0
+ */
+public enum SensorType {
+
+    EAG_SOIL_MOISTURE       (19969, 410160000, Group.MULTICHANNEL), // [%]
+    EAG_SOIL_SALINITY       (20228, 800010000, Group.MULTICHANNEL), // [VIC]
+    SOIL_TEMPERATURE        (17153, 340400000, Group.MULTICHANNEL), // [°C]
+    LEAF_WETNESS            (4, 790010000),     // [min]
+    WIND_SPEED              (5, 470110000),     // [m/s]
+    PRECIPITATION           (6, 480070000),     // [mm]
+    WIND_DIRECTION          (143, 470130000),   // [deg]
+    WIND_SPEED_MAX          (49, 470120000),    // [m/s]
+    DEW_POINT               (21, 420050000),    // [°C]
+    SOLAR_RADIATION         (600, 620020000),   // [W/m2]
+    BATTERY                 (7, 360220000),     // [mV]
+    SOLAR_PANEL             (30, 360230000),    // [mV]
+    VPD                     (25, 460070000),    // [mbar]
+    DELTA_T                 (27, 810010000),    // [°C]
+
+    HC_AIR_TEMPERATURE      (506, 340400000),   // [°C]
+    HC_RELATIVE_HUMIDITY    (507, 410170000),   // [%]
+    HC_SERIAL_NUMBER        (508, 680030000),
+
+
+
+    LATITUDE                (650, Group.POSITION),
+    LONGITUDE               (651, Group.POSITION),
+    ALTITUDE                (652, Group.POSITION),
+    HDOP                    (659, Group.POSITION), // Horizontal dilution  of position
+    
+    ;
+
+    public enum Group {
+        POSITION, MULTICHANNEL
+    }
+
+    private static final Map<Long, SensorType> VALUES;
+    static {
+        VALUES = new HashMap<>();
+        for (SensorType value : values()) {
+            VALUES.put(value.code, value);
+        }
+    }
+
+    private final long code, id;
+    private final Group group;
+
+    SensorType(long code, long id) {
+        this(code, id, null);
+    }
+
+    SensorType(long code, Group group) {
+        this(code, -1, group);
+    }
+
+    SensorType(long code, long id, Group group) {
+        this.code = code;
+        this.id = id;
+        this.group = group;
+    }
+
+    public long getId() {
+        return id;
+    }
+
+    public long getCode() {
+        return code;
+    }
+
+    public boolean isGrouped() {
+        return group != null;
+    }
+
+    public Group getGroup() {
+        return group;
+    }
+
+    public boolean containsGroup(Group group) {
+        return group != null && this.group == group;
+    }
+
+    public static int countOfGroup(Group group) {
+        int count = 0;
+        for (SensorType value : values()) {
+            if (value.getId() == -1 && value.group == group) {
+                count++;
+            }
+        }
+        return count;
+    }
+
+    public static SensorType of(long code) {
+        return VALUES.getOrDefault(code, null);
+    }
+}

+ 44 - 0
connector-model/src/main/java/cz/senslog/connector/model/fieldclimate/StationData.java

@@ -0,0 +1,44 @@
+package cz.senslog.connector.model.fieldclimate;
+
+import java.util.List;
+import java.util.Map;
+
+import static cz.senslog.connector.json.BasicJson.objectToJson;
+
+public class StationData {
+
+    private String id;
+
+    private List<SensorDataInfo> sensors;
+
+    private List<Map<String, String>> data;
+
+    public List<SensorDataInfo> getSensors() {
+        return sensors;
+    }
+
+    public void setSensors(List<SensorDataInfo> sensors) {
+        this.sensors = sensors;
+    }
+
+    public List<Map<String, String>> getData() {
+        return data;
+    }
+
+    public void setData(List<Map<String, String>> data) {
+        this.data = data;
+    }
+
+    public String getId() {
+        return id;
+    }
+
+    public void setId(String id) {
+        this.id = id;
+    }
+
+    @Override
+    public String toString() {
+        return objectToJson(this);
+    }
+}

+ 35 - 0
connector-model/src/main/java/cz/senslog/connector/model/fieldclimate/StationInfo.java

@@ -0,0 +1,35 @@
+package cz.senslog.connector.model.fieldclimate;
+
+import static cz.senslog.connector.json.BasicJson.objectToJson;
+
+public class StationInfo {
+
+    public static class Name {
+
+        private String original;
+
+        public String getOriginal() {
+            return original;
+        }
+
+        public void setOriginal(String original) {
+            this.original = original;
+        }
+    }
+
+    private Name name;
+
+
+    public Name getName() {
+        return name;
+    }
+
+    public void setName(Name name) {
+        this.name = name;
+    }
+
+    @Override
+    public String toString() {
+        return objectToJson(this);
+    }
+}

+ 1 - 30
connector-model/src/main/java/cz/senslog/connector/model/v1/Observation.java

@@ -1,9 +1,6 @@
 package cz.senslog.connector.model.v1;
 
-import java.time.ZonedDateTime;
-
 import static cz.senslog.connector.json.BasicJson.objectToJson;
-import static java.time.format.DateTimeFormatter.ofPattern;
 
 /**
  * The class {@code Observation} represents a record for Senslog V1.
@@ -12,10 +9,7 @@ import static java.time.format.DateTimeFormatter.ofPattern;
  * @version 1.0
  * @since 1.0
  */
-public class Observation {
-
-    /** Identifier of sensor/area (knows as EUI). */
-    private Long unitId;
+public class Observation extends Record {
 
     /** Identifier of specific sensor which measured the value (e.q. temperature, humidity, etc...). */
     private Long sensorId;
@@ -23,17 +17,6 @@ public class Observation {
     /** Concrete value of the sensor. */
     private Float value;
 
-    /** Timestamp when the value was measured. */
-    private ZonedDateTime time;
-
-    public Long getUnitId() {
-        return unitId;
-    }
-
-    public void setUnitId(Long unitId) {
-        this.unitId = unitId;
-    }
-
     public Long getSensorId() {
         return sensorId;
     }
@@ -50,18 +33,6 @@ public class Observation {
         this.value = value;
     }
 
-    public ZonedDateTime getTime() {
-        return time;
-    }
-
-    public String getFormattedTime() {
-        return time != null ? time.format(ofPattern("yyyy-MM-dd HH:mm:ssZ")) : "";
-    }
-
-    public void setTime(ZonedDateTime time) {
-        this.time = time;
-    }
-
     @Override
     public String toString() {
         return objectToJson(this);

+ 73 - 0
connector-model/src/main/java/cz/senslog/connector/model/v1/Position.java

@@ -0,0 +1,73 @@
+package cz.senslog.connector.model.v1;
+
+import static cz.senslog.connector.json.BasicJson.objectToJson;
+
+/**
+ * The class {@code Position} represents a record of position for Senslog V1.
+ *
+ * @author Lukas Cerny
+ * @version 1.0
+ * @since 1.0
+ */
+public class Position extends Record {
+
+    /** Latitude of position */
+    private Float latitude;
+
+    /** Longitude of position */
+    private Float longitude;
+
+    /** Altitude of position */
+    private Float altitude;
+
+    /** Speed of the unit */
+    private Float speed;
+
+    /** Horizontal dilution  of position */
+    private Integer dilutionOfPrecision;
+
+    public Float getLatitude() {
+        return latitude;
+    }
+
+    public void setLatitude(Float latitude) {
+        this.latitude = latitude;
+    }
+
+    public Float getLongitude() {
+        return longitude;
+    }
+
+    public void setLongitude(Float longitude) {
+        this.longitude = longitude;
+    }
+
+    public Float getAltitude() {
+        return altitude;
+    }
+
+    public void setAltitude(Float altitude) {
+        this.altitude = altitude;
+    }
+
+    public Float getSpeed() {
+        return speed;
+    }
+
+    public void setSpeed(Float speed) {
+        this.speed = speed;
+    }
+
+    public Integer getDilutionOfPrecision() {
+        return dilutionOfPrecision;
+    }
+
+    public void setDilutionOfPrecision(Integer dilutionOfPrecision) {
+        this.dilutionOfPrecision = dilutionOfPrecision;
+    }
+
+    @Override
+    public String toString() {
+        return objectToJson(this);
+    }
+}

+ 36 - 0
connector-model/src/main/java/cz/senslog/connector/model/v1/Record.java

@@ -0,0 +1,36 @@
+package cz.senslog.connector.model.v1;
+
+import java.time.ZonedDateTime;
+
+import static java.time.format.DateTimeFormatter.ofPattern;
+
+
+public abstract class Record {
+
+    /** Identifier of area for sensors */
+    private Long unitId;
+
+    /** Timestamp when the value was measured. */
+    private ZonedDateTime time;
+
+    public Long getUnitId() {
+        return unitId;
+    }
+
+    public void setUnitId(Long unitId) {
+        this.unitId = unitId;
+    }
+
+    public ZonedDateTime getTime() {
+        return time;
+    }
+
+    public String getFormattedTime() {
+        return time != null ? time.format(ofPattern("yyyy-MM-dd HH:mm:ssZ")) : "";
+    }
+
+    public void setTime(ZonedDateTime time) {
+        this.time = time;
+    }
+
+}

+ 3 - 3
connector-model/src/main/java/cz/senslog/connector/model/v1/SenslogV1Model.java

@@ -16,7 +16,7 @@ import java.util.List;
 public class SenslogV1Model extends AbstractModel {
 
     /** List of observations */
-    private final List<Observation> observations;
+    private final List<Record> observations;
 
     /**
      * Constructor of the class sets all attributes.
@@ -24,12 +24,12 @@ public class SenslogV1Model extends AbstractModel {
      * @param from - start of the time range.
      * @param to - end of the time range.
      */
-    public SenslogV1Model(List<Observation> observations, LocalDateTime from, LocalDateTime to) {
+    public SenslogV1Model(List<Record> observations, LocalDateTime from, LocalDateTime to) {
         super(from, to);
         this.observations = observations;
     }
 
-    public List<Observation> getObservations() {
+    public List<Record> getObservations() {
         return  observations;
     }
 }

+ 4 - 2
connector-model/src/test/java/cz/senslog/connector/model/converter/AzureModelSenslogV1ModelConverterTest.java

@@ -4,6 +4,7 @@ import cz.senslog.connector.model.azure.AzureModel;
 import cz.senslog.connector.model.azure.SensorData;
 import cz.senslog.connector.model.azure.SensorInfo;
 import cz.senslog.connector.model.v1.Observation;
+import cz.senslog.connector.model.v1.Record;
 import cz.senslog.connector.model.v1.SenslogV1Model;
 import org.junit.jupiter.api.Test;
 
@@ -48,10 +49,11 @@ class AzureModelSenslogV1ModelConverterTest {
         AzureModelSenslogV1ModelConverter converter = new AzureModelSenslogV1ModelConverter();
         SenslogV1Model senslogV1Model = converter.convert(azureModel);
 
-        List<Observation> observations = senslogV1Model.getObservations();
+        List<Record> observations = senslogV1Model.getObservations();
         assertEquals(6, observations.size());
 
-        for (Observation observation : observations) {
+        for (Record record : observations) {
+            Observation observation = (Observation)record;
 
             assertEquals(10002376L, observation.getUnitId());
             assertEquals("1970-01-01 00:00:00+0000", observation.getFormattedTime());

+ 338 - 0
connector-model/src/test/java/cz/senslog/connector/model/converter/FieldClimateModelSenslogV1ModelConverterTest.java

@@ -0,0 +1,338 @@
+package cz.senslog.connector.model.converter;
+
+import cz.senslog.connector.model.fieldclimate.FieldClimateModel;
+import cz.senslog.connector.model.fieldclimate.SensorDataInfo;
+import cz.senslog.connector.model.fieldclimate.StationData;
+import cz.senslog.connector.model.v1.Observation;
+import cz.senslog.connector.model.v1.Position;
+import cz.senslog.connector.model.v1.SenslogV1Model;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static cz.senslog.connector.model.fieldclimate.SensorType.*;
+import static java.time.LocalDateTime.MAX;
+import static java.time.LocalDateTime.MIN;
+import static java.util.Collections.emptyList;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class FieldClimateModelSenslogV1ModelConverterTest {
+
+    private FieldClimateModel createBasicModel() {
+        List<StationData> stations = new ArrayList<>();
+        StationData station = new StationData();
+        stations.add(station);
+
+        List<SensorDataInfo> sensors = new ArrayList<>();
+        SensorDataInfo sensor = new SensorDataInfo();
+        sensors.add(sensor);
+
+        List<Map<String, String>> sensorData = new ArrayList<>();
+        Map<String, String> data = new HashMap<>();
+        sensorData.add(data);
+
+        // set station
+        station.setId("0120821E");
+        station.setSensors(sensors);
+        station.setData(sensorData);
+
+        // set sensor
+        sensor.setName(DEW_POINT.name());
+        sensor.setMac("mac");
+        sensor.setSerial("serial");
+        sensor.setCh(1234L);
+        sensor.setCode(DEW_POINT.getCode());
+
+        Map<String, Integer> aggrs = new HashMap<>();
+        aggrs.put("avg", 1);
+        sensor.setAggr(aggrs);
+
+        // set data
+        data.put("1234_mac_serial_21_avg", "20");
+        data.put("date", "1970-01-01T00:00:00");
+
+        return new FieldClimateModel(stations, MIN, MAX);
+    }
+
+    @Test
+    void convert_EmptyModel_Empty() {
+
+        SenslogV1Model model = new FieldClimateModelSenslogV1ModelConverter()
+                .convert(new FieldClimateModel(emptyList(), MIN, MAX));
+
+        assertTrue(model.getObservations().isEmpty());
+        assertEquals(MIN, model.getFrom());
+        assertEquals(MAX, model.getTo());
+    }
+
+    @Test
+    void convert_BasicModel_ValidObservation() {
+
+        SenslogV1Model model = new FieldClimateModelSenslogV1ModelConverter().convert(createBasicModel());
+
+        assertEquals(1, model.getObservations().size());
+
+        Observation observation = (Observation) model.getObservations().get(0);
+        assertEquals(18907678, observation.getUnitId());
+        assertEquals(DEW_POINT.getId(), observation.getSensorId());
+        assertEquals(20, observation.getValue());
+        LocalDateTime timestamp = LocalDateTime.of(1970, 1, 1, 0, 0);
+        assertEquals(timestamp, observation.getTime().toLocalDateTime());
+    }
+
+    @Test
+    void convert_UnknownSensor_EmptyModel() {
+
+        FieldClimateModel fcModel = createBasicModel();
+        SensorDataInfo sensor = fcModel.getStations().get(0).getSensors().get(0);
+
+        sensor.setCode(Long.MIN_VALUE);
+
+        SenslogV1Model model = new FieldClimateModelSenslogV1ModelConverter().convert(fcModel);
+
+        assertEquals(0, model.getObservations().size());
+    }
+
+    @Test
+    void convert_MultiChannelSensor_ValidObservation() {
+
+        List<StationData> stations = new ArrayList<>();
+        StationData station = new StationData();
+        stations.add(station);
+
+        List<SensorDataInfo> sensors = new ArrayList<>();
+        SensorDataInfo sensor = new SensorDataInfo();
+        sensors.add(sensor);
+
+        List<Map<String, String>> sensorData = new ArrayList<>();
+        Map<String, String> data = new HashMap<>();
+        sensorData.add(data);
+
+        // set station
+        station.setId("0120821E");
+        station.setSensors(sensors);
+        station.setData(sensorData);
+
+        // set sensor
+        sensor.setName(EAG_SOIL_MOISTURE.name());
+        sensor.setMac("mac");
+        sensor.setSerial("serial");
+        sensor.setCh(1234L);
+        sensor.setCode(19969L);
+
+        Map<String, Integer> aggrs = new HashMap<>();
+        aggrs.put("avg", 1);
+        sensor.setAggr(aggrs);
+
+        // set data
+        data.put("1234_mac_serial_19969_avg", "42");
+        LocalDateTime timestamp = LocalDateTime.of(1970, 1, 1, 0, 0);
+        data.put("date", timestamp.format(DateTimeFormatter.ISO_DATE_TIME));
+
+        SenslogV1Model model = new FieldClimateModelSenslogV1ModelConverter()
+                .convert(new FieldClimateModel(stations, MIN, MAX));
+
+        assertEquals(1, model.getObservations().size());
+
+        Observation observation = (Observation) model.getObservations().get(0);
+        assertEquals(18907678, observation.getUnitId());
+        assertEquals(410161234L, observation.getSensorId());
+        assertEquals(42, observation.getValue());
+        assertEquals(timestamp, observation.getTime().toLocalDateTime());
+    }
+
+    @Test
+    void convert_NoSensorData_EmptyModel() {
+
+        FieldClimateModel fcModel = createBasicModel();
+        Map<String, String> data = fcModel.getStations().get(0).getData().get(0);
+
+        data.clear();
+
+        SenslogV1Model model = new FieldClimateModelSenslogV1ModelConverter().convert(fcModel);
+
+        assertEquals(0, model.getObservations().size());
+    }
+
+    @Test
+    void convert_LocationSensors_ValidPosition() {
+        List<StationData> stations = new ArrayList<>();
+        StationData station = new StationData();
+        stations.add(station);
+
+        List<SensorDataInfo> sensors = new ArrayList<>();
+
+        List<Map<String, String>> sensorData = new ArrayList<>();
+        Map<String, String> data = new HashMap<>();
+        sensorData.add(data);
+
+        // set station
+        station.setId("0120821E");
+        station.setSensors(sensors);
+        station.setData(sensorData);
+
+        LocalDateTime timestamp = LocalDateTime.of(1970, 1, 1, 0, 0);
+        data.put("date", timestamp.format(DateTimeFormatter.ISO_DATE_TIME));
+
+        {
+            // set LATITUDE sensor
+            SensorDataInfo sensor = new SensorDataInfo();
+            sensors.add(sensor);
+
+            sensor.setName(LATITUDE.name());
+            sensor.setMac("mac");
+            sensor.setSerial("serial");
+            sensor.setCh(1L);
+            sensor.setCode(LATITUDE.getCode());
+
+            Map<String, Integer> aggrs = new HashMap<>();
+            aggrs.put("last", 1);
+            sensor.setAggr(aggrs);
+
+            data.put("1_mac_serial_650_last", "56.45688");
+        }
+        {
+            // set LONGITUDE sensor
+            SensorDataInfo sensor = new SensorDataInfo();
+            sensors.add(sensor);
+
+            sensor.setName(LONGITUDE.name());
+            sensor.setMac("mac");
+            sensor.setSerial("serial");
+            sensor.setCh(1L);
+            sensor.setCode(LONGITUDE.getCode());
+
+            Map<String, Integer> aggrs = new HashMap<>();
+            aggrs.put("last", 1);
+            sensor.setAggr(aggrs);
+
+            data.put("1_mac_serial_651_last", "23.426302");
+        }
+        {
+            // set ALTITUDE sensor
+            SensorDataInfo sensor = new SensorDataInfo();
+            sensors.add(sensor);
+
+            sensor.setName(ALTITUDE.name());
+            sensor.setMac("mac");
+            sensor.setSerial("serial");
+            sensor.setCh(1L);
+            sensor.setCode(ALTITUDE.getCode());
+
+            Map<String, Integer> aggrs = new HashMap<>();
+            aggrs.put("last", 1);
+            sensor.setAggr(aggrs);
+
+            data.put("1_mac_serial_652_last", "49.3");
+        }
+        {
+            // set HDOP sensor
+            SensorDataInfo sensor = new SensorDataInfo();
+            sensors.add(sensor);
+
+            sensor.setName(HDOP.name());
+            sensor.setMac("mac");
+            sensor.setSerial("serial");
+            sensor.setCh(1L);
+            sensor.setCode(HDOP.getCode());
+
+            Map<String, Integer> aggrs = new HashMap<>();
+            aggrs.put("last", 1);
+            sensor.setAggr(aggrs);
+
+            data.put("1_mac_serial_659_last", "1");
+        }
+
+        SenslogV1Model model = new FieldClimateModelSenslogV1ModelConverter()
+                .convert(new FieldClimateModel(stations, MIN, MAX));
+
+        assertEquals(1, model.getObservations().size());
+
+        Position position = (Position) model.getObservations().get(0);
+
+        assertEquals(56.45688f, position.getLatitude());
+        assertEquals(23.426302f, position.getLongitude());
+        assertEquals(49.3f, position.getAltitude());
+        assertEquals(1, position.getDilutionOfPrecision());
+
+        assertEquals(18907678, position.getUnitId());
+        assertEquals(timestamp, position.getTime().toLocalDateTime());
+    }
+
+    @Test
+    void convert_MultiAggrValues_OneObservation() {
+
+        FieldClimateModel fiModel = createBasicModel();
+
+        Map<String, Integer> aggrs = fiModel.getStations().get(0).getSensors().get(0).getAggr();
+        aggrs.clear();
+
+        aggrs.put("avg", 1);
+        aggrs.put("min", 1);
+        aggrs.put("max", 1);
+
+        Map<String, String> data = fiModel.getStations().get(0).getData().get(0);
+
+        data.put("1234_mac_serial_21_avg", "20");
+        data.put("1234_mac_serial_21_min", "10");
+        data.put("1234_mac_serial_21_max", "30");
+
+
+        SenslogV1Model model = new FieldClimateModelSenslogV1ModelConverter().convert(fiModel);
+
+        assertEquals(1, model.getObservations().size());
+        Observation observation = (Observation) model.getObservations().get(0);
+        assertEquals(20, observation.getValue());
+    }
+
+    @Test
+    void convert_AggrValuesOnOff_OneObservation(){
+        FieldClimateModel fiModel = createBasicModel();
+
+        Map<String, Integer> aggrs = fiModel.getStations().get(0).getSensors().get(0).getAggr();
+        aggrs.clear();
+
+        aggrs.put("min", 0);
+        aggrs.put("max", 1);
+
+        Map<String, String> data = fiModel.getStations().get(0).getData().get(0);
+
+        data.put("1234_mac_serial_21_min", "10");
+        data.put("1234_mac_serial_21_max", "30");
+
+
+        SenslogV1Model model = new FieldClimateModelSenslogV1ModelConverter().convert(fiModel);
+
+        assertEquals(1, model.getObservations().size());
+        Observation observation = (Observation) model.getObservations().get(0);
+        assertEquals(30, observation.getValue());
+    }
+
+    @Test
+    void convert_AggrValuesOff_EmptyModel(){
+        FieldClimateModel fiModel = createBasicModel();
+
+        Map<String, Integer> aggrs = fiModel.getStations().get(0).getSensors().get(0).getAggr();
+        aggrs.clear();
+
+        aggrs.put("min", 0);
+        aggrs.put("max", 0);
+
+        Map<String, String> data = fiModel.getStations().get(0).getData().get(0);
+
+        data.put("1234_mac_serial_21_min", "10");
+        data.put("1234_mac_serial_21_max", "30");
+
+
+        SenslogV1Model model = new FieldClimateModelSenslogV1ModelConverter().convert(fiModel);
+
+        assertEquals(0, model.getObservations().size());
+    }
+
+}

+ 80 - 21
connector-push-rest-senslog-v1/src/main/java/cz/senslog/connector/push/rest/senslog/v1/SenslogV1Pusher.java

@@ -3,13 +3,19 @@ package cz.senslog.connector.push.rest.senslog.v1;
 import cz.senslog.connector.config.model.HostConfig;
 import cz.senslog.connector.http.*;
 import cz.senslog.connector.model.v1.Observation;
+import cz.senslog.connector.model.v1.Position;
+import cz.senslog.connector.model.v1.Record;
 import cz.senslog.connector.model.v1.SenslogV1Model;
 import cz.senslog.connector.push.api.ConnectorPusher;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 
-import java.util.*;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Queue;
+import java.util.UUID;
 import java.util.concurrent.LinkedTransferQueue;
+import java.util.function.Supplier;
 
 /**
  * The class {@code SenslogV1Pusher} represents an implementation of {@link ConnectorPusher}.
@@ -44,10 +50,10 @@ class  SenslogV1Pusher implements ConnectorPusher<SenslogV1Model> {
     private final HttpClient httpClient;
 
     /** Queue of observations wait to be send. */
-    private final Queue<Observation> observationQueue;
+    private final Queue<Record> observationQueue;
 
     /** List of failed observations. */
-    private final List<Observation> failedObservations;
+    private final List<Record> failedObservations;
 
     /**
      * Constructor of the class sets all attributes.
@@ -72,7 +78,7 @@ class  SenslogV1Pusher implements ConnectorPusher<SenslogV1Model> {
             return;
         }
 
-        List<Observation> observations = model.getObservations();
+        List<Record> observations = model.getObservations();
         logger.info("Received {} observation to send.", observations.size());
 
         logger.debug("Adding all new observations to the queue.");
@@ -84,27 +90,33 @@ class  SenslogV1Pusher implements ConnectorPusher<SenslogV1Model> {
 
         int counter = 0;
         while (!observationQueue.isEmpty()) {
-            Observation observation = observationQueue.remove();
+            Record record = observationQueue.remove();
 
             String id = UUID.randomUUID().toString();
             counter += 1;
 
             logger.debug("Creating a request for the observation {}.", id);
-            logger.info("Observation {} {}", id, observation);
-
-            HostConfig hostConfig = config.getHost();
-            HttpRequest request = HttpRequest.newBuilder()
-                    .contentType(ContentType.TEXT_PLAIN)
-                    .url(URLBuilder.newBuilder(hostConfig.getDomain(), hostConfig.getPath())
-                            .addParam("Operation", "InsertObservation")
-                            .addParam("value", observation.getValue())
-                            .addParam("date", observation.getFormattedTime())
-                            .addParam("unit_id", observation.getUnitId())
-                            .addParam("sensor_id", observation.getSensorId())
-                            .build())
-                    .GET().build();
-
-            logger.debug("Request for the {} was created successfully.", id);
+            logger.info("Observation {} {}", id, record);
+
+            if (!validateRequiredValues(
+                    record::getUnitId,
+                    record::getTime)
+            ) {
+                logger.error("Observation {} can not be send because required values are null.", id); continue;
+            }
+
+            HttpRequest request = null; // TODO refactor
+            if (record instanceof Observation) {
+                request = prepareRequest((Observation) record, config.getHost());
+            } else if (record instanceof Position) {
+                request = prepareRequest((Position) record, config.getHost());
+            }
+
+            if (request == null) {
+                logger.error("Request for the {} was not created.", id); continue;
+            } else {
+                logger.debug("Request for the {} was created successfully.", id);
+            }
 
             logger.info("Sending request for the {}", id);
             HttpResponse response = httpClient.send(request);
@@ -115,7 +127,7 @@ class  SenslogV1Pusher implements ConnectorPusher<SenslogV1Model> {
 
 
                 if (counter < ALLOWED_REQUEST_FAILS) {
-                    failedObservations.add(observation);
+                    failedObservations.add(record);
                     continue;
                 } else {
                     return;
@@ -130,4 +142,51 @@ class  SenslogV1Pusher implements ConnectorPusher<SenslogV1Model> {
             }
         }
     }
+
+    private HttpRequest prepareRequest(Observation observation, HostConfig hostConfig) {
+        return HttpRequest.newBuilder()
+                .contentType(ContentType.TEXT_PLAIN)
+                .url(URLBuilder.newBuilder(hostConfig.getDomain(), hostConfig.getPath())
+                        .addParam("Operation", "InsertObservation")
+                        .addParam("value", observation.getValue())
+                        .addParam("date", observation.getFormattedTime())
+                        .addParam("unit_id", observation.getUnitId())
+                        .addParam("sensor_id", observation.getSensorId())
+                        .build())
+                .GET().build();
+    }
+
+    private HttpRequest prepareRequest(Position position, HostConfig hostConfig) {
+
+        if (!validateRequiredValues(
+                position::getLatitude,
+                position::getLongitude)
+        ) {
+            return null;
+        }
+
+        return HttpRequest.newBuilder()
+                .contentType(ContentType.TEXT_PLAIN)
+                .url(URLBuilder.newBuilder(hostConfig.getDomain(), hostConfig.getPath())
+                        .addParam("Operation", "InsertPosition")
+                        .addParam("date", position.getFormattedTime())
+                        .addParam("unit_id", position.getUnitId())
+                        .addParam("lat", position.getLatitude())
+                        .addParam("lon", position.getLongitude())
+                        .addParam("alt", position.getAltitude())
+                        .addParam("speed", position.getSpeed())
+                        .addParam("dop", position.getDilutionOfPrecision())
+                        .build())
+                .GET().build();
+    }
+
+    @SafeVarargs
+    private final boolean validateRequiredValues(Supplier<Object>... attributes) {
+        for (Supplier<Object> attribute : attributes) {
+            if (attribute.get() == null) {
+                return false;
+            }
+        }
+        return true;
+    }
 }

+ 3 - 2
connector-push-rest-senslog-v1/src/test/java/cz/senslog/connector/push/rest/senslog/v1/SenslogV1PusherTest.java

@@ -6,6 +6,7 @@ import cz.senslog.connector.http.HttpCode;
 import cz.senslog.connector.http.HttpRequest;
 import cz.senslog.connector.http.HttpResponse;
 import cz.senslog.connector.model.v1.Observation;
+import cz.senslog.connector.model.v1.Record;
 import cz.senslog.connector.model.v1.SenslogV1Model;
 import org.junit.jupiter.api.Test;
 
@@ -36,7 +37,7 @@ class SenslogV1PusherTest {
 
         SenslogV1Pusher pusher = new SenslogV1Pusher(config, httpClient);
 
-        List<Observation> observations = new ArrayList<>();
+        List<Record> observations = new ArrayList<>();
 
         Observation observation = new Observation();
 
@@ -67,7 +68,7 @@ class SenslogV1PusherTest {
 
         SenslogV1Pusher pusher = new SenslogV1Pusher(config, httpClient);
 
-        List<Observation> observations = new ArrayList<>();
+        List<Record> observations = new ArrayList<>();
 
         Observation observation = new Observation();
 

+ 5 - 5
docker-compose.yaml

@@ -13,19 +13,19 @@ services:
     environment:
       APP_PARAMS: -cf config/lorawanSenslog1.yaml
 
-  fcs2:
-    container_name: fieldclimateSenslog2
+  fcs1:
+    container_name: fieldclimateSenslog1
     build:
       dockerfile: docker/Dockerfile
       context: .
       args:
-         MAVEN_PROFILE: FieldClimateSenslog2
+         MAVEN_PROFILE: FieldClimateSenslog1
     ports:
       - "5005:5005"
     restart: always
     environment:
-        APP_PARAMS: -cf config/test.yaml
-        DEBUG: "false"
+        APP_PARAMS: -cf config/fieldclimateSenslog1.yaml
+        DEBUG: "true"
         LOG_MONITOR: "false"
 
 networks:

+ 1 - 1
docker/Dockerfile

@@ -16,6 +16,6 @@ RUN apk update
 RUN apk add filebeat
 
 RUN mvn clean
-RUN mvn package -P $MAVEN_PROFILE
+RUN mvn package -P $MAVEN_PROFILE -DskipTests=true
 
 ENTRYPOINT ["/bin/sh", "-C", "start.sh"]

+ 2 - 2
pom.xml

@@ -29,10 +29,10 @@
         </profile>
 
         <profile>
-            <id>FieldClimateSenslog2</id>
+            <id>FieldClimateSenslog1</id>
             <modules>
                 <module>connector-fetch-fieldclimate</module>
-                <module>connector-push-rest-senslog-v2</module>
+                <module>connector-push-rest-senslog-v1</module>
             </modules>
         </profile>