Repository: eclipse/leshan
Pull request #1083 (2021-08-19 16:31:32)

Add Observe-Composite support. 
This is a rebase/review/polish branch integrating : 
 - #1058 
 - #1060
 - #1061
 - #1062
 - #1066
 
 more details at https://github.com/eclipse/leshan/issues/1034#issuecomment-901152904

Michal-Wadowski: @sbernard31 Yes, I think all is ok form me. I think you can merge it into master :smile: 

Patch:
diff --git a/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/ObserveCompositeRelationFilter.java b/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/ObserveCompositeRelationFilter.java
new file mode 100644
index 00000000..4308a189
--- /dev/null
+++ b/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/ObserveCompositeRelationFilter.java
@@ -0,0 +1,84 @@
+/*******************************************************************************
+ * Copyright (c) 2021 Orange.
+ * 
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * and Eclipse Distribution License v1.0 which accompany this distribution.
+ * 
+ * The Eclipse Public License is available at
+ *    http://www.eclipse.org/legal/epl-v20.html
+ * and the Eclipse Distribution License is available at
+ *    http://www.eclipse.org/org/documents/edl-v10.html.
+ * 
+ * Contributors:
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
+ *******************************************************************************/
+package org.eclipse.leshan.client.californium;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.eclipse.californium.core.coap.CoAP;
+import org.eclipse.californium.core.coap.Request;
+import org.eclipse.californium.core.observe.ObserveRelation;
+import org.eclipse.californium.core.observe.ObserveRelationFilter;
+import org.eclipse.leshan.core.californium.ObserveUtil;
+import org.eclipse.leshan.core.node.LwM2mPath;
+
+/**
+ * An {@link ObserveRelationFilter} which select {@link ObserveRelation} based on one of resource URIs.
+ */
+class ObserveCompositeRelationFilter implements ObserveRelationFilter {
+
+    private final List<LwM2mPath> paths;
+
+    /**
+     * Instantiates {@link ObserveCompositeRelationFilter} basing on resource URIs.
+     *
+     * @param paths the list of {@link LwM2mPath}
+     */
+    public ObserveCompositeRelationFilter(LwM2mPath... paths) {
+        this.paths = Arrays.asList(paths);
+    }
+
+    @Override
+    public boolean accept(ObserveRelation relation) {
+        Request request = getRequest(relation);
+
+        if (!isValidObserveCompositeRequest(request)) {
+            return false;
+        }
+
+        List<LwM2mPath> observationPaths = getObserveRequestPaths(request);
+
+        if (observationPaths != null) {
+            for (LwM2mPath observePath : observationPaths) {
+                for (LwM2mPath path : paths) {
+                    if (path.startWith(observePath)) {
+                        return true;
+                    }
+                }
+            }
+        }
+
+        return false;
+    }
+
+    private boolean isValidObserveCompositeRequest(Request request) {
+        if (!request.getCode().equals(CoAP.Code.FETCH)) {
+            return false;
+        }
+        if (!request.getOptions().hasContentFormat()) {
+            return false;
+        }
+        return true;
+    }
+
+    private Request getRequest(ObserveRelation relation) {
+        return relation.getExchange().getRequest();
+    }
+
+    private List<LwM2mPath> getObserveRequestPaths(Request request) {
+        return ObserveUtil.getPathsFromContext(request.getUserContext());
+    }
+}
diff --git a/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/RootResource.java b/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/RootResource.java
index 27a0a7b4..1732f876 100644
--- a/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/RootResource.java
+++ b/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/RootResource.java
@@ -13,11 +13,13 @@
  * Contributors:
  *     Sierra Wireless - initial API and implementation
  *     Achim Kraus (Bosch Software Innovations GmbH) - use ServerIdentity
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
  *******************************************************************************/
 package org.eclipse.leshan.client.californium;
 
 import static org.eclipse.leshan.core.californium.ResponseCodeUtil.toCoapResponseCode;
 
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -30,8 +32,10 @@ import org.eclipse.californium.core.server.resources.CoapExchange;
 import org.eclipse.leshan.client.bootstrap.BootstrapHandler;
 import org.eclipse.leshan.client.engine.RegistrationEngine;
 import org.eclipse.leshan.client.resource.LwM2mRootEnabler;
+import org.eclipse.leshan.client.resource.listener.ObjectsListenerAdapter;
 import org.eclipse.leshan.client.servers.ServerIdentity;
 import org.eclipse.leshan.core.Link;
+import org.eclipse.leshan.core.californium.ObserveUtil;
 import org.eclipse.leshan.core.node.LwM2mNode;
 import org.eclipse.leshan.core.node.LwM2mPath;
 import org.eclipse.leshan.core.node.codec.LwM2mDecoder;
@@ -39,10 +43,12 @@ import org.eclipse.leshan.core.node.codec.LwM2mEncoder;
 import org.eclipse.leshan.core.request.BootstrapDeleteRequest;
 import org.eclipse.leshan.core.request.BootstrapDiscoverRequest;
 import org.eclipse.leshan.core.request.ContentFormat;
+import org.eclipse.leshan.core.request.ObserveCompositeRequest;
 import org.eclipse.leshan.core.request.ReadCompositeRequest;
 import org.eclipse.leshan.core.request.WriteCompositeRequest;
 import org.eclipse.leshan.core.response.BootstrapDeleteResponse;
 import org.eclipse.leshan.core.response.BootstrapDiscoverResponse;
+import org.eclipse.leshan.core.response.ObserveCompositeResponse;
 import org.eclipse.leshan.core.response.ReadCompositeResponse;
 import org.eclipse.leshan.core.response.WriteCompositeResponse;
 import org.eclipse.leshan.core.util.StringUtils;
@@ -64,10 +70,13 @@ public class RootResource extends LwM2mClientCoapResource {
         super("", registrationEngine, endpointsManager);
         this.bootstrapHandler = bootstrapHandler;
         setVisible(false);
+        setObservable(true);
         this.coapServer = coapServer;
         this.rootEnabler = rootEnabler;
         this.encoder = encoder;
         this.decoder = decoder;
+
+        addListeners();
     }
 
     @Override
@@ -97,7 +106,6 @@ public class RootResource extends LwM2mClientCoapResource {
         if (identity == null)
             return;
 
-        // Manage Read Composite request
         Request coapRequest = exchange.advanced().getRequest();
 
         // Handle content format for the response
@@ -119,18 +127,47 @@ public class RootResource extends LwM2mClientCoapResource {
         ContentFormat requestContentFormat = ContentFormat.fromCode(exchange.getRequestOptions().getContentFormat());
         List<LwM2mPath> paths = decoder.decodePaths(coapRequest.getPayload(), requestContentFormat);
 
-        ReadCompositeResponse response = rootEnabler.read(identity,
-                new ReadCompositeRequest(paths, requestContentFormat, responseContentFormat, coapRequest));
-        if (response.getCode().isError()) {
-            exchange.respond(toCoapResponseCode(response.getCode()), response.getErrorMessage());
+        if (exchange.getRequestOptions().hasObserve()) {
+            // Manage Observe Composite request
+            ObserveCompositeRequest observeRequest = new ObserveCompositeRequest(requestContentFormat,
+                    responseContentFormat, paths, coapRequest);
+            ObserveCompositeResponse response = rootEnabler.observe(identity, observeRequest);
+
+            updateUserContextWithPaths(coapRequest, paths);
+
+            if (response.getCode().isError()) {
+                exchange.respond(toCoapResponseCode(response.getCode()), response.getErrorMessage());
+                return;
+            } else {
+                exchange.respond(toCoapResponseCode(response.getCode()),
+                        encoder.encodeNodes(response.getContent(), responseContentFormat, rootEnabler.getModel()),
+                        responseContentFormat.getCode());
+                return;
+            }
         } else {
-            // TODO we could maybe face some race condition if an objectEnabler is removed from LwM2mObjectTree between
-            // rootEnabler.read() and rootEnabler.getModel()
-            exchange.respond(toCoapResponseCode(response.getCode()),
-                    encoder.encodeNodes(response.getContent(), responseContentFormat, rootEnabler.getModel()),
-                    responseContentFormat.getCode());
+            // Manage Read Composite request
+            ReadCompositeResponse response = rootEnabler.read(identity,
+                    new ReadCompositeRequest(paths, requestContentFormat, responseContentFormat, coapRequest));
+            if (response.getCode().isError()) {
+                exchange.respond(toCoapResponseCode(response.getCode()), response.getErrorMessage());
+            } else {
+                // TODO we could maybe face some race condition if an objectEnabler is removed from LwM2mObjectTree
+                // between rootEnabler.read() and rootEnabler.getModel()
+                exchange.respond(toCoapResponseCode(response.getCode()),
+                        encoder.encodeNodes(response.getContent(), responseContentFormat, rootEnabler.getModel()),
+                        responseContentFormat.getCode());
+            }
+            return;
         }
-        return;
+    }
+
+    private void updateUserContextWithPaths(Request coapRequest, List<LwM2mPath> paths) {
+        HashMap<String, String> userContext = new HashMap<>();
+        if (coapRequest.getUserContext() != null) {
+            userContext.putAll(coapRequest.getUserContext());
+        }
+        ObserveUtil.addPathsIntoContext(userContext, paths);
+        coapRequest.setUserContext(userContext);
     }
 
     @Override
@@ -180,4 +217,13 @@ public class RootResource extends LwM2mClientCoapResource {
         exchange.respond(toCoapResponseCode(response.getCode()), response.getErrorMessage());
     }
 
+    private void addListeners() {
+        rootEnabler.addListener(new ObjectsListenerAdapter() {
+
+            @Override
+            public void resourceChanged(LwM2mPath... paths) {
+                changed(new ObserveCompositeRelationFilter(paths));
+            }
+        });
+    }
 }
diff --git a/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/object/ObjectResource.java b/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/object/ObjectResource.java
index 96be9d30..dbde2346 100644
--- a/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/object/ObjectResource.java
+++ b/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/object/ObjectResource.java
@@ -16,6 +16,7 @@
  *     Achim Kraus (Bosch Software Innovations GmbH) - use ServerIdentity
  *     Achim Kraus (Bosch Software Innovations GmbH) - implement POST "/oid/iid" 
  *                                                     as UPDATE instance
+ *     Michał Wadowski (Orange)                      - Add Observe-Composite feature.
  *******************************************************************************/
 package org.eclipse.leshan.client.californium.object;
 
@@ -153,9 +154,7 @@ public class ObjectResource extends LwM2mClientCoapResource implements ObjectLis
                     exchange.respond(toCoapResponseCode(response.getCode()), response.getErrorMessage());
                     return;
                 }
