Parcourir la source

Implemented legacy endpoint with nearest location finding of mobile unit, added API for updating locations for static units

Lukas Cerny il y a 1 an
Parent
commit
7a953fe5ed

+ 5 - 5
build.gradle

@@ -53,11 +53,11 @@ dependencies {
     implementation 'org.apache.logging.log4j:log4j-api:2.22.0'
     implementation 'org.apache.logging.log4j:log4j-core:2.22.0'
 
-    implementation 'io.vertx:vertx-core:4.5.7'
-    implementation 'io.vertx:vertx-web:4.5.7'
-    implementation 'io.vertx:vertx-web-openapi:4.5.7'
-    implementation 'io.vertx:vertx-auth-jwt:4.5.7'
-    implementation 'io.vertx:vertx-pg-client:4.5.7'
+    implementation 'io.vertx:vertx-core:4.5.8'
+    implementation 'io.vertx:vertx-web:4.5.8'
+    implementation 'io.vertx:vertx-web-openapi:4.5.8'
+    implementation 'io.vertx:vertx-auth-jwt:4.5.8'
+    implementation 'io.vertx:vertx-pg-client:4.5.8'
     implementation 'org.postgresql:postgresql:42.7.3'
     implementation 'com.ongres.scram:client:2.1'
 

+ 2 - 0
docker-compose.yaml

@@ -18,6 +18,8 @@ services:
       context: .
     env_file:
       - docker.dev.env
+    environment:
+      - TZ="Europe/Prague"
     ports:
       - "8085:8085"
       - "5005:5005"

+ 3 - 2
init.sql

@@ -246,14 +246,15 @@ CREATE TABLE maplog.alert (
     status alert_status NOT NULL,
     time_received TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
 );
+ALTER TABLE maplog.alert OWNER TO senslog;
 
 CREATE TABLE maplog.unit_static_to_mobile (
     static_unit_id  BIGINT NOT NULL,
     mobile_unit_id  BIGINT NOT NULL,
+    the_geom public.geometry NOT NULL,
     PRIMARY KEY (static_unit_id, mobile_unit_id)
 );
-
-ALTER TABLE maplog.alert OWNER TO senslog;
+ALTER TABLE maplog.unit_static_to_mobile OWNER TO senslog;
 
 CREATE SEQUENCE maplog.alert_id_seq START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1;
 

+ 2 - 1
src/main/java/cz/senslog/telemetry/app/Application.java

@@ -20,6 +20,7 @@ import java.io.FileInputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.time.Duration;
+import java.time.ZoneId;
 import java.util.Properties;
 
 public final class Application {
@@ -65,7 +66,7 @@ public final class Application {
     }
 
     public static void start() {
-        logger.info("Starting app '{}', version '{}', build '{}'.", PROJECT_NAME, COMPILED_VERSION, BUILD_VERSION);
+        logger.info("Starting app '{}', version '{}', build '{}', timezone '{}'.", PROJECT_NAME, COMPILED_VERSION, BUILD_VERSION, ZoneId.systemDefault());
 
         PropertyConfig config = PropertyConfig.getInstance();
         DeploymentOptions options = new DeploymentOptions().setConfig(JsonObject.of(

+ 9 - 3
src/main/java/cz/senslog/telemetry/database/domain/StaticUnitBasicInfo.java

@@ -5,15 +5,17 @@ public class StaticUnitBasicInfo {
     private final long staticUnitId;
     private final long mobileUnitId;
     private final long campaignId;
+    private final Location defaultLocation;
 
-    public static StaticUnitBasicInfo of(long staticUnitId, long mobileUnitId, long campaignId) {
-        return new StaticUnitBasicInfo(staticUnitId, mobileUnitId, campaignId);
+    public static StaticUnitBasicInfo of(long staticUnitId, long mobileUnitId, long campaignId, Location defaultLocation) {
+        return new StaticUnitBasicInfo(staticUnitId, mobileUnitId, campaignId, defaultLocation);
     }
 
-    private StaticUnitBasicInfo(long staticUnitId, long mobileUnitId, long campaignId) {
+    private StaticUnitBasicInfo(long staticUnitId, long mobileUnitId, long campaignId, Location defaultLocation) {
         this.staticUnitId = staticUnitId;
         this.mobileUnitId = mobileUnitId;
         this.campaignId = campaignId;
+        this.defaultLocation = defaultLocation;
     }
 
     public long getStaticUnitId() {
@@ -27,4 +29,8 @@ public class StaticUnitBasicInfo {
     public long getCampaignId() {
         return campaignId;
     }
+
+    public Location getDefaultLocation() {
+        return defaultLocation;
+    }
 }

+ 74 - 4
src/main/java/cz/senslog/telemetry/database/repository/MapLogRepository.java

@@ -81,7 +81,7 @@ public class MapLogRepository implements SensLogRepository {
                 .map(RowSet::iterator)
                 .map(it -> it.hasNext() ? it.next().getLong(0) : null)
                 .map(Optional::ofNullable)
-                .map(p -> p.orElseThrow(() -> new IllegalStateException("Event<%d> was not updated due to no such ongoing event.")))
+                .map(p -> p.orElseThrow(() -> new IllegalStateException(String.format("Event<%d> was not updated due to no such ongoing event.", eventId))))
         );
     }
 
@@ -192,15 +192,56 @@ public class MapLogRepository implements SensLogRepository {
                 )).toList());
     }
 
+    @Override
+    public Future<Long> updateLocations(long campaignId, long unitId, List<UnitTelemetry> data) {
+        if (data == null || data.size() < 2) {
+            // less than 2 records can not reproduce the path
+            return Future.succeededFuture(0L);
+        }
+
+        List<Tuple> tuples = new ArrayList<>(data.size());
+        for (int i = 0; i < data.size() - 1; i++) {
+            Location l = data.get(i).getLocation();
+            tuples.add(Tuple.of(campaignId, unitId,
+                    data.get(i).getTimestamp(),
+                    data.get(i + 1).getTimestamp(),
+                    l.getLongitude(),
+                    l.getLatitude(),
+                    l.getAltitude(),
+                    l.getAngle()
+            ));
+        }
+
+        return client.withTransaction(conn -> Future.all(tuples.stream()
+                    .map(tuple -> client
+                            .preparedQuery("UPDATE maplog.obs_telemetry SET the_geom = ST_SetSRID(ST_MakePoint($5, $6, $7, $8), 4326) " +
+                                    "WHERE unit_id = $2 AND $3 <= time_stamp AND time_stamp <= $4 RETURNING id")
+                            .execute(tuple)
+                            .map(SqlResult::rowCount))
+                    .toList())
+            .map(eachCount -> eachCount.list().stream().mapToLong(Integer.class::cast).sum()));
+    }
+
     private static final Function<Row, StaticUnitBasicInfo> ROW_TO_STATIC_UNIT_BASIC_INFO = row -> StaticUnitBasicInfo.of(
         row.getLong("static_unit_id"),
         row.getLong("mobile_unit_id"),
-        row.getLong("campaign_id")
+        row.getLong("campaign_id"),
+        Location.of(
+                row.getFloat("long"),
+                row.getFloat("lat"),
+                row.getFloat("alt"),
+                row.getFloat("angle")
+        )
     );
 
     @Override
     public Future<StaticUnitBasicInfo> findMobileUnitByStaticUnitIdAndTimestamp(long unitId, OffsetDateTime timestamp) {
-        return client.preparedQuery("SELECT ustm.static_unit_id, ustm.mobile_unit_id, utc.campaign_id FROM maplog.unit_static_to_mobile ustm " +
+        return client.preparedQuery("SELECT ustm.static_unit_id, ustm.mobile_unit_id, ustm.the_geom, utc.campaign_id, " +
+                            "ST_X (ST_Transform (ustm.the_geom, 4326)) AS long, " +
+                            "ST_Y (ST_Transform (ustm.the_geom, 4326)) AS lat, " +
+                            "ST_Z (ST_Transform (ustm.the_geom, 4326)) AS alt, " +
+                            "ST_M (ustm.the_geom) AS angle " +
+                        "FROM maplog.unit_static_to_mobile ustm " +
                         "JOIN maplog.unit_to_campaign utc on utc.unit_id = mobile_unit_id " +
                         "JOIN maplog.campaign c on c.id = utc.campaign_id " +
                         "WHERE ustm.static_unit_id = $1 " +
@@ -208,7 +249,9 @@ public class MapLogRepository implements SensLogRepository {
                         "LIMIT 1")
                 .execute(Tuple.of(unitId, timestamp))
                 .map(RowSet::iterator)
-                .map(it -> it.hasNext() ? ROW_TO_STATIC_UNIT_BASIC_INFO.apply(it.next()) : null);
+                .map(it -> it.hasNext() ? ROW_TO_STATIC_UNIT_BASIC_INFO.apply(it.next()) : null)
+                .map(Optional::ofNullable)
+                .map(p -> p.orElseThrow(() -> new IllegalStateException(String.format("No mobile unit for the static unit <%d>.", unitId))));
     }
 
     @Override
@@ -2323,6 +2366,33 @@ public class MapLogRepository implements SensLogRepository {
                 );
     }
 
+    private static final Function<Row, UnitLocation> ROW_TO_UNIT_LOCATION = r -> UnitLocation.of(
+            r.getLong("unit_id"),
+            r.getOffsetDateTime("time_stamp"),
+            Location.of(
+                    r.getFloat("long"),
+                    r.getFloat("lat"),
+                    r.getFloat("alt"),
+                    r.getFloat("angle")
+            )
+    );
+
+    @Override
+    public Future<Optional<UnitLocation>> findNearestLocationToTimestampByCampaignIdAndUnitId(long campaignId, long unitId, OffsetDateTime timestamp, OffsetDateTime from, OffsetDateTime to) {
+        return client.preparedQuery("SELECT unit_id, time_stamp, " +
+                        "ST_X (ST_Transform (the_geom, 4326)) AS long, " +
+                        "ST_Y (ST_Transform (the_geom, 4326)) AS lat, " +
+                        "ST_Z (ST_Transform (the_geom, 4326)) AS alt, " +
+                        "ST_M (the_geom) AS angle " +
+                        "FROM maplog.obs_telemetry WHERE unit_id = $2 AND $4 <= time_stamp AND time_stamp <= $5 " +
+                        "GROUP BY obs_telemetry.id " +
+                        "ORDER BY MIN(abs(extract(epoch from time_stamp) - extract(epoch from $3::timestamp with time zone))) LIMIT 1")
+                .execute(Tuple.of(campaignId, unitId, timestamp, from, to))
+                .map(RowSet::iterator)
+                .map(it -> it.hasNext() ? ROW_TO_UNIT_LOCATION.apply(it.next()) : null)
+                .map(Optional::ofNullable);
+    }
+
     @Override
     public Future<List<UnitLocation>> findUnitsLocationsByCampaignId(
             long campaignId, int limitPerUnit, OffsetDateTime from, OffsetDateTime to, SortType sort, List<Filter> filters

+ 3 - 0
src/main/java/cz/senslog/telemetry/database/repository/SensLogRepository.java

@@ -9,6 +9,7 @@ import io.vertx.core.Future;
 import java.time.OffsetDateTime;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 
 public interface SensLogRepository {
@@ -20,6 +21,7 @@ public interface SensLogRepository {
 
     Future<UnitTelemetry> saveTelemetry(UnitTelemetry data);
     Future<List<UnitTelemetry>> saveAllTelemetry(List<UnitTelemetry> data);
+    Future<Long> updateLocations(long campaignId, long unitId, List<UnitTelemetry> data);
 
     Future<StaticUnitBasicInfo> findMobileUnitByStaticUnitIdAndTimestamp(long unitId, OffsetDateTime timestamp);
     Future<Boolean> createSensor(Sensor sensor, long unitId);
@@ -240,6 +242,7 @@ public interface SensLogRepository {
                     return new PagingRetrieve<>(hasNext, data.size(), data);
                 });
     }
+    Future<Optional<UnitLocation>> findNearestLocationToTimestampByCampaignIdAndUnitId(long campaignId, long unitId, OffsetDateTime timestamp, OffsetDateTime from, OffsetDateTime to);
 
     Future<List<UnitLocation>> findUnitsLocationsByCampaignId(long campaignId, int limitPerUnit, OffsetDateTime from, OffsetDateTime to, SortType sort, List<Filter> filters);
     Future<List<UnitLocation>> findUnitsLocationsByIdentityAndCampaignId(String userIdentity, long campaignId, int limitPerUnit, OffsetDateTime from, OffsetDateTime to, SortType sort, List<Filter> filters);

+ 1 - 0
src/main/java/cz/senslog/telemetry/server/HttpVertxServer.java

@@ -87,6 +87,7 @@ public final class HttpVertxServer extends AbstractVerticle {
                     openAPIRouterBuilder.operation("campaignIdUnitsObservationsGET").handler(apiHandler::campaignIdUnitsObservationsGET);
                     openAPIRouterBuilder.operation("campaignIdUnitsObservationsPOST").handler(apiHandler::campaignIdUnitsObservationsPOST);
                     openAPIRouterBuilder.operation("campaignIdUnitsObservationsLocationsGET").handler(apiHandler::campaignIdUnitsObservationsLocationsGET);
+                    openAPIRouterBuilder.operation("campaignIdUnitsObservationsLocationsPUT").handler(apiHandler::campaignIdUnitsObservationsLocationsPUT);
                     openAPIRouterBuilder.operation("campaignIdUnitIdObservationsGET").handler(apiHandler::campaignIdUnitIdObservationsGET);
                     openAPIRouterBuilder.operation("campaignIdUnitIdLocationsGET").handler(apiHandler::campaignIdUnitIdLocationsGET);
                     openAPIRouterBuilder.operation("campaignIdUnitIdGET").handler(apiHandler::campaignIdUnitIdGET);

+ 1 - 1
src/main/java/cz/senslog/telemetry/server/ws/ContentType.java

@@ -2,7 +2,7 @@ package cz.senslog.telemetry.server.ws;
 
 public enum ContentType {
     JSON    ("application/json"),
-    GEOJSON ("application/geo+json"),
+    GEOJSON ("application/geojson"),
     JSON_SCHEMA ("application/json+schema")
     ;
 

+ 71 - 21
src/main/java/cz/senslog/telemetry/server/ws/OpenAPIHandler.java

@@ -8,6 +8,7 @@ import cz.senslog.telemetry.database.domain.*;
 import cz.senslog.telemetry.database.repository.SensLogRepository;
 import cz.senslog.telemetry.module.EventBusModulePaths;
 import cz.senslog.telemetry.utils.CascadeCondition;
+import cz.senslog.telemetry.utils.Tuple;
 import io.vertx.core.Future;
 import io.vertx.core.Vertx;
 import io.vertx.core.eventbus.DeliveryOptions;
@@ -23,10 +24,7 @@ import org.apache.logging.log4j.Logger;
 
 import java.text.ParseException;
 import java.text.SimpleDateFormat;
-import java.time.OffsetDateTime;
-import java.time.ZoneId;
-import java.time.ZoneOffset;
-import java.time.ZonedDateTime;
+import java.time.*;
 import java.time.format.DateTimeFormatter;
 import java.util.*;
 import java.util.function.*;
@@ -47,8 +45,10 @@ import static io.vertx.core.http.HttpHeaders.ACCEPT;
 import static io.vertx.core.http.HttpHeaders.CONTENT_TYPE;
 import static java.time.OffsetDateTime.ofInstant;
 import static java.time.format.DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+import static java.time.format.DateTimeFormatter.ofPattern;
 import static java.util.Collections.emptyList;
 import static java.util.Comparator.comparing;
+import static java.util.Optional.ofNullable;
 import static java.util.stream.Collectors.*;
 
 public class OpenAPIHandler {
@@ -1437,22 +1437,20 @@ public class OpenAPIHandler {
                                     tel.getInteger("speed"),
                                     tel.getJsonObject("observedValues")
                             )).sorted(comparing(UnitTelemetry::getTimestamp)).collect(toList()))
-                    .ifPresent(validateAndSave);
+                    .ifPresentOrElse(validateAndSave, () -> {throw new IllegalArgumentException("Invalid input data."); } );
 
             case GEOJSON -> Optional.of(rc.body().asJsonObject()).map(j -> j.getJsonArray("features"))
                     .map(jsonArray -> jsonArray.stream().filter(JsonObject.class::isInstance).map(JsonObject.class::cast)
                             .map(feature -> UnitTelemetry.of(
                                     feature.getJsonObject("properties").getLong("unitId"),
                                     OffsetDateTime.parse(feature.getJsonObject("properties").getString("timestamp")),
-                                    Optional.of(feature.getJsonObject("location")).map(location -> Location.of(
-                                            location.getFloat("longitude"),
-                                            location.getFloat("latitude"),
-                                            location.getFloat("altitude")
+                                    Optional.of(feature.getJsonObject("geometry").getJsonArray("coordinates")).map(location -> Location.of(
+                                            location.getFloat(0), location.getFloat(1), location.getFloat(2), 0
                                     )).get(),
                                     feature.getJsonObject("properties").getInteger("speed"),
                                     feature.getJsonObject("observedValues")
                             )).sorted(comparing(UnitTelemetry::getTimestamp)).collect(toList()))
-                    .ifPresent(validateAndSave);
+                    .ifPresentOrElse(validateAndSave, () -> {throw new IllegalArgumentException("Invalid input data."); } );
         }
     }
 
@@ -1781,7 +1779,7 @@ public class OpenAPIHandler {
             value = Double.parseDouble(rc.queryParam("value").get(0));
             unitId = Long.parseLong(rc.queryParam("unit_id").get(0));
             sensorIdStr = rc.queryParam("sensor_id").get(0);
-            timestamp = OffsetDateTime.parse(rc.queryParam("date").get(0), ISO_OFFSET_DATE_TIME);
+            timestamp = ZonedDateTime.of(LocalDateTime.parse(rc.queryParam("date").get(0), ofPattern("yyyy-MM-dd[+][ ]HH:mm:ss")), ZoneOffset.systemDefault()).toOffsetDateTime();
         } catch (Exception e) {
             logger.catching(e);
             rc.response().end("false"); return;
@@ -1791,17 +1789,69 @@ public class OpenAPIHandler {
 
         repo.findMobileUnitByStaticUnitIdAndTimestamp(unitId, timestamp)
                 .compose(u -> repo
-                    .findObservationsByCampaignIdAndUnitId(u.getCampaignId(), u.getMobileUnitId(), timestamp.minusMinutes(minuteDiff), timestamp.plusMinutes(minuteDiff), 0, 1, DESC, emptyList()))
-                .compose(mobileTelemetry -> {
-                    if (mobileTelemetry.isEmpty()) {
-                        // TODO no unit location within -30 - +30
-                        return Future.succeededFuture(false);
-                    } else {
-                        UnitTelemetry lastUnitTelemetry = mobileTelemetry.get(0);
-                        return repo.saveTelemetry(UnitTelemetry.of(unitId, timestamp, lastUnitTelemetry.getLocation(), lastUnitTelemetry.getSpeed(), JsonObject.of(sensorIdStr, value))).map(r -> r.getId() > 0);
-                    }
-                })
+                    .findNearestLocationToTimestampByCampaignIdAndUnitId(u.getCampaignId(), u.getMobileUnitId(), timestamp, timestamp.minusMinutes(minuteDiff), timestamp.plusMinutes(minuteDiff))
+                        .map(t -> t.map(UnitLocation::getLocation).orElseGet(u::getDefaultLocation)))
+                .compose(l -> repo.saveTelemetry(UnitTelemetry.of(unitId, timestamp, l, 0, JsonObject.of(sensorIdStr, value))).map(r -> r.getId() > 0))
                 .onSuccess(res -> rc.response().end(res.toString()))
                 .onFailure(rc::fail);
     }
+
+    public void campaignIdUnitsObservationsLocationsPUT(RoutingContext rc) {
+        AuthBearerUser user = AuthBearerUser.of(rc.user());
+        WSParameters params = WSParameters.wrap(rc.queryParams(), rc.pathParams());
+
+        long campaignId = params.pathParams().campaignId();
+
+        final Consumer<List<UnitTelemetry>> validateAndUpdate = tlsOrig -> repo.findCampaignById(campaignId)
+                .onSuccess(campaign -> Future.all(telemetriesWithinCampaign(campaign, tlsOrig).stream()
+                        .collect(Collectors.groupingBy(UnitTelemetry::getUnitId, Collectors.mapping(Function.identity(), Collectors.toList())))
+                        .entrySet().stream().map(entry -> {
+                            long unitId = entry.getKey();
+                            List<UnitTelemetry> tlsToUpdate = entry.getValue();
+                            return repo.updateLocations(campaignId, unitId, tlsToUpdate);
+                        }).toList())
+                        .onSuccess(f -> rc.response().end(JsonObject.of(
+                                "total", f.list().stream().mapToLong(Long.class::cast).sum()
+                            ).encode()))
+                        .onFailure(rc::fail)
+                )
+                .onFailure(rc::fail);
+
+        switch (ContentType.ofType(rc.request().getHeader(CONTENT_TYPE))) {
+            case JSON -> ofNullable(rc.body().asJsonArray())
+                    .map(arr -> arr.stream()
+                            .filter(JsonObject.class::isInstance).map(JsonObject.class::cast)
+                            .map(tel -> UnitTelemetry.of(
+                                tel.getLong("unitId"),
+                                OffsetDateTime.parse(tel.getString("timestamp")),
+                                Optional.of(tel.getJsonObject("location")).map(location -> Location.of(
+                                        location.getFloat("longitude"),
+                                        location.getFloat("latitude"),
+                                        location.getFloat("altitude")
+                                )).get(),
+                                0, JsonObject.of()
+                            )).sorted(comparing(UnitTelemetry::getTimestamp)).collect(toList()))
+                    .ifPresentOrElse(validateAndUpdate, () -> {throw new IllegalArgumentException("Invalid input data."); } );
+
+            case GEOJSON -> Optional.of(rc.body().asJsonObject()).map(j -> j.getJsonArray("features", JsonArray.of()))
+                    .map(jsonArray -> jsonArray.stream().filter(JsonObject.class::isInstance).map(JsonObject.class::cast)
+                            .map(feature -> UnitTelemetry.of(
+                                    feature.getJsonObject("properties").getLong("unitId"),
+                                    OffsetDateTime.parse(feature.getJsonObject("properties").getString("timestamp")),
+                                    Optional.of(feature.getJsonObject("geometry").getJsonArray("coordinates")).map(location -> Location.of(
+                                            location.getFloat(0), location.getFloat(1), location.getFloat(2), 0
+                                    )).get(),
+                                    0, JsonObject.of()
+                            )).sorted(comparing(UnitTelemetry::getTimestamp)).collect(toList()))
+                    .ifPresentOrElse(validateAndUpdate, () -> {throw new IllegalArgumentException("Invalid input data."); } );
+        }
+    }
+
+    // ok  1. default location for mobile unit if the real one does not exist yet (or at all)
+    // 2. add POST endpoint pro /observations/location
+    //      2.1 mapping loc(A) <--> loc(B) --> change all telemetry observations between
+
+    // 4. connector PPL ->> telemetry/location
+    // 3. prepare example of endpoint MarketPlace ->>> telemetry
+
 }

+ 117 - 30
src/main/resources/openAPISpec.yaml

@@ -136,7 +136,7 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/CampaignObservationPaging'
-            application/geo+json:
+            application/geojson:
               schema:
                 $ref: '#/components/schemas/GeoFeatureCollectionUnit'
         default:
@@ -161,7 +161,7 @@ paths:
               type: array
               items:
                 $ref: '#/components/schemas/CampaignDataObservation'
-          application/geo+json:
+          application/geojson:
             schema:
               $ref: '#/components/schemas/GeoFeatureCollectionUnit'
       responses:
@@ -201,12 +201,43 @@ paths:
         200:
           description: JSON containing stream of telemetry data
           content:
+            application/geojson:
+              schema:
+                $ref: '#/components/schemas/GeoCampaignUnitsMultiLocations'
+            application/json:
+              schema:
+                $ref: '#/components/schemas/CampaignUnitsLocationsPaging'
+        default:
+          description: unexpected error
+          content:
             application/json:
               schema:
-                $ref: '#/components/schemas/CampaignUnitsLocations'
-            application/geo+json:
+                $ref: '#/components/schemas/Error'
+    put:
+      operationId: campaignIdUnitsObservationsLocationsPUT
+      summary: Adjusting locations of all observations within 'fromTime' to 'toTime' interval.
+      tags:
+        - Observation
+      security:
+        - bearerAuth: [ read:infrastructure ]
+      parameters:
+        - $ref: '#/components/parameters/campaignIdParam'
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/CampaignUnitsLocationArray'
+          application/geojson:
+            schema:
+              $ref: '#/components/schemas/GeoCampaignUnitsSingleLocations'
+      responses:
+        200:
+          description: JSON Object containing number of updated observations.
+          content:
+            application/json:
               schema:
-                $ref: '#/components/schemas/GeoCampaignUnitsLocations'
+                type: object
         default:
           description: unexpected error
           content:
@@ -269,7 +300,7 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/CampaignUnitObservationPaging'
-            application/geo+json:
+            application/geojson:
               schema:
                 $ref: '#/components/schemas/GeoFeatureCollectionUnit'
         default:
@@ -306,7 +337,7 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/CampaignUnitLocationPaging'
-            application/geo+json:
+            application/geojson:
               schema:
                 $ref: '#/components/schemas/GeoFeatureUnitMultiLocation'
         default:
@@ -401,7 +432,7 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/CampaignUnitSensorObservationPaging'
-            application/geo+json:
+            application/geojson:
               schema:
                 $ref: '#/components/schemas/GeoFeatureCollectionUnit'
         default:
@@ -1143,7 +1174,7 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ActionEventObservationPaging'
-            application/geo+json:
+            application/geojson:
               schema:
                 $ref: '#/components/schemas/GeoFeatureCollectionUnit'
         default:
@@ -1179,7 +1210,7 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ActionEventLocationPaging'
-            application/geo+json:
+            application/geojson:
               schema:
                 $ref: '#/components/schemas/GeoFeatureUnitMultiLocation'
         default:
@@ -1477,7 +1508,6 @@ paths:
           required: true
           schema:
             type: string
-            format: date-time
         - in: query
           name: unit_id
           required: true
@@ -2423,7 +2453,35 @@ components:
               latitude: 13.3736
               altitude: 350.3
 
-    CampaignUnitsLocations:
+    CampaignUnitsLocationArray:
+      type: array
+      items:
+        $ref: '#/components/schemas/CampaignUnitsLocation'
+
+    CampaignUnitsLocation:
+      type: object
+      required:
+        - unitId
+        - timestamp
+        - location
+      properties:
+        unitId:
+          type: integer
+          format: int64
+        timestamp:
+          type: string
+          format: date-time
+        location:
+          $ref: '#/components/schemas/Location'
+      example:
+        unitId: 25
+        timestamp: "2011-12-03T10:15:30+01:00"
+        location:
+          longitude: 49.7384
+          latitude: 13.3736
+          altitude: 350.3
+
+    CampaignUnitsLocationsPaging:
       type: object
       required:
         - params
@@ -2437,29 +2495,14 @@ components:
             linkTo: campaignIdGET
       properties:
         Campaign@NavigationLink:
-          $ref: '#/components/schemas/CampaignUnitsLocations/x-NavigationLinks/Campaign@NavigationLink'
+          $ref: '#/components/schemas/CampaignUnitsLocationsPaging/x-NavigationLinks/Campaign@NavigationLink'
         params:
           type: object
           description: Used params in URL
         size:
           type: integer
         data:
-          type: array
-          items:
-            type: object
-            required:
-              - unitId
-              - timestamp
-              - location
-            properties:
-              unitId:
-                type: integer
-                format: int64
-              timestamp:
-                type: string
-                format: date-time
-              location:
-                $ref: '#/components/schemas/Location'
+          $ref: '#/components/schemas/CampaignUnitsLocationArray'
       example:
         Campaign@NavigationLink: "<domain>/campaigns/1"
         params:
@@ -2475,7 +2518,51 @@ components:
               latitude: 13.3736
               altitude: 350.3
 
-    GeoCampaignUnitsLocations:
+    GeoCampaignUnitsSingleLocations:
+      type: object
+      required:
+        - type
+        - features
+      properties:
+        type:
+          type: string
+          enum:
+            - FeatureCollection
+        metadata:
+          type: object
+        features:
+          type: array
+          minLength: 2
+          items:
+            $ref: '#/components/schemas/GeoFeatureUnitSingleLocation'
+
+    GeoFeatureUnitSingleLocation:
+      type: object
+      required:
+        - type
+        - properties
+        - geometry
+      properties:
+        type:
+          type: string
+          enum:
+            - Feature
+        properties:
+          type: object
+          required:
+            - unitId
+            - timestamp
+          properties:
+            unitId:
+              type: integer
+              format: int64
+            timestamp:
+              type: string
+              format: date-time
+        geometry:
+          $ref: '#/components/schemas/GeoPoint'
+
+    GeoCampaignUnitsMultiLocations:
       type: object
       required:
         - type

+ 12 - 1
src/test/java/cz/senslog/telemetry/MockSensLogRepository.java

@@ -10,6 +10,7 @@ import io.vertx.core.json.JsonObject;
 import java.time.*;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 
 public class MockSensLogRepository implements SensLogRepository {
@@ -47,8 +48,13 @@ public class MockSensLogRepository implements SensLogRepository {
     }
 
     @Override
+    public Future<Long> updateLocations(long campaignId, long unitId, List<UnitTelemetry> data) {
+        return Future.succeededFuture(1L);
+    }
+
+    @Override
     public Future<StaticUnitBasicInfo> findMobileUnitByStaticUnitIdAndTimestamp(long unitId, OffsetDateTime timestamp) {
-        return Future.succeededFuture(StaticUnitBasicInfo.of(10L, 11L, 1L));
+        return Future.succeededFuture(StaticUnitBasicInfo.of(10L, 11L, 1L, Location.of(0, 0, 0, 0)));
     }
 
     @Override
@@ -624,6 +630,11 @@ public class MockSensLogRepository implements SensLogRepository {
     }
 
     @Override
+    public Future<Optional<UnitLocation>> findNearestLocationToTimestampByCampaignIdAndUnitId(long campaignId, long unitId, OffsetDateTime timestamp, OffsetDateTime from, OffsetDateTime to) {
+        return Future.succeededFuture(Optional.of(mockUnitLocations().get(0)));
+    }
+
+    @Override
     public Future<List<UnitLocation>> findUnitsLocationsByCampaignId(long campaignId, int limitPerUnit, OffsetDateTime from, OffsetDateTime to, SortType sort, List<Filter> filters) {
         return Future.succeededFuture(mockUnitLocations());
     }

+ 33 - 0
src/test/java/cz/senslog/telemetry/PlaygroundTest.java

@@ -0,0 +1,33 @@
+package cz.senslog.telemetry;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.temporal.ChronoField;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class PlaygroundTest {
+
+    @Test
+    public void test() {
+
+
+        final String timestamp1 = "2024-06-26+15:30:10";
+        final String timestamp2 = "2024-06-26 15:30:10";
+
+        final LocalDateTime timestamp =LocalDateTime.of(2024, 6, 26, 15, 30, 10, 0);
+
+
+        final DateTimeFormatter formatter = new DateTimeFormatterBuilder()
+                .appendPattern("yyyy-MM-dd[+][ ]HH:mm:ss").toFormatter();
+
+        assertEquals(timestamp, LocalDateTime.parse(timestamp1, formatter));
+        assertEquals(timestamp, LocalDateTime.parse(timestamp2, formatter));
+
+        assertEquals(timestamp, LocalDateTime.parse(timestamp1, DateTimeFormatter.ofPattern("yyyy-MM-dd[+][ ]HH:mm:ss")));
+        assertEquals(timestamp, LocalDateTime.parse(timestamp2, DateTimeFormatter.ofPattern("yyyy-MM-dd[+][ ]HH:mm:ss")));
+    }
+}

+ 5 - 8
src/test/java/cz/senslog/telemetry/server/ws/OpenAPIHandlerTest.java

@@ -47,7 +47,6 @@ import java.util.stream.Stream;
 import static cz.senslog.telemetry.server.ws.ContentType.JSON;
 import static cz.senslog.telemetry.server.ws.OpenAPIHandlerTest.OpenAPIResponseTyp.SUCCESS;
 import static cz.senslog.telemetry.server.ws.OpenAPIHandlerTest.OpenAPIResponseTyp.ERROR;
-import static java.time.ZoneOffset.UTC;
 import static java.time.format.DateTimeFormatter.ISO_OFFSET_DATE_TIME;
 import static java.time.format.DateTimeFormatter.ISO_ZONED_DATE_TIME;
 import static java.util.Collections.emptyList;
@@ -2887,18 +2886,16 @@ class OpenAPIHandlerTest {
         final long staticSensorId = 10L;
         final long sensorId = 1001L;
         final double sensorValue = 4.5;
-        final String sensorTimestamp = "2024-01-01T15:30:10Z";
-        final OffsetDateTime unitTimestamp = OffsetDateTime.of(2024, 1, 1, 15, 30, 10, 0, UTC);
+        final String sensorTimestamp = "2024-06-26%2b15:30:10"; // 2024-06-26+12:43:00 + prague
+        final OffsetDateTime unitTimestamp = ZonedDateTime.of(LocalDateTime.of(2024, 6, 26, 15, 30, 10, 0), TimeZone.getTimeZone("ECT").toZoneId()).toOffsetDateTime();
         final Location lastMobileUnitLocation = Location.of(50f, 49f, 350f, 0f);
 
         Mockito.when(repo.findMobileUnitByStaticUnitIdAndTimestamp(anyLong(), any())).thenReturn(Future.succeededFuture(
-                StaticUnitBasicInfo.of(staticSensorId, 11L, 1L)
+                StaticUnitBasicInfo.of(staticSensorId, 11L, 1L, Location.of(10, 10, 10, 0))
         ));
 
-        Mockito.when(repo.findObservationsByCampaignIdAndUnitId(anyLong(), anyLong(), any(), any(), anyInt(), anyInt(), any(), any()))
-                .thenReturn(Future.succeededFuture(
-                    List.of(UnitTelemetry.of(1L, 11L, unitTimestamp, lastMobileUnitLocation, 0, JsonObject.of()))
-                ));
+        Mockito.when(repo.findNearestLocationToTimestampByCampaignIdAndUnitId(anyLong(), anyLong(), any(), any(), any()))
+                .thenReturn(Future.succeededFuture(Optional.of(UnitLocation.of(11L, unitTimestamp, lastMobileUnitLocation))));
 
         ArgumentCaptor<UnitTelemetry> obsTelemetryCapture = ArgumentCaptor.forClass(UnitTelemetry.class);