-            }
-
-            else {
+            } else {
                 if (identity.isLwm2mBootstrapServer()) {
                     // Manage Bootstrap Read Request
                     BootstrapReadRequest readRequest = new BootstrapReadRequest(requestedContentFormat, URI,
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mObjectTree.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mObjectTree.java
index 95da9c07..8900fd72 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mObjectTree.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mObjectTree.java
@@ -93,7 +93,7 @@ public class LwM2mObjectTree implements Startable, Stoppable, Destroyable {
         listeners.add(listener);
     }
 
-    public void removedListener(ObjectsListener listener) {
+    public void removeListener(ObjectsListener listener) {
         listeners.remove(listener);
     }
 
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mRootEnabler.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mRootEnabler.java
index 6b33f00f..251ef518 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mRootEnabler.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mRootEnabler.java
@@ -12,13 +12,17 @@
  * 
  * Contributors:
  *     Sierra Wireless - initial API and implementation
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
  *******************************************************************************/
 package org.eclipse.leshan.client.resource;
 
+import org.eclipse.leshan.client.resource.listener.ObjectsListener;
 import org.eclipse.leshan.client.servers.ServerIdentity;
 import org.eclipse.leshan.core.model.LwM2mModel;
+import org.eclipse.leshan.core.request.ObserveCompositeRequest;
 import org.eclipse.leshan.core.request.ReadCompositeRequest;
 import org.eclipse.leshan.core.request.WriteCompositeRequest;
+import org.eclipse.leshan.core.response.ObserveCompositeResponse;
 import org.eclipse.leshan.core.response.ReadCompositeResponse;
 import org.eclipse.leshan.core.response.WriteCompositeResponse;
 
@@ -32,4 +36,10 @@ public interface LwM2mRootEnabler {
     WriteCompositeResponse write(ServerIdentity identity, WriteCompositeRequest request);
 
     LwM2mModel getModel();
+
+    ObserveCompositeResponse observe(ServerIdentity identity, ObserveCompositeRequest request);
+
+    void addListener(ObjectsListener listener);
+
+    void removeListener(ObjectsListener listener);
 }
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/RootEnabler.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/RootEnabler.java
index 649db34e..2f3161e3 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/RootEnabler.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/RootEnabler.java
@@ -12,6 +12,7 @@
  * 
  * Contributors:
  *     Sierra Wireless - initial API and implementation
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
  *******************************************************************************/
 package org.eclipse.leshan.client.resource;
 
@@ -20,6 +21,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
 
+import org.eclipse.leshan.client.resource.listener.ObjectsListener;
 import org.eclipse.leshan.client.servers.ServerIdentity;
 import org.eclipse.leshan.core.model.LwM2mModel;
 import org.eclipse.leshan.core.model.ObjectModel;
@@ -31,11 +33,14 @@ import org.eclipse.leshan.core.node.LwM2mPath;
 import org.eclipse.leshan.core.node.LwM2mResource;
 import org.eclipse.leshan.core.node.LwM2mResourceInstance;
 import org.eclipse.leshan.core.node.LwM2mSingleResource;
+import org.eclipse.leshan.core.request.ObserveCompositeRequest;
+import org.eclipse.leshan.core.request.ObserveRequest;
 import org.eclipse.leshan.core.request.ReadCompositeRequest;
 import org.eclipse.leshan.core.request.ReadRequest;
 import org.eclipse.leshan.core.request.WriteCompositeRequest;
 import org.eclipse.leshan.core.request.WriteRequest;
 import org.eclipse.leshan.core.request.WriteRequest.Mode;
+import org.eclipse.leshan.core.response.ObserveCompositeResponse;
 import org.eclipse.leshan.core.response.ReadCompositeResponse;
 import org.eclipse.leshan.core.response.ReadResponse;
 import org.eclipse.leshan.core.response.WriteCompositeResponse;
@@ -56,6 +61,16 @@ public class RootEnabler implements LwM2mRootEnabler {
         this.tree = tree;
     }
 
+    @Override
+    public void addListener(ObjectsListener listener) {
+        tree.addListener(listener);
+    }
+
+    @Override
+    public void removeListener(ObjectsListener listener) {
+        tree.removeListener(listener);
+    }
+
     @Override
     public ReadCompositeResponse read(ServerIdentity identity, ReadCompositeRequest request) {
         List<LwM2mPath> paths = request.getPaths();
@@ -193,6 +208,46 @@ public class RootEnabler implements LwM2mRootEnabler {
 
     }
 
+    @Override
+    public synchronized ObserveCompositeResponse observe(ServerIdentity identity, ObserveCompositeRequest request) {
+        List<LwM2mPath> paths = request.getPaths();
+
+        // Read Nodes
+        Map<LwM2mPath, LwM2mNode> content = new HashMap<>();
+        boolean isEmpty = true; // true if don't succeed to read any of requested path
+        for (LwM2mPath path : paths) {
+            // Get corresponding object enabler
+            Integer objectId = path.getObjectId();
+            LwM2mObjectEnabler objectEnabler = tree.getObjectEnabler(objectId);
+
+            LwM2mNode node = null;
+            if (objectEnabler != null) {
+                ReadResponse response = objectEnabler.observe(identity,
+                        new ObserveRequest(request.getResponseContentFormat(), path, request.getCoapRequest())
+                );
+                if (response.isSuccess()) {
+                    node = response.getContent();
+                    isEmpty = false;
+                } else {
+                    LOG.debug("Server {} try to read node {} in a Observe-Composite Request {} but it failed for {} " +
+                            "{}", identity, path, paths, response.getCode(), response.getErrorMessage()
+                    );
+                }
+            } else {
+                LOG.debug("Server {} try to read node {} in a Observe-Composite Request {} but it failed because " +
+                        "Object {} is not supported", identity, path, paths, objectId
+                );
+            }
+
+            content.put(path, node);
+        }
+        if (isEmpty) {
+            return ObserveCompositeResponse.notFound();
+        } else {
+            return ObserveCompositeResponse.success(content);
+        }
+    }
+
     @Override
     public LwM2mModel getModel() {
         return tree.getModel();
diff --git a/leshan-core-cf/src/main/java/org/eclipse/leshan/core/californium/ObserveUtil.java b/leshan-core-cf/src/main/java/org/eclipse/leshan/core/californium/ObserveUtil.java
new file mode 100644
index 00000000..2ec67999
--- /dev/null
+++ b/leshan-core-cf/src/main/java/org/eclipse/leshan/core/californium/ObserveUtil.java
@@ -0,0 +1,222 @@
+/*******************************************************************************
+ * Copyright (c) 2017 Sierra Wireless and others.
+ * 
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * and Eclipse Distribution License v1.0 which accompany this distribution.
+ * 
+ * The Eclipse Public License is available at
+ *    http://www.eclipse.org/legal/epl-v20.html
+ * and the Eclipse Distribution License is available at
+ *    http://www.eclipse.org/org/documents/edl-v10.html.
+ * 
+ * Contributors:
+ *     Sierra Wireless - initial API and implementation
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
+ *     Michał Wadowski (Orange) - Add Cancel Composite-Observation feature.
+ *******************************************************************************/
+package org.eclipse.leshan.core.californium;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import org.eclipse.californium.core.coap.CoAP;
+import org.eclipse.californium.core.coap.Request;
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.observation.CompositeObservation;
+import org.eclipse.leshan.core.observation.SingleObservation;
+import org.eclipse.leshan.core.request.ContentFormat;
+import org.eclipse.leshan.core.request.ObserveCompositeRequest;
+import org.eclipse.leshan.core.request.ObserveRequest;
+
+/**
+ * Utility functions to help to handle observation in Leshan.
+ */
+public class ObserveUtil {
+
+    /* keys used to populate the request context */
+    public static final String CTX_ENDPOINT = "leshan-endpoint";
+    public static final String CTX_REGID = "leshan-regId";
+    public static final String CTX_LWM2M_PATH = "leshan-path";
+
+    /**
+     * Create a LWM2M observation from a CoAP request.
+     */
+    public static SingleObservation createLwM2mObservation(Request request) {
+        ObserveCommon observeCommon = new ObserveCommon(request);
+
+        if (observeCommon.lwm2mPaths.size() != 1) {
+            throw new IllegalStateException(
+                    "1 path is expected in observe request context but was " + observeCommon.lwm2mPaths);
+        }
+
+        return new SingleObservation(request.getToken().getBytes(), observeCommon.regId,
+                observeCommon.lwm2mPaths.get(0), observeCommon.responseContentFormat, observeCommon.context);
+    }
+
+    public static CompositeObservation createLwM2mCompositeObservation(Request request) {
+        ObserveCommon observeCommon = new ObserveCommon(request);
+
+        return new CompositeObservation(request.getToken().getBytes(), observeCommon.regId, observeCommon.lwm2mPaths,
+                observeCommon.requestContentFormat, observeCommon.responseContentFormat, observeCommon.context);
+    }
+
+    private static class ObserveCommon {
+        String regId;
+        Map<String, String> context;
+        List<LwM2mPath> lwm2mPaths;
+        ContentFormat requestContentFormat;
+        ContentFormat responseContentFormat;
+
+        public ObserveCommon(Request request) {
+            if (request.getUserContext() == null) {
+                throw new IllegalStateException("missing request context");
+            }
+
+            context = new HashMap<>();
+
+            for (Entry<String, String> ctx : request.getUserContext().entrySet()) {
+                switch (ctx.getKey()) {
+                case CTX_REGID:
+                    regId = ctx.getValue();
+                    break;
+                case CTX_LWM2M_PATH:
+                    lwm2mPaths = getPathsFromContext(request.getUserContext());
+                    break;
+                case CTX_ENDPOINT:
+                    break;
+                default:
+                    context.put(ctx.getKey(), ctx.getValue());
+                }
+            }
+
+            if (lwm2mPaths == null || lwm2mPaths.size() == 0) {
+                throw new IllegalStateException("missing path in request context");
+            }
+
+            if (request.getOptions().hasContentFormat()) {
+                requestContentFormat = ContentFormat.fromCode(request.getOptions().getContentFormat());
+            }
+
+            if (request.getOptions().hasAccept()) {
+                responseContentFormat = ContentFormat.fromCode(request.getOptions().getAccept());
+            }
+        }
+    }
+
+    /**
+     * Extract {@link LwM2mPath} list from encoded information in user context.
+     * 
+     * @param userContext user context
+     * @return the list of {@link LwM2mPath}
+     */
+    public static List<LwM2mPath> getPathsFromContext(Map<String, String> userContext) {
+        if (userContext.containsKey(CTX_LWM2M_PATH)) {
+            List<LwM2mPath> lwm2mPaths = new ArrayList<>();
+            String pathsEncoded = userContext.get(CTX_LWM2M_PATH);
+
+            for (String path : pathsEncoded.split("\n")) {
+                lwm2mPaths.add(new LwM2mPath(path));
+            }
+            return lwm2mPaths;
+        }
+        return null;
+    }
+
+    /**
+     * Create a CoAP observe request context with specific keys needed for internal Leshan working.
+     */
+    public static Map<String, String> createCoapObserveRequestContext(String endpoint, String registrationId,
+            ObserveRequest request) {
+        Map<String, String> context = new HashMap<>();
+        context.put(CTX_ENDPOINT, endpoint);
+        context.put(CTX_REGID, registrationId);
+
+        addPathsIntoContext(context, Collections.singletonList(request.getPath()));
+
+        context.putAll(request.getContext());
+        return context;
+    }
+
+    public static Map<String, String> createCoapObserveCompositeRequestContext(String endpoint, String registrationId,
+            ObserveCompositeRequest request) {
+        Map<String, String> context = new HashMap<>();
+        context.put(CTX_ENDPOINT, endpoint);
+        context.put(CTX_REGID, registrationId);
+
+        addPathsIntoContext(context, request.getPaths());
+
+        context.putAll(request.getContext());
+        return context;
+    }
+
+    /**
+     * Update user context with encoded list of {@link LwM2mPath}.
+     * 
+     * @param context user context
+     * @param paths the list of {@link LwM2mPath}
+     */
+    public static void addPathsIntoContext(Map<String, String> context, List<LwM2mPath> paths) {
+        StringBuilder sb = new StringBuilder();
+        for (LwM2mPath path : paths) {
+            sb.append(path.toString());
+            sb.append("\n");
+        }
+        context.put(CTX_LWM2M_PATH, sb.toString());
+    }
+
+    public static String extractRegistrationId(org.eclipse.californium.core.observe.Observation observation) {
+        return observation.getRequest().getUserContext().get(CTX_REGID);
+    }
+
+    public static LwM2mPath extractLwm2mPath(org.eclipse.californium.core.observe.Observation observation) {
+        if (observation.getRequest().getCode() == CoAP.Code.GET) {
+            return new LwM2mPath(observation.getRequest().getUserContext().get(CTX_LWM2M_PATH));
+        } else {
+            throw new IllegalStateException(
+                    "Observation targeting only ont path must be a GET but was " + observation.getRequest().getCode());
+        }
+    }
+
+    public static List<LwM2mPath> extractLwm2mPaths(org.eclipse.californium.core.observe.Observation observation) {
+        if (observation.getRequest().getCode() == CoAP.Code.FETCH) {
+            List<LwM2mPath> lwm2mPath = new ArrayList<>();
+            String pathsAsString = observation.getRequest().getUserContext().get(CTX_LWM2M_PATH);
+            for (String path : pathsAsString.split("\n")) {
+                lwm2mPath.add(new LwM2mPath(path));
+            }
+
+            if (lwm2mPath.size() == 0) {
+                throw new IllegalStateException("missing paths in request context");
+            }
+            return lwm2mPath;
+        } else {
+            throw new IllegalStateException(
+                    "Observation targeting several path must be a FETCH but was " + observation.getRequest().getCode());
+        }
+    }
+
+    public static String extractEndpoint(org.eclipse.californium.core.observe.Observation observation) {
+        return observation.getRequest().getUserContext().get(CTX_ENDPOINT);
+    }
+
+    /**
+     * Validate the Californium observation. It is valid if it contains all necessary context for Leshan.
+     */
+    public static String validateCoapObservation(org.eclipse.californium.core.observe.Observation observation) {
+        if (!observation.getRequest().getUserContext().containsKey(CTX_REGID))
+            throw new IllegalStateException("missing registrationId info in the request context");
+        if (!observation.getRequest().getUserContext().containsKey(CTX_LWM2M_PATH))
+            throw new IllegalStateException("missing lwm2m path info in the request context");
+
+        String endpoint = observation.getRequest().getUserContext().get(CTX_ENDPOINT);
+        if (endpoint == null)
+            throw new IllegalStateException("missing endpoint info in the request context");
+
+        return endpoint;
+    }
+}
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mPath.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mPath.java
index 18ca4f36..1b37315b 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mPath.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mPath.java
@@ -15,6 +15,9 @@
  *******************************************************************************/
 package org.eclipse.leshan.core.node;
 
+import java.util.ArrayList;
+import java.util.List;
+
 import org.eclipse.leshan.core.util.Validate;
 
 /**
@@ -422,4 +425,21 @@ public class LwM2mPath implements Comparable<LwM2mPath> {
 
         return new LwM2mPath(path);
     }
+
+    /**
+     * Create list of LwM2mPath from list of paths
+     *
+     * @param paths list of paths as {@link String}.
+     * @return list of paths as {@link LwM2mPath}.
+     *
+     * @exception LwM2mNodeException if path is invalid (e.g. too big number in path)
+     * @exception IllegalArgumentException if path length is invalid or if path contains not Numeric value
+     */
+    public static List<LwM2mPath> getLwM2mPathList(List<String> paths) {
+        List<LwM2mPath> res = new ArrayList<>(paths.size());
+        for (String path : paths) {
+            res.add(new LwM2mPath(path));
+        }
+        return res;
+    }
 }
\ No newline at end of file
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/observation/CompositeObservation.java b/leshan-core/src/main/java/org/eclipse/leshan/core/observation/CompositeObservation.java
new file mode 100644
index 00000000..d99ddf2b
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/observation/CompositeObservation.java
@@ -0,0 +1,121 @@
+/*******************************************************************************
+ * Copyright (c) 2021 Orange.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * and Eclipse Distribution License v1.0 which accompany this distribution.
+ *
+ * The Eclipse Public License is available at
+ *    http://www.eclipse.org/legal/epl-v20.html
+ * and the Eclipse Distribution License is available at
+ *    http://www.eclipse.org/org/documents/edl-v10.html.
+ *
+ * Contributors:
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
+ *     Michał Wadowski (Orange) - Add Cancel Composite-Observation feature.
+ *******************************************************************************/
+package org.eclipse.leshan.core.observation;
+
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.leshan.core.node.LwM2mNode;
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.request.ContentFormat;
+import org.eclipse.leshan.core.util.Hex;
+
+/**
+ * An composite-observation of a resource provided by a LWM2M Client.
+ */
+public class CompositeObservation extends Observation {
+
+    private final List<LwM2mPath> paths;
+    private final ContentFormat requestContentFormat;
+    private final ContentFormat responseContentFormat;
+
+    /**
+     * Instantiates an {@link CompositeObservation} for the given node paths.
+     *
+     * @param id token identifier of the observation
+     * @param registrationId client's unique registration identifier.
+     * @param paths resources paths for which the composite-observation is set.
+     * @param requestContentFormat The {@link ContentFormat} used to encode the list of {@link LwM2mPath}
+     * @param responseContentFormat The {@link ContentFormat} requested to encode the {@link LwM2mNode} of the response.
+     * @param context additional information relative to this observation.
+     */
+    public CompositeObservation(byte[] id, String registrationId, List<LwM2mPath> paths,
+            ContentFormat requestContentFormat, ContentFormat responseContentFormat, Map<String, String> context) {
+        super(id, registrationId, context);
+        this.requestContentFormat = requestContentFormat;
+        this.responseContentFormat = responseContentFormat;
+        this.paths = paths;
+    }
+
+    /**
+     * @return the {@link ContentFormat} used to encode the list of {@link LwM2mPath}
+     */
+    public ContentFormat getRequestContentFormat() {
+        return requestContentFormat;
+    }
+
+    /**
+     * @return the {@link ContentFormat} requested to encode the {@link LwM2mNode} of the response.
+     */
+    public ContentFormat getResponseContentFormat() {
+        return responseContentFormat;
+    }
+
+    /**
+     * Gets the observed resources paths.
+     *
+     * @return the resources paths
+     */
+    public List<LwM2mPath> getPaths() {
+        return paths;
+    }
+
+    @Override
+    public String toString() {
+        return String.format(
+                "CompositeObservation [paths=%s, id=%s, requestContentFormat=%s, responseContentFormat=%s, registrationId=%s, context=%s]",
+                paths, Hex.encodeHexString(id), requestContentFormat, responseContentFormat, registrationId, context);
+    }
+
+    @Override
+    public int hashCode() {
+        final int prime = 31;
+        int result = super.hashCode();
+        result = prime * result + ((paths == null) ? 0 : paths.hashCode());
+        result = prime * result + ((requestContentFormat == null) ? 0 : requestContentFormat.hashCode());
+        result = prime * result + ((responseContentFormat == null) ? 0 : responseContentFormat.hashCode());
+        return result;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj)
+            return true;
+        if (!super.equals(obj))
+            return false;
+        if (getClass() != obj.getClass())
+            return false;
+        CompositeObservation other = (CompositeObservation) obj;
+        if (paths == null) {
+            if (other.paths != null)
+                return false;
+        } else if (!paths.equals(other.paths))
+            return false;
+        if (requestContentFormat == null) {
+            if (other.requestContentFormat != null)
+                return false;
+        } else if (!requestContentFormat.equals(other.requestContentFormat))
+            return false;
+        if (responseContentFormat == null) {
+            if (other.responseContentFormat != null)
+                return false;
+        } else if (!responseContentFormat.equals(other.responseContentFormat))
+            return false;
+        return true;
+    }
+
+}
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/observation/Observation.java b/leshan-core/src/main/java/org/eclipse/leshan/core/observation/Observation.java
index c26dff52..24f92786 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/observation/Observation.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/observation/Observation.java
@@ -12,6 +12,8 @@
  * 
  * Contributors:
  *     Sierra Wireless - initial API and implementation
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
+ *     Michał Wadowski (Orange) - Add Cancel Composite-Observation feature.
  *******************************************************************************/
 package org.eclipse.leshan.core.observation;
 
@@ -20,35 +22,24 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 
-import org.eclipse.leshan.core.node.LwM2mPath;
-import org.eclipse.leshan.core.request.ContentFormat;
-import org.eclipse.leshan.core.util.Hex;
-
 /**
- * An observation of a resource provided by a LWM2M Client.
+ * An abstract class for observation of a resource provided by a LWM2M Client.
  */
-public class Observation {
+public abstract class Observation {
 
-    private final byte[] id;
-    private final LwM2mPath path;
-    private final ContentFormat contentFormat;
-    private final String registrationId;
-    private final Map<String, String> context;
+    protected final byte[] id;
+    protected final String registrationId;
+    protected final Map<String, String> context;
 
     /**
-     * Instantiates an {@link Observation} for the given node path.
-     * 
+     * An abstract constructor for {@link Observation}.
+     *
      * @param id token identifier of the observation
      * @param registrationId client's unique registration identifier.
-     * @param path resource path for which the observation is set.
-     * @param contentFormat contentFormat used to read the resource (could be null).
      * @param context additional information relative to this observation.
      */
-    public Observation(byte[] id, String registrationId, LwM2mPath path, ContentFormat contentFormat,
-            Map<String, String> context) {
+    public Observation(byte[] id, String registrationId, Map<String, String> context) {
         this.id = id;
-        this.path = path;
-        this.contentFormat = contentFormat;
         this.registrationId = registrationId;
         if (context != null)
             this.context = Collections.unmodifiableMap(new HashMap<>(context));
@@ -73,24 +64,6 @@ public class Observation {
         return registrationId;
     }
 
-    /**
-     * Gets the observed resource path.
-     * 
-     * @return the resource path
-     */
-    public LwM2mPath getPath() {
-        return path;
-    }
-
-    /**
-     * Gets the requested contentFormat (could be null).
-     * 
-     * @return the resource path
-     */
-    public ContentFormat getContentFormat() {
-        return contentFormat;
-    }
-
     /**
      * @return the contextual information relative to this observation.
      */
@@ -98,19 +71,12 @@ public class Observation {
         return context;
     }
 
-    @Override
-    public String toString() {
-        return String.format("Observation [id=%s, path=%s, registrationId=%s, contentFormat=%s context=%s]",
-                Hex.encodeHexString(id), path, registrationId, contentFormat, context);
-    }
-
     @Override
     public int hashCode() {
         final int prime = 31;
         int result = 1;
         result = prime * result + ((context == null) ? 0 : context.hashCode());
         result = prime * result + Arrays.hashCode(id);
-        result = prime * result + ((path == null) ? 0 : path.hashCode());
         result = prime * result + ((registrationId == null) ? 0 : registrationId.hashCode());
         return result;
     }
@@ -131,11 +97,6 @@ public class Observation {
             return false;
         if (!Arrays.equals(id, other.id))
             return false;
-        if (path == null) {
-            if (other.path != null)
-                return false;
-        } else if (!path.equals(other.path))
-            return false;
         if (registrationId == null) {
             if (other.registrationId != null)
                 return false;
@@ -143,4 +104,5 @@ public class Observation {
             return false;
         return true;
     }
+
 }
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/observation/SingleObservation.java b/leshan-core/src/main/java/org/eclipse/leshan/core/observation/SingleObservation.java
new file mode 100644
index 00000000..a86c3a68
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/observation/SingleObservation.java
@@ -0,0 +1,105 @@
+/*******************************************************************************
+ * Copyright (c) 2013-2015 Sierra Wireless and others.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * and Eclipse Distribution License v1.0 which accompany this distribution.
+ *
+ * The Eclipse Public License is available at
+ *    http://www.eclipse.org/legal/epl-v20.html
+ * and the Eclipse Distribution License is available at
+ *    http://www.eclipse.org/org/documents/edl-v10.html.
+ *
+ * Contributors:
+ *     Sierra Wireless - initial API and implementation
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
+ *     Michał Wadowski (Orange) - Add Cancel Composite-Observation feature.
+ *******************************************************************************/
+package org.eclipse.leshan.core.observation;
+
+import java.util.Map;
+
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.request.ContentFormat;
+import org.eclipse.leshan.core.util.Hex;
+
+/**
+ * An observation of a resource provided by a LWM2M Client.
+ */
+public class SingleObservation extends Observation {
+
+    private final LwM2mPath path;
+
+    protected final ContentFormat contentFormat;
+
+    /**
+     * Instantiates an {@link SingleObservation} for the given node path.
+     *
+     * @param id token identifier of the observation
+     * @param registrationId client's unique registration identifier.
+     * @param path resource path for which the observation is set.
+     * @param contentFormat contentFormat used to read the resource (could be null).
+     * @param context additional information relative to this observation.
+     */
+    public SingleObservation(byte[] id, String registrationId, LwM2mPath path, ContentFormat contentFormat,
+            Map<String, String> context) {
+        super(id, registrationId, context);
+        this.path = path;
+        this.contentFormat = contentFormat;
+    }
+
+    /**
+     * Gets the observed resource path.
+     *
+     * @return the resource path
+     */
+    public LwM2mPath getPath() {
+        return path;
+    }
+
+    /**
+     * Gets the requested contentFormat (could be null).
+     *
+     * @return the resource path
+     */
+    public ContentFormat getContentFormat() {
+        return contentFormat;
+    }
+
+    @Override
+    public String toString() {
+        return String.format("SingleObservation [path=%s, id=%s, contentFormat=%s, registrationId=%s, context=%s]",
+                path, Hex.encodeHexString(id), contentFormat, registrationId, context);
+    }
+
+    @Override
+    public int hashCode() {
+        final int prime = 31;
+        int result = super.hashCode();
+        result = prime * result + ((contentFormat == null) ? 0 : contentFormat.hashCode());
+        result = prime * result + ((path == null) ? 0 : path.hashCode());
+        return result;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj)
+            return true;
+        if (!super.equals(obj))
+            return false;
+        if (getClass() != obj.getClass())
+            return false;
+        SingleObservation other = (SingleObservation) obj;
+        if (contentFormat == null) {
+            if (other.contentFormat != null)
+                return false;
+        } else if (!contentFormat.equals(other.contentFormat))
+            return false;
+        if (path == null) {
+            if (other.path != null)
+                return false;
+        } else if (!path.equals(other.path))
+            return false;
+        return true;
+    }
+}
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/CancelCompositeObservationRequest.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/CancelCompositeObservationRequest.java
new file mode 100644
index 00000000..8a79cb73
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/CancelCompositeObservationRequest.java
@@ -0,0 +1,73 @@
+/*******************************************************************************
+ * Copyright (c) 2021 Orange.
+ * 
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * and Eclipse Distribution License v1.0 which accompany this distribution.
+ * 
+ * The Eclipse Public License is available at
+ *    http://www.eclipse.org/legal/epl-v20.html
+ * and the Eclipse Distribution License is available at
+ *    http://www.eclipse.org/org/documents/edl-v10.html.
+ * 
+ * Contributors:
+ *     Michał Wadowski (Orange) - Add Cancel Composite-Observation feature.
+ *******************************************************************************/
+package org.eclipse.leshan.core.request;
+
+import java.util.List;
+
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.observation.CompositeObservation;
+import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.response.CancelCompositeObservationResponse;
+import org.eclipse.leshan.core.util.Hex;
+
+/**
+ * A Lightweight M2M request for actively cancel an composite-observation.
+ * <p>
+ * At server side this will not remove the observation from the observation store, to do it you need to use
+ * {@code ObservationService#cancelObservation()}
+ * </p>
+ */
+public class CancelCompositeObservationRequest extends AbstractLwM2mRequest<CancelCompositeObservationResponse>
+        implements CompositeDownlinkRequest<CancelCompositeObservationResponse> {
+
+    private final CompositeObservation observation;
+
+    /**
+     * @param observation the observation to cancel actively
+     */
+    public CancelCompositeObservationRequest(CompositeObservation observation) {
+        super(null);
+        this.observation = observation;
+    }
+
+    @Override
+    public void accept(DownlinkRequestVisitor visitor) {
+        visitor.visit(this);
+    }
+
+    public Observation getObservation() {
+        return observation;
+    }
+
+    @Override
+    public final String toString() {
+        return String.format("CancelCompositeObservation [paths=%s token=%s]", getPaths(),
+                Hex.encodeHexString(observation.getId()));
+    }
+
+    @Override
+    public List<LwM2mPath> getPaths() {
+        return observation.getPaths();
+    }
+
+    public ContentFormat getRequestContentFormat() {
+        return observation.getRequestContentFormat();
+    }
+
+    public ContentFormat getResponseContentFormat() {
+        return observation.getResponseContentFormat();
+    }
+}
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/CancelObservationRequest.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/CancelObservationRequest.java
index 3f60300a..7ee475b3 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/request/CancelObservationRequest.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/CancelObservationRequest.java
@@ -16,6 +16,7 @@
 package org.eclipse.leshan.core.request;
 
 import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.observation.SingleObservation;
 import org.eclipse.leshan.core.request.exception.InvalidRequestException;
 import org.eclipse.leshan.core.response.CancelObservationResponse;
 import org.eclipse.leshan.core.util.Hex;
@@ -29,12 +30,12 @@ import org.eclipse.leshan.core.util.Hex;
  */
 public class CancelObservationRequest extends AbstractSimpleDownlinkRequest<CancelObservationResponse> {
 
-    private final Observation observation;
+    private final SingleObservation observation;
 
     /**
      * @param observation the observation to cancel actively
      */
-    public CancelObservationRequest(Observation observation) {
+    public CancelObservationRequest(SingleObservation observation) {
         super(observation.getPath(), null);
         if (getPath().isRoot())
             throw new InvalidRequestException("Observe request cannot target root path");
@@ -45,6 +46,10 @@ public class CancelObservationRequest extends AbstractSimpleDownlinkRequest<Canc
         return observation;
     }
 
+    public ContentFormat getContentFormat() {
+        return observation.getContentFormat();
+    }
+
     @Override
     public void accept(DownlinkRequestVisitor visitor) {
         visitor.visit(this);
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/CompositeDownlinkRequest.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/CompositeDownlinkRequest.java
index 511acb95..9157519a 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/request/CompositeDownlinkRequest.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/CompositeDownlinkRequest.java
@@ -15,7 +15,7 @@
  *******************************************************************************/
 package org.eclipse.leshan.core.request;
 
-import java.util.Collection;
+import java.util.List;
 
 import org.eclipse.leshan.core.node.LwM2mPath;
 import org.eclipse.leshan.core.response.LwM2mResponse;
@@ -26,7 +26,7 @@ import org.eclipse.leshan.core.response.LwM2mResponse;
 public interface CompositeDownlinkRequest<T extends LwM2mResponse> extends DownlinkRequest<T> {
 
     /**
-     * @return the collection of node path targeted by the request.
+     * @return the list of node paths targeted by the request.
      */
-    Collection<LwM2mPath> getPaths();
+    List<LwM2mPath> getPaths();
 }
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/DownLinkRequestVisitorAdapter.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/DownLinkRequestVisitorAdapter.java
index 30437689..c1ac4d4e 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/request/DownLinkRequestVisitorAdapter.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/DownLinkRequestVisitorAdapter.java
@@ -12,6 +12,7 @@
  * 
  * Contributors:
  *     Sierra Wireless - initial API and implementation
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
  *******************************************************************************/
 package org.eclipse.leshan.core.request;
 
@@ -84,4 +85,13 @@ public class DownLinkRequestVisitorAdapter implements DownlinkRequestVisitor {
     @Override
     public void visit(BootstrapFinishRequest request) {
     }
+
+    @Override
+    public void visit(ObserveCompositeRequest request) {
+    }
+
+    @Override
+    public void visit(CancelCompositeObservationRequest request) {
+
+    }
 }
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/DownlinkRequestVisitor.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/DownlinkRequestVisitor.java
index d1fb964a..4d92999d 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/request/DownlinkRequestVisitor.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/DownlinkRequestVisitor.java
@@ -12,6 +12,8 @@
  * 
  * Contributors:
  *     Sierra Wireless - initial API and implementation
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
+ *     Michał Wadowski (Orange) - Add Cancel Composite-Observation feature.
  *******************************************************************************/
 package org.eclipse.leshan.core.request;
 
@@ -39,6 +41,10 @@ public interface DownlinkRequestVisitor {
 
     void visit(ReadCompositeRequest request);
 
+    void visit(ObserveCompositeRequest request);
+
+    void visit(CancelCompositeObservationRequest request);
+
     void visit(WriteCompositeRequest writeCompositeRequest);
 
     void visit(BootstrapDiscoverRequest request);
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/ObserveCompositeRequest.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/ObserveCompositeRequest.java
new file mode 100644
index 00000000..a7706444
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/ObserveCompositeRequest.java
@@ -0,0 +1,131 @@
+/*******************************************************************************
+ * Copyright (c) 2021 Orange.
+ * 
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * and Eclipse Distribution License v1.0 which accompany this distribution.
+ * 
+ * The Eclipse Public License is available at
+ *    http://www.eclipse.org/legal/epl-v20.html
+ * and the Eclipse Distribution License is available at
+ *    http://www.eclipse.org/org/documents/edl-v10.html.
+ * 
+ * Contributors:
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
+ *******************************************************************************/
+package org.eclipse.leshan.core.request;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.leshan.core.node.LwM2mNode;
+import org.eclipse.leshan.core.node.LwM2mNodeException;
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.request.exception.InvalidRequestException;
+import org.eclipse.leshan.core.response.ObserveCompositeResponse;
+
+/**
+ * A Lightweight M2M request for observing changes of multiple Resources, Resources within an Object Instance or for all
+ * the Object Instances of an Object within the LWM2M Client.
+ */
+public class ObserveCompositeRequest extends AbstractLwM2mRequest<ObserveCompositeResponse>
+        implements CompositeDownlinkRequest<ObserveCompositeResponse> {
+
+    private final ContentFormat requestContentFormat;
+    private final ContentFormat responseContentFormat;
+
+    private final List<LwM2mPath> paths;
+
+    private final Map<String, String> context;
+
+    /**
+     * Create ObserveCompositeRequest Request.
+     *
+     * @param requestContentFormat The {@link ContentFormat} used to encode the list of {@link LwM2mPath}
+     * @param responseContentFormat The {@link ContentFormat} requested to encode the {@link LwM2mNode} of the response.
+     * @param paths List of {@link LwM2mPath} corresponding to {@link LwM2mNode} to read.
+     * @exception InvalidRequestException if path has invalid format.
+     *
+     */
+    public ObserveCompositeRequest(ContentFormat requestContentFormat, ContentFormat responseContentFormat,
+            String... paths) {
+        this(requestContentFormat, responseContentFormat, getLwM2mPathList(Arrays.asList(paths)));
+    }
+
+    private static List<LwM2mPath> getLwM2mPathList(List<String> paths) {
+        try {
+            return LwM2mPath.getLwM2mPathList(paths);
+        } catch (LwM2mNodeException | IllegalArgumentException e) {
+            throw new InvalidRequestException("invalid path format");
+        }
+    }
+
+    /**
+     * Create ObserveCompositeRequest Request.
+     *
+     * @param requestContentFormat The {@link ContentFormat} used to encode the list of {@link LwM2mPath}
+     * @param responseContentFormat The {@link ContentFormat} requested to encode the {@link LwM2mNode} of the response.
+     * @param paths List of {@link LwM2mPath} corresponding to {@link LwM2mNode} to read.
+     *
+     */
+    public ObserveCompositeRequest(ContentFormat requestContentFormat, ContentFormat responseContentFormat,
+            List<LwM2mPath> paths) {
+        this(requestContentFormat, responseContentFormat, paths, null);
+    }
+
+    /**
+     * Create ObserveCompositeRequest Request.
+     *
+     * @param requestContentFormat The {@link ContentFormat} used to encode the list of {@link LwM2mPath}
+     * @param responseContentFormat The {@link ContentFormat} requested to encode the {@link LwM2mNode} of the response.
+     * @param paths List of {@link LwM2mPath} corresponding to {@link LwM2mNode} to read.
+     * @param coapRequest the underlying request.
+     *
+     */
+    public ObserveCompositeRequest(ContentFormat requestContentFormat, ContentFormat responseContentFormat,
+            List<LwM2mPath> paths, Object coapRequest) {
+        super(coapRequest);
+
+        this.requestContentFormat = requestContentFormat;
+        this.responseContentFormat = responseContentFormat;
+        this.paths = paths;
+
+        this.context = Collections.emptyMap();
+    }
+
+    @Override
+    public void accept(DownlinkRequestVisitor visitor) {
+        visitor.visit(this);
+    }
+
+    /**
+     * @return List of {@link LwM2mPath} corresponding to {@link LwM2mNode} to read.
+     */
+    @Override
+    public List<LwM2mPath> getPaths() {
+        return paths;
+    }
+
+    /**
+     * @return the {@link ContentFormat} used to encode the list of {@link LwM2mPath}
+     */
+    public ContentFormat getRequestContentFormat() {
+        return requestContentFormat;
+    }
+
+    /**
+     * @return the {@link ContentFormat} requested to encode the {@link LwM2mNode} of the response.
+     */
+    public ContentFormat getResponseContentFormat() {
+        return responseContentFormat;
+    }
+
+    /**
+     * @return map containing the additional information relative to this observe-composite request.
+     */
+    public Map<String, String> getContext() {
+        return context;
+    }
+}
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/ObserveRequest.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/ObserveRequest.java
index 0599e0e6..8ce7d760 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/request/ObserveRequest.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/ObserveRequest.java
@@ -170,6 +170,11 @@ public class ObserveRequest extends AbstractSimpleDownlinkRequest<ObserveRespons
         this(format, newPath(path), context, null);
     }
 
+    public ObserveRequest(ContentFormat format, LwM2mPath path, Object coapRequest)
+            throws InvalidRequestException {
+        this(format, path, null, coapRequest);
+    }
+
     private ObserveRequest(ContentFormat format, LwM2mPath target, Map<String, String> context, Object coapRequest) {
         super(target, coapRequest);
         if (target.isRoot())
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/ReadCompositeRequest.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/ReadCompositeRequest.java
index b9b7d9e5..4e7ac61f 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/request/ReadCompositeRequest.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/ReadCompositeRequest.java
@@ -15,10 +15,11 @@
  *******************************************************************************/
 package org.eclipse.leshan.core.request;
 
-import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 
 import org.eclipse.leshan.core.node.LwM2mNode;
+import org.eclipse.leshan.core.node.LwM2mNodeException;
 import org.eclipse.leshan.core.node.LwM2mPath;
 import org.eclipse.leshan.core.request.exception.InvalidRequestException;
 import org.eclipse.leshan.core.response.ReadCompositeResponse;
@@ -40,11 +41,12 @@ public class ReadCompositeRequest extends AbstractLwM2mRequest<ReadCompositeResp
      * @param requestContentFormat The {@link ContentFormat} used to encode the list of {@link LwM2mPath}
      * @param responseContentFormat The {@link ContentFormat} requested to encode the {@link LwM2mNode} of the response.
      * @param paths List of {@link LwM2mPath} corresponding to {@link LwM2mNode} to read.
+     * @exception InvalidRequestException if path has invalid format.
      * 
      */
     public ReadCompositeRequest(ContentFormat requestContentFormat, ContentFormat responseContentFormat,
             String... paths) {
-        this(newPaths(paths), requestContentFormat, responseContentFormat, null);
+        this(getLwM2mPathList(Arrays.asList(paths)), requestContentFormat, responseContentFormat, null);
     }
 
     /**
@@ -53,11 +55,20 @@ public class ReadCompositeRequest extends AbstractLwM2mRequest<ReadCompositeResp
      * @param requestContentFormat The {@link ContentFormat} used to encode the list of {@link LwM2mPath}
      * @param responseContentFormat The {@link ContentFormat} requested to encode the {@link LwM2mNode} of the response.
      * @param paths List of {@link LwM2mPath} corresponding to {@link LwM2mNode} to read.
+     * @exception InvalidRequestException if path has invalid format.
      * 
      */
     public ReadCompositeRequest(ContentFormat requestContentFormat, ContentFormat responseContentFormat,
             List<String> paths) {
-        this(newPaths(paths), requestContentFormat, responseContentFormat, null);
+        this(getLwM2mPathList(paths), requestContentFormat, responseContentFormat, null);
+    }
+
+    private static List<LwM2mPath> getLwM2mPathList(List<String> paths) {
+        try {
+            return LwM2mPath.getLwM2mPathList(paths);
+        } catch (LwM2mNodeException | IllegalArgumentException e) {
+            throw new InvalidRequestException("invalid path format");
+        }
     }
 
     /**
@@ -69,7 +80,8 @@ public class ReadCompositeRequest extends AbstractLwM2mRequest<ReadCompositeResp
      * @param requestContentFormat The {@link ContentFormat} used to encode the list of {@link LwM2mPath}
      * @param responseContentFormat The {@link ContentFormat} requested to encode the {@link LwM2mNode} of the response.
      * @param coapRequest the underlying request.
-     * 
+     * @exception InvalidRequestException if paths list is invalid.
+     *
      */
     public ReadCompositeRequest(List<LwM2mPath> paths, ContentFormat requestContentFormat,
             ContentFormat responseContentFormat, Object coapRequest) {
@@ -154,27 +166,4 @@ public class ReadCompositeRequest extends AbstractLwM2mRequest<ReadCompositeResp
         return true;
     }
 
-    protected static List<LwM2mPath> newPaths(List<String> paths) {
-        try {
-            List<LwM2mPath> res = new ArrayList<>(paths.size());
-            for (String path : paths) {
-                res.add(new LwM2mPath(path));
-            }
-            return res;
-        } catch (IllegalArgumentException e) {
-            throw new InvalidRequestException();
-        }
-    }
-
-    protected static List<LwM2mPath> newPaths(String[] paths) {
-        try {
-            List<LwM2mPath> res = new ArrayList<>(paths.length);
-            for (String path : paths) {
-                res.add(new LwM2mPath(path));
-            }
-            return res;
-        } catch (IllegalArgumentException e) {
-            throw new InvalidRequestException();
-        }
-    }
 }
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/response/CancelCompositeObservationResponse.java b/leshan-core/src/main/java/org/eclipse/leshan/core/response/CancelCompositeObservationResponse.java
new file mode 100644
index 00000000..7028a489
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/response/CancelCompositeObservationResponse.java
@@ -0,0 +1,41 @@
+/*******************************************************************************
+ * Copyright (c) 2021 Orange.
+ * 
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * and Eclipse Distribution License v1.0 which accompany this distribution.
+ * 
+ * The Eclipse Public License is available at
+ *    http://www.eclipse.org/legal/epl-v20.html
+ * and the Eclipse Distribution License is available at
+ *    http://www.eclipse.org/org/documents/edl-v10.html.
+ * 
+ * Contributors:
+ *     Michał Wadowski (Orange) - Add Cancel Composite-Observation feature.
+ *******************************************************************************/
+package org.eclipse.leshan.core.response;
+
+import java.util.Map;
+
+import org.eclipse.leshan.core.ResponseCode;
+import org.eclipse.leshan.core.node.LwM2mNode;
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.observation.CompositeObservation;
+
+public class CancelCompositeObservationResponse extends ObserveCompositeResponse {
+
+    public CancelCompositeObservationResponse(ResponseCode code, Map<LwM2mPath, LwM2mNode> content, String errorMessage,
+            Object coapResponse, CompositeObservation observation) {
+        super(code, content, errorMessage, coapResponse, observation);
+    }
+
+    @Override
+    public String toString() {
+        if (errorMessage != null) {
+            return String.format("CancelCompositeObservationResponse [code=%s, errormessage=%s]", code, errorMessage);
+        } else {
+            return String.format("CancelCompositeObservationResponse [code=%s, content=%s, observation=%s]", code,
+                    content, observation);
+        }
+    }
+}
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/response/CancelObservationResponse.java b/leshan-core/src/main/java/org/eclipse/leshan/core/response/CancelObservationResponse.java
index fd606706..1384ea28 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/response/CancelObservationResponse.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/response/CancelObservationResponse.java
@@ -20,17 +20,17 @@ import java.util.List;
 import org.eclipse.leshan.core.ResponseCode;
 import org.eclipse.leshan.core.node.LwM2mNode;
 import org.eclipse.leshan.core.node.TimestampedLwM2mNode;
-import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.observation.SingleObservation;
 
 public class CancelObservationResponse extends ObserveResponse {
 
     public CancelObservationResponse(ResponseCode code, LwM2mNode content, List<TimestampedLwM2mNode> timestampedValues,
-            Observation observation, String errorMessage) {
+            SingleObservation observation, String errorMessage) {
         super(code, content, timestampedValues, observation, errorMessage);
     }
 
     public CancelObservationResponse(ResponseCode code, LwM2mNode content, List<TimestampedLwM2mNode> timestampedValues,
-            Observation observation, String errorMessage, Object coapResponse) {
+            SingleObservation observation, String errorMessage, Object coapResponse) {
         super(code, content, timestampedValues, observation, errorMessage, coapResponse);
     }
 
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/response/ObserveCompositeResponse.java b/leshan-core/src/main/java/org/eclipse/leshan/core/response/ObserveCompositeResponse.java
new file mode 100644
index 00000000..586c7d19
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/response/ObserveCompositeResponse.java
@@ -0,0 +1,98 @@
+/*******************************************************************************
+ * Copyright (c) 2021 Orange.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * and Eclipse Distribution License v1.0 which accompany this distribution.
+ *
+ * The Eclipse Public License is available at
+ *    http://www.eclipse.org/legal/epl-v20.html
+ * and the Eclipse Distribution License is available at
+ *    http://www.eclipse.org/org/documents/edl-v10.html.
+ *
+ * Contributors:
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
+ *******************************************************************************/
+package org.eclipse.leshan.core.response;
+
+import java.util.Map;
+
+import org.eclipse.leshan.core.ResponseCode;
+import org.eclipse.leshan.core.node.LwM2mNode;
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.observation.CompositeObservation;
+
+/**
+ * Specialized ReadCompositeResponse to a Observe-Composite request, with the corresponding Observation.
+ *
+ * This can be useful to listen to updates on the specific Observation.
+ */
+public class ObserveCompositeResponse extends ReadCompositeResponse {
+
+    protected final CompositeObservation observation;
+
+    public ObserveCompositeResponse(ResponseCode code, Map<LwM2mPath, LwM2mNode> content, String errorMessage,
+            Object coapResponse, CompositeObservation observation) {
+        super(code, content, errorMessage, coapResponse);
+        this.observation = observation;
+    }
+
+    public CompositeObservation getObservation() {
+        return observation;
+    }
+
+    @Override
+    public String toString() {
+        if (errorMessage != null)
+            return String.format("ObserveCompositeResponse [code=%s, errorMessage=%s]", code, errorMessage);
+        else
+            return String.format("ObserveCompositeResponse [code=%s, observation=%s, content=%s]", code, observation,
+                    content);
+    }
+
+    @Override
+    public boolean isValid() {
+        switch (code.getCode()) {
+        case ResponseCode.CONTENT_CODE:
+        case ResponseCode.BAD_REQUEST_CODE:
+        case ResponseCode.NOT_FOUND_CODE:
+        case ResponseCode.UNAUTHORIZED_CODE:
+        case ResponseCode.METHOD_NOT_ALLOWED_CODE:
+        case ResponseCode.UNSUPPORTED_CONTENT_FORMAT_CODE:
+        case ResponseCode.INTERNAL_SERVER_ERROR_CODE:
+            return true;
+        default:
+            return false;
+        }
+    }
+
+    // Syntactic sugar static constructors:
+
+    public static ObserveCompositeResponse success(Map<LwM2mPath, LwM2mNode> content) {
+        return new ObserveCompositeResponse(ResponseCode.CONTENT, content, null, null, null);
+    }
+
+    public static ObserveCompositeResponse badRequest(String errorMessage) {
+        return new ObserveCompositeResponse(ResponseCode.BAD_REQUEST, null, errorMessage, null, null);
+    }
+
+    public static ObserveCompositeResponse notFound() {
+        return new ObserveCompositeResponse(ResponseCode.NOT_FOUND, null, null, null, null);
+    }
+
+    public static ObserveCompositeResponse unauthorized() {
+        return new ObserveCompositeResponse(ResponseCode.UNAUTHORIZED, null, null, null, null);
+    }
+
+    public static ObserveCompositeResponse methodNotAllowed() {
+        return new ObserveCompositeResponse(ResponseCode.METHOD_NOT_ALLOWED, null, null, null, null);
+    }
+
+    public static ObserveCompositeResponse notAcceptable() {
+        return new ObserveCompositeResponse(ResponseCode.UNSUPPORTED_CONTENT_FORMAT, null, null, null, null);
+    }
+
+    public static ObserveCompositeResponse internalServerError(String errorMessage) {
+        return new ObserveCompositeResponse(ResponseCode.INTERNAL_SERVER_ERROR, null, errorMessage, null, null);
+    }
+}
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/response/ObserveResponse.java b/leshan-core/src/main/java/org/eclipse/leshan/core/response/ObserveResponse.java
index 1a865d09..e03aef11 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/response/ObserveResponse.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/response/ObserveResponse.java
@@ -20,7 +20,7 @@ import java.util.List;
 import org.eclipse.leshan.core.ResponseCode;
 import org.eclipse.leshan.core.node.LwM2mNode;
 import org.eclipse.leshan.core.node.TimestampedLwM2mNode;
-import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.observation.SingleObservation;
 import org.eclipse.leshan.core.request.exception.InvalidResponseException;
 
 /**
@@ -30,16 +30,16 @@ import org.eclipse.leshan.core.request.exception.InvalidResponseException;
  */
 public class ObserveResponse extends ReadResponse {
 
-    protected final Observation observation;
+    protected final SingleObservation observation;
     protected final List<TimestampedLwM2mNode> timestampedValues;
 
     public ObserveResponse(ResponseCode code, LwM2mNode content, List<TimestampedLwM2mNode> timestampedValues,
-            Observation observation, String errorMessage) {
+            SingleObservation observation, String errorMessage) {
         this(code, content, timestampedValues, observation, errorMessage, null);
     }
 
     public ObserveResponse(ResponseCode code, LwM2mNode content, List<TimestampedLwM2mNode> timestampedValues,
-            Observation observation, String errorMessage, Object coapResponse) {
+            SingleObservation observation, String errorMessage, Object coapResponse) {
         super(code, timestampedValues != null && !timestampedValues.isEmpty() ? timestampedValues.get(0).getNode()
                 : content, errorMessage, coapResponse);
 
@@ -74,7 +74,7 @@ public class ObserveResponse extends ReadResponse {
             return String.format("ObserveResponse [code=%s, content=%s, observation=%s]", code, content, observation);
     }
 
-    public Observation getObservation() {
+    public SingleObservation getObservation() {
         return observation;
     }
 
diff --git a/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/observation/ObservationServiceImpl.java b/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/observation/ObservationServiceImpl.java
index 9d9d698b..61630a93 100644
--- a/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/observation/ObservationServiceImpl.java
+++ b/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/observation/ObservationServiceImpl.java
@@ -12,6 +12,7 @@
  * 
  * Contributors:
  *     Sierra Wireless - initial API and implementation
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
  *******************************************************************************/
 package org.eclipse.leshan.server.californium.observation;
 
@@ -21,6 +22,7 @@ import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.CopyOnWriteArrayList;
 
@@ -31,16 +33,23 @@ import org.eclipse.californium.core.coap.Token;
 import org.eclipse.californium.core.network.Endpoint;
 import org.eclipse.californium.core.observe.NotificationListener;
 import org.eclipse.californium.core.observe.ObservationStore;
+import org.eclipse.leshan.core.ResponseCode;
 import org.eclipse.leshan.core.californium.EndpointContextUtil;
+import org.eclipse.leshan.core.californium.ObserveUtil;
 import org.eclipse.leshan.core.model.LwM2mModel;
+import org.eclipse.leshan.core.node.LwM2mNode;
 import org.eclipse.leshan.core.node.LwM2mPath;
 import org.eclipse.leshan.core.node.TimestampedLwM2mNode;
 import org.eclipse.leshan.core.node.codec.CodecException;
 import org.eclipse.leshan.core.node.codec.LwM2mDecoder;
+import org.eclipse.leshan.core.observation.CompositeObservation;
 import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.observation.SingleObservation;
 import org.eclipse.leshan.core.request.ContentFormat;
 import org.eclipse.leshan.core.request.Identity;
 import org.eclipse.leshan.core.request.exception.InvalidResponseException;
+import org.eclipse.leshan.core.response.AbstractLwM2mResponse;
+import org.eclipse.leshan.core.response.ObserveCompositeResponse;
 import org.eclipse.leshan.core.response.ObserveResponse;
 import org.eclipse.leshan.core.util.Hex;
 import org.eclipse.leshan.server.californium.registration.CaliforniumRegistrationStore;
@@ -190,8 +199,10 @@ public class ObservationServiceImpl implements ObservationService, NotificationL
         Set<Observation> result = new HashSet<>();
         LwM2mPath lwPath = new LwM2mPath(resourcePath);
         for (Observation obs : getObservations(registrationId)) {
-            if (lwPath.equals(obs.getPath())) {
-                result.add(obs);
+            if (obs instanceof SingleObservation) {
+                if (lwPath.equals(((SingleObservation) obs).getPath())) {
+                    result.add(obs);
+                }
             }
         }
         return result;
@@ -261,11 +272,19 @@ public class ObservationServiceImpl implements ObservationService, NotificationL
             LwM2mModel model = modelProvider.getObjectModel(registration);
 
             // create response
-            ObserveResponse response = createObserveResponse(observation, model, coapResponse);
-
-            // notify all listeners
-            for (ObservationListener listener : listeners) {
-                listener.onResponse(observation, registration, response);
+            AbstractLwM2mResponse response = createObserveResponse(observation, model, coapResponse);
+
+            if (response != null) {
+                // notify all listeners
+                for (ObservationListener listener : listeners) {
+                    if (observation instanceof SingleObservation && response instanceof ObserveResponse) {
+                        listener.onResponse((SingleObservation) observation, registration, (ObserveResponse) response);
+                    }
+                    if (observation instanceof CompositeObservation && response instanceof ObserveCompositeResponse) {
+                        listener.onResponse((CompositeObservation) observation, registration,
+                                (ObserveCompositeResponse) response);
+                    }
+                }
             }
         } catch (InvalidResponseException e) {
             if (LOG.isDebugEnabled()) {
@@ -284,10 +303,10 @@ public class ObservationServiceImpl implements ObservationService, NotificationL
                 listener.onError(observation, registration, e);
             }
         }
-
     }
 
-    private ObserveResponse createObserveResponse(Observation observation, LwM2mModel model, Response coapResponse) {
+    private AbstractLwM2mResponse createObserveResponse(Observation observation, LwM2mModel model,
+            Response coapResponse) {
         // CHANGED response is supported for backward compatibility with old spec.
         if (coapResponse.getCode() != CoAP.ResponseCode.CHANGED
                 && coapResponse.getCode() != CoAP.ResponseCode.CONTENT) {
@@ -303,17 +322,36 @@ public class ObservationServiceImpl implements ObservationService, NotificationL
 
         // decode response
         try {
-            List<TimestampedLwM2mNode> timestampedNodes = decoder.decodeTimestampedData(coapResponse.getPayload(),
-                    contentFormat, observation.getPath(), model);
-
-            // create lwm2m response
-            if (timestampedNodes.size() == 1 && !timestampedNodes.get(0).isTimestamped()) {
-                return new ObserveResponse(toLwM2mResponseCode(coapResponse.getCode()),
-                        timestampedNodes.get(0).getNode(), null, observation, null, coapResponse);
-            } else {
-                return new ObserveResponse(toLwM2mResponseCode(coapResponse.getCode()), null, timestampedNodes,
-                        observation, null, coapResponse);
+            ResponseCode responseCode = toLwM2mResponseCode(coapResponse.getCode());
+
+            if (observation instanceof SingleObservation) {
+                SingleObservation singleObservation = (SingleObservation) observation;
+
+                List<TimestampedLwM2mNode> timestampedNodes = decoder.decodeTimestampedData(coapResponse.getPayload(),
+                        contentFormat, singleObservation.getPath(), model);
+
+                // create lwm2m response
+                if (timestampedNodes.size() == 1 && !timestampedNodes.get(0).isTimestamped()) {
+                    return new ObserveResponse(responseCode, timestampedNodes.get(0).getNode(), null, singleObservation,
+                            null, coapResponse);
+                } else {
+                    return new ObserveResponse(responseCode, null, timestampedNodes, singleObservation, null,
+                            coapResponse);
+                }
+            } else if (observation instanceof CompositeObservation) {
+
+                CompositeObservation compositeObservation = (CompositeObservation) observation;
+
+                Map<LwM2mPath, LwM2mNode> nodes = decoder.decodeNodes(coapResponse.getPayload(), contentFormat,
+                        compositeObservation.getPaths(), model);
+
+                return new ObserveCompositeResponse(responseCode, nodes, null, coapResponse, compositeObservation);
             }
+
+            throw new IllegalStateException(
+                    "observation must be a CompositeObservation or a SingleObservation but was " + observation == null
+                            ? null
+                            : observation.getClass().getSimpleName());
         } catch (CodecException e) {
             if (LOG.isDebugEnabled()) {
                 byte[] payload = coapResponse.getPayload() == null ? new byte[0] : coapResponse.getPayload();
diff --git a/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/observation/ObserveUtil.java b/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/observation/ObserveUtil.java
deleted file mode 100644
index d9b78d51..00000000
--- a/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/observation/ObserveUtil.java
+++ /dev/null
@@ -1,115 +0,0 @@
-/*******************************************************************************
- * Copyright (c) 2017 Sierra Wireless and others.
- * 
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the Eclipse Public License v2.0
- * and Eclipse Distribution License v1.0 which accompany this distribution.
- * 
- * The Eclipse Public License is available at
- *    http://www.eclipse.org/legal/epl-v20.html
- * and the Eclipse Distribution License is available at
- *    http://www.eclipse.org/org/documents/edl-v10.html.
- * 
- * Contributors:
- *     Sierra Wireless - initial API and implementation
- *******************************************************************************/
-package org.eclipse.leshan.server.californium.observation;
-
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Map.Entry;
-
-import org.eclipse.californium.core.coap.Request;
-import org.eclipse.leshan.core.node.LwM2mPath;
-import org.eclipse.leshan.core.observation.Observation;
-import org.eclipse.leshan.core.request.ContentFormat;
-import org.eclipse.leshan.core.request.ObserveRequest;
-import org.eclipse.leshan.server.californium.registration.CaliforniumRegistrationStore;
-
-/**
- * Utility functions to help to handle observation in Leshan. Those helper functions are only needed if you're
- * implementing your own {@link CaliforniumRegistrationStore}.
- */
-public class ObserveUtil {
-
-    /* keys used to populate the request context */
-    public static final String CTX_ENDPOINT = "leshan-endpoint";
-    public static final String CTX_REGID = "leshan-regId";
-    public static final String CTX_LWM2M_PATH = "leshan-path";
-
-    /**
-     * Create a LWM2M observation from a CoAP request.
-     */
-    public static Observation createLwM2mObservation(Request request) {
-        String regId = null;
-        String lwm2mPath = null;
-        Map<String, String> context = null;
-
-        for (Entry<String, String> ctx : request.getUserContext().entrySet()) {
-            switch (ctx.getKey()) {
-            case CTX_REGID:
-                regId = ctx.getValue();
-                break;
-            case CTX_LWM2M_PATH:
-                lwm2mPath = ctx.getValue();
-                break;
-            case CTX_ENDPOINT:
-                break;
-            default:
-                if (context == null) {
-                    context = new HashMap<>();
-                }
-                context.put(ctx.getKey(), ctx.getValue());
-            }
-        }
-
-        ContentFormat contentFormat = null;
-        if (request.getOptions().hasAccept()) {
-            contentFormat = ContentFormat.fromCode(request.getOptions().getAccept());
-        }
-        return new Observation(request.getToken().getBytes(), regId, new LwM2mPath(lwm2mPath), contentFormat, context);
-    }
-
-    /**
-     * Create a CoAP observe request context with specific keys needed for internal Leshan working.
-     */
-    public static Map<String, String> createCoapObserveRequestContext(String endpoint, String registrationId,
-            ObserveRequest request) {
-        Map<String, String> context = new HashMap<>();
-        context.put(CTX_ENDPOINT, endpoint);
-        context.put(CTX_REGID, registrationId);
-        context.put(CTX_LWM2M_PATH, request.getPath().toString());
-        for (Entry<String, String> ctx : request.getContext().entrySet()) {
-            context.put(ctx.getKey(), ctx.getValue());
-        }
-        return context;
-    }
-
-    public static String extractRegistrationId(org.eclipse.californium.core.observe.Observation observation) {
-        return observation.getRequest().getUserContext().get(CTX_REGID);
-    }
-
-    public static String extractLwm2mPath(org.eclipse.californium.core.observe.Observation observation) {
-        return observation.getRequest().getUserContext().get(CTX_LWM2M_PATH);
-    }
-
-    public static String extractEndpoint(org.eclipse.californium.core.observe.Observation observation) {
-        return observation.getRequest().getUserContext().get(CTX_ENDPOINT);
-    }
-
-    /**
-     * Validate the Californium observation. It is valid if it contains all necessary context for Leshan.
-     */
-    public static String validateCoapObservation(org.eclipse.californium.core.observe.Observation observation) {
-        if (!observation.getRequest().getUserContext().containsKey(CTX_REGID))
-            throw new IllegalStateException("missing registrationId info in the request context");
-        if (!observation.getRequest().getUserContext().containsKey(CTX_LWM2M_PATH))
-            throw new IllegalStateException("missing lwm2m path info in the request context");
-
-        String endpoint = observation.getRequest().getUserContext().get(CTX_ENDPOINT);
-        if (endpoint == null)
-            throw new IllegalStateException("missing endpoint info in the request context");
-
-        return endpoint;
-    }
-}
diff --git a/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/registration/InMemoryRegistrationStore.java b/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/registration/InMemoryRegistrationStore.java
index 45c41824..4f5a5f31 100644
--- a/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/registration/InMemoryRegistrationStore.java
+++ b/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/registration/InMemoryRegistrationStore.java
@@ -21,6 +21,7 @@
  *                                                     EndpointContext
  *     Achim Kraus (Bosch Software Innovations GmbH) - update to modified 
  *                                                     ObservationStore API
+ *     Michał Wadowski (Orange)                      - Add Observe-Composite feature.
  *******************************************************************************/
 package org.eclipse.leshan.server.californium.registration;
 
@@ -42,6 +43,7 @@ import java.util.concurrent.TimeUnit;
 import java.util.concurrent.locks.ReadWriteLock;
 import java.util.concurrent.locks.ReentrantReadWriteLock;
 
+import org.eclipse.californium.core.coap.CoAP;
 import org.eclipse.californium.core.coap.Token;
 import org.eclipse.californium.core.observe.ObservationStoreException;
 import org.eclipse.californium.core.observe.ObservationUtil;
@@ -49,10 +51,12 @@ import org.eclipse.californium.elements.EndpointContext;
 import org.eclipse.leshan.core.Destroyable;
 import org.eclipse.leshan.core.Startable;
 import org.eclipse.leshan.core.Stoppable;
+import org.eclipse.leshan.core.californium.ObserveUtil;
+import org.eclipse.leshan.core.observation.CompositeObservation;
 import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.observation.SingleObservation;
 import org.eclipse.leshan.core.request.Identity;
 import org.eclipse.leshan.core.util.NamedThreadFactory;
-import org.eclipse.leshan.server.californium.observation.ObserveUtil;
 import org.eclipse.leshan.server.registration.Deregistration;
 import org.eclipse.leshan.server.registration.ExpirationListener;
 import org.eclipse.leshan.server.registration.Registration;
@@ -248,7 +252,7 @@ public class InMemoryRegistrationStore implements CaliforniumRegistrationStore,
             lock.writeLock().lock();
             // cancel existing observations for the same path and registration id.
             for (Observation obs : unsafeGetObservations(registrationId)) {
-                if (observation.getPath().equals(obs.getPath()) && !Arrays.equals(observation.getId(), obs.getId())) {
+                if (areTheSamePaths(observation, obs) && !Arrays.equals(observation.getId(), obs.getId())) {
                     unsafeRemoveObservation(new Token(obs.getId()));
                     removed.add(obs);
                 }
@@ -260,6 +264,16 @@ public class InMemoryRegistrationStore implements CaliforniumRegistrationStore,
         return removed;
     }
 
+    private boolean areTheSamePaths(Observation observation, Observation obs) {
+        if (observation instanceof SingleObservation && obs instanceof SingleObservation) {
+            return ((SingleObservation) observation).getPath().equals(((SingleObservation) obs).getPath());
+        }
+        if (observation instanceof CompositeObservation && obs instanceof CompositeObservation) {
+            return ((CompositeObservation) observation).getPaths().equals(((CompositeObservation) obs).getPaths());
+        }
+        return false;
+    }
+
     @Override
     public Observation removeObservation(String registrationId, byte[] observationId) {
         try {
@@ -446,7 +460,13 @@ public class InMemoryRegistrationStore implements CaliforniumRegistrationStore,
         if (cfObs == null)
             return null;
 
-        return ObserveUtil.createLwM2mObservation(cfObs.getRequest());
+        if (cfObs.getRequest().getCode() == CoAP.Code.GET) {
+            return ObserveUtil.createLwM2mObservation(cfObs.getRequest());
+        } else if (cfObs.getRequest().getCode() == CoAP.Code.FETCH) {
+            return ObserveUtil.createLwM2mCompositeObservation(cfObs.getRequest());
+        } else {
+            throw new IllegalStateException("Observation request can be GET or FETCH only");
+        }
     }
 
     private String validateObservation(org.eclipse.californium.core.observe.Observation observation)
diff --git a/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/request/CaliforniumLwM2mRequestSender.java b/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/request/CaliforniumLwM2mRequestSender.java
index 5bd03b9c..53e1d97f 100644
--- a/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/request/CaliforniumLwM2mRequestSender.java
+++ b/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/request/CaliforniumLwM2mRequestSender.java
@@ -19,12 +19,14 @@ package org.eclipse.leshan.server.californium.request;
 import org.eclipse.californium.core.coap.Request;
 import org.eclipse.californium.core.coap.Response;
 import org.eclipse.californium.core.network.Endpoint;
+import org.eclipse.leshan.core.Destroyable;
 import org.eclipse.leshan.core.californium.CoapResponseCallback;
 import org.eclipse.leshan.core.model.LwM2mModel;
 import org.eclipse.leshan.core.node.LwM2mNode;
 import org.eclipse.leshan.core.node.codec.CodecException;
 import org.eclipse.leshan.core.node.codec.LwM2mDecoder;
 import org.eclipse.leshan.core.node.codec.LwM2mEncoder;
+import org.eclipse.leshan.core.observation.Observation;
 import org.eclipse.leshan.core.request.DownlinkRequest;
 import org.eclipse.leshan.core.request.exception.InvalidResponseException;
 import org.eclipse.leshan.core.request.exception.RequestCanceledException;
@@ -34,10 +36,10 @@ import org.eclipse.leshan.core.request.exception.TimeoutException;
 import org.eclipse.leshan.core.request.exception.UnconnectedPeerException;
 import org.eclipse.leshan.core.response.ErrorCallback;
 import org.eclipse.leshan.core.response.LwM2mResponse;
+import org.eclipse.leshan.core.response.ObserveCompositeResponse;
 import org.eclipse.leshan.core.response.ObserveResponse;
 import org.eclipse.leshan.core.response.ResponseCallback;
 import org.eclipse.leshan.core.util.Validate;
-import org.eclipse.leshan.core.Destroyable;
 import org.eclipse.leshan.server.californium.observation.ObservationServiceImpl;
 import org.eclipse.leshan.server.model.LwM2mModelProvider;
 import org.eclipse.leshan.server.registration.Registration;
@@ -107,8 +109,16 @@ public class CaliforniumLwM2mRequestSender implements LwM2mRequestSender, CoapRe
                 destination.canInitiateConnection());
 
         // Handle special observe case
-        if (response != null && response.getClass() == ObserveResponse.class && response.isSuccess()) {
-            observationService.addObservation(destination, ((ObserveResponse) response).getObservation());
+        if (response != null && response.isSuccess()) {
+            Observation observation = null;
+            if (response instanceof ObserveResponse) {
+                observation = ((ObserveResponse) response).getObservation();
+            } else if (response instanceof ObserveCompositeResponse) {
+                observation = ((ObserveCompositeResponse) response).getObservation();
+            }
+            if (observation != null) {
+                observationService.addObservation(destination, observation);
+            }
         }
         return response;
     }
diff --git a/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/request/CoapRequestBuilder.java b/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/request/CoapRequestBuilder.java
index 07bb59fe..7220fb63 100644
--- a/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/request/CoapRequestBuilder.java
+++ b/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/request/CoapRequestBuilder.java
@@ -15,6 +15,8 @@
  *     Achim Kraus (Bosch Software Innovations GmbH) - use Identity as destination
  *                                                     and transform them to 
  *                                                     EndpointContext for requests
+ *     Michał Wadowski (Orange)                      - Add Observe-Composite feature.
+ *     Michał Wadowski (Orange)                      - Add Cancel Composite-Observation feature.
  *******************************************************************************/
 package org.eclipse.leshan.server.californium.request;
 
@@ -22,6 +24,7 @@ import org.eclipse.californium.core.coap.MediaTypeRegistry;
 import org.eclipse.californium.core.coap.Request;
 import org.eclipse.californium.elements.EndpointContext;
 import org.eclipse.leshan.core.californium.EndpointContextUtil;
+import org.eclipse.leshan.core.californium.ObserveUtil;
 import org.eclipse.leshan.core.model.LwM2mModel;
 import org.eclipse.leshan.core.node.LwM2mNode;
 import org.eclipse.leshan.core.node.LwM2mObject;
@@ -33,6 +36,7 @@ import org.eclipse.leshan.core.request.BootstrapDiscoverRequest;
 import org.eclipse.leshan.core.request.BootstrapFinishRequest;
 import org.eclipse.leshan.core.request.BootstrapReadRequest;
 import org.eclipse.leshan.core.request.BootstrapWriteRequest;
+import org.eclipse.leshan.core.request.CancelCompositeObservationRequest;
 import org.eclipse.leshan.core.request.CancelObservationRequest;
 import org.eclipse.leshan.core.request.ContentFormat;
 import org.eclipse.leshan.core.request.CreateRequest;
@@ -42,6 +46,7 @@ import org.eclipse.leshan.core.request.DownlinkRequest;
 import org.eclipse.leshan.core.request.DownlinkRequestVisitor;
 import org.eclipse.leshan.core.request.ExecuteRequest;
 import org.eclipse.leshan.core.request.Identity;
+import org.eclipse.leshan.core.request.ObserveCompositeRequest;
 import org.eclipse.leshan.core.request.ObserveRequest;
 import org.eclipse.leshan.core.request.ReadCompositeRequest;
 import org.eclipse.leshan.core.request.ReadRequest;
@@ -49,7 +54,6 @@ import org.eclipse.leshan.core.request.WriteAttributesRequest;
 import org.eclipse.leshan.core.request.WriteCompositeRequest;
 import org.eclipse.leshan.core.request.WriteRequest;
 import org.eclipse.leshan.core.util.StringUtils;
-import org.eclipse.leshan.server.californium.observation.ObserveUtil;
 import org.eclipse.leshan.server.request.LowerLayerConfig;
 
 /**
@@ -175,8 +179,8 @@ public class CoapRequestBuilder implements DownlinkRequestVisitor {
         coapRequest = Request.newGet();
         coapRequest.setObserveCancel();
         coapRequest.setToken(request.getObservation().getId());
-        if (request.getObservation().getContentFormat() != null)
-            coapRequest.getOptions().setAccept(request.getObservation().getContentFormat().getCode());
+        if (request.getContentFormat() != null)
+            coapRequest.getOptions().setAccept(request.getContentFormat().getCode());
         setTarget(coapRequest, request.getPath());
         applyLowerLayerConfig(coapRequest);
     }
@@ -192,6 +196,43 @@ public class CoapRequestBuilder implements DownlinkRequestVisitor {
         applyLowerLayerConfig(coapRequest);
     }
 
+    @Override
+    public void visit(ObserveCompositeRequest request) {
+        coapRequest = Request.newFetch();
+
+        coapRequest.getOptions().setContentFormat(request.getRequestContentFormat().getCode());
+
+        coapRequest.setPayload(encoder.encodePaths(request.getPaths(), request.getRequestContentFormat()));
+
+        if (request.getResponseContentFormat() != null) {
+            coapRequest.getOptions().setAccept(request.getResponseContentFormat().getCode());
+        }
+
+        coapRequest.setObserve();
+        setTarget(coapRequest, LwM2mPath.ROOTPATH);
+
+        coapRequest.setUserContext(
+                ObserveUtil.createCoapObserveCompositeRequestContext(endpoint, registrationId, request));
+        applyLowerLayerConfig(coapRequest);
+    }
+
+    @Override
+    public void visit(CancelCompositeObservationRequest request) {
+        coapRequest = Request.newFetch();
+        coapRequest.setObserveCancel();
+        coapRequest.setToken(request.getObservation().getId());
+
+        coapRequest.getOptions().setContentFormat(request.getRequestContentFormat().getCode());
+        coapRequest.setPayload(encoder.encodePaths(request.getPaths(), request.getRequestContentFormat()));
+        if (request.getResponseContentFormat() != null) {
+            coapRequest.getOptions().setAccept(request.getResponseContentFormat().getCode());
+        }
+
+        setTarget(coapRequest, LwM2mPath.ROOTPATH);
+
+        applyLowerLayerConfig(coapRequest);
+    }
+
     @Override
     public void visit(WriteCompositeRequest request) {
         coapRequest = Request.newIPatch();
diff --git a/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/request/LwM2mResponseBuilder.java b/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/request/LwM2mResponseBuilder.java
index 0c35e2d6..c1e2975e 100644
--- a/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/request/LwM2mResponseBuilder.java
+++ b/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/request/LwM2mResponseBuilder.java
@@ -12,6 +12,8 @@
  * 
  * Contributors:
  *     Sierra Wireless - initial API and implementation
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
+ *     Michał Wadowski (Orange) - Add Cancel Composite-Observation feature.
  *******************************************************************************/
 package org.eclipse.leshan.server.californium.request;
 
@@ -25,17 +27,20 @@ import org.eclipse.californium.core.coap.Request;
 import org.eclipse.californium.core.coap.Response;
 import org.eclipse.leshan.core.Link;
 import org.eclipse.leshan.core.ResponseCode;
+import org.eclipse.leshan.core.californium.ObserveUtil;
 import org.eclipse.leshan.core.model.LwM2mModel;
 import org.eclipse.leshan.core.node.LwM2mNode;
 import org.eclipse.leshan.core.node.LwM2mPath;
 import org.eclipse.leshan.core.node.codec.CodecException;
 import org.eclipse.leshan.core.node.codec.LwM2mDecoder;
-import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.observation.CompositeObservation;
+import org.eclipse.leshan.core.observation.SingleObservation;
 import org.eclipse.leshan.core.request.BootstrapDeleteRequest;
 import org.eclipse.leshan.core.request.BootstrapDiscoverRequest;
 import org.eclipse.leshan.core.request.BootstrapFinishRequest;
 import org.eclipse.leshan.core.request.BootstrapReadRequest;
 import org.eclipse.leshan.core.request.BootstrapWriteRequest;
+import org.eclipse.leshan.core.request.CancelCompositeObservationRequest;
 import org.eclipse.leshan.core.request.CancelObservationRequest;
 import org.eclipse.leshan.core.request.ContentFormat;
 import org.eclipse.leshan.core.request.CreateRequest;
@@ -44,6 +49,7 @@ import org.eclipse.leshan.core.request.DiscoverRequest;
 import org.eclipse.leshan.core.request.DownlinkRequestVisitor;
 import org.eclipse.leshan.core.request.ExecuteRequest;
 import org.eclipse.leshan.core.request.LwM2mRequest;
+import org.eclipse.leshan.core.request.ObserveCompositeRequest;
 import org.eclipse.leshan.core.request.ObserveRequest;
 import org.eclipse.leshan.core.request.ReadCompositeRequest;
 import org.eclipse.leshan.core.request.ReadRequest;
@@ -56,12 +62,14 @@ import org.eclipse.leshan.core.response.BootstrapDiscoverResponse;
 import org.eclipse.leshan.core.response.BootstrapFinishResponse;
 import org.eclipse.leshan.core.response.BootstrapReadResponse;
 import org.eclipse.leshan.core.response.BootstrapWriteResponse;
+import org.eclipse.leshan.core.response.CancelCompositeObservationResponse;
 import org.eclipse.leshan.core.response.CancelObservationResponse;
 import org.eclipse.leshan.core.response.CreateResponse;
 import org.eclipse.leshan.core.response.DeleteResponse;
 import org.eclipse.leshan.core.response.DiscoverResponse;
 import org.eclipse.leshan.core.response.ExecuteResponse;
 import org.eclipse.leshan.core.response.LwM2mResponse;
+import org.eclipse.leshan.core.response.ObserveCompositeResponse;
 import org.eclipse.leshan.core.response.ObserveResponse;
 import org.eclipse.leshan.core.response.ReadCompositeResponse;
 import org.eclipse.leshan.core.response.ReadResponse;
@@ -69,7 +77,6 @@ import org.eclipse.leshan.core.response.WriteAttributesResponse;
 import org.eclipse.leshan.core.response.WriteCompositeResponse;
 import org.eclipse.leshan.core.response.WriteResponse;
 import org.eclipse.leshan.core.util.Hex;
-import org.eclipse.leshan.server.californium.observation.ObserveUtil;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -106,7 +113,7 @@ public class LwM2mResponseBuilder<T extends LwM2mResponse> implements DownlinkRe
             // handle error response:
             lwM2mresponse = new ReadResponse(toLwM2mResponseCode(coapResponse.getCode()), null,
                     coapResponse.getPayloadString(), coapResponse);
-        } else if (coapResponse.getCode() == org.eclipse.californium.core.coap.CoAP.ResponseCode.CONTENT) {
+        } else if (isResponseCodeContent()) {
             // handle success response:
             LwM2mNode content = decodeCoapResponse(request.getPath(), coapResponse, request, clientEndpoint);
             lwM2mresponse = new ReadResponse(ResponseCode.CONTENT, content, null, coapResponse);
@@ -122,7 +129,7 @@ public class LwM2mResponseBuilder<T extends LwM2mResponse> implements DownlinkRe
             // handle error response:
             lwM2mresponse = new DiscoverResponse(toLwM2mResponseCode(coapResponse.getCode()), null,
                     coapResponse.getPayloadString(), coapResponse);
-        } else if (coapResponse.getCode() == org.eclipse.californium.core.coap.CoAP.ResponseCode.CONTENT) {
+        } else if (isResponseCodeContent()) {
             // handle success response:
             Link[] links;
             if (MediaTypeRegistry.APPLICATION_LINK_FORMAT != coapResponse.getOptions().getContentFormat()) {
@@ -144,7 +151,7 @@ public class LwM2mResponseBuilder<T extends LwM2mResponse> implements DownlinkRe
             // handle error response:
             lwM2mresponse = new WriteResponse(toLwM2mResponseCode(coapResponse.getCode()),
                     coapResponse.getPayloadString(), coapResponse);
-        } else if (coapResponse.getCode() == org.eclipse.californium.core.coap.CoAP.ResponseCode.CHANGED) {
+        } else if (isResponseCodeChanged()) {
             // handle success response:
             lwM2mresponse = new WriteResponse(ResponseCode.CHANGED, null, coapResponse);
         } else {
@@ -159,7 +166,7 @@ public class LwM2mResponseBuilder<T extends LwM2mResponse> implements DownlinkRe
             // handle error response:
             lwM2mresponse = new WriteAttributesResponse(toLwM2mResponseCode(coapResponse.getCode()),
                     coapResponse.getPayloadString(), coapResponse);
-        } else if (coapResponse.getCode() == org.eclipse.californium.core.coap.CoAP.ResponseCode.CHANGED) {
+        } else if (isResponseCodeChanged()) {
             // handle success response:
             lwM2mresponse = new WriteAttributesResponse(ResponseCode.CHANGED, null, coapResponse);
         } else {
@@ -174,7 +181,7 @@ public class LwM2mResponseBuilder<T extends LwM2mResponse> implements DownlinkRe
             // handle error response:
             lwM2mresponse = new ExecuteResponse(toLwM2mResponseCode(coapResponse.getCode()),
                     coapResponse.getPayloadString(), coapResponse);
-        } else if (coapResponse.getCode() == org.eclipse.californium.core.coap.CoAP.ResponseCode.CHANGED) {
+        } else if (isResponseCodeChanged()) {
             // handle success response:
             lwM2mresponse = new ExecuteResponse(ResponseCode.CHANGED, null, coapResponse);
         } else {
@@ -222,21 +229,18 @@ public class LwM2mResponseBuilder<T extends LwM2mResponse> implements DownlinkRe
             // handle error response:
             lwM2mresponse = new ObserveResponse(toLwM2mResponseCode(coapResponse.getCode()), null, null, null,
                     coapResponse.getPayloadString(), coapResponse);
-        } else if (coapResponse.getCode() == org.eclipse.californium.core.coap.CoAP.ResponseCode.CONTENT
+        } else if (isResponseCodeContent()
                 // This is for backward compatibility, when the spec say notification used CHANGED code
-                || coapResponse.getCode() == org.eclipse.californium.core.coap.CoAP.ResponseCode.CHANGED) {
+                || isResponseCodeChanged()) {
             // handle success response:
             LwM2mNode content = decodeCoapResponse(request.getPath(), coapResponse, request, clientEndpoint);
+            SingleObservation observation = null;
             if (coapResponse.getOptions().hasObserve()) {
                 // observe request successful
-                Observation observation = ObserveUtil.createLwM2mObservation(coapRequest);
-                // add the observation to an ObserveResponse instance
-                lwM2mresponse = new ObserveResponse(toLwM2mResponseCode(coapResponse.getCode()), content, null,
-                        observation, null, coapResponse);
-            } else {
-                lwM2mresponse = new ObserveResponse(toLwM2mResponseCode(coapResponse.getCode()), content, null, null,
-                        null, coapResponse);
+                observation = ObserveUtil.createLwM2mObservation(coapRequest);
             }
+            lwM2mresponse = new ObserveResponse(toLwM2mResponseCode(coapResponse.getCode()), content, null, observation,
+                    null, coapResponse);
         } else {
             // handle unexpected response:
             handleUnexpectedResponseCode(clientEndpoint, request, coapResponse);
@@ -249,9 +253,9 @@ public class LwM2mResponseBuilder<T extends LwM2mResponse> implements DownlinkRe
             // handle error response:
             lwM2mresponse = new CancelObservationResponse(toLwM2mResponseCode(coapResponse.getCode()), null, null, null,
                     coapResponse.getPayloadString(), coapResponse);
-        } else if (coapResponse.getCode() == org.eclipse.californium.core.coap.CoAP.ResponseCode.CONTENT
+        } else if (isResponseCodeContent()
                 // This is for backward compatibility, when the spec say notification used CHANGED code
-                || coapResponse.getCode() == org.eclipse.californium.core.coap.CoAP.ResponseCode.CHANGED) {
+                || isResponseCodeChanged()) {
             // handle success response:
             LwM2mNode content = decodeCoapResponse(request.getPath(), coapResponse, request, clientEndpoint);
             lwM2mresponse = new CancelObservationResponse(toLwM2mResponseCode(coapResponse.getCode()), content, null,
@@ -268,7 +272,7 @@ public class LwM2mResponseBuilder<T extends LwM2mResponse> implements DownlinkRe
             // handle error response:
             lwM2mresponse = new ReadCompositeResponse(toLwM2mResponseCode(coapResponse.getCode()), null,
                     coapResponse.getPayloadString(), coapResponse);
-        } else if (coapResponse.getCode() == org.eclipse.californium.core.coap.CoAP.ResponseCode.CONTENT) {
+        } else if (isResponseCodeContent()) {
             // handle success response:
             Map<LwM2mPath, LwM2mNode> content = decodeCompositeCoapResponse(request.getPaths(), coapResponse, request,
                     clientEndpoint);
@@ -279,13 +283,56 @@ public class LwM2mResponseBuilder<T extends LwM2mResponse> implements DownlinkRe
         }
     }
 
+    @Override
+    public void visit(ObserveCompositeRequest request) {
+        if (coapResponse.isError()) {
+            // handle error response:
+            lwM2mresponse = new ObserveCompositeResponse(toLwM2mResponseCode(coapResponse.getCode()), null,
+                    coapResponse.getPayloadString(), coapResponse, null);
+
+        } else if (isResponseCodeContent()) {
+            // handle success response:
+            Map<LwM2mPath, LwM2mNode> content = decodeCompositeCoapResponse(request.getPaths(), coapResponse, request,
+                    clientEndpoint);
+
+            CompositeObservation observation = null;
+            if (coapResponse.getOptions().hasObserve()) {
+                // observe request successful
+                observation = ObserveUtil.createLwM2mCompositeObservation(coapRequest);
+            }
+            lwM2mresponse = new ObserveCompositeResponse(toLwM2mResponseCode(coapResponse.getCode()), content, null,
+                    coapResponse, observation);
+        } else {
+            // handle unexpected response:
+            handleUnexpectedResponseCode(clientEndpoint, request, coapResponse);
+        }
+    }
+
+    @Override
+    public void visit(CancelCompositeObservationRequest request) {
+        if (coapResponse.isError()) {
+            // handle error response:
+            lwM2mresponse = new CancelCompositeObservationResponse(toLwM2mResponseCode(coapResponse.getCode()), null,
+                    coapResponse.getPayloadString(), coapResponse, null);
+        } else if (isResponseCodeContent() || isResponseCodeChanged()) {
+            // handle success response:
+            Map<LwM2mPath, LwM2mNode> content = decodeCompositeCoapResponse(request.getPaths(), coapResponse, request,
+                    clientEndpoint);
+            lwM2mresponse = new CancelCompositeObservationResponse(toLwM2mResponseCode(coapResponse.getCode()), content,
+                    null, coapResponse, null);
+        } else {
+            // handle unexpected response:
+            handleUnexpectedResponseCode(clientEndpoint, request, coapResponse);
+        }
+    }
+
     @Override
     public void visit(WriteCompositeRequest request) {
         if (coapResponse.isError()) {
             // handle error response:
             lwM2mresponse = new WriteCompositeResponse(toLwM2mResponseCode(coapResponse.getCode()),
                     coapResponse.getPayloadString(), coapResponse);
-        } else if (coapResponse.getCode() == org.eclipse.californium.core.coap.CoAP.ResponseCode.CHANGED) {
+        } else if (isResponseCodeChanged()) {
             // handle success response:
             lwM2mresponse = new WriteCompositeResponse(ResponseCode.CHANGED, null, coapResponse);
         } else {
@@ -300,7 +347,7 @@ public class LwM2mResponseBuilder<T extends LwM2mResponse> implements DownlinkRe
             // handle error response:
             lwM2mresponse = new BootstrapDiscoverResponse(toLwM2mResponseCode(coapResponse.getCode()), null,
                     coapResponse.getPayloadString(), coapResponse);
-        } else if (coapResponse.getCode() == org.eclipse.californium.core.coap.CoAP.ResponseCode.CONTENT) {
+        } else if (isResponseCodeContent()) {
             // handle success response:
             Link[] links;
             if (MediaTypeRegistry.APPLICATION_LINK_FORMAT != coapResponse.getOptions().getContentFormat()) {
@@ -322,7 +369,7 @@ public class LwM2mResponseBuilder<T extends LwM2mResponse> implements DownlinkRe
             // handle error response:
             lwM2mresponse = new BootstrapWriteResponse(toLwM2mResponseCode(coapResponse.getCode()),
                     coapResponse.getPayloadString(), coapResponse);
-        } else if (coapResponse.getCode() == org.eclipse.californium.core.coap.CoAP.ResponseCode.CHANGED) {
+        } else if (isResponseCodeChanged()) {
             // handle success response:
             lwM2mresponse = new BootstrapWriteResponse(ResponseCode.CHANGED, null, coapResponse);
         } else {
@@ -337,7 +384,7 @@ public class LwM2mResponseBuilder<T extends LwM2mResponse> implements DownlinkRe
             // handle error response:
             lwM2mresponse = new BootstrapReadResponse(toLwM2mResponseCode(coapResponse.getCode()), null,
                     coapResponse.getPayloadString(), coapResponse);
-        } else if (coapResponse.getCode() == org.eclipse.californium.core.coap.CoAP.ResponseCode.CONTENT) {
+        } else if (isResponseCodeContent()) {
             // handle success response:
             LwM2mNode content = decodeCoapResponse(request.getPath(), coapResponse, request, clientEndpoint);
             lwM2mresponse = new BootstrapReadResponse(ResponseCode.CONTENT, content, null, coapResponse);
@@ -368,7 +415,7 @@ public class LwM2mResponseBuilder<T extends LwM2mResponse> implements DownlinkRe
             // handle error response:
             lwM2mresponse = new BootstrapFinishResponse(toLwM2mResponseCode(coapResponse.getCode()),
                     coapResponse.getPayloadString(), coapResponse);
-        } else if (coapResponse.getCode() == org.eclipse.californium.core.coap.CoAP.ResponseCode.CHANGED) {
+        } else if (isResponseCodeChanged()) {
             // handle success response:
             lwM2mresponse = new BootstrapFinishResponse(ResponseCode.CHANGED, null, coapResponse);
         } else {
@@ -377,6 +424,14 @@ public class LwM2mResponseBuilder<T extends LwM2mResponse> implements DownlinkRe
         }
     }
 
+    private boolean isResponseCodeContent() {
+        return coapResponse.getCode() == org.eclipse.californium.core.coap.CoAP.ResponseCode.CONTENT;
+    }
+
+    private boolean isResponseCodeChanged() {
+        return coapResponse.getCode() == org.eclipse.californium.core.coap.CoAP.ResponseCode.CHANGED;
+    }
+
     private LwM2mNode decodeCoapResponse(LwM2mPath path, Response coapResponse, LwM2mRequest<?> request,
             String endpoint) {
 
diff --git a/leshan-server-core/src/main/java/org/eclipse/leshan/server/observation/ObservationListener.java b/leshan-server-core/src/main/java/org/eclipse/leshan/server/observation/ObservationListener.java
index 5bb6a299..bdb32612 100644
--- a/leshan-server-core/src/main/java/org/eclipse/leshan/server/observation/ObservationListener.java
+++ b/leshan-server-core/src/main/java/org/eclipse/leshan/server/observation/ObservationListener.java
@@ -15,7 +15,10 @@
  *******************************************************************************/
 package org.eclipse.leshan.server.observation;
 
+import org.eclipse.leshan.core.observation.CompositeObservation;
 import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.observation.SingleObservation;
+import org.eclipse.leshan.core.response.ObserveCompositeResponse;
 import org.eclipse.leshan.core.response.ObserveResponse;
 import org.eclipse.leshan.server.registration.Registration;
 
@@ -50,7 +53,17 @@ public interface ObservationListener {
      * @param response the lwm2m response received (successful or error response)
      * 
      */
-    void onResponse(Observation observation, Registration registration, ObserveResponse response);
+    void onResponse(SingleObservation observation, Registration registration, ObserveResponse response);
+
+    /**
+     * Called on new notification.
+     *
+     * @param observation the composite-observation for which new data are received
+     * @param registration the registration concerned by this observation
+     * @param response the lwm2m observe-composite response received (successful or error response)
+     *
+     */
+    void onResponse(CompositeObservation observation, Registration registration, ObserveCompositeResponse response);
 
     /**
      * Called when an error occurs on new notification.
diff --git a/leshan-server-core/src/main/java/org/eclipse/leshan/server/queue/PresenceStateListener.java b/leshan-server-core/src/main/java/org/eclipse/leshan/server/queue/PresenceStateListener.java
index e77dd148..49155b6c 100644
--- a/leshan-server-core/src/main/java/org/eclipse/leshan/server/queue/PresenceStateListener.java
+++ b/leshan-server-core/src/main/java/org/eclipse/leshan/server/queue/PresenceStateListener.java
@@ -17,7 +17,10 @@ package org.eclipse.leshan.server.queue;
 
 import java.util.Collection;
 
+import org.eclipse.leshan.core.observation.CompositeObservation;
 import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.observation.SingleObservation;
+import org.eclipse.leshan.core.response.ObserveCompositeResponse;
 import org.eclipse.leshan.core.response.ObserveResponse;
 import org.eclipse.leshan.server.observation.ObservationListener;
 import org.eclipse.leshan.server.registration.Registration;
@@ -66,7 +69,18 @@ public class PresenceStateListener implements RegistrationListener, ObservationL
      * @since 1.1
      */
     @Override
-    public void onResponse(Observation observation, Registration registration, ObserveResponse response) {
+    public void onResponse(SingleObservation observation, Registration registration, ObserveResponse response) {
+        presenceService.setAwake(registration);
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * @since 2.0
+     */
+    @Override
+    public void onResponse(CompositeObservation observation, Registration registration,
+            ObserveCompositeResponse response) {
         presenceService.setAwake(registration);
     }
 
diff --git a/leshan-server-demo/src/main/java/org/eclipse/leshan/server/demo/servlet/EventServlet.java b/leshan-server-demo/src/main/java/org/eclipse/leshan/server/demo/servlet/EventServlet.java
index d5db6be0..dd9310c9 100644
--- a/leshan-server-demo/src/main/java/org/eclipse/leshan/server/demo/servlet/EventServlet.java
+++ b/leshan-server-demo/src/main/java/org/eclipse/leshan/server/demo/servlet/EventServlet.java
@@ -12,12 +12,14 @@
  * 
  * Contributors:
  *     Sierra Wireless - initial API and implementation
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
  *******************************************************************************/
 package org.eclipse.leshan.server.demo.servlet;
 
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 
@@ -27,7 +29,11 @@ import org.eclipse.californium.core.network.Endpoint;
 import org.eclipse.jetty.servlets.EventSource;
 import org.eclipse.jetty.servlets.EventSourceServlet;
 import org.eclipse.leshan.core.node.LwM2mNode;
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.observation.CompositeObservation;
 import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.observation.SingleObservation;
+import org.eclipse.leshan.core.response.ObserveCompositeResponse;
 import org.eclipse.leshan.core.response.ObserveResponse;
 import org.eclipse.leshan.server.californium.LeshanServer;
 import org.eclipse.leshan.server.demo.servlet.json.LwM2mNodeSerializer;
@@ -127,16 +133,42 @@ public class EventServlet extends EventSourceServlet {
         }
 
         @Override
-        public void onResponse(Observation observation, Registration registration, ObserveResponse response) {
+        public void onResponse(SingleObservation observation, Registration registration, ObserveResponse response) {
+            String path = getObservationPaths(observation);
+            LwM2mNode content = response.getContent();
+            String stringContent = content.toString();
+            String jsonContent = gson.toJson(content);
+
+            onResponseCommon(registration, path, stringContent, jsonContent);
+        }
+
+        @Override
+        public void onResponse(CompositeObservation observation, Registration registration,
+                ObserveCompositeResponse response) {
+            String path = getObservationPaths(observation);
+
+            Map<LwM2mPath, LwM2mNode> content = response.getContent();
+            String stringContent = content.toString();
+            String jsonContent = gson.toJson(content);
+
+            onResponseCommon(registration, path, stringContent, jsonContent);
+        }
+
+        private void onResponseCommon(Registration registration, String path, String stringContent,
+                String jsonContent) {
+
             if (LOG.isDebugEnabled()) {
-                LOG.debug("Received notification from [{}] containing value [{}]", observation.getPath(),
-                        response.getContent().toString());
+                LOG.debug("Received notification from [{}] containing value [{}]", path, stringContent);
             }
 
             if (registration != null) {
-                String data = new StringBuilder("{\"ep\":\"").append(registration.getEndpoint()).append("\",\"res\":\"")
-                        .append(observation.getPath().toString()).append("\",\"val\":")
-                        .append(gson.toJson(response.getContent())).append("}").toString();
+                String data = new StringBuilder("{\"ep\":\"")
+                        .append(registration.getEndpoint())
+                        .append("\",\"res\":\"")
+                        .append(path).append("\",\"val\":")
+                        .append(jsonContent)
+                        .append("}")
+                        .toString();
 
                 sendEvent(EVENT_NOTIFICATION, data, registration.getEndpoint());
             }
@@ -146,7 +178,7 @@ public class EventServlet extends EventSourceServlet {
         public void onError(Observation observation, Registration registration, Exception error) {
             if (LOG.isWarnEnabled()) {
                 LOG.warn(String.format("Unable to handle notification of [%s:%s]", observation.getRegistrationId(),
-                        observation.getPath()), error);
+                        getObservationPaths(observation)), error);
             }
         }
 
@@ -155,6 +187,16 @@ public class EventServlet extends EventSourceServlet {
         }
     };
 
+    private String getObservationPaths(final Observation observation) {
+        String path = null;
+        if (observation instanceof SingleObservation) {
+            path = ((SingleObservation) observation).getPath().toString();
+        } else if (observation instanceof CompositeObservation) {
+            path = ((CompositeObservation) observation).getPaths().toString();
+        }
+        return path;
+    }
+
     public EventServlet(LeshanServer server, int securePort) {
         server.getRegistrationService().addListener(this.registrationListener);
         server.getObservationService().addListener(this.observationListener);
diff --git a/leshan-server-redis/src/main/java/org/eclipse/leshan/server/redis/RedisRegistrationStore.java b/leshan-server-redis/src/main/java/org/eclipse/leshan/server/redis/RedisRegistrationStore.java
index 36f4d123..7b318eb0 100644
--- a/leshan-server-redis/src/main/java/org/eclipse/leshan/server/redis/RedisRegistrationStore.java
+++ b/leshan-server-redis/src/main/java/org/eclipse/leshan/server/redis/RedisRegistrationStore.java
@@ -16,6 +16,7 @@
  *                                                     EndpointContext
  *     Achim Kraus (Bosch Software Innovations GmbH) - update to modified 
  *                                                     ObservationStore API
+ *     Michał Wadowski (Orange)                      - Add Observe-Composite feature.
  *******************************************************************************/
 package org.eclipse.leshan.server.redis;
 
@@ -35,17 +36,20 @@ import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
 
+import org.eclipse.californium.core.coap.CoAP;
 import org.eclipse.californium.core.coap.Token;
 import org.eclipse.californium.core.observe.ObservationStoreException;
 import org.eclipse.californium.elements.EndpointContext;
 import org.eclipse.leshan.core.Destroyable;
 import org.eclipse.leshan.core.Startable;
 import org.eclipse.leshan.core.Stoppable;
+import org.eclipse.leshan.core.californium.ObserveUtil;
+import org.eclipse.leshan.core.observation.CompositeObservation;
 import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.observation.SingleObservation;
 import org.eclipse.leshan.core.request.Identity;
 import org.eclipse.leshan.core.util.NamedThreadFactory;
 import org.eclipse.leshan.core.util.Validate;
-import org.eclipse.leshan.server.californium.observation.ObserveUtil;
 import org.eclipse.leshan.server.californium.registration.CaliforniumRegistrationStore;
 import org.eclipse.leshan.server.redis.serialization.IdentitySerDes;
 import org.eclipse.leshan.server.redis.serialization.ObservationSerDes;
@@ -504,8 +508,7 @@ public class RedisRegistrationStore implements CaliforniumRegistrationStore, Sta
 
                 // cancel existing observations for the same path and registration id.
                 for (Observation obs : getObservations(j, registrationId)) {
-                    if (observation.getPath().equals(obs.getPath())
-                            && !Arrays.equals(observation.getId(), obs.getId())) {
+                    if (areTheSamePaths(observation, obs) && !Arrays.equals(observation.getId(), obs.getId())) {
                         removed.add(obs);
                         unsafeRemoveObservation(j, registrationId, obs.getId());
                     }
@@ -518,6 +521,16 @@ public class RedisRegistrationStore implements CaliforniumRegistrationStore, Sta
         return removed;
     }
 
+    private boolean areTheSamePaths(Observation observation, Observation obs) {
+        if (observation instanceof SingleObservation && obs instanceof SingleObservation) {
+            return ((SingleObservation) observation).getPath().equals(((SingleObservation) obs).getPath());
+        }
+        if (observation instanceof CompositeObservation && obs instanceof CompositeObservation) {
+            return ((CompositeObservation) observation).getPaths().equals(((CompositeObservation) obs).getPaths());
+        }
+        return false;
+    }
+
     @Override
     public Observation removeObservation(String registrationId, byte[] observationId) {
         try (Jedis j = pool.getResource()) {
@@ -751,7 +764,13 @@ public class RedisRegistrationStore implements CaliforniumRegistrationStore, Sta
         if (cfObs == null)
             return null;
 
-        return ObserveUtil.createLwM2mObservation(cfObs.getRequest());
+        if (cfObs.getRequest().getCode() == CoAP.Code.GET) {
+            return ObserveUtil.createLwM2mObservation(cfObs.getRequest());
+        } else if (cfObs.getRequest().getCode() == CoAP.Code.FETCH) {
+            return ObserveUtil.createLwM2mCompositeObservation(cfObs.getRequest());
+        } else {
+            throw new IllegalStateException("Observation request can be GET or FETCH only");
+        }
     }
 
     /* *************** Expiration handling **************** */


Test patch:
diff --git a/leshan-core/src/test/java/org/eclipse/leshan/core/response/ObserveCompositeResponseTest.java b/leshan-core/src/test/java/org/eclipse/leshan/core/response/ObserveCompositeResponseTest.java
new file mode 100644
index 00000000..4cd14a34
--- /dev/null
+++ b/leshan-core/src/test/java/org/eclipse/leshan/core/response/ObserveCompositeResponseTest.java
@@ -0,0 +1,44 @@
+/*******************************************************************************
+ * Copyright (c) 2021 Orange.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * and Eclipse Distribution License v1.0 which accompany this distribution.
+ *
+ * The Eclipse Public License is available at
+ *    http://www.eclipse.org/legal/epl-v20.html
+ * and the Eclipse Distribution License is available at
+ *    http://www.eclipse.org/org/documents/edl-v10.html.
+ *
+ * Contributors:
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
+ *******************************************************************************/
+package org.eclipse.leshan.core.response;
+
+import static org.eclipse.leshan.core.ResponseCode.CONTENT;
+import static org.eclipse.leshan.core.node.LwM2mSingleResource.newResource;
+import static org.junit.Assert.assertEquals;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.eclipse.leshan.core.node.LwM2mNode;
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.junit.Test;
+
+public class ObserveCompositeResponseTest {
+
+    @Test
+    public void should_create_response_with_content() {
+        // given
+        Map<LwM2mPath, LwM2mNode> exampleContent = new HashMap<>();
+        exampleContent.put(new LwM2mPath("/1/2/3"), newResource(15, "example 1"));
+        exampleContent.put(new LwM2mPath("/2/3/4"), newResource(16, "example 2"));
+
+        // when
+        ObserveCompositeResponse response = new ObserveCompositeResponse(CONTENT, exampleContent, null, null, null);
+
+        // then
+        assertEquals(exampleContent, response.getContent());
+    }
+}
diff --git a/leshan-core/src/test/java/org/eclipse/leshan/core/response/ObserveResponseTest.java b/leshan-core/src/test/java/org/eclipse/leshan/core/response/ObserveResponseTest.java
new file mode 100644
index 00000000..012060f2
--- /dev/null
+++ b/leshan-core/src/test/java/org/eclipse/leshan/core/response/ObserveResponseTest.java
@@ -0,0 +1,100 @@
+/*******************************************************************************
+ * Copyright (c) 2021 Orange.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * and Eclipse Distribution License v1.0 which accompany this distribution.
+ *
+ * The Eclipse Public License is available at
+ *    http://www.eclipse.org/legal/epl-v20.html
+ * and the Eclipse Distribution License is available at
+ *    http://www.eclipse.org/org/documents/edl-v10.html.
+ *
+ * Contributors:
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
+ *******************************************************************************/
+package org.eclipse.leshan.core.response;
+
+import static org.eclipse.leshan.core.ResponseCode.*;
+import static org.eclipse.leshan.core.node.LwM2mSingleResource.newResource;
+import static org.junit.Assert.*;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+import org.eclipse.leshan.core.ResponseCode;
+import org.eclipse.leshan.core.node.LwM2mSingleResource;
+import org.eclipse.leshan.core.node.TimestampedLwM2mNode;
+import org.eclipse.leshan.core.request.exception.InvalidResponseException;
+import org.junit.Test;
+import org.junit.function.ThrowingRunnable;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class ObserveResponseTest {
+
+    @Parameters(name = "{0}")
+    public static Collection<?> responseCodes() {
+        return Arrays.asList(CONTENT, CHANGED);
+    }
+
+    private final ResponseCode responseCode;
+
+    public ObserveResponseTest(ResponseCode responseCode) {
+        this.responseCode = responseCode;
+    }
+
+    @Test
+    public void should_throw_invalid_response_exception_if_no_content() {
+        assertThrows(InvalidResponseException.class, new ThrowingRunnable() {
+            @Override
+            public void run() {
+                new ObserveResponse(responseCode, null, null, null, null);
+            }
+        });
+    }
+
+    @Test
+    public void should_throw_invalid_response_exception_if_no_content_and_empty_timestamped_values() {
+        assertThrows(InvalidResponseException.class, new ThrowingRunnable() {
+            @Override
+            public void run() {
+                new ObserveResponse(responseCode, null, Collections.<TimestampedLwM2mNode> emptyList(), null, null);
+            }
+        });
+    }
+
+    @Test
+    public void should_not_throw_exception_if_has_content() {
+        // given
+        LwM2mSingleResource exampleResource = newResource(15, "example");
+
+        // when
+        ObserveResponse response = new ObserveResponse(responseCode, exampleResource, null, null, null);
+
+        // then
+        assertEquals(exampleResource, response.getContent());
+        assertNull(response.getTimestampedLwM2mNode());
+    }
+
+    @Test
+    public void should_get_content_from_first_of_timestamped_values() {
+        // given
+        List<TimestampedLwM2mNode> timestampedValues = Arrays.asList(
+                new TimestampedLwM2mNode(123L, newResource(15, "example 1")),
+                new TimestampedLwM2mNode(456L, newResource(15, "example 2")));
+
+        LwM2mSingleResource content = responseCode == CHANGED ? newResource(15, "example 1") : null;
+
+        // when
+        ObserveResponse response = new ObserveResponse(responseCode, content, timestampedValues, null, null);
+
+        // then
+        assertEquals(timestampedValues.get(0).getNode(), response.getContent());
+        assertEquals(timestampedValues, response.getTimestampedLwM2mNode());
+    }
+}
diff --git a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/RegistrationTest.java b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/RegistrationTest.java
index 4a0dcdc0..8fd66129 100644
--- a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/RegistrationTest.java
+++ b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/RegistrationTest.java
@@ -40,6 +40,7 @@ import org.eclipse.leshan.core.Link;
 import org.eclipse.leshan.core.ResponseCode;
 import org.eclipse.leshan.core.node.LwM2mPath;
 import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.observation.SingleObservation;
 import org.eclipse.leshan.core.request.ContentFormat;
 import org.eclipse.leshan.core.request.ObserveRequest;
 import org.eclipse.leshan.core.request.ReadRequest;
@@ -220,7 +221,7 @@ public class RegistrationTest {
         Registration currentRegistration = helper.getCurrentRegistration();
         Set<Observation> observations = helper.server.getObservationService().getObservations(currentRegistration);
         assertEquals(1, observations.size());
-        Observation obs = observations.iterator().next();
+        SingleObservation obs = (SingleObservation) observations.iterator().next();
         assertEquals(currentRegistration.getId(), obs.getRegistrationId());
         assertEquals(new LwM2mPath(3, 0), obs.getPath());
 
diff --git a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/observe/ObserveCompositeTest.java b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/observe/ObserveCompositeTest.java
new file mode 100644
index 00000000..862ca50a
--- /dev/null
+++ b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/observe/ObserveCompositeTest.java
@@ -0,0 +1,430 @@
+/*******************************************************************************
+ * Copyright (c) 2021 Orange.
+ * 
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * and Eclipse Distribution License v1.0 which accompany this distribution.
+ * 
+ * The Eclipse Public License is available at
+ *    http://www.eclipse.org/legal/epl-v20.html
+ * and the Eclipse Distribution License is available at
+ *    http://www.eclipse.org/org/documents/edl-v10.html.
+ * 
+ * Contributors:
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
+ *******************************************************************************/
+package org.eclipse.leshan.integration.tests.observe;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+import static org.hamcrest.core.IsInstanceOf.instanceOf;
+import static org.junit.Assert.*;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+import org.eclipse.californium.core.coap.Response;
+import org.eclipse.leshan.core.ResponseCode;
+import org.eclipse.leshan.core.node.LwM2mNode;
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.node.LwM2mSingleResource;
+import org.eclipse.leshan.core.observation.CompositeObservation;
+import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.request.CancelCompositeObservationRequest;
+import org.eclipse.leshan.core.request.ContentFormat;
+import org.eclipse.leshan.core.request.ObserveCompositeRequest;
+import org.eclipse.leshan.core.request.ReadRequest;
+import org.eclipse.leshan.core.request.WriteCompositeRequest;
+import org.eclipse.leshan.core.request.WriteRequest;
+import org.eclipse.leshan.core.response.CancelCompositeObservationResponse;
+import org.eclipse.leshan.core.response.LwM2mResponse;
+import org.eclipse.leshan.core.response.ObserveCompositeResponse;
+import org.eclipse.leshan.core.response.ReadResponse;
+import org.eclipse.leshan.core.response.WriteCompositeResponse;
+import org.eclipse.leshan.integration.tests.util.IntegrationTestHelper;
+import org.eclipse.leshan.server.registration.Registration;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ObserveCompositeTest {
+
+    protected IntegrationTestHelper helper = new IntegrationTestHelper();
+    private Registration currentRegistration;
+    private TestObservationListener listener;
+
+    @Before
+    public void start() {
+        helper.initialize();
+        helper.createServer();
+        helper.server.start();
+        helper.createClient();
+        helper.client.start();
+        helper.waitForRegistrationAtServerSide(1);
+
+        currentRegistration = helper.getCurrentRegistration();
+        listener = new TestObservationListener();
+        helper.server.getObservationService().addListener(listener);
+    }
+
+    @After
+    public void stop() {
+        helper.client.destroy(false);
+        helper.server.destroy();
+        helper.dispose();
+    }
+
+    @Test
+    public void can_composite_observe_on_single_resource() throws InterruptedException {
+        // Send ObserveCompositeRequest
+        ObserveCompositeResponse observeResponse = helper.server.send(currentRegistration,
+                new ObserveCompositeRequest(ContentFormat.SENML_JSON, ContentFormat.SENML_JSON, "/3/0/15"));
+
+        // Assert that ObserveCompositeResponse is valid
+        assertEquals(ResponseCode.CONTENT, observeResponse.getCode());
+        assertNotNull(observeResponse.getCoapResponse());
+        assertThat(observeResponse.getCoapResponse(), is(instanceOf(Response.class)));
+
+        CompositeObservation observation = observeResponse.getObservation();
+
+        // Assert that CompositeObservation contains expected paths
+        assertNotNull(observation);
+        assertEquals(1, observation.getPaths().size());
+        assertEquals("/3/0/15", observation.getPaths().get(0).toString());
+
+        // Assert that there is one valid observation
+        assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
+        Set<Observation> observations = helper.server.getObservationService()
+                .getObservations(helper.getCurrentRegistration());
+        assertEquals("We should have only one observation", 1, observations.size());
+        assertTrue("New observation is not there", observations.contains(observation));
+
+        // Write single example value
+        LwM2mResponse writeResponse = helper.server.send(helper.getCurrentRegistration(),
+                new WriteRequest(3, 0, 15, "Europe/Paris"));
+        listener.waitForNotification(2000);
+        assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
+
+        // Assert that response contains expected paths
+        assertTrue(listener.receivedNotify().get());
+        Map<LwM2mPath, LwM2mNode> content = listener.getObserveCompositeResponse().getContent();
+        assertEquals(1, content.size());
+        assertTrue(content.containsKey(new LwM2mPath("/3/0/15")));
+
+        // Assert that listener response contains expected values
+        assertEquals(LwM2mSingleResource.newStringResource(15, "Europe/Paris"), content.get(new LwM2mPath("/3/0/15")));
+
+        // Assert that listener has Response
+        assertNotNull(listener.getObserveCompositeResponse().getCoapResponse());
+        assertThat(listener.getObserveCompositeResponse().getCoapResponse(), is(instanceOf(Response.class)));
+    }
+
+    @Test
+    public void should_not_get_response_if_modified_other_resource_than_observed() throws InterruptedException {
+        // Send ObserveCompositeRequest
+        ObserveCompositeResponse observeResponse = helper.server.send(currentRegistration,
+                new ObserveCompositeRequest(ContentFormat.SENML_JSON, ContentFormat.SENML_JSON, "/3/0/14"));
+
+        // Assert that ObserveCompositeResponse is valid
+        assertEquals(ResponseCode.CONTENT, observeResponse.getCode());
+        assertNotNull(observeResponse.getCoapResponse());
+        assertThat(observeResponse.getCoapResponse(), is(instanceOf(Response.class)));
+
+        CompositeObservation observation = observeResponse.getObservation();
+
+        // Assert that CompositeObservation contains expected paths
+        assertNotNull(observation);
+        assertEquals(1, observation.getPaths().size());
+        assertEquals("/3/0/14", observation.getPaths().get(0).toString());
+
+        // Assert that there is one valid observation
+        assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
+        Set<Observation> observations = helper.server.getObservationService()
+                .getObservations(helper.getCurrentRegistration());
+        assertEquals("We should have only one observation", 1, observations.size());
+        assertTrue("New observation is not there", observations.contains(observation));
+
+        // Write single example value
+        LwM2mResponse writeResponse = helper.server.send(helper.getCurrentRegistration(),
+                new WriteRequest(3, 0, 15, "Europe/Paris"));
+        listener.waitForNotification(2000);
+        assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
+
+        // Assert that listener has no response
+        assertFalse(listener.receivedNotify().get());
+    }
+
+    @Test
+    public void can_composite_observe_on_multiple_resources() throws InterruptedException {
+        // Send ObserveCompositeRequest
+        ObserveCompositeResponse observeResponse = helper.server.send(currentRegistration,
+                new ObserveCompositeRequest(ContentFormat.SENML_JSON, ContentFormat.SENML_JSON, "/3/0/15", "/3/0/14"));
+
+        // Assert that ObserveCompositeResponse is valid
+        assertEquals(ResponseCode.CONTENT, observeResponse.getCode());
+        assertNotNull(observeResponse.getCoapResponse());
+        assertThat(observeResponse.getCoapResponse(), is(instanceOf(Response.class)));
+
+        CompositeObservation observation = observeResponse.getObservation();
+
+        // Assert that CompositeObservation contains expected paths
+        assertNotNull(observation);
+        assertEquals(2, observation.getPaths().size());
+        assertEquals("/3/0/15", observation.getPaths().get(0).toString());
+        assertEquals("/3/0/14", observation.getPaths().get(1).toString());
+
+        // Assert that there is one valid observation
+        assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
+        Set<Observation> observations = helper.server.getObservationService()
+                .getObservations(helper.getCurrentRegistration());
+        assertEquals("We should have only one observation", 1, observations.size());
+        assertTrue("New observation is not there", observations.contains(observation));
+
+        // Write single example value
+        LwM2mResponse writeResponse = helper.server.send(helper.getCurrentRegistration(),
+                new WriteRequest(3, 0, 15, "Europe/Paris"));
+        listener.waitForNotification(2000);
+        assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
+
+        // Assert that response contains exactly the same paths
+        assertTrue(listener.receivedNotify().get());
+        Map<LwM2mPath, LwM2mNode> content = listener.getObserveCompositeResponse().getContent();
+        assertEquals(2, content.size());
+        assertTrue(content.containsKey(new LwM2mPath("/3/0/15")));
+        assertTrue(content.containsKey(new LwM2mPath("/3/0/14")));
+
+        // Assert that listener response contains expected values
+        assertEquals(LwM2mSingleResource.newStringResource(new LwM2mPath("/3/0/15").getResourceId(), "Europe/Paris"),
+                content.get(new LwM2mPath("/3/0/15")));
+        assertEquals(LwM2mSingleResource.newStringResource(new LwM2mPath("/3/0/14").getResourceId(), "+02"),
+                content.get(new LwM2mPath("/3/0/14")));
+
+        // Assert that listener has Response
+        assertNotNull(listener.getObserveCompositeResponse().getCoapResponse());
+        assertThat(listener.getObserveCompositeResponse().getCoapResponse(), is(instanceOf(Response.class)));
+    }
+
+    @Test
+    public void can_composite_observe_on_multiple_resources_with_write_composite() throws InterruptedException {
+        // Send ObserveCompositeRequest
+        ObserveCompositeResponse observeResponse = helper.server.send(currentRegistration,
+                new ObserveCompositeRequest(ContentFormat.SENML_JSON, ContentFormat.SENML_JSON, "/3/0/15", "/3/0/14"));
+
+        // Assert that ObserveCompositeResponse is valid
+        assertEquals(ResponseCode.CONTENT, observeResponse.getCode());
+        assertNotNull(observeResponse.getCoapResponse());
+        assertThat(observeResponse.getCoapResponse(), is(instanceOf(Response.class)));
+
+        CompositeObservation observation = observeResponse.getObservation();
+
+        // Assert that CompositeObservation contains expected paths
+        assertNotNull(observation);
+        assertEquals(2, observation.getPaths().size());
+        assertEquals("/3/0/15", observation.getPaths().get(0).toString());
+        assertEquals("/3/0/14", observation.getPaths().get(1).toString());
+
+        // Assert that there is one valid observation
+        assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
+        Set<Observation> observations = helper.server.getObservationService()
+                .getObservations(helper.getCurrentRegistration());
+        assertEquals("We should have only one observation", 1, observations.size());
+        assertTrue("New observation is not there", observations.contains(observation));
+
+        // Write example composite values
+        Map<String, Object> nodes = new HashMap<>();
+        nodes.put("/3/0/15", "Europe/Paris");
+        nodes.put("/3/0/14", "+11");
+        WriteCompositeResponse writeResponse = helper.server.send(helper.getCurrentRegistration(),
+                new WriteCompositeRequest(ContentFormat.SENML_JSON, nodes));
+        listener.waitForNotification(2000);
+        assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
+
+        // Assert that response contains expected paths
+        assertTrue(listener.receivedNotify().get());
+        Map<LwM2mPath, LwM2mNode> content = listener.getObserveCompositeResponse().getContent();
+        assertEquals(2, content.size());
+        assertTrue(content.containsKey(new LwM2mPath("/3/0/15")));
+        assertTrue(content.containsKey(new LwM2mPath("/3/0/14")));
+
+        // Assert that listener response contains expected values
+        assertEquals(LwM2mSingleResource.newStringResource(new LwM2mPath("/3/0/15").getResourceId(), "Europe/Paris"),
+                content.get(new LwM2mPath("/3/0/15")));
+        assertEquals(LwM2mSingleResource.newStringResource(new LwM2mPath("/3/0/14").getResourceId(), "+11"),
+                content.get(new LwM2mPath("/3/0/14")));
+
+        // Assert that listener has Response
+        assertNotNull(listener.getObserveCompositeResponse().getCoapResponse());
+        assertThat(listener.getObserveCompositeResponse().getCoapResponse(), is(instanceOf(Response.class)));
+    }
+
+    @Test
+    public void can_observe_instance() throws InterruptedException {
+        // Send ObserveCompositeRequest
+        ObserveCompositeResponse observeResponse = helper.server.send(currentRegistration,
+                new ObserveCompositeRequest(ContentFormat.SENML_JSON, ContentFormat.SENML_JSON, "/3/0"));
+
+        // Assert that ObserveCompositeResponse is valid
+        assertEquals(ResponseCode.CONTENT, observeResponse.getCode());
+        assertNotNull(observeResponse.getCoapResponse());
+        assertThat(observeResponse.getCoapResponse(), is(instanceOf(Response.class)));
+
+        CompositeObservation observation = observeResponse.getObservation();
+
+        // Assert that CompositeObservation contains expected paths
+        assertNotNull(observation);
+        assertEquals(1, observation.getPaths().size());
+        assertEquals("/3/0", observation.getPaths().get(0).toString());
+
+        // Assert that there is one valid observation
+        assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
+        Set<Observation> observations = helper.server.getObservationService()
+                .getObservations(helper.getCurrentRegistration());
+        assertEquals("We should have only one observation", 1, observations.size());
+        assertTrue("New observation is not there", observations.contains(observation));
+
+        // Write single example value
+        LwM2mResponse writeResponse = helper.server.send(helper.getCurrentRegistration(),
+                new WriteRequest(3, 0, 15, "Europe/Paris"));
+        listener.waitForNotification(2000);
+        assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
+
+        // Assert that response contains expected paths
+        assertTrue(listener.receivedNotify().get());
+        Map<LwM2mPath, LwM2mNode> content = listener.getObserveCompositeResponse().getContent();
+        assertEquals(1, content.size());
+        assertTrue(content.containsKey(new LwM2mPath("/3/0")));
+
+        // Assert that listener response equals to ReadResponse
+        ReadResponse readResp = helper.server.send(helper.getCurrentRegistration(),
+                new ReadRequest(ContentFormat.SENML_JSON, "/3/0"));
+        assertEquals(readResp.getContent(), content.get(new LwM2mPath("/3/0")));
+    }
+
+    @Test
+    public void can_observe_object() throws InterruptedException {
+        // Send ObserveCompositeRequest
+        ObserveCompositeResponse observeResponse = helper.server.send(currentRegistration,
+                new ObserveCompositeRequest(ContentFormat.SENML_JSON, ContentFormat.SENML_JSON, "/3"));
+
+        // Assert that ObserveCompositeResponse is valid
+        assertEquals(ResponseCode.CONTENT, observeResponse.getCode());
+        assertNotNull(observeResponse.getCoapResponse());
+        assertThat(observeResponse.getCoapResponse(), is(instanceOf(Response.class)));
+
+        CompositeObservation observation = observeResponse.getObservation();
+
+        // Assert that CompositeObservation contains expected paths
+        assertNotNull(observation);
+        assertEquals(1, observation.getPaths().size());
+        assertEquals("/3", observation.getPaths().get(0).toString());
+
+        // Assert that there is one valid observation
+        assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
+        Set<Observation> observations = helper.server.getObservationService()
+                .getObservations(helper.getCurrentRegistration());
+        assertEquals("We should have only one observation", 1, observations.size());
+        assertTrue("New observation is not there", observations.contains(observation));
+
+        // Write single example value
+        LwM2mResponse writeResponse = helper.server.send(helper.getCurrentRegistration(),
+                new WriteRequest(3, 0, 15, "Europe/Paris"));
+        listener.waitForNotification(2000);
+        assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
+
+        // Assert that response contains expected paths
+        assertTrue(listener.receivedNotify().get());
+        Map<LwM2mPath, LwM2mNode> content = listener.getObserveCompositeResponse().getContent();
+        assertEquals(1, content.size());
+        assertTrue(content.containsKey(new LwM2mPath("/3")));
+
+        // Assert that listener response equals to ReadResponse
+        ReadResponse readResp = helper.server.send(helper.getCurrentRegistration(),
+                new ReadRequest(ContentFormat.SENML_JSON, "/3"));
+        assertEquals(readResp.getContent(), content.get(new LwM2mPath("/3")));
+    }
+
+    @Test
+    public void can_passive_cancel_composite_observation() throws InterruptedException {
+        // Send ObserveCompositeRequest
+        ObserveCompositeResponse observeCompositeResponse = helper.server.send(currentRegistration,
+                new ObserveCompositeRequest(ContentFormat.SENML_JSON, ContentFormat.SENML_JSON, "/3/0/15"));
+
+        CompositeObservation observation = observeCompositeResponse.getObservation();
+
+        // Write single example value
+        LwM2mResponse writeResponse = helper.server
+                .send(helper.getCurrentRegistration(), new WriteRequest(3, 0, 15, "Europe/Paris"));
+        listener.waitForNotification(2000);
+        assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
+
+        // cancel observation : passive way
+        helper.server.getObservationService().cancelObservation(observation);
+        Set<Observation> observations =
+                helper.server.getObservationService().getObservations(helper.getCurrentRegistration());
+        assertTrue("Observation should be removed", observations.isEmpty());
+
+        // write device timezone
+        listener.reset();
+
+        // Write single value
+        writeResponse = helper.server
+                .send(helper.getCurrentRegistration(), new WriteRequest(3, 0, 15, "Europe/London"));
+        listener.waitForNotification(2000);
+        assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
+
+        assertFalse("Observation should be cancelled", listener.receivedNotify().get());
+    }
+
+    @Test
+    public void can_active_cancel_composite_observation() throws InterruptedException {
+        // Send ObserveCompositeRequest
+        ObserveCompositeResponse observeCompositeResponse = helper.server.send(currentRegistration,
+                new ObserveCompositeRequest(ContentFormat.SENML_JSON, ContentFormat.SENML_JSON, "/3/0/15"));
+
+        CompositeObservation observation = observeCompositeResponse.getObservation();
+
+        // Write single example value
+        LwM2mResponse writeResponse = helper.server
+                .send(helper.getCurrentRegistration(), new WriteRequest(3, 0, 15, "Europe/Paris"));
+        listener.waitForNotification(2000);
+        assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
+
+        // cancel observation : active way
+        CancelCompositeObservationResponse response = helper.server.send(helper.getCurrentRegistration(),
+                new CancelCompositeObservationRequest(observation));
+        assertTrue(response.isSuccess());
+        assertEquals(ResponseCode.CONTENT, response.getCode());
+
+        // Assert that response contains exactly the same paths
+        assertTrue(listener.receivedNotify().get());
+        Map<LwM2mPath, LwM2mNode> content = listener.getObserveCompositeResponse().getContent();
+        assertEquals(1, content.size());
+        assertTrue(content.containsKey(new LwM2mPath("/3/0/15")));
+
+        // Assert that listener response contains exact values
+        assertEquals(LwM2mSingleResource.newStringResource(new LwM2mPath("/3/0/15").getResourceId(), "Europe/Paris"),
+                content.get(new LwM2mPath("/3/0/15")));
+
+        // active cancellation does not remove observation from store : it should be done manually using
+        // ObservationService().cancelObservation(observation)
+
+        // Assert that there is one valid observation
+        assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
+        Set<Observation> observations = helper.server.getObservationService()
+                .getObservations(helper.getCurrentRegistration());
+        assertEquals("We should have only one observation", 1, observations.size());
+        assertTrue("New observation is not there", observations.contains(observation));
+
+        // Write device timezone
+        listener.reset();
+
+        writeResponse = helper.server
+                .send(helper.getCurrentRegistration(), new WriteRequest(3, 0, 15, "Europe/London"));
+        listener.waitForNotification(2000);
+        assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
+
+        assertFalse("Observation should be cancelled", listener.receivedNotify().get());
+    }
+
+}
diff --git a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/observe/ObserveTest.java b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/observe/ObserveTest.java
index 200e115f..c045f315 100644
--- a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/observe/ObserveTest.java
+++ b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/observe/ObserveTest.java
@@ -38,6 +38,7 @@ import org.eclipse.leshan.core.node.LwM2mSingleResource;
 import org.eclipse.leshan.core.node.codec.LwM2mValueChecker;
 import org.eclipse.leshan.core.node.codec.json.LwM2mNodeJsonEncoder;
 import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.observation.SingleObservation;
 import org.eclipse.leshan.core.request.CancelObservationRequest;
 import org.eclipse.leshan.core.request.ContentFormat;
 import org.eclipse.leshan.core.request.ObserveRequest;
@@ -87,12 +88,12 @@ public class ObserveTest {
         assertThat(observeResponse.getCoapResponse(), is(instanceOf(Response.class)));
 
         // an observation response should have been sent
-        Observation observation = observeResponse.getObservation();
+        SingleObservation observation = observeResponse.getObservation();
         assertEquals("/3/0/15", observation.getPath().toString());
         assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
         Set<Observation> observations = helper.server.getObservationService()
                 .getObservations(helper.getCurrentRegistration());
-        assertTrue("We should have only on observation", observations.size() == 1);
+        assertEquals("We should have only one observation", 1, observations.size());
         assertTrue("New observation is not there", observations.contains(observation));
 
         // write device timezone
@@ -103,9 +104,10 @@ public class ObserveTest {
         listener.waitForNotification(2000);
         assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
         assertTrue(listener.receivedNotify().get());
-        assertEquals(LwM2mSingleResource.newStringResource(15, "Europe/Paris"), listener.getResponse().getContent());
-        assertNotNull(listener.getResponse().getCoapResponse());
-        assertThat(listener.getResponse().getCoapResponse(), is(instanceOf(Response.class)));
+        assertEquals(LwM2mSingleResource.newStringResource(15, "Europe/Paris"),
+                (listener.getObserveResponse()).getContent());
+        assertNotNull(listener.getObserveResponse().getCoapResponse());
+        assertThat(listener.getObserveResponse().getCoapResponse(), is(instanceOf(Response.class)));
     }
 
     @Test
@@ -122,7 +124,7 @@ public class ObserveTest {
         assertThat(observeResponse.getCoapResponse(), is(instanceOf(Response.class)));
 
         // an observation response should have been sent
-        Observation observation = observeResponse.getObservation();
+        SingleObservation observation = observeResponse.getObservation();
         assertEquals(expectedPath, observation.getPath().toString());
         assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
         Set<Observation> observations = helper.server.getObservationService()
@@ -138,9 +140,10 @@ public class ObserveTest {
         listener.waitForNotification(2000);
         assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
         assertTrue(listener.receivedNotify().get());
-        assertEquals(LwM2mResourceInstance.newStringInstance(0, "a new string"), listener.getResponse().getContent());
-        assertNotNull(listener.getResponse().getCoapResponse());
-        assertThat(listener.getResponse().getCoapResponse(), is(instanceOf(Response.class)));
+        assertEquals(LwM2mResourceInstance.newStringInstance(0, "a new string"),
+                listener.getObserveResponse().getContent());
+        assertNotNull(listener.getObserveResponse().getCoapResponse());
+        assertThat(listener.getObserveResponse().getCoapResponse(), is(instanceOf(Response.class)));
     }
 
     @Test
@@ -157,7 +160,7 @@ public class ObserveTest {
         assertThat(observeResponse.getCoapResponse(), is(instanceOf(Response.class)));
 
         // an observation response should have been sent
-        Observation observation = observeResponse.getObservation();
+        SingleObservation observation = observeResponse.getObservation();
         assertEquals(expectedPath, observation.getPath().toString());
         assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
         Set<Observation> observations = helper.server.getObservationService()
@@ -173,8 +176,9 @@ public class ObserveTest {
         listener.waitForNotification(2000);
         assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
         assertTrue(listener.receivedNotify().get());
-        assertEquals(LwM2mResourceInstance.newStringInstance(0, "a new string"), listener.getResponse().getContent());
-        assertNotNull(listener.getResponse().getCoapResponse());
+        assertEquals(LwM2mResourceInstance.newStringInstance(0, "a new string"),
+                listener.getObserveResponse().getContent());
+        assertNotNull(listener.getObserveResponse().getCoapResponse());
 
         // cancel observation : passive way
         helper.server.getObservationService().cancelObservation(observation);
@@ -206,7 +210,7 @@ public class ObserveTest {
         assertThat(observeResponse.getCoapResponse(), is(instanceOf(Response.class)));
 
         // an observation response should have been sent
-        Observation observation = observeResponse.getObservation();
+        SingleObservation observation = observeResponse.getObservation();
         assertEquals(expectedPath, observation.getPath().toString());
         assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
         Set<Observation> observations = helper.server.getObservationService()
@@ -222,8 +226,9 @@ public class ObserveTest {
         listener.waitForNotification(2000);
         assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
         assertTrue(listener.receivedNotify().get());
-        assertEquals(LwM2mResourceInstance.newStringInstance(0, "a new string"), listener.getResponse().getContent());
-        assertNotNull(listener.getResponse().getCoapResponse());
+        assertEquals(LwM2mResourceInstance.newStringInstance(0, "a new string"),
+                listener.getObserveResponse().getContent());
+        assertNotNull(listener.getObserveResponse().getCoapResponse());
 
         // cancel observation : active way
         CancelObservationResponse response = helper.server.send(helper.getCurrentRegistration(),
@@ -261,12 +266,12 @@ public class ObserveTest {
         assertThat(observeResponse.getCoapResponse(), is(instanceOf(Response.class)));
 
         // an observation response should have been sent
-        Observation observation = observeResponse.getObservation();
+        SingleObservation observation = observeResponse.getObservation();
         assertEquals("/3/0/15", observation.getPath().toString());
         assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
         Set<Observation> observations = helper.server.getObservationService()
                 .getObservations(helper.getCurrentRegistration());
-        assertTrue("We should have only on observation", observations.size() == 1);
+        assertEquals("We should have only one observation", 1, observations.size());
         assertTrue("New observation is not there", observations.contains(observation));
 
         // write device timezone
@@ -277,9 +282,10 @@ public class ObserveTest {
         listener.waitForNotification(2000);
         assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
         assertTrue(listener.receivedNotify().get());
-        assertEquals(LwM2mSingleResource.newStringResource(15, "Europe/Paris"), listener.getResponse().getContent());
-        assertNotNull(listener.getResponse().getCoapResponse());
-        assertThat(listener.getResponse().getCoapResponse(), is(instanceOf(Response.class)));
+        assertEquals(LwM2mSingleResource.newStringResource(15, "Europe/Paris"),
+                listener.getObserveResponse().getContent());
+        assertNotNull(listener.getObserveResponse().getCoapResponse());
+        assertThat(listener.getObserveResponse().getCoapResponse(), is(instanceOf(Response.class)));
 
         // cancel observation : passive way
         helper.server.getObservationService().cancelObservation(observation);
@@ -310,12 +316,12 @@ public class ObserveTest {
         assertThat(observeResponse.getCoapResponse(), is(instanceOf(Response.class)));
 
         // an observation response should have been sent
-        Observation observation = observeResponse.getObservation();
+        SingleObservation observation = observeResponse.getObservation();
         assertEquals("/3/0/15", observation.getPath().toString());
         assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
         Set<Observation> observations = helper.server.getObservationService()
                 .getObservations(helper.getCurrentRegistration());
-        assertTrue("We should have only on observation", observations.size() == 1);
+        assertEquals("We should have only one observation", 1, observations.size());
         assertTrue("New observation is not there", observations.contains(observation));
 
         // write device timezone
@@ -326,9 +332,10 @@ public class ObserveTest {
         listener.waitForNotification(2000);
         assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
         assertTrue(listener.receivedNotify().get());
-        assertEquals(LwM2mSingleResource.newStringResource(15, "Europe/Paris"), listener.getResponse().getContent());
-        assertNotNull(listener.getResponse().getCoapResponse());
-        assertThat(listener.getResponse().getCoapResponse(), is(instanceOf(Response.class)));
+        assertEquals(LwM2mSingleResource.newStringResource(15, "Europe/Paris"),
+                listener.getObserveResponse().getContent());
+        assertNotNull(listener.getObserveResponse().getCoapResponse());
+        assertThat(listener.getObserveResponse().getCoapResponse(), is(instanceOf(Response.class)));
 
         // cancel observation : active way
         CancelObservationResponse response = helper.server.send(helper.getCurrentRegistration(),
@@ -339,7 +346,7 @@ public class ObserveTest {
         // active cancellation does not remove observation from store : it should be done manually using
         // ObservationService().cancelObservation(observation)
         observations = helper.server.getObservationService().getObservations(helper.getCurrentRegistration());
-        assertTrue("We should have only on observation", observations.size() == 1);
+        assertEquals("We should have only one observation", 1, observations.size());
         assertTrue("Observation should still be there", observations.contains(observation));
 
         // write device timezone
@@ -365,12 +372,12 @@ public class ObserveTest {
         assertThat(observeResponse.getCoapResponse(), is(instanceOf(Response.class)));
 
         // an observation response should have been sent
-        Observation observation = observeResponse.getObservation();
+        SingleObservation observation = observeResponse.getObservation();
         assertEquals("/3/0", observation.getPath().toString());
         assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
         Set<Observation> observations = helper.server.getObservationService()
                 .getObservations(helper.getCurrentRegistration());
-        assertTrue("We should have only on observation", observations.size() == 1);
+        assertEquals("We should have only one observation", 1, observations.size());
         assertTrue("New observation is not there", observations.contains(observation));
 
         // write device timezone
@@ -381,14 +388,14 @@ public class ObserveTest {
         listener.waitForNotification(2000);
         assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
         assertTrue(listener.receivedNotify().get());
-        assertTrue(listener.getResponse().getContent() instanceof LwM2mObjectInstance);
-        assertNotNull(listener.getResponse().getCoapResponse());
-        assertThat(listener.getResponse().getCoapResponse(), is(instanceOf(Response.class)));
+        assertTrue(listener.getObserveResponse().getContent() instanceof LwM2mObjectInstance);
+        assertNotNull(listener.getObserveResponse().getCoapResponse());
+        assertThat(listener.getObserveResponse().getCoapResponse(), is(instanceOf(Response.class)));
 
         // try to read the object instance for comparing
         ReadResponse readResp = helper.server.send(helper.getCurrentRegistration(), new ReadRequest(3, 0));
 
-        assertEquals(readResp.getContent(), listener.getResponse().getContent());
+        assertEquals(readResp.getContent(), listener.getObserveResponse().getContent());
     }
 
     @Test
@@ -403,12 +410,12 @@ public class ObserveTest {
         assertThat(observeResponse.getCoapResponse(), is(instanceOf(Response.class)));
 
         // an observation response should have been sent
-        Observation observation = observeResponse.getObservation();
+        SingleObservation observation = observeResponse.getObservation();
         assertEquals("/3", observation.getPath().toString());
         assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
         Set<Observation> observations = helper.server.getObservationService()
                 .getObservations(helper.getCurrentRegistration());
-        assertTrue("We should have only on observation", observations.size() == 1);
+        assertEquals("We should have only one observation", 1, observations.size());
         assertTrue("New observation is not there", observations.contains(observation));
 
         // write device timezone
@@ -419,14 +426,14 @@ public class ObserveTest {
         listener.waitForNotification(2000);
         assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
         assertTrue(listener.receivedNotify().get());
-        assertTrue(listener.getResponse().getContent() instanceof LwM2mObject);
-        assertNotNull(listener.getResponse().getCoapResponse());
-        assertThat(listener.getResponse().getCoapResponse(), is(instanceOf(Response.class)));
+        assertTrue(listener.getObserveResponse().getContent() instanceof LwM2mObject);
+        assertNotNull(listener.getObserveResponse().getCoapResponse());
+        assertThat(listener.getObserveResponse().getCoapResponse(), is(instanceOf(Response.class)));
 
         // try to read the object for comparing
         ReadResponse readResp = helper.server.send(helper.getCurrentRegistration(), new ReadRequest(3));
 
-        assertEquals(readResp.getContent(), listener.getResponse().getContent());
+        assertEquals(readResp.getContent(), listener.getObserveResponse().getContent());
     }
 
     @Test
@@ -442,12 +449,12 @@ public class ObserveTest {
         assertThat(observeResponse.getCoapResponse(), is(instanceOf(Response.class)));
 
         // an observation response should have been sent
-        Observation observation = observeResponse.getObservation();
+        SingleObservation observation = observeResponse.getObservation();
         assertEquals("/3/0/15", observation.getPath().toString());
         assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
         Set<Observation> observations = helper.server.getObservationService()
                 .getObservations(helper.getCurrentRegistration());
-        assertTrue("We should have only on observation", observations.size() == 1);
+        assertEquals("We should have only one observation", 1, observations.size());
         assertTrue("New observation is not there", observations.contains(observation));
 
         // *** HACK send a notification with unsupported content format *** //
diff --git a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/observe/ObserveTimeStampTest.java b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/observe/ObserveTimeStampTest.java
index d10092cd..affa68c2 100644
--- a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/observe/ObserveTimeStampTest.java
+++ b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/observe/ObserveTimeStampTest.java
@@ -38,6 +38,7 @@ import org.eclipse.leshan.core.node.TimestampedLwM2mNode;
 import org.eclipse.leshan.core.node.codec.DefaultLwM2mEncoder;
 import org.eclipse.leshan.core.node.codec.LwM2mEncoder;
 import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.observation.SingleObservation;
 import org.eclipse.leshan.core.request.ContentFormat;
 import org.eclipse.leshan.core.request.ObserveRequest;
 import org.eclipse.leshan.core.response.ObserveResponse;
@@ -55,9 +56,9 @@ public class ObserveTimeStampTest {
     @Parameters(name = "{0}")
     public static Collection<?> contentFormats() {
         return Arrays.asList(new Object[][] { //
-                                { ContentFormat.JSON }, //
-                                { ContentFormat.SENML_JSON }, //
-                                { ContentFormat.SENML_CBOR } });
+                { ContentFormat.JSON }, //
+                { ContentFormat.SENML_JSON }, //
+                { ContentFormat.SENML_CBOR } });
     }
 
     protected IntegrationTestHelper helper = new IntegrationTestHelper();
@@ -99,12 +100,12 @@ public class ObserveTimeStampTest {
         assertThat(observeResponse.getCoapResponse(), is(instanceOf(Response.class)));
 
         // an observation response should have been sent
-        Observation observation = observeResponse.getObservation();
+        SingleObservation observation = observeResponse.getObservation();
         assertEquals("/3/0/15", observation.getPath().toString());
         assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
         Set<Observation> observations = helper.server.getObservationService()
                 .getObservations(helper.getCurrentRegistration());
-        assertTrue("We should have only on observation", observations.size() == 1);
+        assertTrue("We should have only one observation", observations.size() == 1);
         assertTrue("New observation is not there", observations.contains(observation));
 
         // *** HACK send time-stamped notification as Leshan client does not support it *** //
@@ -125,9 +126,9 @@ public class ObserveTimeStampTest {
         // verify result
         listener.waitForNotification(2000);
         assertTrue(listener.receivedNotify().get());
-        assertEquals(mostRecentNode.getNode(), listener.getResponse().getContent());
-        assertEquals(timestampedNodes, listener.getResponse().getTimestampedLwM2mNode());
-        assertContentFormat(contentFormat, listener.getResponse());
+        assertEquals(mostRecentNode.getNode(), listener.getObserveResponse().getContent());
+        assertEquals(timestampedNodes, listener.getObserveResponse().getTimestampedLwM2mNode());
+        assertContentFormat(contentFormat, listener.getObserveResponse());
     }
 
     @Test
@@ -142,12 +143,12 @@ public class ObserveTimeStampTest {
         assertThat(observeResponse.getCoapResponse(), is(instanceOf(Response.class)));
 
         // an observation response should have been sent
-        Observation observation = observeResponse.getObservation();
+        SingleObservation observation = observeResponse.getObservation();
         assertEquals("/3/0", observation.getPath().toString());
         assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
         Set<Observation> observations = helper.server.getObservationService()
                 .getObservations(helper.getCurrentRegistration());
-        assertTrue("We should have only on observation", observations.size() == 1);
+        assertTrue("We should have only one observation", observations.size() == 1);
         assertTrue("New observation is not there", observations.contains(observation));
 
         // *** HACK send time-stamped notification as Leshan client does not support it *** //
@@ -168,9 +169,9 @@ public class ObserveTimeStampTest {
         // verify result
         listener.waitForNotification(2000);
         assertTrue(listener.receivedNotify().get());
-        assertEquals(mostRecentNode.getNode(), listener.getResponse().getContent());
-        assertEquals(timestampedNodes, listener.getResponse().getTimestampedLwM2mNode());
-        assertContentFormat(contentFormat, listener.getResponse());
+        assertEquals(mostRecentNode.getNode(), listener.getObserveResponse().getContent());
+        assertEquals(timestampedNodes, listener.getObserveResponse().getTimestampedLwM2mNode());
+        assertContentFormat(contentFormat, listener.getObserveResponse());
     }
 
     @Test
@@ -185,12 +186,12 @@ public class ObserveTimeStampTest {
         assertThat(observeResponse.getCoapResponse(), is(instanceOf(Response.class)));
 
         // an observation response should have been sent
-        Observation observation = observeResponse.getObservation();
+        SingleObservation observation = observeResponse.getObservation();
         assertEquals("/3", observation.getPath().toString());
         assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
         Set<Observation> observations = helper.server.getObservationService()
                 .getObservations(helper.getCurrentRegistration());
-        assertTrue("We should have only on observation", observations.size() == 1);
+        assertTrue("We should have only one observation", observations.size() == 1);
         assertTrue("New observation is not there", observations.contains(observation));
 
         // *** HACK send time-stamped notification as Leshan client does not support it *** //
@@ -212,8 +213,8 @@ public class ObserveTimeStampTest {
         // verify result
         listener.waitForNotification(2000);
         assertTrue(listener.receivedNotify().get());
-        assertEquals(mostRecentNode.getNode(), listener.getResponse().getContent());
-        assertEquals(timestampedNodes, listener.getResponse().getTimestampedLwM2mNode());
-        assertContentFormat(contentFormat, listener.getResponse());
+        assertEquals(mostRecentNode.getNode(), listener.getObserveResponse().getContent());
+        assertEquals(timestampedNodes, listener.getObserveResponse().getTimestampedLwM2mNode());
+        assertContentFormat(contentFormat, listener.getObserveResponse());
     }
 }
diff --git a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/observe/TestObservationListener.java b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/observe/TestObservationListener.java
index 6b976515..75333b33 100644
--- a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/observe/TestObservationListener.java
+++ b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/observe/TestObservationListener.java
@@ -5,7 +5,10 @@ import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
 
+import org.eclipse.leshan.core.observation.CompositeObservation;
 import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.observation.SingleObservation;
+import org.eclipse.leshan.core.response.ObserveCompositeResponse;
 import org.eclipse.leshan.core.response.ObserveResponse;
 import org.eclipse.leshan.server.observation.ObservationListener;
 import org.eclipse.leshan.server.registration.Registration;
@@ -15,13 +18,24 @@ public class TestObservationListener implements ObservationListener {
     private CountDownLatch latch = new CountDownLatch(1);
     private final AtomicBoolean receivedNotify = new AtomicBoolean();
     private AtomicInteger counter = new AtomicInteger(0);
-    private ObserveResponse response;
+    private ObserveResponse observeResponse;
+    private ObserveCompositeResponse observeCompositeResponse;
     private Exception error;
 
     @Override
-    public void onResponse(Observation observation, Registration registration, ObserveResponse response) {
+    public void onResponse(SingleObservation observation, Registration registration, ObserveResponse response) {
         receivedNotify.set(true);
-        this.response = response;
+        this.observeResponse = response;
+        this.error = null;
+        this.counter.incrementAndGet();
+        latch.countDown();
+    }
+
+    @Override
+    public void onResponse(CompositeObservation observation, Registration registration,
+            ObserveCompositeResponse response) {
+        receivedNotify.set(true);
+        this.observeCompositeResponse = response;
         this.error = null;
         this.counter.incrementAndGet();
         latch.countDown();
@@ -30,7 +44,8 @@ public class TestObservationListener implements ObservationListener {
     @Override
     public void onError(Observation observation, Registration registration, Exception error) {
         receivedNotify.set(true);
-        this.response = null;
+        this.observeResponse = null;
+        this.observeCompositeResponse = null;
         this.error = error;
         latch.countDown();
     }
@@ -48,8 +63,12 @@ public class TestObservationListener implements ObservationListener {
         return receivedNotify;
     }
 
-    public ObserveResponse getResponse() {
-        return response;
+    public ObserveResponse getObserveResponse() {
+        return observeResponse;
+    }
+
+    public ObserveCompositeResponse getObserveCompositeResponse() {
+        return observeCompositeResponse;
     }
 
     public Exception getError() {
@@ -67,7 +86,8 @@ public class TestObservationListener implements ObservationListener {
     public void reset() {
         latch = new CountDownLatch(1);
         receivedNotify.set(false);
-        response = null;
+        this.observeResponse = null;
+        this.observeCompositeResponse = null;
         error = null;
         this.counter = new AtomicInteger(0);
     }
diff --git a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/server/redis/RedisRegistrationStoreTest.java b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/server/redis/RedisRegistrationStoreTest.java
new file mode 100644
index 00000000..0a6ba5b6
--- /dev/null
+++ b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/server/redis/RedisRegistrationStoreTest.java
@@ -0,0 +1,163 @@
+/*******************************************************************************
+ * Copyright (c) 2021 Orange.
+ * 
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * and Eclipse Distribution License v1.0 which accompany this distribution.
+ * 
+ * The Eclipse Public License is available at
+ *    http://www.eclipse.org/legal/epl-v20.html
+ * and the Eclipse Distribution License is available at
+ *    http://www.eclipse.org/org/documents/edl-v10.html.
+ * 
+ * Contributors:
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
+ *******************************************************************************/
+package org.eclipse.leshan.integration.tests.server.redis;
+
+import static org.junit.Assert.*;
+
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.UnknownHostException;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.californium.core.coap.CoAP;
+import org.eclipse.californium.core.coap.Request;
+import org.eclipse.californium.core.coap.Token;
+import org.eclipse.californium.elements.AddressEndpointContext;
+import org.eclipse.leshan.core.Link;
+import org.eclipse.leshan.core.californium.ObserveUtil;
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.observation.CompositeObservation;
+import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.observation.SingleObservation;
+import org.eclipse.leshan.core.request.BindingMode;
+import org.eclipse.leshan.core.request.ContentFormat;
+import org.eclipse.leshan.core.request.Identity;
+import org.eclipse.leshan.core.request.ObserveCompositeRequest;
+import org.eclipse.leshan.core.request.ObserveRequest;
+import org.eclipse.leshan.integration.tests.util.RedisIntegrationTestHelper;
+import org.eclipse.leshan.server.californium.registration.CaliforniumRegistrationStore;
+import org.eclipse.leshan.server.redis.RedisRegistrationStore;
+import org.eclipse.leshan.server.registration.Registration;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class RedisRegistrationStoreTest {
+
+    private final String ep = "urn:endpoint";
+    private final int port = 23452;
+    private final Long lifetime = 10000L;
+    private final String sms = "0171-32423545";
+    private final EnumSet<BindingMode> binding = EnumSet.of(BindingMode.U, BindingMode.Q, BindingMode.S);
+    private final Link[] objectLinks = Link.parse("</3>".getBytes(StandardCharsets.UTF_8));
+    private final String registrationId = "4711";
+    private final Token exampleToken = Token.EMPTY;
+
+    CaliforniumRegistrationStore store;
+    InetAddress address;
+    Registration registration;
+
+    RedisIntegrationTestHelper helper;
+
+    @Before
+    public void setUp() throws UnknownHostException {
+        helper = new RedisIntegrationTestHelper();
+        address = InetAddress.getLocalHost();
+        store = new RedisRegistrationStore(helper.createJedisPool());
+    }
+
+    @After
+    public void stop() {
+        store.removeRegistration(registrationId);
+    }
+
+    @Test
+    public void get_observation_from_request() {
+        // given
+        String examplePath = "/1/2/3";
+
+        givenASimpleRegistration(lifetime);
+        store.addRegistration(registration);
+
+        org.eclipse.californium.core.observe.Observation observationToStore = prepareCoapObservationOnSingle(
+                examplePath);
+
+        // when
+        store.put(exampleToken, observationToStore);
+
+        // then
+        Observation leshanObservation = store.getObservation(registrationId, exampleToken.getBytes());
+        assertNotNull(leshanObservation);
+        assertTrue(leshanObservation instanceof SingleObservation);
+        SingleObservation observation = (SingleObservation) leshanObservation;
+        assertEquals(examplePath, observation.getPath().toString());
+    }
+
+    @Test
+    public void get_composite_observation_from_request() {
+        // given
+        List<LwM2mPath> examplePaths = Arrays.asList(new LwM2mPath("/1/2/3"), new LwM2mPath("/4/5/6"));
+        Token exampleToken = Token.EMPTY;
+
+        givenASimpleRegistration(lifetime);
+        store.addRegistration(registration);
+
+        org.eclipse.californium.core.observe.Observation observationToStore = prepareCoapObservationOnComposite(
+                examplePaths);
+
+        // when
+        store.put(exampleToken, observationToStore);
+
+        // then
+        Observation leshanObservation = store.getObservation(registrationId, exampleToken.getBytes());
+        assertNotNull(leshanObservation);
+        assertTrue(leshanObservation instanceof CompositeObservation);
+        CompositeObservation observation = (CompositeObservation) leshanObservation;
+        assertEquals(examplePaths, observation.getPaths());
+    }
+
+    private void givenASimpleRegistration(Long lifetime) {
+        Registration.Builder builder = new Registration.Builder(registrationId, ep, Identity.unsecure(address, port));
+
+        registration = builder.lifeTimeInSec(lifetime).smsNumber(sms).bindingMode(binding).objectLinks(objectLinks)
+                .build();
+    }
+
+    private org.eclipse.californium.core.observe.Observation prepareCoapObservationOnSingle(String path) {
+        ObserveRequest observeRequest = new ObserveRequest(null, path);
+
+        Map<String, String> userContext = ObserveUtil.createCoapObserveRequestContext(ep, registrationId,
+                observeRequest);
+
+        return prepareCoapObservation(new Request(CoAP.Code.GET), userContext);
+    }
+
+    private org.eclipse.californium.core.observe.Observation prepareCoapObservationOnComposite(List<LwM2mPath> paths) {
+        ObserveCompositeRequest observeRequest = new ObserveCompositeRequest(null, null, paths);
+
+        Map<String, String> userContext = ObserveUtil.createCoapObserveCompositeRequestContext(ep, registrationId,
+                observeRequest);
+
+        return prepareCoapObservation(new Request(CoAP.Code.FETCH), userContext);
+    }
+
+    private org.eclipse.californium.core.observe.Observation prepareCoapObservation(Request coapRequest,
+            Map<String, String> userContext) {
+        coapRequest.setUserContext(userContext);
+        coapRequest.setToken(exampleToken);
+        coapRequest.setObserve();
+        coapRequest.getOptions().setAccept(ContentFormat.DEFAULT.getCode());
+        coapRequest.setMID(1);
+
+        coapRequest.setDestinationContext(new AddressEndpointContext(new InetSocketAddress(address, port)));
+
+        return new org.eclipse.californium.core.observe.Observation(coapRequest, null);
+    }
+}
diff --git a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/util/RedisIntegrationTestHelper.java b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/util/RedisIntegrationTestHelper.java
index 7a5fc250..07fa2944 100644
--- a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/util/RedisIntegrationTestHelper.java
+++ b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/util/RedisIntegrationTestHelper.java
@@ -41,10 +41,7 @@ public class RedisIntegrationTestHelper extends IntegrationTestHelper {
         builder.setSecurityStore(new InMemorySecurityStore());
 
         // Create redis store
-        String redisURI = System.getenv("REDIS_URI");
-        if (redisURI == null)
-            redisURI = "";
-        Pool<Jedis> jedis = new JedisPool(redisURI);
+        Pool<Jedis> jedis = createJedisPool();
         builder.setRegistrationStore(new RedisRegistrationStore(jedis));
 
         // Build server !
@@ -52,4 +49,11 @@ public class RedisIntegrationTestHelper extends IntegrationTestHelper {
         // monitor client registration
         setupServerMonitoring();
     }
+
+    public Pool<Jedis> createJedisPool() {
+        String redisURI = System.getenv("REDIS_URI");
+        if (redisURI == null)
+            redisURI = "";
+        return new JedisPool(redisURI);
+    }
 }
diff --git a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/write/WriteCompositeTest.java b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/write/WriteCompositeTest.java
index a8a46a1a..53f15745 100644
--- a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/write/WriteCompositeTest.java
+++ b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/write/WriteCompositeTest.java
@@ -33,7 +33,7 @@ import org.eclipse.leshan.core.node.LwM2mObjectInstance;
 import org.eclipse.leshan.core.node.LwM2mPath;
 import org.eclipse.leshan.core.node.LwM2mResourceInstance;
 import org.eclipse.leshan.core.node.LwM2mSingleResource;
-import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.observation.SingleObservation;
 import org.eclipse.leshan.core.request.ContentFormat;
 import org.eclipse.leshan.core.request.ObserveRequest;
 import org.eclipse.leshan.core.request.ReadRequest;
@@ -178,7 +178,7 @@ public class WriteCompositeTest {
         assertThat(observeResponse.getCoapResponse(), is(instanceOf(Response.class)));
 
         // an observation response should have been sent
-        Observation observation = observeResponse.getObservation();
+        SingleObservation observation = observeResponse.getObservation();
         assertEquals("/3/0", observation.getPath().toString());
         assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
 
@@ -194,11 +194,11 @@ public class WriteCompositeTest {
         listener.waitForNotification(1000);
         assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
         assertTrue(listener.receivedNotify().get());
-        assertTrue(listener.getResponse().getContent() instanceof LwM2mObjectInstance);
-        assertNotNull(listener.getResponse().getCoapResponse());
-        assertThat(listener.getResponse().getCoapResponse(), is(instanceOf(Response.class)));
+        assertTrue(listener.getObserveResponse().getContent() instanceof LwM2mObjectInstance);
+        assertNotNull(listener.getObserveResponse().getCoapResponse());
+        assertThat(listener.getObserveResponse().getCoapResponse(), is(instanceOf(Response.class)));
 
-        LwM2mObjectInstance instance = (LwM2mObjectInstance) listener.getResponse().getContent();
+        LwM2mObjectInstance instance = (LwM2mObjectInstance) listener.getObserveResponse().getContent();
         assertEquals("+11", instance.getResource(14).getValue());
         assertEquals("Moon", instance.getResource(15).getValue());
 
diff --git a/leshan-server-cf/src/test/java/org/eclipse/leshan/server/californium/DummyDecoder.java b/leshan-server-cf/src/test/java/org/eclipse/leshan/server/californium/DummyDecoder.java
new file mode 100644
index 00000000..dd6304b0
--- /dev/null
+++ b/leshan-server-cf/src/test/java/org/eclipse/leshan/server/californium/DummyDecoder.java
@@ -0,0 +1,71 @@
+/*******************************************************************************
+ * Copyright (c) 2021 Orange.
+ * 
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * and Eclipse Distribution License v1.0 which accompany this distribution.
+ * 
+ * The Eclipse Public License is available at
+ *    http://www.eclipse.org/legal/epl-v20.html
+ * and the Eclipse Distribution License is available at
+ *    http://www.eclipse.org/org/documents/edl-v10.html.
+ * 
+ * Contributors:
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
+ *******************************************************************************/
+package org.eclipse.leshan.server.californium;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.eclipse.leshan.core.model.LwM2mModel;
+import org.eclipse.leshan.core.node.LwM2mNode;
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.node.LwM2mSingleResource;
+import org.eclipse.leshan.core.node.TimestampedLwM2mNode;
+import org.eclipse.leshan.core.node.codec.CodecException;
+import org.eclipse.leshan.core.node.codec.LwM2mDecoder;
+import org.eclipse.leshan.core.request.ContentFormat;
+
+public class DummyDecoder implements LwM2mDecoder {
+    @Override
+    public LwM2mNode decode(byte[] content, ContentFormat format, LwM2mPath path, LwM2mModel model)
+            throws CodecException {
+        return LwM2mSingleResource.newResource(15, "Example");
+    }
+
+    @Override
+    public <T extends LwM2mNode> T decode(byte[] content, ContentFormat format, LwM2mPath path, LwM2mModel model,
+            Class<T> nodeClass) throws CodecException {
+        return null;
+    }
+
+    @Override
+    public Map<LwM2mPath, LwM2mNode> decodeNodes(byte[] content, ContentFormat format, List<LwM2mPath> paths,
+            LwM2mModel model) throws CodecException {
+        return null;
+    }
+
+    @Override
+    public List<TimestampedLwM2mNode> decodeTimestampedData(byte[] content, ContentFormat format, LwM2mPath path,
+            LwM2mModel model) throws CodecException {
+        return Collections.singletonList(new TimestampedLwM2mNode(null, decode(null, null, null, null)));
+    }
+
+    @Override
+    public List<LwM2mPath> decodePaths(byte[] content, ContentFormat format) throws CodecException {
+        return null;
+    }
+
+    @Override
+    public boolean isSupported(ContentFormat format) {
+        return false;
+    }
+
+    @Override
+    public Set<ContentFormat> getSupportedContentFormat() {
+        return null;
+    }
+}
diff --git a/leshan-server-cf/src/test/java/org/eclipse/leshan/server/californium/observation/ObservationServiceTest.java b/leshan-server-cf/src/test/java/org/eclipse/leshan/server/californium/observation/ObservationServiceTest.java
index 1101952f..862c02b1 100644
--- a/leshan-server-cf/src/test/java/org/eclipse/leshan/server/californium/observation/ObservationServiceTest.java
+++ b/leshan-server-cf/src/test/java/org/eclipse/leshan/server/californium/observation/ObservationServiceTest.java
@@ -12,25 +12,39 @@
  * 
  * Contributors:
  *     Sierra Wireless - initial API and implementation
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
  *******************************************************************************/
 package org.eclipse.leshan.server.californium.observation;
 
+import static org.junit.Assert.*;
+
 import java.net.InetAddress;
 import java.net.UnknownHostException;
 import java.util.Map;
 import java.util.Set;
 
+import org.eclipse.californium.core.coap.CoAP;
 import org.eclipse.californium.core.coap.Request;
+import org.eclipse.californium.core.coap.Response;
 import org.eclipse.leshan.core.californium.EndpointContextUtil;
+import org.eclipse.leshan.core.californium.ObserveUtil;
 import org.eclipse.leshan.core.node.LwM2mPath;
 import org.eclipse.leshan.core.node.codec.DefaultLwM2mDecoder;
+import org.eclipse.leshan.core.observation.CompositeObservation;
 import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.observation.SingleObservation;
 import org.eclipse.leshan.core.request.Identity;
+import org.eclipse.leshan.core.request.ObserveCompositeRequest;
 import org.eclipse.leshan.core.request.ObserveRequest;
+import org.eclipse.leshan.core.response.AbstractLwM2mResponse;
+import org.eclipse.leshan.core.response.ObserveCompositeResponse;
+import org.eclipse.leshan.core.response.ObserveResponse;
 import org.eclipse.leshan.server.californium.CaliforniumTestSupport;
+import org.eclipse.leshan.server.californium.DummyDecoder;
 import org.eclipse.leshan.server.californium.registration.CaliforniumRegistrationStore;
 import org.eclipse.leshan.server.californium.registration.InMemoryRegistrationStore;
 import org.eclipse.leshan.server.model.StandardModelProvider;
+import org.eclipse.leshan.server.observation.ObservationListener;
 import org.eclipse.leshan.server.registration.Registration;
 import org.junit.Assert;
 import org.junit.Before;
@@ -39,22 +53,21 @@ import org.junit.Test;
 public class ObservationServiceTest {
 
     Request coapRequest;
-    LwM2mPath target;
     ObservationServiceImpl observationService;
     CaliforniumRegistrationStore store;
 
-    private CaliforniumTestSupport support = new CaliforniumTestSupport();
+    private final CaliforniumTestSupport support = new CaliforniumTestSupport();
 
     @Before
     public void setUp() throws Exception {
         support.givenASimpleClient();
         store = new InMemoryRegistrationStore();
-        observationService = new ObservationServiceImpl(store, new StandardModelProvider(),
-                new DefaultLwM2mDecoder());
     }
 
     @Test
     public void observe_twice_cancels_first() {
+        createDefaultObservationService();
+
         givenAnObservation(support.registration.getId(), new LwM2mPath(3, 0, 12));
         givenAnObservation(support.registration.getId(), new LwM2mPath(3, 0, 12));
 
@@ -65,6 +78,8 @@ public class ObservationServiceTest {
 
     @Test
     public void cancel_by_client() {
+        createDefaultObservationService();
+
         // create some observations
         givenAnObservation(support.registration.getId(), new LwM2mPath(3, 0, 13));
         givenAnObservation(support.registration.getId(), new LwM2mPath(3, 0, 12));
@@ -81,11 +96,13 @@ public class ObservationServiceTest {
 
         // check its absence
         observations = observationService.getObservations(support.registration);
-        Assert.assertTrue(observations.isEmpty());
+        assertTrue(observations.isEmpty());
     }
 
     @Test
     public void cancel_by_path() {
+        createDefaultObservationService();
+
         // create some observations
         givenAnObservation(support.registration.getId(), new LwM2mPath(3, 0, 13));
         givenAnObservation(support.registration.getId(), new LwM2mPath(3, 0, 12));
@@ -107,7 +124,9 @@ public class ObservationServiceTest {
     }
 
     @Test
-    public void cancel_by_observation() throws UnknownHostException {
+    public void cancel_by_observation() {
+        createDefaultObservationService();
+
         // create some observations
         givenAnObservation(support.registration.getId(), new LwM2mPath(3, 0, 13));
         givenAnObservation(support.registration.getId(), new LwM2mPath(3, 0, 12));
@@ -127,6 +146,62 @@ public class ObservationServiceTest {
         Assert.assertEquals(1, observations.size());
     }
 
+    @Test
+    public void on_notification_observe_response() {
+        // given
+        createDummyDecoderObservationService();
+
+        givenAnObservation(support.registration.getId(), new LwM2mPath("/1/2/3"));
+
+        Response coapResponse = new Response(CoAP.ResponseCode.CONTENT);
+        coapResponse.setToken(coapRequest.getToken());
+
+        CatchResponseObservationListener listener = new CatchResponseObservationListener();
+
+        observationService.addListener(listener);
+
+        // when
+        observationService.onNotification(coapRequest, coapResponse);
+
+        // then
+        assertNotNull(listener.observeResponse);
+        assertNotNull(listener.observation);
+        assertTrue(listener.observeResponse instanceof ObserveResponse);
+        assertTrue(listener.observation instanceof SingleObservation);
+    }
+
+    @Test
+    public void on_notification_composite_observe_response() {
+        // given
+        createDummyDecoderObservationService();
+
+        givenAnCompositeObservation(support.registration.getId(), new LwM2mPath("/1/2/3"));
+
+        Response coapResponse = new Response(CoAP.ResponseCode.CONTENT);
+        coapResponse.setToken(coapRequest.getToken());
+
+        CatchResponseObservationListener listener = new CatchResponseObservationListener();
+
+        observationService.addListener(listener);
+
+        // when
+        observationService.onNotification(coapRequest, coapResponse);
+
+        // then
+        assertNotNull(listener.observeResponse);
+        assertNotNull(listener.observation);
+        assertTrue(listener.observeResponse instanceof ObserveCompositeResponse);
+        assertTrue(listener.observation instanceof CompositeObservation);
+    }
+
+    private void createDummyDecoderObservationService() {
+        observationService = new ObservationServiceImpl(store, new StandardModelProvider(), new DummyDecoder());
+    }
+
+    private void createDefaultObservationService() {
+        observationService = new ObservationServiceImpl(store, new StandardModelProvider(), new DefaultLwM2mDecoder());
+    }
+
     private Observation givenAnObservation(String registrationId, LwM2mPath target) {
         Registration registration = store.getRegistration(registrationId);
         if (registration == null) {
@@ -148,13 +223,36 @@ public class ObservationServiceTest {
 
         store.put(coapRequest.getToken(), new org.eclipse.californium.core.observe.Observation(coapRequest, null));
 
-        Observation observation = ObserveUtil.createLwM2mObservation(coapRequest);
+        SingleObservation observation = ObserveUtil.createLwM2mObservation(coapRequest);
         observationService.addObservation(registration, observation);
 
         return observation;
     }
 
-    public Registration givenASimpleClient(String registrationId) {
+    private Observation givenAnCompositeObservation(String registrationId, LwM2mPath target) {
+        Registration registration = store.getRegistration(registrationId);
+        if (registration == null) {
+            registration = givenASimpleClient(registrationId);
+            store.addRegistration(registration);
+        }
+
+        coapRequest = Request.newFetch();
+        coapRequest.setToken(CaliforniumTestSupport.createToken());
+        coapRequest.setObserve();
+        coapRequest
+                .setDestinationContext(EndpointContextUtil.extractContext(support.registration.getIdentity(), false));
+        Map<String, String> context = ObserveUtil.createCoapObserveCompositeRequestContext(registration.getEndpoint(),
+                registrationId, new ObserveCompositeRequest(null, null, target.toString()));
+        coapRequest.setUserContext(context);
+
+        store.put(coapRequest.getToken(), new org.eclipse.californium.core.observe.Observation(coapRequest, null));
+
+        CompositeObservation observation = ObserveUtil.createLwM2mCompositeObservation(coapRequest);
+
+        return observation;
+    }
+
+    private Registration givenASimpleClient(String registrationId) {
         Registration.Builder builder;
         try {
             builder = new Registration.Builder(registrationId, registrationId + "_ep",
@@ -164,4 +262,38 @@ public class ObservationServiceTest {
             throw new RuntimeException(e);
         }
     }
+
+    private static class CatchResponseObservationListener implements ObservationListener {
+
+        AbstractLwM2mResponse observeResponse;
+        Observation observation;
+
+        @Override
+        public void newObservation(Observation observation, Registration registration) {
+
+        }
+
+        @Override
+        public void cancelled(Observation observation) {
+
+        }
+
+        @Override
+        public void onResponse(SingleObservation observation, Registration registration, ObserveResponse response) {
+            this.observeResponse = response;
+            this.observation = observation;
+        }
+
+        @Override
+        public void onResponse(CompositeObservation observation, Registration registration,
+                ObserveCompositeResponse response) {
+            this.observeResponse = response;
+            this.observation = observation;
+        }
+
+        @Override
+        public void onError(Observation observation, Registration registration, Exception error) {
+
+        }
+    }
 }
diff --git a/leshan-server-cf/src/test/java/org/eclipse/leshan/server/californium/observation/ObserveUtilTest.java b/leshan-server-cf/src/test/java/org/eclipse/leshan/server/californium/observation/ObserveUtilTest.java
new file mode 100644
index 00000000..aaada9ae
--- /dev/null
+++ b/leshan-server-cf/src/test/java/org/eclipse/leshan/server/californium/observation/ObserveUtilTest.java
@@ -0,0 +1,132 @@
+/*******************************************************************************
+ * Copyright (c) 2021 Orange.
+ * 
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * and Eclipse Distribution License v1.0 which accompany this distribution.
+ * 
+ * The Eclipse Public License is available at
+ *    http://www.eclipse.org/legal/epl-v20.html
+ * and the Eclipse Distribution License is available at
+ *    http://www.eclipse.org/org/documents/edl-v10.html.
+ * 
+ * Contributors:
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
+ *     Michał Wadowski (Orange) - Add Cancel Composite-Observation feature.
+ *******************************************************************************/
+package org.eclipse.leshan.server.californium.observation;
+
+import static org.junit.Assert.*;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.californium.core.coap.Request;
+import org.eclipse.californium.core.coap.Token;
+import org.eclipse.leshan.core.californium.ObserveUtil;
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.observation.CompositeObservation;
+import org.eclipse.leshan.core.observation.SingleObservation;
+import org.eclipse.leshan.core.request.ContentFormat;
+import org.eclipse.leshan.core.request.ObserveCompositeRequest;
+import org.eclipse.leshan.core.request.ObserveRequest;
+import org.junit.Test;
+import org.junit.function.ThrowingRunnable;
+
+public class ObserveUtilTest {
+
+    @Test
+    public void should_create_observation_from_context() {
+        // given
+        String examplePath = "/1/2/3";
+        String exampleRegistrationId = "registrationId";
+        Token exampleToken = Token.EMPTY;
+
+        ObserveRequest observeRequest = new ObserveRequest(null, examplePath);
+
+        // when
+        Map<String, String> userContext = ObserveUtil.createCoapObserveRequestContext(null, exampleRegistrationId,
+                observeRequest);
+        userContext.put("extraKey", "extraValue");
+
+        Request coapRequest = new Request(null);
+        coapRequest.setUserContext(userContext);
+        coapRequest.setToken(exampleToken);
+        coapRequest.getOptions().setAccept(ContentFormat.DEFAULT.getCode());
+
+        SingleObservation observation = ObserveUtil.createLwM2mObservation(coapRequest);
+
+        // then
+        assertEquals(examplePath, observation.getPath().toString());
+        assertEquals(exampleRegistrationId, observation.getRegistrationId());
+        assertEquals(exampleToken.getBytes(), observation.getId());
+        assertTrue(observation.getContext().containsKey("extraKey"));
+        assertEquals("extraValue", observation.getContext().get("extraKey"));
+        assertEquals(ContentFormat.DEFAULT, observation.getContentFormat());
+    }
+
+    @Test
+    public void should_create_composite_observation_from_context() {
+        // given
+        List<LwM2mPath> examplePaths = Arrays.asList(new LwM2mPath("/1/2/3"), new LwM2mPath("/4/5/6"));
+        String exampleRegistrationId = "registrationId";
+        Token exampleToken = Token.EMPTY;
+
+        ObserveCompositeRequest observeRequest = new ObserveCompositeRequest(null, null, examplePaths);
+
+        // when
+        Map<String, String> userContext = ObserveUtil.createCoapObserveCompositeRequestContext(null,
+                exampleRegistrationId, observeRequest);
+        userContext.put("extraKey", "extraValue");
+
+        Request coapRequest = new Request(null);
+        coapRequest.setUserContext(userContext);
+        coapRequest.setToken(exampleToken);
+        coapRequest.getOptions().setContentFormat(ContentFormat.CBOR.getCode());
+        coapRequest.getOptions().setAccept(ContentFormat.JSON.getCode());
+
+        CompositeObservation observation = ObserveUtil.createLwM2mCompositeObservation(coapRequest);
+
+        // then
+        assertEquals(examplePaths, observation.getPaths());
+        assertEquals(exampleRegistrationId, observation.getRegistrationId());
+        assertEquals(exampleToken.getBytes(), observation.getId());
+        assertTrue(observation.getContext().containsKey("extraKey"));
+        assertEquals("extraValue", observation.getContext().get("extraKey"));
+        assertEquals(ContentFormat.CBOR, observation.getRequestContentFormat());
+        assertEquals(ContentFormat.JSON, observation.getResponseContentFormat());
+    }
+
+    @Test
+    public void should_not_create_observation_without_context() {
+        // given
+        final Request coapRequest = new Request(null);
+
+        // when / then
+        assertThrows(IllegalStateException.class, new ThrowingRunnable() {
+            @Override
+            public void run() {
+                ObserveUtil.createLwM2mObservation(coapRequest);
+            }
+        });
+    }
+
+    @Test
+    public void should_not_create_observation_without_path_in_context() {
+        // given
+        Map<String, String> userContext = new HashMap<>();
+
+        final Request coapRequest = new Request(null);
+        coapRequest.setUserContext(userContext);
+
+        // when / then
+        assertThrows(IllegalStateException.class, new ThrowingRunnable() {
+            @Override
+            public void run() {
+                ObserveUtil.createLwM2mObservation(coapRequest);
+            }
+        });
+    }
+}
diff --git a/leshan-server-cf/src/test/java/org/eclipse/leshan/server/californium/registration/InMemoryRegistrationStoreTest.java b/leshan-server-cf/src/test/java/org/eclipse/leshan/server/californium/registration/InMemoryRegistrationStoreTest.java
index 91ca39bd..0ba11de2 100644
--- a/leshan-server-cf/src/test/java/org/eclipse/leshan/server/californium/registration/InMemoryRegistrationStoreTest.java
+++ b/leshan-server-cf/src/test/java/org/eclipse/leshan/server/californium/registration/InMemoryRegistrationStoreTest.java
@@ -12,18 +12,35 @@
  * 
  * Contributors:
  *     Sierra Wireless - initial API and implementation
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
  *******************************************************************************/
 package org.eclipse.leshan.server.californium.registration;
 
+import static org.junit.Assert.*;
+
 import java.net.InetAddress;
+import java.net.UnknownHostException;
 import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
 import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
 
+import org.eclipse.californium.core.coap.CoAP;
+import org.eclipse.californium.core.coap.Request;
+import org.eclipse.californium.core.coap.Token;
 import org.eclipse.leshan.core.Link;
+import org.eclipse.leshan.core.californium.ObserveUtil;
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.observation.CompositeObservation;
+import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.observation.SingleObservation;
 import org.eclipse.leshan.core.request.BindingMode;
+import org.eclipse.leshan.core.request.ContentFormat;
 import org.eclipse.leshan.core.request.Identity;
+import org.eclipse.leshan.core.request.ObserveCompositeRequest;
+import org.eclipse.leshan.core.request.ObserveRequest;
 import org.eclipse.leshan.server.registration.Registration;
-import org.eclipse.leshan.server.registration.RegistrationStore;
 import org.eclipse.leshan.server.registration.RegistrationUpdate;
 import org.eclipse.leshan.server.registration.UpdatedRegistration;
 import org.junit.Assert;
@@ -32,19 +49,23 @@ import org.junit.Test;
 
 public class InMemoryRegistrationStoreTest {
 
-    RegistrationStore store;
-    String ep = "urn:endpoint";
+    private final String ep = "urn:endpoint";
+    private final int port = 23452;
+    private final Long lifetime = 10000L;
+    private final String sms = "0171-32423545";
+    private final EnumSet<BindingMode> binding = EnumSet.of(BindingMode.U, BindingMode.Q, BindingMode.S);
+    private final Link[] objectLinks = Link.parse("</3>".getBytes(StandardCharsets.UTF_8));
+    private final String registrationId = "4711";
+    private final Token exampleToken = Token.EMPTY;
+    private final String examplePath = "/1/2/3";
+    private final List<LwM2mPath> examplePaths = Arrays.asList(new LwM2mPath("/1/2/3"), new LwM2mPath("/4/5/6"));
+
+    CaliforniumRegistrationStore store;
     InetAddress address;
-    int port = 23452;
-    Long lifetime = 10000L;
-    String sms = "0171-32423545";
-    EnumSet<BindingMode> binding = EnumSet.of(BindingMode.U, BindingMode.Q, BindingMode.S);
-    Link[] objectLinks = Link.parse("</3>".getBytes(StandardCharsets.UTF_8));
-    String registrationId = "4711";
     Registration registration;
 
     @Before
-    public void setUp() throws Exception {
+    public void setUp() throws UnknownHostException {
         address = InetAddress.getLocalHost();
         store = new InMemoryRegistrationStore();
     }
@@ -57,16 +78,16 @@ public class InMemoryRegistrationStoreTest {
         RegistrationUpdate update = new RegistrationUpdate(registrationId, Identity.unsecure(address, port), null, null,
                 null, null, null);
         UpdatedRegistration updatedRegistration = store.updateRegistration(update);
-        Assert.assertEquals(lifetime, updatedRegistration.getUpdatedRegistration().getLifeTimeInSec());
+        assertEquals(lifetime, updatedRegistration.getUpdatedRegistration().getLifeTimeInSec());
         Assert.assertSame(binding, updatedRegistration.getUpdatedRegistration().getBindingMode());
-        Assert.assertEquals(sms, updatedRegistration.getUpdatedRegistration().getSmsNumber());
+        assertEquals(sms, updatedRegistration.getUpdatedRegistration().getSmsNumber());
 
-        Assert.assertEquals(registration, updatedRegistration.getPreviousRegistration());
+        assertEquals(registration, updatedRegistration.getPreviousRegistration());
 
         Registration reg = store.getRegistrationByEndpoint(ep);
-        Assert.assertEquals(lifetime, reg.getLifeTimeInSec());
+        assertEquals(lifetime, reg.getLifeTimeInSec());
         Assert.assertSame(binding, reg.getBindingMode());
-        Assert.assertEquals(sms, reg.getSmsNumber());
+        assertEquals(sms, reg.getSmsNumber());
     }
 
     @Test
@@ -91,6 +112,92 @@ public class InMemoryRegistrationStoreTest {
         Assert.assertTrue(reg.isAlive());
     }
 
+    @Test
+    public void put_coap_observation_with_valid_request() {
+        // given
+        givenASimpleRegistration(lifetime);
+        store.addRegistration(registration);
+
+        org.eclipse.californium.core.observe.Observation observationToStore = prepareCoapObservation();
+
+        // when
+        store.put(exampleToken, observationToStore);
+
+        // then
+        org.eclipse.californium.core.observe.Observation observationFetched = store.get(exampleToken);
+
+        assertNotNull(observationFetched);
+        assertEquals(observationToStore.toString(), observationFetched.toString());
+    }
+
+    @Test
+    public void get_observation_from_request() {
+        // given
+        givenASimpleRegistration(lifetime);
+        store.addRegistration(registration);
+
+        org.eclipse.californium.core.observe.Observation observationToStore = prepareCoapObservation();
+
+        // when
+        store.put(exampleToken, observationToStore);
+
+        // then
+        Observation leshanObservation = store.getObservation(registrationId, exampleToken.getBytes());
+        assertNotNull(leshanObservation);
+        assertTrue(leshanObservation instanceof SingleObservation);
+        SingleObservation observation = (SingleObservation) leshanObservation;
+        assertEquals(examplePath, observation.getPath().toString());
+    }
+
+    @Test
+    public void get_composite_observation_from_request() {
+        // given
+        givenASimpleRegistration(lifetime);
+        store.addRegistration(registration);
+
+        org.eclipse.californium.core.observe.Observation observationToStore = prepareCoapCompositeObservation();
+
+        // when
+        store.put(exampleToken, observationToStore);
+
+        // then
+        Observation leshanObservation = store.getObservation(registrationId, exampleToken.getBytes());
+        assertNotNull(leshanObservation);
+        assertTrue(leshanObservation instanceof CompositeObservation);
+        CompositeObservation observation = (CompositeObservation) leshanObservation;
+        assertEquals(examplePaths, observation.getPaths());
+    }
+
+    private org.eclipse.californium.core.observe.Observation prepareCoapObservation() {
+        ObserveRequest observeRequest = new ObserveRequest(null, examplePath);
+
+        Map<String, String> userContext = ObserveUtil.createCoapObserveRequestContext(ep, registrationId,
+                observeRequest);
+
+        Request coapRequest = new Request(CoAP.Code.GET);
+        coapRequest.setUserContext(userContext);
+        coapRequest.setToken(exampleToken);
+        coapRequest.setObserve();
+        coapRequest.getOptions().setAccept(ContentFormat.DEFAULT.getCode());
+
+        return new org.eclipse.californium.core.observe.Observation(coapRequest, null);
+    }
+
+    private org.eclipse.californium.core.observe.Observation prepareCoapCompositeObservation() {
+        ObserveCompositeRequest observeRequest = new ObserveCompositeRequest(null, null, examplePaths);
+
+        Map<String, String> userContext = ObserveUtil.createCoapObserveCompositeRequestContext(ep, registrationId,
+                observeRequest);
+
+        Request coapRequest = new Request(CoAP.Code.FETCH);
+        coapRequest.setUserContext(userContext);
+        coapRequest.setToken(exampleToken);
+        coapRequest.setObserve();
+        coapRequest.getOptions().setAccept(ContentFormat.DEFAULT.getCode());
+
+        return new org.eclipse.californium.core.observe.Observation(coapRequest, null);
+    }
+
     private void givenASimpleRegistration(Long lifetime) {
 
         Registration.Builder builder = new Registration.Builder(registrationId, ep, Identity.unsecure(address, port));
diff --git a/leshan-server-cf/src/test/java/org/eclipse/leshan/server/californium/request/LwM2mResponseBuilderTest.java b/leshan-server-cf/src/test/java/org/eclipse/leshan/server/californium/request/LwM2mResponseBuilderTest.java
new file mode 100644
index 00000000..e9aab00f
--- /dev/null
+++ b/leshan-server-cf/src/test/java/org/eclipse/leshan/server/californium/request/LwM2mResponseBuilderTest.java
@@ -0,0 +1,102 @@
+/*******************************************************************************
+ * Copyright (c) 2021 Orange.
+ * 
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * and Eclipse Distribution License v1.0 which accompany this distribution.
+ * 
+ * The Eclipse Public License is available at
+ *    http://www.eclipse.org/legal/epl-v20.html
+ * and the Eclipse Distribution License is available at
+ *    http://www.eclipse.org/org/documents/edl-v10.html.
+ * 
+ * Contributors:
+ *     Michał Wadowski (Orange) - Add Observe-Composite feature.
+ *******************************************************************************/
+package org.eclipse.leshan.server.californium.request;
+
+import static org.junit.Assert.*;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.californium.core.coap.CoAP;
+import org.eclipse.californium.core.coap.Request;
+import org.eclipse.californium.core.coap.Response;
+import org.eclipse.californium.core.coap.Token;
+import org.eclipse.leshan.core.californium.ObserveUtil;
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.observation.CompositeObservation;
+import org.eclipse.leshan.core.observation.SingleObservation;
+import org.eclipse.leshan.core.request.ObserveCompositeRequest;
+import org.eclipse.leshan.core.request.ObserveRequest;
+import org.eclipse.leshan.core.response.ObserveCompositeResponse;
+import org.eclipse.leshan.core.response.ObserveResponse;
+import org.eclipse.leshan.server.californium.DummyDecoder;
+import org.junit.Test;
+
+public class LwM2mResponseBuilderTest {
+
+    @Test
+    public void visit_observe_request() {
+        // given
+        String examplePath = "/1/2/3";
+
+        ObserveRequest observeRequest = new ObserveRequest(null, examplePath);
+
+        Map<String, String> userContext = ObserveUtil.createCoapObserveRequestContext(null, null, observeRequest);
+
+        Request coapRequest = new Request(null);
+        coapRequest.setToken(Token.EMPTY);
+        coapRequest.setUserContext(userContext);
+
+        Response coapResponse = new Response(CoAP.ResponseCode.CONTENT);
+        coapResponse.getOptions().setObserve(1);
+
+        LwM2mResponseBuilder<ObserveResponse> responseBuilder = new LwM2mResponseBuilder<>(coapRequest, coapResponse,
+                null, null, new DummyDecoder());
+        // when
+        responseBuilder.visit(observeRequest);
+
+        // then
+        ObserveResponse response = responseBuilder.getResponse();
+        assertNotNull(response);
+        assertNotNull(response.getObservation());
+
+        SingleObservation observation = response.getObservation();
+        assertEquals(examplePath, observation.getPath().toString());
+    }
+
+    @Test
+    public void visit_observe_composite_request() {
+        // given
+        List<LwM2mPath> examplePaths = Arrays.asList(new LwM2mPath("/1/2/3"), new LwM2mPath("/4/5/6"));
+
+        ObserveCompositeRequest observeRequest = new ObserveCompositeRequest(null, null, examplePaths);
+
+        Map<String, String> userContext = ObserveUtil.createCoapObserveCompositeRequestContext(null, null,
+                observeRequest);
+
+        Request coapRequest = new Request(null);
+        coapRequest.setToken(Token.EMPTY);
+        coapRequest.setUserContext(userContext);
+
+        Response coapResponse = new Response(CoAP.ResponseCode.CONTENT);
+        coapResponse.getOptions().setObserve(1);
+
+        LwM2mResponseBuilder<ObserveCompositeResponse> responseBuilder = new LwM2mResponseBuilder<>(coapRequest,
+                coapResponse, null, null, new DummyDecoder());
+        // when
+        responseBuilder.visit(observeRequest);
+
+        // then
+        ObserveCompositeResponse response = responseBuilder.getResponse();
+        assertNotNull(response);
+        assertNotNull(response.getObservation());
+
+        CompositeObservation observation = response.getObservation();
+        assertEquals(examplePaths, observation.getPaths());
+    }
+
+}
diff --git a/leshan-server-redis/src/test/java/org/eclipse/leshan/server/redis/serialization/RegistrationSerDesTest.java b/leshan-server-redis/src/test/java/org/eclipse/leshan/server/redis/serialization/RegistrationSerDesTest.java
index 86b8f6c1..55694702 100644
--- a/leshan-server-redis/src/test/java/org/eclipse/leshan/server/redis/serialization/RegistrationSerDesTest.java
+++ b/leshan-server-redis/src/test/java/org/eclipse/leshan/server/redis/serialization/RegistrationSerDesTest.java
@@ -31,7 +31,7 @@ import org.junit.Test;
 public class RegistrationSerDesTest {
 
     @Test
-    public void ser_and_des_are_equals() throws Exception {
+    public void ser_and_des_are_equals() {
         Link[] objs = new Link[2];
         Map<String, Object> att = new HashMap<>();
         att.put("ts", 12);
@@ -55,7 +55,7 @@ public class RegistrationSerDesTest {
     }
 
     @Test
-    public void ser_and_des_are_equals_with_app_data() throws Exception {
+    public void ser_and_des_are_equals_with_app_data() {
         Link[] objs = new Link[2];
         Map<String, Object> att = new HashMap<>();
         att.put("ts", 12);
diff --git a/leshan-server-redis/src/test/java/org/eclipse/leshan/server/redis/serialization/SecurityInfoSerDesTest.java b/leshan-server-redis/src/test/java/org/eclipse/leshan/server/redis/serialization/SecurityInfoSerDesTest.java
index aca37ebf..2ba2e305 100644
--- a/leshan-server-redis/src/test/java/org/eclipse/leshan/server/redis/serialization/SecurityInfoSerDesTest.java
+++ b/leshan-server-redis/src/test/java/org/eclipse/leshan/server/redis/serialization/SecurityInfoSerDesTest.java
@@ -27,7 +27,6 @@ import java.security.spec.ECPublicKeySpec;
 import java.security.spec.KeySpec;
 
 import org.eclipse.leshan.core.util.Hex;
-import org.eclipse.leshan.server.redis.serialization.SecurityInfoSerDes;
 import org.eclipse.leshan.server.security.SecurityInfo;
 import org.junit.Test;